Skip to content

Commit 7f2e929

Browse files
committed
feat: cypher generation and chat with data
Signed-off-by: Dan Selman <[email protected]>
1 parent 5adbd4a commit 7f2e929

File tree

4 files changed

+169
-30
lines changed

4 files changed

+169
-30
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,4 @@ Runtime result:
7070
- NEO4J_USER: <optional> defaults to `neo4j`
7171

7272
### Text Embeddings
73-
- OPENAI_API_KEY: <optional> the OpenAI API key. If not set embeddings are not computed and written to the agreement graph and similarity search is not possible.
73+
- OPENAI_API_KEY: <optional> the OpenAI API key. If not set embeddings are not computed and written to the agreement graph and similarity search, natural language to Cypher generation ("chat with data") is not possible.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@accordproject/concerto-graph",
3-
"version": "1.0.2",
3+
"version": "1.1.0",
44
"description": "Concerto Graph",
55
"main": "dist/src/graphmodel.js",
66
"license": "Apache-2.0",

src/demo/index.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ async function run() {
7373
}
7474
const graphModel = new GraphModel([MODEL], options);
7575
await graphModel.connect();
76+
await graphModel.deleteGraph();
7677
await graphModel.dropIndexes();
7778
await graphModel.createConstraints();
7879
await graphModel.createVectorIndexes();
@@ -129,13 +130,31 @@ async function run() {
129130
const fullTextResults = await graphModel.fullTextQuery('Movie', fullTextSearch, 2);
130131
console.log(fullTextResults);
131132
if(process.env.OPENAI_API_KEY) {
132-
const search = 'Working in a boring job and looking for love.';
133+
const search = 'working in a boring job and looking for love.';
133134
console.log(`Searching for movies related to: '${search}'`);
134135
const results = await graphModel.similarityQuery('Movie', 'summary', search, 3);
135-
console.log(results);
136+
console.log(results);
137+
138+
const chat = 'Which director has directed both Johnny Depp and Jonathan Pryce, but not necessarily in the same movie?';
139+
console.log(`Chat with data: ${chat}`);
140+
const cypher = await graphModel.textToCypher(chat);
141+
console.log(`Converted to Cypher query: ${cypher}`);
142+
const chatResult = await graphModel.chatWithData(chat);
143+
console.log(JSON.stringify(chatResult, null, 2));
144+
145+
const chat2 = `Which director has directed a movie that is about the concepts of ${search}? Return a single movie.`;
146+
const chatResult2 = await graphModel.chatWithData(chat2);
147+
console.log(JSON.stringify(chatResult2, null, 2));
148+
136149
}
137150
await graphModel.closeSession(context);
138151
console.log('done');
152+
process.exit();
139153
}
140154

141-
run();
155+
try {
156+
run();
157+
}
158+
catch(err) {
159+
console.log(err);
160+
}

src/graphmodel.ts

Lines changed: 145 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,104 @@ export type SimilarityResult = {
3232
export async function getOpenAiEmbedding(text: string): Promise<Array<number>> {
3333
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
3434
const response = await openai.embeddings.create({
35-
model: "text-embedding-ada-002",
35+
model: "text-embedding-3-small",
3636
input: text,
3737
encoding_format: "float",
3838
});
3939
return response.data[0].embedding;
4040
}
4141

42+
export async function textToCypher(options:GraphModelOptions, text: string, ctoModel): Promise<string | null> {
43+
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
44+
45+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
46+
const messages:any = [{
47+
role: 'system',
48+
content: `Convert the natural language query delimited by triple quotes to a Neo4J Cypher query.
49+
Just return the Cypher query, without an explanation for how it works. Do not enclose the result in a markdown code block.
50+
The nodes and edges in Neo4j are described via the following Accord Project Concerto model:
51+
52+
Concerto mode:
53+
\`\`\`
54+
${ctoModel}
55+
\`\`\`
56+
57+
Concerto properties with the @vector_index decorator have a Neo4J vector index. The name
58+
of the vector index is the lowercase name of the declaration with '_' and the lowercase
59+
name of the property appended.
60+
61+
Concerto declarations with any properties with the @fulltext_index decorator have a
62+
full text index. The name of the full text index is the lowercase name of the declaration
63+
with '_fulltext' appended.
64+
65+
Here is an example NeoJ4 query that matches 3 movies by conceptual similarity
66+
(using vector cosine similarity):
67+
MATCH (l:Movie)
68+
CALL db.index.vector.queryNodes('movie_summary', 3, [-0.042983294,-0.00888215, ...] )
69+
YIELD node AS similar, score
70+
MATCH (similar)
71+
RETURN similar.identifier as identifier, similar.summary as content, score limit 3
72+
73+
Natural language query: """${text}
74+
"""
75+
`}];
76+
77+
const EMBEDDINGS_MAGIC = '<EMBEDDINGS>';
78+
79+
const params: OpenAI.Chat.ChatCompletionCreateParams = {
80+
temperature: 0.1,
81+
tools: [
82+
{
83+
"type": "function",
84+
"function": {
85+
"name": "get_embeddings",
86+
"description": "Get vector embeddings for a query string",
87+
"parameters": {
88+
"type": "object",
89+
"properties": {
90+
"query": {
91+
"type": "string",
92+
}
93+
},
94+
"required": ["query"]
95+
}
96+
}
97+
}
98+
],
99+
tool_choice: "auto",
100+
messages,
101+
model: 'gpt-4o',
102+
};
103+
let chatCompletion: OpenAI.Chat.ChatCompletion = await openai.chat.completions.create(params);
104+
if(chatCompletion.choices[0].message.tool_calls && options.embeddingFunction) {
105+
if(chatCompletion.choices[0].message.tool_calls.length > 0) {
106+
const tool = chatCompletion.choices[0].message.tool_calls[0];
107+
options.logger?.log(`Calling tool: ${tool.function.name}`);
108+
if(tool.function.name === 'get_embeddings') {
109+
messages.push(chatCompletion.choices[0].message);
110+
messages.push({
111+
"role":"tool",
112+
"tool_call_id": tool.id,
113+
"name": tool.function.name,
114+
"content": EMBEDDINGS_MAGIC
115+
})
116+
chatCompletion = await openai.chat.completions.create(params);
117+
const args = JSON.parse(tool.function.arguments);
118+
const embeddings = await options.embeddingFunction(args.query);
119+
if(chatCompletion.choices[0].message.content) {
120+
options.logger?.log(`Tool replacing embeddings: ${chatCompletion.choices[0].message.content}`);
121+
chatCompletion.choices[0].message.content = chatCompletion.choices[0].message.content.replaceAll(EMBEDDINGS_MAGIC, JSON.stringify(embeddings));
122+
}
123+
}
124+
else {
125+
throw new Error(`Unrecognized tool: ${tool.function}`);
126+
}
127+
}
128+
}
129+
return chatCompletion.choices[0].message.content;
130+
}
131+
132+
42133
export function getObjectChecksum(obj: PropertyBag) {
43134
const deterministicReplacer = (_, v) =>
44135
typeof v !== 'object' || v === null || Array.isArray(v) ? v :
@@ -431,34 +522,63 @@ export class GraphModel {
431522
}
432523
}
433524

434-
async fullTextQuery(typeName: string, searchText: string, count: number) {
435-
try {
436-
const graphNode = this.getGraphNodeDeclaration(typeName);
437-
const fullTextIndex = this.getFullTextIndex(graphNode);
438-
if (!fullTextIndex) {
439-
throw new Error(`No full text index for properties of ${typeName}`);
525+
async textToCypher(text: string): Promise<string | null> {
526+
const ctoModels = this.modelManager.getModels().reduce((prev, cur) => prev += cur.content, '');
527+
return textToCypher(this.options, text, ctoModels);
528+
}
529+
530+
async chatWithData(text: string) {
531+
const cypher = await this.textToCypher(text);
532+
if (cypher) {
533+
const context = await this.openSession();
534+
const transaction = await context.session.beginTransaction();
535+
try {
536+
const queryResult = await this.query(cypher);
537+
return queryResult ? queryResult.records.map(v => {
538+
const result = {};
539+
v.keys.forEach( k => {
540+
result[k] = v.get(k);
541+
})
542+
return result;
543+
}) : [];
544+
}
545+
catch (err) {
546+
this.options.logger?.log((err as object).toString());
547+
transaction?.rollback();
548+
throw err;
440549
}
441-
const indexName = this.getFullTextIndexName(graphNode);
442-
const props = fullTextIndex.properties.map(p => `node.${p}`);
443-
props.push('node.identifier');
444-
const q = `CALL db.index.fulltext.queryNodes("${indexName}", "${searchText}") YIELD node, score RETURN ${props.join(',')}, score limit ${count}`;
445-
const queryResult = await this.query(q);
446-
return queryResult ? queryResult.records.map(v => {
447-
const result = {};
448-
fullTextIndex.properties.forEach(p => {
449-
result[p] = v.get(`node.${p}`)
450-
});
451-
result['score'] = v.get('score');
452-
result['identifier'] = v.get('node.identifier');
453-
return result;
454-
}) : [];
455-
}
456-
catch (err) {
457-
this.options.logger?.log((err as object).toString());
458-
throw err;
459550
}
551+
throw new Error(`Failed to convert to Cypher query ${text}`);
460552
}
461553

554+
async fullTextQuery(typeName: string, searchText: string, count: number) {
555+
try {
556+
const graphNode = this.getGraphNodeDeclaration(typeName);
557+
const fullTextIndex = this.getFullTextIndex(graphNode);
558+
if (!fullTextIndex) {
559+
throw new Error(`No full text index for properties of ${typeName}`);
560+
}
561+
const indexName = this.getFullTextIndexName(graphNode);
562+
const props = fullTextIndex.properties.map(p => `node.${p}`);
563+
props.push('node.identifier');
564+
const q = `CALL db.index.fulltext.queryNodes("${indexName}", "${searchText}") YIELD node, score RETURN ${props.join(',')}, score limit ${count}`;
565+
const queryResult = await this.query(q);
566+
return queryResult ? queryResult.records.map(v => {
567+
const result = {};
568+
fullTextIndex.properties.forEach(p => {
569+
result[p] = v.get(`node.${p}`)
570+
});
571+
result['score'] = v.get('score');
572+
result['identifier'] = v.get('node.identifier');
573+
return result;
574+
}) : [];
575+
}
576+
catch (err) {
577+
this.options.logger?.log((err as object).toString());
578+
throw err;
579+
}
580+
}
581+
462582
private async validateAndTransformProperties(transaction, decl: ClassDeclaration, properties: PropertyBag): Promise<PropertyBag> {
463583
const factory = new Factory(this.modelManager);
464584
const serializer = new Serializer(factory, this.modelManager);

0 commit comments

Comments
 (0)