Persisting Responses

To complete this challenge, you must write the functions to save and retrieve conversation history.

In modules/agent/history.ts, you must:

  1. Modify the saveHistory() to save the history to the database

  2. Modify the getHistory() to retrieve the correct information from the database

Open history.ts in an Online IDE →

Connecting to Neo4j

The Neo4jGraph object has a .query() method that you can use to send both read and write workloads to Neo4j.

The function expects two parameters: the Cypher statement (a string), and an object containing parameters in key/value format. To reference the value from a parameter in a Cypher statement, prefix the key with a $.

The functions accept a generic that defines the shape of the results returned by the statement.

You can specify whether the Cypher statement executes in a read or write transaction by specifying "READ" or "WRITE" as the third parameter.

To learn how to integrate Neo4j into a TypeScript project, check out the Building Neo4j Applications with TypeScript course.

View graph.ts
typescript
// tag::import[]
import { Neo4jGraph } from "@langchain/community/graphs/neo4j_graph";
// end::import[]

// tag::graph[]
// <1> The singleton instance
let graph: Neo4jGraph;

/**
 * Return the existing `graph` object or create one
 * has not already been created
 *
 * @returns {Promise<Neo4jGraph>}
 */
export async function initGraph(): Promise<Neo4jGraph> {
  // tag::create[]
  if (!graph) {
    // Create singleton and wait for connection to be verified
    graph = await Neo4jGraph.initialize({
      url: process.env.NEO4J_URI as string,
      username: process.env.NEO4J_USERNAME as string,
      password: process.env.NEO4J_PASSWORD as string,
      database: process.env.NEO4J_DATABASE as string | undefined,
    });
  }
  // end::create[]

  // tag::return[]
  return graph;
  // end::return[]
}
// end::graph[]

Saving History

To save the history, modify the saveHistory() function.

typescript
saveHistory() Signature
/**
 * Save a question and response to the database
 *
 * @param {string} sessionId
 * @param {string} source
 * @param {string} input
 * @param {string} rephrasedQuestion
 * @param {string} output
 * @param {string[]} ids
 * @param {string | null} cypher
 * @returns {string}  The ID of the Message node
 */
export async function saveHistory(
  sessionId: string,
  source: string,
  input: string,
  rephrasedQuestion: string,
  output: string,
  ids: string[],
  cypher: string | null = null
): Promise<string> {
  // TODO: Execute the Cypher statement from /cypher/save-response.cypher in a write transaction
  // const graph = await initGraph()
  // const res = await graph.query<{id: string}>(cypher, params, "WRITE")
  // return res[0].id
}

Call the initGraph() function to get the singleton Neo4jGraph instance, then call the .query() method using the following Cypher statement as the first parameter.

The statement should run in a write transaction.

cypher
Save Conversation History
MERGE (session:Session { id: $sessionId }) // (1)

// <2> Create new response
CREATE (response:Response {
  id: randomUuid(),
  createdAt: datetime(),
  source: $source,
  input: $input,
  output: $output,
  rephrasedQuestion: $rephrasedQuestion,
  cypher: $cypher
})
CREATE (session)-[:HAS_RESPONSE]->(response)

WITH session, response

CALL {
  WITH session, response

  // <3> Remove existing :LAST_RESPONSE relationship if it exists
  MATCH (session)-[lrel:LAST_RESPONSE]->(last)
  DELETE lrel

  // <4? Create :NEXT relationship
  CREATE (last)-[:NEXT]->(response)
}

// <5> Create new :LAST_RESPONSE relationship
CREATE (session)-[:LAST_RESPONSE]->(response)

// <6> Create relationship to context nodes
WITH response

CALL {
  WITH response
  UNWIND $ids AS id
  MATCH (context)
  WHERE elementId(context) = id
  CREATE (response)-[:CONTEXT]->(context)

  RETURN count(*) AS count
}

RETURN DISTINCT response.id AS id

Your code should resemble the following:

typescript
Save History
const graph = await initGraph();
const res = await graph.query<{ id: string }>(
  `
  MERGE (session:Session { id: $sessionId }) // (1)

  // <2> Create new response
  CREATE (response:Response {
    id: randomUuid(),
    createdAt: datetime(),
    source: $source,
    input: $input,
    output: $output,
    rephrasedQuestion: $rephrasedQuestion,
    cypher: $cypher,
    ids: $ids
  })
  CREATE (session)-[:HAS_RESPONSE]->(response)

  WITH session, response

  CALL {
  WITH session, response

    // <3> Remove existing :LAST_RESPONSE relationship if it exists
    MATCH (session)-[lrel:LAST_RESPONSE]->(last)
    DELETE lrel

    // <4? Create :NEXT relationship
    CREATE (last)-[:NEXT]->(response)
  }


  // <5> Create new :LAST_RESPONSE relationship
  CREATE (session)-[:LAST_RESPONSE]->(response)

  // <6> Create relationship to context nodes
  WITH response

  CALL {
    WITH response
    UNWIND $ids AS id
    MATCH (context)
    WHERE elementId(context) = id
    CREATE (response)-[:CONTEXT]->(context)

    RETURN count(*) AS count
  }

  RETURN DISTINCT response.id AS id
`,
  {
    sessionId,
    source,
    input,
    output,
    rephrasedQuestion,
    cypher: cypher,
    ids,
  },
  "WRITE"
);

Finally, use the id key from the first object in the res array to return the newly created response’s UUID.

typescript
Return the Response ID
return res && res.length ? res[0].id : "";
View Solution
typescript
The implemented saveHistory() Function
/**
 * Save a question and response to the database
 *
 * @param {string} sessionId
 * @param {string} source
 * @param {string} input
 * @param {string} rephrasedQuestion
 * @param {string} output
 * @param {string[]} ids
 * @param {string | null} cypher
 * @returns {string}  The ID of the Message node
 */
export async function saveHistory(
  sessionId: string,
  source: string,
  input: string,
  rephrasedQuestion: string,
  output: string,
  ids: string[],
  cypher: string | null = null
): Promise<string> {
  const graph = await initGraph();
  const res = await graph.query<{ id: string }>(
    `
    MERGE (session:Session { id: $sessionId }) // (1)

    // <2> Create new response
    CREATE (response:Response {
      id: randomUuid(),
      createdAt: datetime(),
      source: $source,
      input: $input,
      output: $output,
      rephrasedQuestion: $rephrasedQuestion,
      cypher: $cypher,
      ids: $ids
    })
    CREATE (session)-[:HAS_RESPONSE]->(response)

    WITH session, response

    CALL {
    WITH session, response

      // <3> Remove existing :LAST_RESPONSE relationship if it exists
      MATCH (session)-[lrel:LAST_RESPONSE]->(last)
      DELETE lrel

      // <4? Create :NEXT relationship
      CREATE (last)-[:NEXT]->(response)
    }


    // <5> Create new :LAST_RESPONSE relationship
    CREATE (session)-[:LAST_RESPONSE]->(response)

    // <6> Create relationship to context nodes
    WITH response

    CALL {
      WITH response
      UNWIND $ids AS id
      MATCH (context)
      WHERE elementId(context) = id
      CREATE (response)-[:CONTEXT]->(context)

      RETURN count(*) AS count
    }

    RETURN DISTINCT response.id AS id
  `,
    {
      sessionId,
      source,
      input,
      output,
      rephrasedQuestion,
      cypher: cypher,
      ids,
    },
    "WRITE"
  );

  return res && res.length ? res[0].id : "";
}

Getting History

To retrieve the history saved in the previous function, you must modify the getHistory() function.

typescript
getHistory() Signature
export async function getHistory(
  sessionId: string,
  limit: number = 5
): Promise<ChatbotResponse[]> {
  // TODO: Execute the Cypher statement from /cypher/get-history.cypher in a read transaction
  // TODO: Use string templating to make the limit dynamic: 0..${limit}
  // const graph = await initGraph()
  // const res = await graph.query<ChatbotResponse>(cypher, { sessionId }, "READ")
  // return res
}

Replace the // TODO comment with a call to the read() helper function imported from graph.ts.

Use the following Cypher statement as the first parameter to the read() function and an object containing the sessionId passed to the function as an argument.

cypher
Get Conversation History
MATCH (:Session {id: $sessionId})-[:LAST_RESPONSE]->(last)
// Use string templating to make the limit dynamic: 0..${limit}
MATCH path = (start)-[:NEXT*0..5]->(last)
WHERE length(path) = 5 OR NOT EXISTS { ()-[:NEXT]->(start) }
UNWIND nodes(path) AS response
RETURN response.id AS id,
  response.input AS input,
  response.rephrasedQuestion AS rephrasedQuestion,
  response.output AS output,
  response.cypher AS cypher,
  response.createdAt AS createdAt,
  [ (response)-[:CONTEXT]->(n) | elementId(n) ] AS context

Your code should resemble the following:

typescript
Return the messages
const graph = await initGraph();
const res = await graph.query<ChatbotResponse>(
  `
    MATCH (:Session {id: $sessionId})-[:LAST_RESPONSE]->(last)
    MATCH path = (start)-[:NEXT*0..${limit}]->(last)
    WHERE length(path) = 5 OR NOT EXISTS { ()-[:NEXT]->(start) }
    UNWIND nodes(path) AS response
    RETURN response.id AS id,
      response.input AS input,
      response.rephrasedQuestion AS rephrasedQuestion,
      response.output AS output,
      response.cypher AS cypher,
      response.createdAt AS createdAt,
      [ (response)-[:CONTEXT]->(n) | elementId(n) ] AS context
  `,
  { sessionId },
  "READ"
);

Finally, you can return the res variable.

typescript
Return the messages
return res as ChatbotResponse[];
View Solution
typescript
The Implemented getHistory() Function
export async function getHistory(
  sessionId: string,
  limit: number = 5
): Promise<ChatbotResponse[]> {
  const graph = await initGraph();
  const res = await graph.query<ChatbotResponse>(
    `
      MATCH (:Session {id: $sessionId})-[:LAST_RESPONSE]->(last)
      MATCH path = (start)-[:NEXT*0..${limit}]->(last)
      WHERE length(path) = 5 OR NOT EXISTS { ()-[:NEXT]->(start) }
      UNWIND nodes(path) AS response
      RETURN response.id AS id,
        response.input AS input,
        response.rephrasedQuestion AS rephrasedQuestion,
        response.output AS output,
        response.cypher AS cypher,
        response.createdAt AS createdAt,
        [ (response)-[:CONTEXT]->(n) | elementId(n) ] AS context
    `,
    { sessionId },
    "READ"
  );

  return res as ChatbotResponse[];
}

Testing your changes

If you have followed the instructions, you should be able to run the following unit test to verify the response using the npm run test command.

sh
Running the Test
npm run test history.test.ts
View Unit Test
typescript
history.test.ts
import { close } from "../graph";
import { getHistory, saveHistory } from "./history";
import { Neo4jGraph } from "@langchain/community/graphs/neo4j_graph";

describe("Conversation History", () => {
  let graph: Neo4jGraph;
  let ids: string[];

  beforeAll(async () => {
    graph = await Neo4jGraph.initialize({
      url: process.env.NEO4J_URI as string,
      username: process.env.NEO4J_USERNAME as string,
      password: process.env.NEO4J_PASSWORD as string,
    });

    // Delete responses
    await graph.query(
      'MATCH (s:Session)-[:HAS_RESPONSE]->(r:Response) WHERE s.id IN ["test-1", "test-2", "test-3"] DETACH DELETE s, r'
    );

    // Create some test sources to link to
    const [first] = (await graph.query(`
            MERGE (t1:TestSource {id: 1})
            MERGE (t2:TestSource {id: 2})
            MERGE (t3:TestSource {id: 3})

            RETURN [ elementId(t1), elementId(t2), elementId(t3) ] AS ids
        `)) as Record<string, any>[];

    ids = first.ids;
  });

  afterAll(async () => {
    await graph.close();
    await close();
  });

  it("should save conversation history", async () => {
    const sessionId = "test-1";
    const source = "cypher";
    const input = "Who directed The Matrix?";
    const rephrasedQuestion = "Director of The Matrix";
    const output = "The Matrix was directed by The Wachowskis";
    const cypher =
      'MATCH (p:Person)-[:DIRECTED]->(m:Movie {title: "The Matrix"}) RETURN p.name AS name';

    // Save message
    const id = await saveHistory(
      sessionId,
      source,
      input,
      rephrasedQuestion,
      output,
      ids,
      cypher
    );

    expect(id).toBeDefined();

    // Get History
    const history = await getHistory(sessionId, 5);

    expect(history?.length).toBeGreaterThanOrEqual(1);

    const returnedIds = history.map((m) => m.id);

    // Was message returned in the history
    expect(returnedIds).toContain(id);

    // Check sources
    const res = await graph.query(
      `
        MATCH (s:Session {id: $sessionId})-[:LAST_RESPONSE]->(r)
        RETURN r { .* } AS properties,
        [ (r)-[:CONTEXT]->(c) | elementId(c) ] AS context
      `,
      { sessionId }
    );

    expect(res).toBeDefined();
    expect(res?.length).toBeGreaterThanOrEqual(1);

    // Has context been linked?
    const first = res![0];

    expect(first.properties.id).toEqual(id);
    expect(first.properties.source).toEqual(source);
    expect(first.properties.input).toEqual(input);
    expect(first.properties.rephrasedQuestion).toEqual(rephrasedQuestion);
    expect(first.properties.cypher).toEqual(cypher);
    expect(first.properties.output).toEqual(output);

    // Have all context nodes been linked to?
    for (const id of ids) {
      expect(first.context).toContain(id);
    }
  });

  it("should save a chain of responses", async () => {
    const sessionId = "test-3";
    const source = "retriever";
    const firstInput = "Who directed Toy Story?";
    const secondInput = "Who acted in it?";
    const thirdInput = "What else have the acted in together?";

    // Save message
    const messages = [
      await saveHistory(sessionId, source, firstInput, "", "", []),
      await saveHistory(sessionId, source, secondInput, "", "", []),
      await saveHistory(sessionId, source, thirdInput, "", "", []),
    ];

    for (const message of messages) {
      expect(message).toBeDefined();
    }

    // Get History
    const history = await getHistory(sessionId, 5);

    const returnedIds = history.map((m) => m.id).join(",");

    // Responses should be returned in order
    expect(messages.join(",")).toBe(returnedIds);
  });
});

Verifying the Test

If every test in the test suite has passed, two (:Session) nodes and four (:Response) nodes will be created in your database.

Click the Check Database button below to verify the tests have succeeded.

Hint

You can compare your code with the solution in src/solutions/modules/agent/history.ts and double-check that the conditions have been met in the test suite.

Solution

You can compare your code with the solution in src/solutions/modules/agent/history.ts and double-check that the conditions have been met in the test suite.

You can also run the following Cypher statement to double-check that the information is being correctly added to your database.

cypher
MATCH (s:Session)-[r:HAS_MESSAGE]->(m)
WHERE s.id in ['test-1', 'test-2', 'test-3']
RETURN *

Once you have verified your code and re-ran the tests, click Try again…​* to complete the challenge.

Summary

In this lesson, you wrote the code to save and retrieve conversation history in a Neo4j database.

In the next lesson, you will construct a chain that will take this history to rephrase the user’s input into a standalone question.