Skip to content

Latest commit

 

History

History
393 lines (304 loc) · 11.1 KB

File metadata and controls

393 lines (304 loc) · 11.1 KB

Song Recommender with LangGraph Memory

Demonstration of LangGraph memory persistence using conversational AI to recommend music based on user preferences. This project showcases how to build stateful, multi-turn conversations where the AI remembers context across messages.

🎯 Goals

This project exemplifies:

  • Memory Persistence: Using LangGraph's MemorySaver for conversation history
  • Thread-based Sessions: Separate conversation contexts per user/session
  • Conversational AI: Natural dialogue that asks questions and builds context
  • Minimal Architecture: Simple single-node graph focusing on memory
  • Real LLM Integration: Live testing with actual OpenRouter API calls

Features

  • 🎵 Smart Recommendations: AI suggests songs based on learned preferences
  • 💬 Conversational Memory: Remembers user name, favorite bands, genres, etc.
  • 🔄 Thread Isolation: Different users/sessions maintain separate memories
  • 📝 Dynamic Learning: Updates understanding as conversation progresses
  • 🧪 Integration Tests: Real API tests verify memory persistence

Architecture

LangGraph Workflow

https://docs.langchain.com/oss/javascript/integrations/vectorstores/libsql https://docs.langchain.com/oss/javascript/langgraph/persistence#memory-store

START → chat (with memory) → END
         ↓
   Checkpointer persists state
   across invocations

Project Structure

src/
  ├── config.ts                     # Configuration with memory settings
  ├── index.ts                      # Interactive CLI chat interface
  ├── graph/
  │   ├── graph.ts                  # Simple graph with one chat node
  │   └── factory.ts                # Graph builder with memory service
  ├── services/
  │   ├── memory-service.ts         # MemorySaver initialization
  │   └── openrouter-service.ts     # LLM client with chat method
  └── utils/
      └── prompt-loader.ts          # Template loading
prompts/
  └── system.txt                    # System prompt for song recommender
tests/
  └── chat-memory.test.ts           # Integration tests with real LLM

How Memory Works

This project demonstrates two types of memory:

1. Conversation Memory (MemorySaver)

  • Thread ID: Each conversation has a unique thread_id for isolation
  • In-Memory Storage: Conversation history stored in memory during the session
  • Automatic Replay: Previous messages included when invoking with same thread_id

2. User Preferences (LibSQL Vector Store)

  • Vector Embeddings: User preferences stored as embedded documents using OpenAI embeddings
  • Semantic Search: Uses @libsql/client with vector similarity for intelligent matching
  • Persistent Storage: Preferences persisted to SQLite database across restarts
  • Document Structure: Each preference stored with rich metadata (name, bands, genres, age, mood)

Why Vector Store for Preferences?

  • Better semantic understanding of user taste
  • Can find similar music preferences across users
  • Enables more intelligent recommendations based on embedded meaning
  • Scalable approach for large user bases

This demonstrates production-ready patterns:

  • Conversation state for short-term context
  • Vector store for long-term, searchable user data
  • Hybrid memory approach for AI applications
// First message
await graph.invoke(
  { messages: [new HumanMessage("Hi! I'm Alex")] },
  { configurable: { thread_id: "user-123" } }
);

// Second message - AI remembers "Alex" from previous message
await graph.invoke(
  { messages: [new HumanMessage("Recommend some songs")] },
  { configurable: { thread_id: "user-123" } }
);

Setup

  1. Install dependencies:

    npm install
  2. Configure environment (.env):

    OPENROUTER_API_KEY=your_openrouter_key_here
    OPENROUTER_HTTP_REFERER=http://localhost:3000
    OPENROUTER_X_TITLE=Song Recommender
    OPENAI_API_KEY=your_openai_key_here  # For embeddings
  3. Run the chat interface:

    npm run chat
  4. Run tests:

    npm test

Example Conversation

You: Hi!
AI: Hello there! It's great to see you! What's your name, and what kind of music do you enjoy?

You: My name is Alex and I love rock music, especially Foo Fighters.
AI: Nice to meet you, Alex! Foo Fighters are amazing! Do you have any other favorite bands or specific rock subgenres you enjoy?

You: Can you recommend some songs for me?
AI: Absolutely, Alex! Since you love Foo Fighters, you might enjoy "Everlong" for its powerful energy...

Key Learnings

  • Stateful Conversations: Memory enables natural multi-turn dialogues
  • Thread Management: Separate threads isolate different users/sessions
  • Simple Architecture: One node + memory checkpointer is all you need
  • Testing Strategy: Integration tests with real LLM verify behavior

Next Steps

  • Add SQLite/PostgreSQL persistence for production use
  • Implement conversation summarization for long histories
  • Add structured data extraction to store preferences explicitly
  • Build web interface with session management └── v1/ ├── plan.json # Outline generation prompt ├── draft.json # Section writing prompt └── review.json # Quality review prompt tests/ └── article-generator.test.ts # Real API integration test
  │   ├── graph.ts          # StateGraph definition with co-located types
  │   ├── factory.ts        # Graph creation factory
  │   └── nodes/            # LangGraph nodes (workflow steps)
  │       ├── outline.node.ts    # Generate article structure + parsing
  │       ├── research.node.ts   # Research sections in parallel
  │       ├── write.node.ts      # Write sections + assembly
  │       └── review.node.ts     # Polish final article
  ├── services/
  │   └── openrouter-service.ts  # OpenRouter SDK wrapper (implements LLMClient)
  └── utils/
      └── prompt-loader.ts  # Load prompts from template files

prompts/                    # Prompt templates with variables
  ├── outline.txt           # Section structure generation
  ├── research.txt          # Research individual sections
  ├── write-section.txt     # Write section content
  └── review.txt            # Review and improve

tests/
  └── article-generator.test.ts  # Graph workflow tests

Installation

npm install

Configuration

Create .env file:

# OpenRouter Configuration (required)
OPENROUTER_API_KEY=sk-or-v1-...
OPENROUTER_MODEL=anthropic/claude-3.5-sonnet
OPENROUTER_HTTP_REFERER=https://your-site.com
OPENROUTER_X_TITLE=Article Generator

# Model Configuration
MODEL_TIMEOUT=60000
MODEL_MAX_RETRIES=3

# Article Configuration
MIN_SECTIONS=3
MAX_SECTIONS=8
TARGET_WORDS_PER_SECTION=200

# Logging
LOG_LEVEL=info

Usage

Generate Article

# Using topic flag
npm run generate -- --topic "Test-Driven Development in TypeScript"

# With custom output path
npm run generate -- --topic "Docker Best Practices" --output my-article.md

Run Tests

npm test

How It Works

1. Outline Node

Generates article structure:

  • Title
  • Introduction
  • Sections with key points
  • Conclusion

State Updates: outline, currentStep

2. Research Node

Researches all sections in parallel:

const researchPromises = sections.map(section =>
  llmClient.generate(researchPrompt)
);
const results = await Promise.all(researchPromises);

State Updates: researchResults, currentStep

3. Write Sections Node

Writes each section sequentially using research:

  • Loops through sections
  • Uses section research + key points
  • Calculates word count
  • Builds draft article

State Updates: sections, draftArticle, totalWords, currentStep

4. Review Node

Reviews and improves final article:

  • Checks tone and style
  • Improves transitions
  • Ensures consistency
  • Polishes language

State Updates: finalArticle, currentStep

LangGraph Concepts

StateGraph

Defines the workflow with typed state:

const ArticleStateAnnotation = Annotation.Root({
  topic: Annotation<string>,
  outline: Annotation<any>,
  researchResults: Annotation<string[]>,
  sections: Annotation<any[]>,
  draftArticle: Annotation<string>,
  finalArticle: Annotation<string>,
  totalWords: Annotation<number>,
  currentStep: Annotation<string>,
});

Node Functions

Each node receives state and returns partial state updates:

export const createOutlineNode = (llmClient: LLMClient) => {
  return async (state: GraphState): Promise<Partial<GraphState>> => {
    const outline = await generateOutline(state.topic);
    return {
      outline,
      currentStep: 'outline_completed',
    };
  };
};

Graph Construction

const workflow = new StateGraph({ stateSchema: ArticleStateAnnotation })
  .addNode('generateOutline', outlineNode)
  .addNode('conductResearch', researchNode)
  .addNode('writeSections', writeSectionsNode)
  .addNode('reviewArticle', reviewNode)
  .addEdge(START, 'generateOutline')
  .addEdge('generateOutline', 'conductResearch')
  .addEdge('conductResearch', 'writeSections')
  .addEdge('writeSections', 'reviewArticle')
  .addEdge('reviewArticle', END);

return workflow.compile();

Testing Strategy

Uses MockLLMClient with deterministic responses:

class MockLLMClient implements LLMClient {
  responses: Map<string, string>;

  async generate(prompt: string): Promise<string> {
    if (prompt.includes('outline')) return mockOutline;
    if (prompt.includes('Research')) return mockResearch;
    if (prompt.includes('Write')) return mockSection;
    if (prompt.includes('Review')) return mockReview;
  }
}

Tests verify:

  • ✅ Complete article generation through graph
  • ✅ Multiple LLM calls in chain
  • ✅ Correct state flow through all nodes
  • ✅ Word count calculation

Key Patterns

Single Responsibility Principle

  • Nodes: One transformation per node
  • Services: LLM interactions only
  • Utils: Reusable helpers (prompt loading)
  • Config: Environment management

Dependency Injection

Nodes receive dependencies as parameters:

createOutlineNode(llmClient: LLMClient, config: ArticleConfig)

Immutable State

Nodes return new state objects, never mutate:

return {
  ...state,
  outline: newOutline,
};

Prompt Templates

Prompts stored in files, not code:

const prompt = await PromptLoader.load('outline', {
  topic: state.topic,
  minSections: config.minSections,
  maxSections: config.maxSections,
});

Learning Objectives

  1. Prompt Chaining: Build complex outputs from simple steps
  2. LangGraph: State management in LLM workflows
  3. Parallel Execution: Research sections concurrently
  4. Sequential Processing: Write sections in order
  5. State Transitions: Track progress through workflow
  6. Testing: Mock LLMs for deterministic tests

Node Version

Requires Node.js >= 22.0.0 for TypeScript strip-types support.

License

MIT