To complete this challenge, you must write the functions to save and retrieve conversation history.
In modules/agent/history.ts
, you must:
-
Modify the
saveHistory()
to save the history to the database -
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
// 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.
/**
* 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.
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:
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.
return res && res.length ? res[0].id : "";
View Solution
/**
* 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.
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.
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:
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.
return res as ChatbotResponse[];
View Solution
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.
npm run test history.test.ts
View Unit Test
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.
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.