From c56ca3fda4dc36b6db989cbf178d2c01ff12c722 Mon Sep 17 00:00:00 2001
From: Sam Zhong
Date: Mon, 1 Jun 2026 19:56:21 +0000
Subject: [PATCH 01/17] Add chat memory design spec
Document the planned independent chat-session memory model with optional history-session attachment so implementation can follow the agreed LangGraph approach.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../specs/2026-06-01-chat-memory-design.md | 108 ++++++++++++++++++
1 file changed, 108 insertions(+)
create mode 100644 docs/superpowers/specs/2026-06-01-chat-memory-design.md
diff --git a/docs/superpowers/specs/2026-06-01-chat-memory-design.md b/docs/superpowers/specs/2026-06-01-chat-memory-design.md
new file mode 100644
index 0000000..f81604d
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-01-chat-memory-design.md
@@ -0,0 +1,108 @@
+# Chat Short-Term Memory Design
+
+## Goal
+
+Add short-term memory to the chatbot using LangGraph so each chat conversation can remember prior turns. Chat memory should be independent from the existing document-processing history sessions, while still allowing a chat to attach a history session when the user wants to ask about processed files.
+
+## Current Context
+
+The current chat endpoint is stateless. The frontend chat page sends a user query and optional document markdown to `/api/chat/query`, and the backend forwards the prompt to the existing LLM service. The app already has a separate history/session system for processed files and results, but `ChatPage` does not currently receive or persist a session id.
+
+## Core Model
+
+Use two separate concepts:
+
+- `chat_session_id`: identifies one chatbot conversation and maps directly to LangGraph `thread_id`.
+- `attached_session_id`: optionally points to an existing document/history session whose processed files should be available as request-time context.
+
+A chat session owns conversation memory. A document/history session owns processed files and extraction results. The chat session may reference one attached document session, but document state is not part of the chat identity.
+
+## User Experience
+
+From the chat page, the user can:
+
+1. Start a blank independent chat.
+2. Select an existing history session in advanced mode and attach it to the current chat.
+3. Upload/process new files from chat, creating or updating a document/history session, then attach that session to the chat.
+
+This supports multiple independent conversations about the same processed files without mixing chat histories. It also lets users reset chat memory without losing their document-processing history.
+
+## Backend Architecture
+
+Add a `ChatMemoryService` that owns LangGraph execution for chat. The FastAPI route remains responsible for request validation, model/provider parameters, session attachment lookup, and response formatting.
+
+`ChatMemoryService` responsibilities:
+
+- Build and invoke the LangGraph chat graph.
+- Use `chat_session_id` as `configurable.thread_id`.
+- Persist graph state with a Postgres checkpointer.
+- Keep graph state small.
+- Compose prompts from system instructions, remembered messages, and request-time document context.
+
+Graph state should include:
+
+- `messages`: prior user and assistant turns.
+- `attachment`: lightweight metadata such as `attached_session_id`, selected document ids, hashes, timestamps, or titles.
+
+Graph state should not include full document markdown or large extraction payloads. Existing document services remain the source of truth for processed file content.
+
+## Data Flow
+
+1. The frontend creates or loads a `chat_session_id` for the active chat.
+2. The frontend sends `/api/chat/query` with:
+ - `chat_session_id`
+ - user query
+ - optional `attached_session_id`
+ - existing model/provider fields
+3. The backend validates the request.
+4. If `attached_session_id` is present, the backend loads current document context from the existing history/session services.
+5. The backend invokes `ChatMemoryService` with LangGraph config `thread_id = chat_session_id`.
+6. LangGraph loads prior state from the Postgres checkpointer, appends the new user turn, calls the model, and checkpoints the updated state.
+7. The backend returns the assistant response plus chat metadata.
+
+Document context can influence the current response, but it is not stored as full content in chat memory.
+
+## Attachment Behavior
+
+A chat can point at one attached document/history session at a time. Changing the attachment updates the chat's lightweight attachment metadata and affects future responses, but does not erase prior messages.
+
+If the user wants a clean conversation about different files, the frontend should make starting a new chat easy. The system should not silently merge unrelated document contexts into one conversation.
+
+## Error Handling
+
+- Missing `chat_session_id`: the frontend should create it; the backend should validate and return a clear error if absent.
+- Invalid `attached_session_id`: return a user-friendly error and allow the chat to continue unattached.
+- Oversized document context: do not checkpoint large content; summarize, truncate, or surface the existing context-window warning.
+- Checkpointer failure: fail the chat request visibly instead of silently losing memory.
+- Model failure: avoid creating ambiguous half-saved turns; retry behavior should be explicit in service tests.
+
+## Testing Strategy
+
+Backend tests:
+
+- Request validation for `chat_session_id` and optional `attached_session_id`.
+- One `chat_session_id` remembers prior turns.
+- Two different `chat_session_id`s do not share history.
+- Attached document context influences a response without being stored as full markdown in graph state.
+- Invalid or unavailable `attached_session_id` returns a clear error.
+
+Frontend tests:
+
+- Starting a blank chat creates an independent `chat_session_id`.
+- Selecting an existing history session attaches it to the current chat.
+- Starting a new chat does not reuse the prior chat's memory unless explicitly selected.
+
+Manual validation:
+
+- Process files in the workflow.
+- Open Chat and attach the processed history session.
+- Ask a document-specific question and a follow-up that depends on chat history.
+- Start a blank chat and confirm prior chat memory does not leak.
+- Reattach the same history session in a second chat and confirm document context is available without sharing conversation history.
+
+## Recommended Implementation Order
+
+1. Add backend request fields and `ChatMemoryService` with LangGraph checkpointing.
+2. Add frontend `chat_session_id` lifecycle for blank independent chats.
+3. Add history-session attachment UI and backend document-context lookup.
+4. Add tests for memory isolation and attachment behavior.
From 0fcbd5367e2d2cd11c41465753b113647bbc85eb Mon Sep 17 00:00:00 2001
From: Sam Zhong
Date: Thu, 4 Jun 2026 16:14:39 +0000
Subject: [PATCH 02/17] feat: implement chat session management with history
attachment
- Added functions to create, retrieve, and reset chat session IDs using sessionStorage and in-memory storage.
- Integrated chat session handling in ChatPage component, allowing users to attach history sessions and manage document uploads accordingly.
- Updated UI to reflect changes in session management, including disabling document uploads when a history session is attached.
- Enhanced error handling and user feedback for session-related actions.
---
backend/api/chat/router.py | 100 +-
backend/requirements.txt | 2 +
backend/services/chat_memory/__init__.py | 3 +
.../chat_memory/chat_memory_service.py | 347 +++++
.../services/chat_memory/document_context.py | 77 ++
backend/services/llm/anthropic.py | 5 +-
backend/services/llm/llm_service.py | 1 +
.../2026-06-01-chat-memory-implementation.md | 1123 +++++++++++++++++
frontend/components/ChatPage.tsx | 215 +++-
frontend/utils/chatSession.ts | 79 ++
10 files changed, 1874 insertions(+), 78 deletions(-)
create mode 100644 backend/services/chat_memory/__init__.py
create mode 100644 backend/services/chat_memory/chat_memory_service.py
create mode 100644 backend/services/chat_memory/document_context.py
create mode 100644 docs/superpowers/plans/2026-06-01-chat-memory-implementation.md
create mode 100644 frontend/utils/chatSession.ts
diff --git a/backend/api/chat/router.py b/backend/api/chat/router.py
index 2652e34..bc3921f 100644
--- a/backend/api/chat/router.py
+++ b/backend/api/chat/router.py
@@ -1,73 +1,81 @@
"""Chat API endpoint for support staff chatbot"""
+from typing import Optional
+
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
-from pydantic import BaseModel
-from typing import Optional
+from pydantic import BaseModel, Field
from core.auth import get_current_user
-from services.llm.llm_service import LLMService
+from services.chat_memory import ChatMemoryRequest, ChatMemoryService
+from services.chat_memory.chat_memory_service import GENERIC_MODEL_ERROR_MESSAGE
+from services.chat_memory.document_context import build_attached_session_context
router = APIRouter(prefix="/api/chat", tags=["chat"])
-llm_service = LLMService()
+chat_memory_service: Optional[ChatMemoryService] = None
class ChatQueryRequest(BaseModel):
- query: str
+ chat_session_id: str = Field(min_length=1)
+ query: str = Field(min_length=1)
document_markdown: Optional[str] = None
- model_type: str # "azure", "gemini", "anthropic", "llama", "azure-llama", "macbook"
+ attached_session_id: Optional[str] = None
+ model_type: str
model_id: Optional[str] = None
deployment: Optional[str] = None
api_version: Optional[str] = None
+def get_chat_memory_service() -> ChatMemoryService:
+ global chat_memory_service
+ if chat_memory_service is None:
+ chat_memory_service = ChatMemoryService()
+ return chat_memory_service
+
+
@router.post("/query", dependencies=[Depends(get_current_user)])
-async def chat_query(request: ChatQueryRequest):
- """
- Send a chat message with optional document context.
+async def chat_query(
+ request: ChatQueryRequest,
+ current_user: dict = Depends(get_current_user),
+):
+ document_context = request.document_markdown
+ attached_session_id = request.attached_session_id
+ attached_session_warning: Optional[str] = None
- When document_markdown is provided, it is injected into the prompt so the
- model can answer questions about the uploaded document.
- """
- if request.document_markdown:
- user_prompt = (
- "The following document has been uploaded by the user:\n\n"
- f"\n{request.document_markdown}\n\n\n"
- f"User question: {request.query}"
+ if request.attached_session_id:
+ attached_context = await build_attached_session_context(
+ user_id=current_user["id"],
+ attached_session_id=request.attached_session_id,
)
- system_message = (
- "You are a helpful document assistant for Health Canada support staff. "
- "Answer the user's question based on the provided document. "
- "If the answer is not found in the document, say so clearly and offer "
- "general guidance if possible."
- )
- else:
- user_prompt = request.query
- system_message = (
- "You are a helpful assistant for Health Canada support staff. "
- "Answer questions clearly and concisely."
- )
-
- result = await llm_service.generate_paragraph(
- user_prompt=user_prompt,
- model_type=request.model_type,
- model_id=request.model_id,
- deployment=request.deployment,
- api_version=request.api_version,
- max_tokens=4096,
- temperature=0.3,
- system_message=system_message,
- )
+ if attached_context is None:
+ attached_session_id = None
+ attached_session_warning = (
+ "Attached session not found or has no processed document content. Continuing without attachment."
+ )
+ else:
+ document_context = attached_context
- if not result.get("success"):
+ try:
+ result = await get_chat_memory_service().invoke(
+ ChatMemoryRequest(
+ chat_session_id=request.chat_session_id,
+ query=request.query,
+ model_type=request.model_type,
+ model_id=request.model_id,
+ deployment=request.deployment,
+ api_version=request.api_version,
+ document_context=document_context,
+ attached_session_id=attached_session_id,
+ )
+ )
+ if attached_session_warning and isinstance(result, dict):
+ result["attached_session_warning"] = attached_session_warning
+ return result
+ except Exception:
return JSONResponse(
status_code=500,
content={
"success": False,
- "error": result.get(
- "error", "The model call failed. Please try again."
- ),
+ "error": GENERIC_MODEL_ERROR_MESSAGE,
},
)
-
- return {"success": True, "response": result.get("content", "")}
diff --git a/backend/requirements.txt b/backend/requirements.txt
index ac2de57..930e095 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -18,6 +18,8 @@ deepeval>=3.6.9
google-cloud-aiplatform>=1.122.0
langchain-openai>=1.0.1
langchain-google-vertexai>=3.0.1
+langgraph>=1.2.4
+langgraph-checkpoint-postgres>=3.1.0
pytest>=8.4.2
pytest-asyncio>=1.2.0
anthropic[vertex]>=0.39.0
diff --git a/backend/services/chat_memory/__init__.py b/backend/services/chat_memory/__init__.py
new file mode 100644
index 0000000..202685b
--- /dev/null
+++ b/backend/services/chat_memory/__init__.py
@@ -0,0 +1,3 @@
+from .chat_memory_service import ChatMemoryRequest, ChatMemoryService
+
+__all__ = ["ChatMemoryRequest", "ChatMemoryService"]
diff --git a/backend/services/chat_memory/chat_memory_service.py b/backend/services/chat_memory/chat_memory_service.py
new file mode 100644
index 0000000..2e76219
--- /dev/null
+++ b/backend/services/chat_memory/chat_memory_service.py
@@ -0,0 +1,347 @@
+from __future__ import annotations
+
+import asyncio
+import atexit
+import inspect
+import weakref
+from dataclasses import dataclass
+from typing import Annotated, Any, Dict, List, Optional, TypedDict
+
+from services.llm.llm_service import LLMService
+
+LANGGRAPH_INSTALL_MESSAGE = (
+ "LangGraph chat memory dependencies are unavailable. "
+ "Install backend requirements and retry."
+)
+GENERIC_MODEL_ERROR_MESSAGE = "The model call failed. Please try again."
+
+
+class ModelProviderError(Exception):
+ pass
+
+try:
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
+ from langchain_core.runnables import RunnableConfig
+
+ LANGCHAIN_MESSAGES_IMPORT_ERROR: Optional[ImportError] = None
+except ImportError as exc:
+ AIMessage = BaseMessage = HumanMessage = RunnableConfig = Any # type: ignore[assignment]
+ LANGCHAIN_MESSAGES_IMPORT_ERROR = exc
+
+try:
+ from langgraph.graph import END, START, StateGraph
+ from langgraph.graph.message import add_messages
+
+ LANGGRAPH_GRAPH_IMPORT_ERROR: Optional[ImportError] = None
+except ImportError as exc:
+ END = "__end__"
+ START = "__start__"
+ StateGraph = None
+ LANGGRAPH_GRAPH_IMPORT_ERROR = exc
+
+ def add_messages(existing: List[Any], new: List[Any]) -> List[Any]:
+ return [*(existing or []), *(new or [])]
+
+
+try:
+ from langgraph.checkpoint.memory import InMemorySaver as LangGraphInMemorySaver
+
+ LANGGRAPH_MEMORY_IMPORT_ERROR: Optional[ImportError] = None
+except ImportError:
+ try:
+ from langgraph.checkpoint.memory import MemorySaver as LangGraphInMemorySaver
+
+ LANGGRAPH_MEMORY_IMPORT_ERROR = None
+ except ImportError as exc:
+ LangGraphInMemorySaver = None
+ LANGGRAPH_MEMORY_IMPORT_ERROR = exc
+
+
+@dataclass
+class ChatMemoryRequest:
+ chat_session_id: str
+ query: str
+ model_type: str
+ model_id: Optional[str] = None
+ deployment: Optional[str] = None
+ api_version: Optional[str] = None
+ document_context: Optional[str] = None
+ attached_session_id: Optional[str] = None
+
+
+class ChatState(TypedDict):
+ messages: Annotated[List[BaseMessage], add_messages]
+ attachment: Optional[Dict[str, Optional[str]]]
+
+
+class ChatMemoryService:
+ def __init__(
+ self,
+ llm_service: Optional[LLMService] = None,
+ checkpointer: Optional[Any] = None,
+ use_memory_checkpointer: bool = False,
+ ):
+ self.llm_service = llm_service or LLMService()
+ self._use_memory_checkpointer = use_memory_checkpointer
+ self._async_checkpointer_context = None
+ self._did_register_atexit_close = False
+ self._initialization_lock = asyncio.Lock()
+ self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
+ self._ensure_langgraph_dependencies(require_in_memory=use_memory_checkpointer)
+ self.checkpointer = checkpointer
+ self.graph = self._build_graph(checkpointer) if checkpointer is not None else None
+
+ def _ensure_langgraph_dependencies(self, require_in_memory: bool = False) -> None:
+ import_errors: List[str] = []
+
+ if LANGCHAIN_MESSAGES_IMPORT_ERROR is not None:
+ import_errors.append(f"langchain_core.messages: {LANGCHAIN_MESSAGES_IMPORT_ERROR}")
+ if LANGGRAPH_GRAPH_IMPORT_ERROR is not None:
+ import_errors.append(f"langgraph.graph: {LANGGRAPH_GRAPH_IMPORT_ERROR}")
+ if require_in_memory and LANGGRAPH_MEMORY_IMPORT_ERROR is not None:
+ import_errors.append(
+ f"langgraph.checkpoint.memory: {LANGGRAPH_MEMORY_IMPORT_ERROR}"
+ )
+
+ if import_errors:
+ details = "; ".join(import_errors)
+ raise RuntimeError(f"{LANGGRAPH_INSTALL_MESSAGE} Import errors: {details}")
+
+ def _get_async_postgres_saver_class(self) -> Any:
+ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
+
+ return AsyncPostgresSaver
+
+ async def _build_checkpointer(self) -> Any:
+ if self._use_memory_checkpointer:
+ return self._build_in_memory_checkpointer()
+
+ try:
+ AsyncPostgresSaver = self._get_async_postgres_saver_class()
+ except Exception as exc:
+ raise RuntimeError(
+ f"Failed to import LangGraph Postgres checkpointer: {exc}"
+ ) from exc
+
+ try:
+ from models.base import DATABASE_URL
+
+ saver_context = AsyncPostgresSaver.from_conn_string(DATABASE_URL)
+ self._async_checkpointer_context = saver_context
+ saver = await saver_context.__aenter__()
+ self._register_checkpointer_cleanup()
+ if hasattr(saver, "setup"):
+ setup_result = saver.setup()
+ if inspect.isawaitable(setup_result):
+ await setup_result
+ return saver
+ except Exception as exc:
+ await self._close_async_checkpointer_context()
+ raise RuntimeError(
+ f"Failed to initialize LangGraph Postgres checkpointer: {exc}"
+ ) from exc
+
+ def _register_checkpointer_cleanup(self) -> None:
+ if self._did_register_atexit_close:
+ return
+ atexit.register(self._close_checkpointer_context_at_exit)
+ self._did_register_atexit_close = True
+
+ def _close_checkpointer_context_at_exit(self) -> None:
+ if self._async_checkpointer_context is None:
+ return
+ try:
+ asyncio.run(self._close_async_checkpointer_context())
+ except Exception:
+ pass
+
+ async def _close_async_checkpointer_context(self) -> None:
+ if self._async_checkpointer_context is None:
+ return
+ try:
+ await self._async_checkpointer_context.__aexit__(None, None, None)
+ except Exception:
+ pass
+ finally:
+ self._async_checkpointer_context = None
+
+ def _build_in_memory_checkpointer(self) -> Any:
+ if LangGraphInMemorySaver is None:
+ detail = (
+ str(LANGGRAPH_MEMORY_IMPORT_ERROR)
+ if LANGGRAPH_MEMORY_IMPORT_ERROR is not None
+ else "LangGraph in-memory saver is unavailable."
+ )
+ raise RuntimeError(
+ f"{LANGGRAPH_INSTALL_MESSAGE} Unable to import LangGraph in-memory saver: {detail}"
+ ) from LANGGRAPH_MEMORY_IMPORT_ERROR
+ return LangGraphInMemorySaver()
+
+ def _build_graph(self, checkpointer: Any) -> Any:
+ self._ensure_langgraph_dependencies()
+
+ graph = StateGraph(ChatState)
+ graph.add_node("generate", self._generate_response)
+ graph.add_edge(START, "generate")
+ graph.add_edge("generate", END)
+ return graph.compile(checkpointer=checkpointer)
+
+ async def _ensure_graph(self) -> Any:
+ if self.graph is not None:
+ return self.graph
+
+ async with self._initialization_lock:
+ if self.graph is not None:
+ return self.graph
+
+ checkpointer = self.checkpointer or await self._build_checkpointer()
+ self.checkpointer = checkpointer
+ self.graph = self._build_graph(checkpointer)
+ return self.graph
+
+ def _get_session_lock(self, chat_session_id: str) -> asyncio.Lock:
+ lock = self._session_locks.get(chat_session_id)
+ if lock is None:
+ lock = asyncio.Lock()
+ self._session_locks[chat_session_id] = lock
+ return lock
+
+ async def invoke(self, request: ChatMemoryRequest) -> Dict[str, Any]:
+ config = {
+ "configurable": {
+ "thread_id": request.chat_session_id,
+ "model_type": request.model_type,
+ "model_id": request.model_id,
+ "deployment": request.deployment,
+ "api_version": request.api_version,
+ "document_context": request.document_context,
+ }
+ }
+ attachment = self._build_attachment(request.attached_session_id)
+ lock = self._get_session_lock(request.chat_session_id)
+ graph = await self._ensure_graph()
+
+ try:
+ async with lock:
+ starting_state = await graph.aget_state(config)
+ state_config = getattr(starting_state, "config", None) or config
+ prior_messages = list(starting_state.values.get("messages", []))
+ next_messages = [*prior_messages, HumanMessage(content=request.query)]
+ result = await self._generate_response(
+ {
+ "messages": next_messages,
+ "attachment": attachment,
+ },
+ config=config,
+ )
+ ai_messages = result.get("messages", [])
+ persisted_config = await graph.aupdate_state(
+ state_config,
+ {
+ "messages": [*next_messages, *ai_messages],
+ "attachment": attachment,
+ },
+ as_node="generate",
+ )
+ persisted_state = await graph.aget_state(persisted_config)
+ response = self._extract_ai_response(persisted_state.values.get("messages", []))
+ persisted_attachment = persisted_state.values.get("attachment") or {}
+ return {
+ "success": True,
+ "response": response,
+ "chat_session_id": request.chat_session_id,
+ "attached_session_id": persisted_attachment.get("attached_session_id"),
+ }
+ except ModelProviderError:
+ return {
+ "success": False,
+ "response": "",
+ "chat_session_id": request.chat_session_id,
+ "attached_session_id": request.attached_session_id,
+ "error": GENERIC_MODEL_ERROR_MESSAGE,
+ }
+
+ async def _generate_response(
+ self,
+ state: ChatState,
+ config: Optional[RunnableConfig] = None,
+ ) -> Dict[str, Any]:
+ configurable = (config or {}).get("configurable", {})
+ prompt = self._build_user_prompt(
+ messages=state.get("messages", []),
+ document_context=configurable.get("document_context"),
+ )
+ system_message = self._build_system_message(
+ has_document_context=bool(configurable.get("document_context"))
+ )
+
+ try:
+ result = await self.llm_service.generate_paragraph(
+ user_prompt=prompt,
+ model_type=configurable["model_type"],
+ model_id=configurable.get("model_id"),
+ deployment=configurable.get("deployment"),
+ api_version=configurable.get("api_version"),
+ max_tokens=4096,
+ temperature=0.3,
+ system_message=system_message,
+ )
+ except Exception as exc:
+ raise ModelProviderError(GENERIC_MODEL_ERROR_MESSAGE) from exc
+ if not result.get("success"):
+ raise ModelProviderError(str(result.get("error", GENERIC_MODEL_ERROR_MESSAGE)))
+
+ return {"messages": [AIMessage(content=str(result.get("content", "")))]}
+
+ def _build_user_prompt(
+ self,
+ messages: List[BaseMessage],
+ document_context: Optional[str],
+ ) -> str:
+ history_lines: List[str] = []
+ current_query = ""
+
+ for index, message in enumerate(messages):
+ is_last = index == len(messages) - 1
+ if isinstance(message, HumanMessage):
+ if is_last:
+ current_query = str(message.content)
+ else:
+ history_lines.append(f"User: {message.content}")
+ elif isinstance(message, AIMessage):
+ history_lines.append(f"Assistant: {message.content}")
+
+ sections: List[str] = []
+ if history_lines:
+ sections.append("Conversation so far:\n" + "\n".join(history_lines))
+ if document_context:
+ sections.append(
+ "Current document context for this request:\n"
+ f"{document_context}\n\n"
+ "Use this context when it is relevant. Do not assume it remains available in future turns unless it is provided again."
+ )
+ sections.append(f"User question: {current_query}")
+ return "\n\n".join(sections)
+
+ def _build_system_message(self, has_document_context: bool) -> str:
+ if has_document_context:
+ return (
+ "You are a helpful document assistant for Health Canada support staff. "
+ "Answer the user's question using the current document context and remembered conversation. "
+ "If the answer is not found in the document context, say so clearly and offer general guidance if possible."
+ )
+ return (
+ "You are a helpful assistant for Health Canada support staff. "
+ "Answer questions clearly and concisely using remembered conversation when relevant."
+ )
+
+ def _build_attachment(self, attached_session_id: Optional[str]) -> Optional[Dict[str, Optional[str]]]:
+ if attached_session_id is None:
+ return None
+ return {"attached_session_id": attached_session_id}
+
+ def _extract_ai_response(self, messages: List[BaseMessage]) -> str:
+ for message in reversed(messages):
+ if isinstance(message, AIMessage):
+ return str(message.content)
+ return ""
diff --git a/backend/services/chat_memory/document_context.py b/backend/services/chat_memory/document_context.py
new file mode 100644
index 0000000..a4bbaf8
--- /dev/null
+++ b/backend/services/chat_memory/document_context.py
@@ -0,0 +1,77 @@
+from typing import Optional
+
+from services.document.organized_file_service import get_organized_file_service
+from services.session.session_service import get_session_service
+
+MAX_ATTACHED_SESSION_CONTEXT_CHARS = 200_000
+
+
+def _build_document_block(filename: str, file_hash: str, markdown: str) -> str:
+ return (
+ f'\n'
+ f"{markdown}\n"
+ ""
+ )
+
+
+async def build_attached_session_context(
+ user_id: str,
+ attached_session_id: Optional[str],
+ session_service=None,
+ file_service=None,
+) -> Optional[str]:
+ if not attached_session_id:
+ return None
+
+ session_service = session_service or get_session_service()
+ file_service = file_service or get_organized_file_service()
+
+ session = session_service.get_session(user_id, attached_session_id)
+ if session is None:
+ return None
+
+ document_blocks = []
+ current_length = 0
+ separator = "\n\n"
+
+ for document in session.documents:
+ processor_used = document.processor_used or "azure_doc_intelligence"
+ markdown = await file_service.get_processed_content(
+ document.file_hash, processor_used
+ )
+ if not markdown:
+ continue
+
+ block = _build_document_block(document.filename, document.file_hash, markdown)
+ separator_length = len(separator) if document_blocks else 0
+ remaining_budget = MAX_ATTACHED_SESSION_CONTEXT_CHARS - current_length - separator_length
+ if remaining_budget <= 0:
+ break
+
+ if len(block) > remaining_budget:
+ if document_blocks:
+ break
+
+ opening_tag = f'\n'
+ closing_tag = "\n"
+ max_markdown_length = remaining_budget - len(opening_tag) - len(closing_tag)
+ if max_markdown_length <= 0:
+ break
+ block = _build_document_block(
+ document.filename,
+ document.file_hash,
+ markdown[:max_markdown_length],
+ )
+ if len(block) > remaining_budget:
+ block = block[:remaining_budget]
+ if not block.endswith(closing_tag):
+ content_budget = max(0, remaining_budget - len(opening_tag) - len(closing_tag))
+ block = opening_tag + markdown[:content_budget] + closing_tag
+
+ document_blocks.append(block)
+ current_length += separator_length + len(block)
+
+ if current_length >= MAX_ATTACHED_SESSION_CONTEXT_CHARS:
+ break
+
+ return "\n\n".join(document_blocks) if document_blocks else None
diff --git a/backend/services/llm/anthropic.py b/backend/services/llm/anthropic.py
index 79f34c0..e872d0b 100644
--- a/backend/services/llm/anthropic.py
+++ b/backend/services/llm/anthropic.py
@@ -394,6 +394,7 @@ async def generate_paragraph_with_anthropic(
model_id: Optional[str] = None,
max_tokens: int = 2048,
temperature: float = 0.0,
+ system_message: Optional[str] = None,
project_id_override: Optional[str] = None,
location_override: Optional[str] = None,
service_account_path_override: Optional[Path] = None,
@@ -402,7 +403,9 @@ async def generate_paragraph_with_anthropic(
used_model_id = model_id or "claude-sonnet-4-5@20250929"
# System message for paragraph generation
- system = "You are a scientific writing assistant. Your task is to synthesize extracted information into a cohesive, well-structured paragraph while maintaining complete accuracy. Follow the instructions exactly and preserve all factual details from the provided entities."
+ system = system_message or (
+ "You are a scientific writing assistant. Your task is to synthesize extracted information into a cohesive, well-structured paragraph while maintaining complete accuracy. Follow the instructions exactly and preserve all factual details from the provided entities."
+ )
messages = [{"role": "user", "content": user_prompt}]
diff --git a/backend/services/llm/llm_service.py b/backend/services/llm/llm_service.py
index aa62465..64bdb4e 100644
--- a/backend/services/llm/llm_service.py
+++ b/backend/services/llm/llm_service.py
@@ -419,6 +419,7 @@ async def generate_paragraph(
model_id,
max_tokens,
temperature,
+ system_message=system_message,
)
self._record_session_metrics(session_id, "gcp", result)
return result
diff --git a/docs/superpowers/plans/2026-06-01-chat-memory-implementation.md b/docs/superpowers/plans/2026-06-01-chat-memory-implementation.md
new file mode 100644
index 0000000..3847782
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-01-chat-memory-implementation.md
@@ -0,0 +1,1123 @@
+# Chat Memory Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add independent LangGraph-backed memory for each chatbot conversation, with optional attachment to an existing document/history session.
+
+**Architecture:** The frontend creates a durable `chat_session_id` for each chat. The backend uses that ID as LangGraph's `thread_id`, stores short-term message state through a checkpointer, and loads attached document context from existing session/document services at request time. Chat memory and document-processing sessions remain separate; `attached_session_id` is only a reference.
+
+**Tech Stack:** FastAPI, Pydantic, Python LangGraph, LangGraph Postgres checkpointer, existing `LLMService`, React/TypeScript, existing authenticated fetch/session APIs.
+
+---
+
+## File Structure
+
+Backend files:
+
+- Modify: `backend/requirements.txt`
+ - Add `langgraph` and `langgraph-checkpoint-postgres`.
+- Create: `backend/services/chat_memory/__init__.py`
+ - Export `ChatMemoryService`, `ChatMessage`, and `ChatMemoryRequest`.
+- Create: `backend/services/chat_memory/chat_memory_service.py`
+ - Build the LangGraph chat graph, use `chat_session_id` as `thread_id`, and call `LLMService.generate_paragraph`.
+- Create: `backend/services/chat_memory/document_context.py`
+ - Load markdown for an optional `attached_session_id` using existing `SessionService` and `OrganizedFileService` methods.
+- Modify: `backend/api/chat/router.py`
+ - Accept `chat_session_id` and optional `attached_session_id`; delegate to `ChatMemoryService`.
+- Create: `backend/tests/test_chat_memory_service.py`
+ - Unit tests for independent memory and prompt construction using an in-memory LangGraph checkpointer.
+- Create: `backend/tests/test_chat_router.py`
+ - Unit tests for request validation and route delegation.
+
+Frontend files:
+
+- Create: `frontend/utils/chatSession.ts`
+ - Generate, store, reset, and retrieve independent chat session IDs.
+- Modify: `frontend/components/ChatPage.tsx`
+ - Send `chat_session_id`, optionally send `attached_session_id`, support starting a new chat, and add a simple history-session attachment selector.
+- Reuse: `frontend/types/session.ts`
+ - Use existing `SessionSummary` type for the selector.
+
+No database model table is needed for chat messages in this first implementation. LangGraph checkpoint tables are owned by the LangGraph checkpointer package.
+
+---
+
+### Task 1: Add LangGraph dependencies
+
+**Files:**
+- Modify: `backend/requirements.txt:19-29`
+
+- [ ] **Step 1: Add dependency lines**
+
+Edit `backend/requirements.txt` so the LangChain/LangGraph block includes these lines:
+
+```txt
+langchain-openai>=1.0.1
+langchain-google-vertexai>=3.0.1
+langgraph>=1.0.0
+langgraph-checkpoint-postgres>=3.0.0
+```
+
+- [ ] **Step 2: Install backend dependencies locally**
+
+Run:
+
+```bash
+cd backend && python -m pip install -r requirements.txt
+```
+
+Expected: installation completes with `langgraph` and `langgraph-checkpoint-postgres` installed.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/requirements.txt
+git commit -m "Add LangGraph chat memory dependencies"
+```
+
+---
+
+### Task 2: Write failing ChatMemoryService tests
+
+**Files:**
+- Create: `backend/tests/test_chat_memory_service.py`
+
+- [ ] **Step 1: Create the failing tests**
+
+Create `backend/tests/test_chat_memory_service.py` with:
+
+```python
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from services.chat_memory.chat_memory_service import ChatMemoryRequest, ChatMemoryService
+
+
+class FakeLLMService:
+ def __init__(self):
+ self.prompts = []
+
+ async def generate_paragraph(self, **kwargs):
+ self.prompts.append(kwargs)
+ user_prompt = kwargs["user_prompt"]
+ if "What did I ask before?" in user_prompt:
+ return {"success": True, "content": "You asked about licensing."}
+ return {"success": True, "content": "First answer."}
+
+
+@pytest.mark.asyncio
+async def test_chat_session_remembers_prior_turns():
+ fake_llm = FakeLLMService()
+ service = ChatMemoryService(llm_service=fake_llm, use_memory_checkpointer=True)
+
+ first = await service.invoke(
+ ChatMemoryRequest(
+ chat_session_id="chat-a",
+ query="Tell me about licensing.",
+ model_type="azure",
+ )
+ )
+ second = await service.invoke(
+ ChatMemoryRequest(
+ chat_session_id="chat-a",
+ query="What did I ask before?",
+ model_type="azure",
+ )
+ )
+
+ assert first["success"] is True
+ assert second["success"] is True
+ assert second["response"] == "You asked about licensing."
+ assert "Tell me about licensing." in fake_llm.prompts[1]["user_prompt"]
+
+
+@pytest.mark.asyncio
+async def test_chat_sessions_do_not_share_history():
+ fake_llm = FakeLLMService()
+ service = ChatMemoryService(llm_service=fake_llm, use_memory_checkpointer=True)
+
+ await service.invoke(
+ ChatMemoryRequest(
+ chat_session_id="chat-a",
+ query="Tell me about licensing.",
+ model_type="azure",
+ )
+ )
+ await service.invoke(
+ ChatMemoryRequest(
+ chat_session_id="chat-b",
+ query="What did I ask before?",
+ model_type="azure",
+ )
+ )
+
+ chat_b_prompt = fake_llm.prompts[-1]["user_prompt"]
+ assert "Tell me about licensing." not in chat_b_prompt
+
+
+@pytest.mark.asyncio
+async def test_document_context_is_injected_but_not_saved_as_message():
+ fake_llm = FakeLLMService()
+ service = ChatMemoryService(llm_service=fake_llm, use_memory_checkpointer=True)
+
+ await service.invoke(
+ ChatMemoryRequest(
+ chat_session_id="chat-doc",
+ query="Summarize this.",
+ model_type="azure",
+ document_context="Large protocol markdown",
+ attached_session_id="session-1",
+ )
+ )
+ await service.invoke(
+ ChatMemoryRequest(
+ chat_session_id="chat-doc",
+ query="What did I ask?",
+ model_type="azure",
+ )
+ )
+
+ first_prompt = fake_llm.prompts[0]["user_prompt"]
+ second_prompt = fake_llm.prompts[1]["user_prompt"]
+ assert "Large protocol markdown" in first_prompt
+ assert "Large protocol markdown" not in second_prompt
+ assert "Summarize this." in second_prompt
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+cd backend && python -m pytest tests/test_chat_memory_service.py -v
+```
+
+Expected: FAIL with `ModuleNotFoundError: No module named 'services.chat_memory'`.
+
+---
+
+### Task 3: Implement ChatMemoryService
+
+**Files:**
+- Create: `backend/services/chat_memory/__init__.py`
+- Create: `backend/services/chat_memory/chat_memory_service.py`
+- Test: `backend/tests/test_chat_memory_service.py`
+
+- [ ] **Step 1: Create package export**
+
+Create `backend/services/chat_memory/__init__.py`:
+
+```python
+from .chat_memory_service import ChatMemoryRequest, ChatMemoryService
+
+__all__ = ["ChatMemoryRequest", "ChatMemoryService"]
+```
+
+- [ ] **Step 2: Create the service implementation**
+
+Create `backend/services/chat_memory/chat_memory_service.py`:
+
+```python
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional, TypedDict
+
+from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
+from langgraph.checkpoint.memory import InMemorySaver
+from langgraph.graph import END, START, StateGraph
+
+from services.llm.llm_service import LLMService
+
+
+@dataclass
+class ChatMemoryRequest:
+ chat_session_id: str
+ query: str
+ model_type: str
+ model_id: Optional[str] = None
+ deployment: Optional[str] = None
+ api_version: Optional[str] = None
+ document_context: Optional[str] = None
+ attached_session_id: Optional[str] = None
+
+
+class ChatState(TypedDict):
+ messages: List[BaseMessage]
+ query: str
+ model_type: str
+ model_id: Optional[str]
+ deployment: Optional[str]
+ api_version: Optional[str]
+ document_context: Optional[str]
+ attached_session_id: Optional[str]
+
+
+class ChatMemoryService:
+ def __init__(
+ self,
+ llm_service: Optional[LLMService] = None,
+ checkpointer: Optional[Any] = None,
+ use_memory_checkpointer: bool = False,
+ ):
+ self.llm_service = llm_service or LLMService()
+ self.checkpointer = checkpointer or self._build_checkpointer(use_memory_checkpointer)
+ self.graph = self._build_graph()
+
+ def _build_checkpointer(self, use_memory_checkpointer: bool):
+ if use_memory_checkpointer:
+ return InMemorySaver()
+
+ try:
+ from langgraph.checkpoint.postgres import PostgresSaver
+ from models.base import DATABASE_URL
+
+ saver = PostgresSaver.from_conn_string(DATABASE_URL)
+ saver.setup()
+ return saver
+ except Exception as exc:
+ print(f"[CHAT_MEMORY] Falling back to in-memory checkpointer: {exc}")
+ return InMemorySaver()
+
+ def _build_graph(self):
+ graph = StateGraph(ChatState)
+ graph.add_node("generate", self._generate_response)
+ graph.add_edge(START, "generate")
+ graph.add_edge("generate", END)
+ return graph.compile(checkpointer=self.checkpointer)
+
+ async def invoke(self, request: ChatMemoryRequest) -> Dict[str, Any]:
+ state: ChatState = {
+ "messages": [HumanMessage(content=request.query)],
+ "query": request.query,
+ "model_type": request.model_type,
+ "model_id": request.model_id,
+ "deployment": request.deployment,
+ "api_version": request.api_version,
+ "document_context": request.document_context,
+ "attached_session_id": request.attached_session_id,
+ }
+ config = {"configurable": {"thread_id": request.chat_session_id}}
+ result = await self.graph.ainvoke(state, config=config)
+ messages = result.get("messages", [])
+ response = ""
+ for message in reversed(messages):
+ if isinstance(message, AIMessage):
+ response = str(message.content)
+ break
+ return {
+ "success": True,
+ "response": response,
+ "chat_session_id": request.chat_session_id,
+ "attached_session_id": request.attached_session_id,
+ }
+
+ async def _generate_response(self, state: ChatState) -> Dict[str, Any]:
+ prompt = self._build_user_prompt(
+ messages=state.get("messages", []),
+ current_query=state["query"],
+ document_context=state.get("document_context"),
+ )
+ system_message = self._build_system_message(bool(state.get("document_context")))
+ result = await self.llm_service.generate_paragraph(
+ user_prompt=prompt,
+ model_type=state["model_type"],
+ model_id=state.get("model_id"),
+ deployment=state.get("deployment"),
+ api_version=state.get("api_version"),
+ max_tokens=4096,
+ temperature=0.3,
+ system_message=system_message,
+ )
+ if not result.get("success"):
+ raise RuntimeError(result.get("error", "The model call failed. Please try again."))
+ return {"messages": [AIMessage(content=result.get("content", ""))]}
+
+ def _build_user_prompt(
+ self,
+ messages: List[BaseMessage],
+ current_query: str,
+ document_context: Optional[str],
+ ) -> str:
+ history_lines = []
+ for message in messages[:-1]:
+ if isinstance(message, HumanMessage):
+ history_lines.append(f"User: {message.content}")
+ elif isinstance(message, AIMessage):
+ history_lines.append(f"Assistant: {message.content}")
+
+ sections = []
+ if history_lines:
+ sections.append("Conversation so far:\n" + "\n".join(history_lines))
+ if document_context:
+ sections.append(
+ "Current document context for this request:\n"
+ f"{document_context}\n\n"
+ "Use this context when it is relevant. Do not assume it remains attached in future turns unless it is provided again."
+ )
+ sections.append(f"User question: {current_query}")
+ return "\n\n".join(sections)
+
+ def _build_system_message(self, has_document_context: bool) -> str:
+ if has_document_context:
+ return (
+ "You are a helpful document assistant for Health Canada support staff. "
+ "Answer the user's question using the current document context and the remembered conversation. "
+ "If the answer is not found in the document context, say so clearly and offer general guidance if possible."
+ )
+ return (
+ "You are a helpful assistant for Health Canada support staff. "
+ "Answer questions clearly and concisely using the remembered conversation when relevant."
+ )
+```
+
+- [ ] **Step 3: Run service tests**
+
+Run:
+
+```bash
+cd backend && python -m pytest tests/test_chat_memory_service.py -v
+```
+
+Expected: PASS for all three tests.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add backend/services/chat_memory/__init__.py backend/services/chat_memory/chat_memory_service.py backend/tests/test_chat_memory_service.py
+git commit -m "Add LangGraph chat memory service"
+```
+
+---
+
+### Task 4: Add document-session context loader tests
+
+**Files:**
+- Create: `backend/services/chat_memory/document_context.py`
+- Modify: `backend/tests/test_chat_memory_service.py`
+
+- [ ] **Step 1: Add tests for attached session context**
+
+Append to `backend/tests/test_chat_memory_service.py`:
+
+```python
+from services.chat_memory.document_context import build_attached_session_context
+
+
+class FakeDocument:
+ def __init__(self, file_hash, filename, processor_used="azure_doc_intelligence"):
+ self.file_hash = file_hash
+ self.filename = filename
+ self.processor_used = processor_used
+
+
+class FakeSession:
+ def __init__(self):
+ self.documents = [
+ FakeDocument("hash-1", "protocol.pdf"),
+ FakeDocument("hash-2", "consent.pdf"),
+ ]
+
+
+class FakeSessionService:
+ def get_session(self, user_id, session_id):
+ if session_id == "missing-session":
+ return None
+ return FakeSession()
+
+
+class FakeFileService:
+ async def get_processed_content(self, file_hash, processor):
+ return f"markdown for {file_hash} via {processor}"
+
+
+@pytest.mark.asyncio
+async def test_build_attached_session_context_loads_session_documents():
+ context = await build_attached_session_context(
+ user_id="user-1",
+ attached_session_id="session-1",
+ session_service=FakeSessionService(),
+ file_service=FakeFileService(),
+ )
+
+ assert '' in context
+ assert "markdown for hash-1 via azure_doc_intelligence" in context
+ assert '' in context
+
+
+@pytest.mark.asyncio
+async def test_build_attached_session_context_returns_none_for_missing_session():
+ context = await build_attached_session_context(
+ user_id="user-1",
+ attached_session_id="missing-session",
+ session_service=FakeSessionService(),
+ file_service=FakeFileService(),
+ )
+
+ assert context is None
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+cd backend && python -m pytest tests/test_chat_memory_service.py -v
+```
+
+Expected: FAIL with `ModuleNotFoundError` or missing `build_attached_session_context`.
+
+---
+
+### Task 5: Implement attached session context loading
+
+**Files:**
+- Create: `backend/services/chat_memory/document_context.py`
+- Test: `backend/tests/test_chat_memory_service.py`
+
+- [ ] **Step 1: Create context loader**
+
+Create `backend/services/chat_memory/document_context.py`:
+
+```python
+from typing import Optional
+
+from services.document.organized_file_service import get_organized_file_service
+from services.session.session_service import get_session_service
+
+
+async def build_attached_session_context(
+ user_id: str,
+ attached_session_id: Optional[str],
+ session_service=None,
+ file_service=None,
+) -> Optional[str]:
+ if not attached_session_id:
+ return None
+
+ session_service = session_service or get_session_service()
+ file_service = file_service or get_organized_file_service()
+ session = session_service.get_session(user_id, attached_session_id)
+ if session is None:
+ return None
+
+ document_blocks = []
+ for doc in session.documents:
+ processor_used = doc.processor_used or "azure_doc_intelligence"
+ markdown = await file_service.get_processed_content(doc.file_hash, processor_used)
+ if not markdown:
+ continue
+ document_blocks.append(
+ f'\n'
+ f"{markdown}\n"
+ ""
+ )
+
+ return "\n\n".join(document_blocks) if document_blocks else None
+```
+
+- [ ] **Step 2: Run tests**
+
+Run:
+
+```bash
+cd backend && python -m pytest tests/test_chat_memory_service.py -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/services/chat_memory/document_context.py backend/tests/test_chat_memory_service.py
+git commit -m "Load attached session context for chat"
+```
+
+---
+
+### Task 6: Write failing chat router tests
+
+**Files:**
+- Create: `backend/tests/test_chat_router.py`
+
+- [ ] **Step 1: Create route tests**
+
+Create `backend/tests/test_chat_router.py`:
+
+```python
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from api.chat import router as chat_router
+from api.chat.router import ChatQueryRequest, chat_query
+
+
+class FakeChatMemoryService:
+ def __init__(self):
+ self.requests = []
+
+ async def invoke(self, request):
+ self.requests.append(request)
+ return {
+ "success": True,
+ "response": "remembered response",
+ "chat_session_id": request.chat_session_id,
+ "attached_session_id": request.attached_session_id,
+ }
+
+
+@pytest.mark.asyncio
+async def test_chat_query_delegates_to_memory_service(monkeypatch):
+ fake_service = FakeChatMemoryService()
+ monkeypatch.setattr(chat_router, "chat_memory_service", fake_service)
+ monkeypatch.setattr(chat_router, "build_attached_session_context", lambda **kwargs: None)
+
+ response = await chat_query(
+ ChatQueryRequest(
+ chat_session_id="chat-1",
+ query="hello",
+ model_type="azure",
+ ),
+ current_user={"id": "user-1"},
+ )
+
+ assert response["success"] is True
+ assert response["response"] == "remembered response"
+ assert fake_service.requests[0].chat_session_id == "chat-1"
+ assert fake_service.requests[0].query == "hello"
+
+
+@pytest.mark.asyncio
+async def test_chat_query_uses_attached_session_context(monkeypatch):
+ fake_service = FakeChatMemoryService()
+
+ async def fake_context(**kwargs):
+ return 'protocol markdown'
+
+ monkeypatch.setattr(chat_router, "chat_memory_service", fake_service)
+ monkeypatch.setattr(chat_router, "build_attached_session_context", fake_context)
+
+ await chat_query(
+ ChatQueryRequest(
+ chat_session_id="chat-1",
+ attached_session_id="session-1",
+ query="summarize",
+ model_type="azure",
+ ),
+ current_user={"id": "user-1"},
+ )
+
+ assert fake_service.requests[0].attached_session_id == "session-1"
+ assert "protocol markdown" in fake_service.requests[0].document_context
+```
+
+- [ ] **Step 2: Run route tests to verify they fail**
+
+Run:
+
+```bash
+cd backend && python -m pytest tests/test_chat_router.py -v
+```
+
+Expected: FAIL because `ChatQueryRequest` does not accept `chat_session_id` and `chat_query` does not accept `current_user` directly.
+
+---
+
+### Task 7: Update chat route to use ChatMemoryService
+
+**Files:**
+- Modify: `backend/api/chat/router.py:3-73`
+- Test: `backend/tests/test_chat_router.py`
+
+- [ ] **Step 1: Replace route implementation**
+
+Update `backend/api/chat/router.py` to:
+
+```python
+"""Chat API endpoint for support staff chatbot"""
+
+from typing import Optional
+
+from fastapi import APIRouter, Depends
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, Field
+
+from core.auth import get_current_user
+from services.chat_memory import ChatMemoryRequest, ChatMemoryService
+from services.chat_memory.document_context import build_attached_session_context
+
+router = APIRouter(prefix="/api/chat", tags=["chat"])
+chat_memory_service = ChatMemoryService()
+
+
+class ChatQueryRequest(BaseModel):
+ chat_session_id: str = Field(min_length=1)
+ query: str = Field(min_length=1)
+ document_markdown: Optional[str] = None
+ attached_session_id: Optional[str] = None
+ model_type: str
+ model_id: Optional[str] = None
+ deployment: Optional[str] = None
+ api_version: Optional[str] = None
+
+
+@router.post("/query", dependencies=[Depends(get_current_user)])
+async def chat_query(
+ request: ChatQueryRequest,
+ current_user: dict = Depends(get_current_user),
+):
+ document_context = request.document_markdown
+ if request.attached_session_id:
+ attached_context = await build_attached_session_context(
+ user_id=current_user["id"],
+ attached_session_id=request.attached_session_id,
+ )
+ if attached_context is None:
+ return JSONResponse(
+ status_code=404,
+ content={
+ "success": False,
+ "error": "Attached session not found or has no processed document content.",
+ },
+ )
+ document_context = attached_context
+
+ try:
+ return await chat_memory_service.invoke(
+ ChatMemoryRequest(
+ chat_session_id=request.chat_session_id,
+ attached_session_id=request.attached_session_id,
+ query=request.query,
+ document_context=document_context,
+ model_type=request.model_type,
+ model_id=request.model_id,
+ deployment=request.deployment,
+ api_version=request.api_version,
+ )
+ )
+ except Exception as exc:
+ return JSONResponse(
+ status_code=500,
+ content={
+ "success": False,
+ "error": str(exc) or "The model call failed. Please try again.",
+ },
+ )
+```
+
+- [ ] **Step 2: Run route tests**
+
+Run:
+
+```bash
+cd backend && python -m pytest tests/test_chat_router.py -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Run all chat memory backend tests**
+
+Run:
+
+```bash
+cd backend && python -m pytest tests/test_chat_memory_service.py tests/test_chat_router.py -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add backend/api/chat/router.py backend/tests/test_chat_router.py
+git commit -m "Route chat requests through memory service"
+```
+
+---
+
+### Task 8: Add frontend chat session utility
+
+**Files:**
+- Create: `frontend/utils/chatSession.ts`
+
+- [ ] **Step 1: Create utility**
+
+Create `frontend/utils/chatSession.ts`:
+
+```ts
+const CHAT_SESSION_STORAGE_KEY = "science-gpt.chat_session_id";
+
+export function createChatSessionId(): string {
+ return crypto.randomUUID();
+}
+
+export function getOrCreateChatSessionId(): string {
+ const existing = sessionStorage.getItem(CHAT_SESSION_STORAGE_KEY);
+ if (existing) return existing;
+ const next = createChatSessionId();
+ sessionStorage.setItem(CHAT_SESSION_STORAGE_KEY, next);
+ return next;
+}
+
+export function resetChatSessionId(): string {
+ const next = createChatSessionId();
+ sessionStorage.setItem(CHAT_SESSION_STORAGE_KEY, next);
+ return next;
+}
+```
+
+- [ ] **Step 2: Run frontend build**
+
+Run:
+
+```bash
+cd frontend && npm run build
+```
+
+Expected: TypeScript compilation succeeds.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add frontend/utils/chatSession.ts
+git commit -m "Add frontend chat session id utility"
+```
+
+---
+
+### Task 9: Send chat_session_id from ChatPage
+
+**Files:**
+- Modify: `frontend/components/ChatPage.tsx:1-619`
+
+- [ ] **Step 1: Add imports**
+
+In `frontend/components/ChatPage.tsx`, add this import near the existing utility imports:
+
+```ts
+import {
+ getOrCreateChatSessionId,
+ resetChatSessionId,
+} from "../utils/chatSession";
+```
+
+- [ ] **Step 2: Add chat session state**
+
+Inside `ChatPage`, after the existing `ratings` state, add:
+
+```ts
+const [chatSessionId, setChatSessionId] = useState(() =>
+ getOrCreateChatSessionId()
+);
+```
+
+- [ ] **Step 3: Include chat_session_id in request body**
+
+In the `/api/chat/query` request body, add `chat_session_id` before `query`:
+
+```ts
+body: JSON.stringify({
+ chat_session_id: chatSessionId,
+ query,
+ document_markdown: documentMarkdown,
+ model_type: modelConfig.modelType,
+ model_id: modelConfig.modelId,
+ deployment: modelConfig.deployment ?? null,
+ api_version: modelConfig.apiVersion ?? null,
+}),
+```
+
+- [ ] **Step 4: Update sendQuery dependencies**
+
+Change the `sendQuery` dependency array to include `chatSessionId`:
+
+```ts
+[chatSessionId, docs, getModelConfig]
+```
+
+- [ ] **Step 5: Add a New Chat handler**
+
+Below `handleRegenerate`, add:
+
+```ts
+const handleNewChat = useCallback(() => {
+ const nextSessionId = resetChatSessionId();
+ setChatSessionId(nextSessionId);
+ setMessages([]);
+ setRatings({});
+ setContextError(false);
+}, []);
+```
+
+- [ ] **Step 6: Add a New Chat button**
+
+In the header right actions before `Simplified Mode`, add:
+
+```tsx
+
+```
+
+- [ ] **Step 7: Run frontend build**
+
+Run:
+
+```bash
+cd frontend && npm run build
+```
+
+Expected: TypeScript compilation succeeds.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add frontend/components/ChatPage.tsx
+git commit -m "Send independent chat session id"
+```
+
+---
+
+### Task 10: Add optional history-session attachment UI
+
+**Files:**
+- Modify: `frontend/components/ChatPage.tsx:1-740`
+- Reuse: `frontend/types/session.ts`
+
+- [ ] **Step 1: Add imports**
+
+Add these imports to `frontend/components/ChatPage.tsx`:
+
+```ts
+import { authenticatedFetch } from "../utils/authUtils";
+import { SessionSummary } from "../types/session";
+```
+
+Keep the existing `getValidToken` import because upload/document calls still use it.
+
+- [ ] **Step 2: Add attachment state**
+
+Inside `ChatPage`, near document state, add:
+
+```ts
+const [historySessions, setHistorySessions] = useState([]);
+const [sessionsLoading, setSessionsLoading] = useState(false);
+const [attachedSessionId, setAttachedSessionId] = useState(null);
+```
+
+- [ ] **Step 3: Fetch sessions for the selector**
+
+Add this effect below the model-loading effect:
+
+```ts
+useEffect(() => {
+ let cancelled = false;
+
+ async function fetchSessions() {
+ try {
+ setSessionsLoading(true);
+ const response = await authenticatedFetch("/api/sessions");
+ if (!response.ok) throw new Error("Failed to fetch sessions");
+ const data = await response.json();
+ if (!cancelled) setHistorySessions(data.sessions ?? []);
+ } catch {
+ if (!cancelled) setHistorySessions([]);
+ } finally {
+ if (!cancelled) setSessionsLoading(false);
+ }
+ }
+
+ fetchSessions();
+ return () => {
+ cancelled = true;
+ };
+}, []);
+```
+
+- [ ] **Step 4: Send attached_session_id in request body**
+
+Add `attached_session_id` to the chat request body:
+
+```ts
+body: JSON.stringify({
+ chat_session_id: chatSessionId,
+ attached_session_id: attachedSessionId,
+ query,
+ document_markdown: documentMarkdown,
+ model_type: modelConfig.modelType,
+ model_id: modelConfig.modelId,
+ deployment: modelConfig.deployment ?? null,
+ api_version: modelConfig.apiVersion ?? null,
+}),
+```
+
+- [ ] **Step 5: Update sendQuery dependencies**
+
+Change the `sendQuery` dependency array to:
+
+```ts
+[attachedSessionId, chatSessionId, docs, getModelConfig]
+```
+
+- [ ] **Step 6: Add a session selector in the header**
+
+In the header left area, after the model selector ``, add:
+
+```tsx
+
+```
+
+- [ ] **Step 7: Clear attachment when starting a new chat**
+
+Update `handleNewChat`:
+
+```ts
+const handleNewChat = useCallback(() => {
+ const nextSessionId = resetChatSessionId();
+ setChatSessionId(nextSessionId);
+ setMessages([]);
+ setRatings({});
+ setContextError(false);
+ setAttachedSessionId(null);
+}, []);
+```
+
+- [ ] **Step 8: Run frontend build**
+
+Run:
+
+```bash
+cd frontend && npm run build
+```
+
+Expected: TypeScript compilation succeeds.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add frontend/components/ChatPage.tsx
+git commit -m "Attach history sessions to chat requests"
+```
+
+---
+
+### Task 11: Verify backend and frontend together
+
+**Files:**
+- Verify only; no expected file changes unless a prior task fails.
+
+- [ ] **Step 1: Run backend chat tests**
+
+Run:
+
+```bash
+cd backend && python -m pytest tests/test_chat_memory_service.py tests/test_chat_router.py -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 2: Run existing backend smoke test that does not require DB**
+
+Run:
+
+```bash
+cd backend && python -m pytest tests/test_auth_migration.py::test_models_load -v
+```
+
+Expected: PASS and includes existing app tables.
+
+- [ ] **Step 3: Run frontend build**
+
+Run:
+
+```bash
+cd frontend && npm run build
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Run frontend lint if the repo already passes lint on this branch**
+
+Run:
+
+```bash
+cd frontend && npm run lint
+```
+
+Expected: PASS. If lint fails on pre-existing unrelated files, capture the failure and do not fix unrelated lint issues.
+
+- [ ] **Step 5: Manual browser validation**
+
+Run the app locally with the usual backend and frontend dev commands, then verify:
+
+1. Open Chat.
+2. Send “Remember that my topic is licensing.”
+3. Send “What is my topic?” and confirm the assistant remembers licensing.
+4. Click New Chat.
+5. Send “What is my topic?” and confirm the prior chat memory does not leak.
+6. Attach a history session from the selector.
+7. Ask a document-specific question and confirm the assistant uses attached document content.
+8. Start another New Chat, attach the same history session, and confirm the document is available but prior conversation is not.
+
+- [ ] **Step 6: Final commit if verification required fixes**
+
+If verification required fixes, commit only those files:
+
+```bash
+git add
+git commit -m "Fix chat memory verification issues"
+```
+
+---
+
+## Self-Review
+
+Spec coverage:
+
+- Independent `chat_session_id` memory: Tasks 2, 3, 8, 9, and 11.
+- LangGraph checkpointing: Tasks 1 and 3.
+- Optional `attached_session_id`: Tasks 4, 5, 7, 10, and 11.
+- No full document markdown in persistent memory: Tasks 2, 3, and 5.
+- Existing history/session source of truth: Tasks 4, 5, 7, and 10.
+- Testing and manual validation: Tasks 2, 4, 6, 11.
+
+Placeholder scan: no `TBD`, `TODO`, “implement later”, or unspecified code steps remain.
+
+Type consistency:
+
+- Backend request field is `chat_session_id` everywhere.
+- Optional attachment field is `attached_session_id` everywhere.
+- Frontend utility names are `getOrCreateChatSessionId` and `resetChatSessionId` everywhere.
+- LangGraph thread config uses `thread_id = chat_session_id`.
diff --git a/frontend/components/ChatPage.tsx b/frontend/components/ChatPage.tsx
index 1245211..62e7c3c 100644
--- a/frontend/components/ChatPage.tsx
+++ b/frontend/components/ChatPage.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect, useCallback } from "react";
+import { useState, useRef, useEffect, useCallback, ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
@@ -30,7 +30,13 @@ import {
pickBestFromList,
ModelConfig,
} from "../utils/modelSelection";
-import { getValidToken } from "../utils/authUtils";
+import { authenticatedFetch, getValidToken } from "../utils/authUtils";
+import {
+ createChatSessionId,
+ getOrCreateChatSessionId,
+ resetChatSessionId,
+} from "../utils/chatSession";
+import { SessionSummary } from "../types/session";
import { toast } from "./ui/sonner";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -42,6 +48,12 @@ interface Message {
}
const MAX_DOCS = 5;
+const HISTORY_DISABLED_WITH_DOCS_TITLE =
+ "Start a new chat or remove uploaded documents before attaching history.";
+const DOC_UPLOAD_DISABLED_WITH_HISTORY_TITLE =
+ "History sessions use saved context only. Start a new chat to upload documents.";
+const REGENERATE_DISABLED_WITH_MEMORY_TITLE =
+ "Regenerate is unavailable for chat memory sessions until turn replacement is supported.";
type DocEntry =
| { status: "loading"; file: File; tempId: string }
@@ -61,6 +73,25 @@ interface ChatPageProps {
onSignOut?: () => void;
}
+interface MarkdownCodeProps {
+ inline?: boolean;
+ children?: ReactNode;
+}
+
+interface ChatModelConfig extends ModelConfig {
+ model_type?: string;
+}
+
+const getHistorySessionLabel = (session: SessionSummary): string => {
+ const trimmedName = session.name?.trim();
+ if (trimmedName) return trimmedName;
+
+ const firstDocumentName = session.document_names[0]?.trim();
+ if (firstDocumentName) return firstDocumentName;
+
+ return "Untitled Session";
+};
+
// ─── Small sub-components ─────────────────────────────────────────────────────
function AvatarAI() {
@@ -78,6 +109,8 @@ interface MessageRowProps {
rating: "up" | "down" | null;
onRate: (id: string, rating: "up" | "down") => void;
onCopy: (content: string) => void;
+ canRegenerate: boolean;
+ regenerateTitle: string;
onRegenerate: (id: string) => void;
isRegenerating: boolean;
}
@@ -87,6 +120,8 @@ function MessageRow({
rating,
onRate,
onCopy,
+ canRegenerate,
+ regenerateTitle,
onRegenerate,
isRegenerating,
}: MessageRowProps) {
@@ -158,7 +193,7 @@ function MessageRow({
li: ({ children }) => (
{children}
),
- code: ({ inline, children }: any) =>
+ code: ({ inline, children }: MarkdownCodeProps) =>
inline ? (
{children}
@@ -240,9 +275,9 @@ function MessageRow({
/>
onRegenerate(message.id)}
- disabled={isRegenerating}
+ disabled={!canRegenerate || isRegenerating}
>
>({});
+ const [chatSessionId, setChatSessionId] = useState(() =>
+ getOrCreateChatSessionId()
+ );
const [availableModels, setAvailableModels] = useState([]);
const [selectedModelId, setSelectedModelId] = useState("");
const [modelsLoading, setModelsLoading] = useState(true);
const [docs, setDocs] = useState
- {attachedSessionId && (
-
- Uploaded documents are disabled while a history session is
- attached.
-
- )}
)}
@@ -1058,12 +961,8 @@ export function ChatPage({ onSwitchToWorkflow, onSignOut }: ChatPageProps) {
/>