Save AI Response

Ok I’ve given this a lot of thought and I just don’t see it as a worthwhile tradeoff. It’s a fair bit of code complication to be able to serve cached AI responses. Plus, it doesn’t get used a whole lot in the first place. So if you want to hold on to an Ask AI response, you’ll have to re-generate it.

In the interest of time, if anybody else really wants this, let me know. Here’s the Claude plan for how to build it, which I spent a while on refining, in case we do want to build it. This also works if a self-hoster wants to build it by feeding Claude or Codex this plan:

 Plan: Persist Ask AI Conversations

 Context

 Forum request (save-ai-response/13479): Users want Ask AI conversations saved so they appear when re-opening a
 story. Currently, conversations are ephemeral -- they vanish on navigation.

 Design: The Celery task saves conversations automatically after each AI response. Conversations are returned inline
 in existing story payload endpoints (same pattern as user_tags, user_notes, highlights). No new API endpoints.

 - Regular stories: 30-day TTL via MongoDB TTL index (refreshed on each exchange)
 - Starred stories: permanent (expires_at=None, TTL index ignores null)
 - One conversation per user per story -- latest thread wins. Re-ask with a different model overwrites the saved
 conversation. The comparison view is a within-session tool, not persisted.

 ---
 1. New Model: MAskAIConversation

 File: apps/ask_ai/models.py

 class MAskAIConversation(mongo.Document):
     user_id = mongo.IntField(required=True)
     story_hash = mongo.StringField(max_length=32, required=True)
     question_id = mongo.StringField(max_length=64)
     custom_question = mongo.StringField()
     model = mongo.StringField(max_length=32)  # Model used for latest response

     # [{role, content, model?, question_id?}] as compressed JSON
     conversation_z = mongo.BinaryField()

     is_permanent = mongo.BooleanField(default=False)
     expires_at = mongo.DateTimeField()  # null = permanent
     created_at = mongo.DateTimeField(default=datetime.datetime.utcnow)
     updated_at = mongo.DateTimeField(default=datetime.datetime.utcnow)

     meta = {
         "collection": "ask_ai_conversations",
         "indexes": [
             {"fields": ["user_id", "story_hash"], "unique": True},
             {"fields": ["expires_at"], "expireAfterSeconds": 0},
         ],
         "allow_inheritance": False,
     }

 Message format in conversation_z:
 [
     {"role": "user", "content": "Summarize in one sentence", "question_id": "sentence"},
     {"role": "assistant", "content": "The article discusses...", "model": "opus"},
     {"role": "user", "content": "What about the economic impact?"},
     {"role": "assistant", "content": "The economic impact...", "model": "opus"}
 ]

 Class methods:

 - save_conversation(user_id, story_hash, messages, question_id, custom_question, model) -- upserts. Checks
 MStarredStory.objects(user_id=user_id, story_hash=story_hash).count() to decide is_permanent/expires_at. Always
 refreshes expires_at = now+30d for non-permanent. Enforces max 20 messages (if exceeded, drops oldest pair but keeps
  first user message for question context). Skips save if compressed size > 100KB.
 - append_exchange(user_id, story_hash, user_content, assistant_content, model) -- loads existing doc, appends {role:
  "user", content} and {role: "assistant", content, model}, saves. Creates new doc if none exists (edge case:
 follow-up on story whose conversation was TTL-expired). Same size/count limits.
 - get_conversations_for_stories(user_id, story_hashes) -- bulk fetch. Returns {story_hash: {question_id,
 custom_question, model, messages}} dict. Query uses (user_id, story_hash) unique index. Only fetches documents,
 decompresses conversation_z.
 - set_permanent(user_id, story_hash) -- update(set__is_permanent=True, unset__expires_at=True). No-op if no doc.
 - set_expiring(user_id, story_hash) -- update(set__is_permanent=False, set__expires_at=now+30d). No-op if no doc.

 Properties: conversation property with getter (decompress + JSON parse) and setter (JSON serialize + compress),
 following MAskAIResponse.response_text pattern.

 ---
 2. Celery Task Saves Automatically

 File: apps/ask_ai/tasks.py

 Two save points, both before publish_event("complete") to ensure persistence precedes the UI signal. Wrapped in
 try/except so a Mongo failure doesn't prevent the user from seeing their response.

 Cached path (line 94-116)

 Insert before publish_event("complete") at line 112:

 # Save conversation (cached response)
 try:
     from .prompts import get_prompt
     prompt = get_prompt(question_id)
     question_text = prompt.short_text if prompt else question_id
     messages = [
         {"role": "user", "content": question_text, "question_id": question_id},
         {"role": "assistant", "content": response_text, "model": cache_model_key},
     ]
     MAskAIConversation.save_conversation(
         user_id=user_id, story_hash=story_hash,
         messages=messages, question_id=question_id,
         custom_question=None, model=cache_model_key,
     )
 except Exception:
     logging.user(user, "~BB~FGAsk AI: ~FRFailed to save conversation~FG (cached)")

 publish_event("complete")

 Live path (line 189-243)

 Insert before publish_event("complete") at line 214:

 # Save conversation
 try:
     if conversation_history:
         # Follow-up: append to existing conversation
         MAskAIConversation.append_exchange(
             user_id=user_id, story_hash=story_hash,
             user_content=custom_question or conversation_history[-1].get("content", ""),
             assistant_content=full_response_text,
             model=cache_model_key,
         )
     else:
         # Initial question: create/overwrite conversation
         from .prompts import get_prompt
         if custom_question:
             question_text = custom_question
         else:
             prompt = get_prompt(question_id)
             question_text = prompt.short_text if prompt else question_id
         messages = [
             {"role": "user", "content": question_text, "question_id": question_id},
             {"role": "assistant", "content": full_response_text, "model": cache_model_key},
         ]
         MAskAIConversation.save_conversation(
             user_id=user_id, story_hash=story_hash,
             messages=messages, question_id=question_id,
             custom_question=custom_question, model=cache_model_key,
         )
 except Exception:
     logging.user(user, "~BB~FGAsk AI: ~FRFailed to save conversation~FG (live)")

 publish_event("complete")

 Re-ask behavior: A re-ask from the frontend sends a new request with the same question_id but different model, and
 no conversation_history. The backend treats it as an initial question and overwrites the saved conversation. This is
  intentional -- comparison is a session tool, the latest response persists.

 ---
 3. Story Payloads Include Conversation Data

 File: apps/reader/views.py

 Add bulk conversation fetch alongside existing starred_stories fetch in each story loader. The conversation data is
 returned as ask_ai_conversation on each story dict.

 load_single_feed() -- after line 1260 (starred_stories dict comprehension)

 from apps.ask_ai.models import MAskAIConversation
 ask_ai_convos = MAskAIConversation.get_conversations_for_stories(user.pk, story_hashes)

 Then in per-story loop, after line 1343 (highlights):

 if story["story_hash"] in ask_ai_convos:
     story["ask_ai_conversation"] = ask_ai_convos[story["story_hash"]]

 load_river_stories__redis() -- after line 2414 (starred_stories dict)

 Same bulk fetch. Then in per-story loop, after line 2529 (highlights):

 if story["story_hash"] in ask_ai_convos:
     story["ask_ai_conversation"] = ask_ai_convos[story["story_hash"]]

 load_starred_stories() -- after line 1709 (shared_stories dict)

 Same pattern. Then in per-story loop, after line 1747:

 if story["story_hash"] in ask_ai_convos:
     story["ask_ai_conversation"] = ask_ai_convos[story["story_hash"]]

 load_read_stories() -- after line 2188 (starred_stories dict)

 Same pattern. Then after line 2213:

 if story["story_hash"] in ask_ai_convos:
     story["ask_ai_conversation"] = ask_ai_convos[story["story_hash"]]

 ---
 4. Star/Unstar Integration

 File: apps/reader/views.py

 On starring -- _mark_story_as_starred()

 After line 4609 (for new) and after line 4620 (for existing):

 from apps.ask_ai.models import MAskAIConversation
 MAskAIConversation.set_permanent(user_id=request.user.pk, story_hash=story_hash)

 On unstarring -- _mark_story_as_unstarred()

 After line 4722 (after save/delete):

 from apps.ask_ai.models import MAskAIConversation
 MAskAIConversation.set_expiring(user_id=request.user.pk, story_hash=story_hash)

 ---
 5. Frontend: Auto-Show on Story Open

 File: media/js/newsblur/views/story_detail_view.js

 After story content renders, check if the story model has ask_ai_conversation. If so, create a restored
 StoryAskAiView inline:

 // In render or post-render hook:
 var conversation = this.model.get('ask_ai_conversation');
 if (conversation && conversation.messages && conversation.messages.length) {
     var ask_ai_pane = new NEWSBLUR.Views.StoryAskAiView({
         story: this.model,
         question_id: conversation.question_id || 'custom',
         custom_question: conversation.custom_question,
         model: conversation.model,
         inline: true,
         restored_conversation: conversation
     });
     var $wrapper = this.$('.NB-story-content-positioning-wrapper');
     $wrapper.append(ask_ai_pane.render().$el);
 }

 When user clicks Ask AI button (show_ask_ai_menu(), line 1867): Show the dropdown menu normally. If a restored pane
 already exists in the DOM, the new pane from the menu selection appends after it (existing open_ask_ai_pane() logic
 handles this via .NB-story-ask-ai-inline.last().after()). No special guard needed.

 ---
 6. Frontend: Restored Mode in StoryAskAiView

 File: media/js/newsblur/views/story_ask_ai_view.js

 initialize() -- before auto-send logic (lines 56-59)

 Add restored_conversation check that runs before the existing auto-send:

 if (this.options.restored_conversation) {
     this.restore_from_conversation(this.options.restored_conversation);
     // Do NOT fall through to send_question()
     return;  // Skip auto-send entirely
 }
 // ...existing transcription_error and auto-send checks...

 Wait -- return in initialize() would skip event binding. Instead:

 // At line 53, BEFORE the auto-send block:
 this.is_restored = false;
 if (this.options.restored_conversation) {
     this.restore_from_conversation(this.options.restored_conversation);
     this.is_restored = true;
 } else if (this.transcription_error) {
     // existing
 } else if (this.question_id !== 'custom' || this.custom_question) {
     this.send_question(this.custom_question);
 }

 restore_from_conversation(conversation) method

 restore_from_conversation: function (conversation) {
     var messages = conversation.messages || [];
     this.conversation_history = [];
     this.section_models = [];
     this.response_text = '';

     for (var i = 0; i < messages.length; i++) {
         var msg = messages[i];
         if (msg.role === 'user') {
             this.conversation_history.push({ role: 'user', content: msg.content });
             if (i === 0) {
                 // First user message -- set question_text for header
                 this.question_text = msg.content;
             } else {
                 // Follow-up -- add visual separator
                 this.response_text += '\n\n---\n\n**You:** ' + msg.content + '\n\n';
             }
         } else if (msg.role === 'assistant') {
             this.conversation_history.push({ role: 'assistant', content: msg.content });
             this.response_text += msg.content;
             this.section_models.push(msg.model || this.model);
         }
     }

     this.original_question_id = conversation.question_id || this.question_id;
     this.original_custom_question = conversation.custom_question || '';
     this.model = conversation.model || this.model;
     this.response_model = this.model;
     this.streaming_started = true;
     this.current_response_text = messages.length > 0 ?
         messages[messages.length - 1].content : '';
 }

 render() -- restored path

 // In render(), after the template is inserted:
 if (this.is_restored) {
     // Render the saved conversation directly
     var $answer = this.$('.NB-story-ask-ai-answer');
     var html = this.markdown_to_html(this.response_text);
     $answer.html(html).show();
     this.$el.removeClass('NB-thinking');

     // Show follow-up input
     this.$('.NB-story-ask-ai-followup-wrapper').show();
     this.$('.NB-story-ask-ai-followup-input').prop('disabled', false);
     this.$('.NB-story-ask-ai-reask-menu').show();
     this.$('.NB-story-ask-ai-send-menu').hide();
     this.update_model_dropdown_selection();
     this.update_thinking_toggle_selection();
     return this;
 }

 ---
 7. Frontend: Update Story Model After Live Completion

 File: media/js/newsblur/views/story_ask_ai_view.js

 In complete_response() (line 564), after conversation_history.push() (line 577-580), update the story model so the
 indicator and auto-show work in the current session without reload:

 // Build messages array matching backend format
 var messages = [];
 for (var i = 0; i < this.conversation_history.length; i++) {
     var entry = this.conversation_history[i];
     var msg = { role: entry.role, content: entry.content };
     if (entry.role === 'assistant' && this.section_models.length > 0) {
         // Map section_models to assistant messages
         var assistant_index = messages.filter(function(m) { return m.role === 'assistant'; }).length;
         msg.model = this.section_models[assistant_index] || this.model;
     }
     if (i === 0 && entry.role === 'user') {
         msg.question_id = this.original_question_id;
     }
     messages.push(msg);
 }
 this.story.set('ask_ai_conversation', {
     question_id: this.original_question_id,
     custom_question: this.original_custom_question || null,
     model: this.model,
     messages: messages
 });

 This ensures:
 - The story title indicator updates immediately
 - If the story detail re-renders, the restored conversation is available

 ---
 8. Frontend: Indicator in Story Title View

 File: media/js/newsblur/views/story_title_view.js

 In all four templates (split, list, grid, magazine), add an indicator near the star/share divs:

 <% if (story.get('ask_ai_conversation')) { %>
     <div class="NB-storytitles-ask-ai-indicator"></div>
 <% } %>

 Place after <div class="NB-storytitles-share"></div> (lines 88, 137, 183, 227 respectively).

 CSS: Style .NB-storytitles-ask-ai-indicator as a small sparkle/AI icon, similar to the star/share indicators. Use an
  existing icon from media/img/icons/nouns/ (like the Ask AI icon).

 File: media/js/newsblur/views/story_detail_view.js

 In the story header template (line 472), add NB-has-conversation class on the Ask AI sidebar button when
 ask_ai_conversation is present:

 <div class="NB-sideoption NB-feed-story-ask-ai
      <% if (show_sideoption_ask_ai && story.get('ask_ai_conversation')) { %>NB-has-conversation<% } %>"
      role="button">

 ---
 9. TTL and Limits

 - TTL refresh: Every save_conversation() and append_exchange() sets updated_at=now and, for non-permanent
 conversations, expires_at=now+30d. Actively used conversations don't expire.
 - Max messages: 20 (10 user + 10 assistant turns). On overflow, drop the 2nd and 3rd messages (oldest user+assistant
  pair), preserving the first user message (original question context).
 - Max size: Skip save if len(conversation_z) > 100KB. Log warning.
 - TTL index: MongoDB's built-in TTL runner checks every 60s. Docs with expires_at=None (null) are ignored.

 ---
 Files Modified

 ┌──────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────┐
 │                     File                     │                             Change                              │
 ├──────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
 │ apps/ask_ai/models.py                        │ Add MAskAIConversation model (~100 lines)                       │
 ├──────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
 │ apps/ask_ai/tasks.py                         │ Save conversation in cached path (before line 112) and live     │
 │                                              │ path (before line 214) (~35 lines)                              │
 ├──────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
 │ apps/reader/views.py                         │ Bulk conversation fetch in 4 story loaders + star/unstar hooks  │
 │                                              │ (~30 lines across 6 spots)                                      │
 ├──────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
 │ media/js/newsblur/views/story_ask_ai_view.js │ restore_from_conversation(), restored render path, story model  │
 │                                              │ update in complete_response() (~80 lines)                       │
 ├──────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
 │ media/js/newsblur/views/story_detail_view.js │ Auto-show restored conversation on render, NB-has-conversation  │
 │                                              │ class (~20 lines)                                               │
 ├──────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
 │ media/js/newsblur/views/story_title_view.js  │ Add .NB-storytitles-ask-ai-indicator in 4 templates (~8 lines)  │
 ├──────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
 │ CSS stylesheet                               │ .NB-storytitles-ask-ai-indicator and .NB-has-conversation       │
 │                                              │ styles (~10 lines)                                              │
 └──────────────────────────────────────────────┴─────────────────────────────────────────────────────────────────┘

 ---
 10. Tests

 File: apps/ask_ai/tests.py (new or append)

 ┌─────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────┐
 │                  Test                   │                           What it verifies                           │
 ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
 │ test_save_initial_conversation          │ save_conversation() creates doc with correct messages, compressed    │
 ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
 │ test_save_overwrites_on_reask           │ Second save_conversation() for same user+story replaces first        │
 ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
 │ test_append_exchange                    │ append_exchange() adds user+assistant messages to existing doc       │
 ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
 │ test_append_creates_if_missing          │ append_exchange() on non-existent doc creates one                    │
 ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
 │ test_cached_response_saves_conversation │ Cached code path also calls save_conversation()                      │
 ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
 │ test_star_sets_permanent                │ set_permanent() sets is_permanent=True, expires_at=None              │
 ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
 │ test_unstar_sets_expiring               │ set_expiring() sets is_permanent=False, expires_at≈now+30d           │
 ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
 │ test_get_conversations_for_stories      │ Bulk fetch returns correct dict for matching hashes, empty for       │
 │                                         │ non-matching                                                         │
 ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
 │ test_max_messages_limit                 │ Conversation capped at 20 messages, oldest pair dropped              │
 ├─────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────┤
 │ test_expires_at_refreshes               │ Each save updates expires_at to new now+30d                          │
 └─────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────┘

 ---
 Verification (Manual)

 1. Ask AI on a story → check db.ask_ai_conversations.find({user_id: X}) has document
 2. Navigate away → re-open story → conversation auto-shows at bottom
 3. Ask follow-up → MongoDB doc updated with new messages
 4. Re-ask with different model → MongoDB doc replaced with latest response only
 5. Star the story → is_permanent=True, expires_at=null
 6. Unstar → is_permanent=False, expires_at set ~30d out
 7. Reload feed → ask_ai_conversation present in story payload
 8. Story list → indicator icon visible on stories with conversations
 9. make test SCOPE=apps.ask_ai passes