Skip to content

Commit be34fb2

Browse files
committed
supporging blog content human-in-the-loop-with-langgraph-and-elasticsearch
1 parent ad80dae commit be34fb2

File tree

7 files changed

+3996
-0
lines changed

7 files changed

+3996
-0
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# LangGraph + Elasticsearch Human-in-the-Loop
2+
3+
Flight search application using LangGraph for human-in-the-loop workflow and Elasticsearch for vector search.
4+
5+
## Prerequisites
6+
7+
- Node.js 18+
8+
- Elasticsearch instance
9+
- OpenAI API key
10+
11+
## Installation
12+
13+
### Quick Install
14+
15+
```bash
16+
npm install
17+
```
18+
19+
### Manual Install (Alternative)
20+
21+
```bash
22+
npm install @elastic/elasticsearch @langchain/community @langchain/core @langchain/langgraph @langchain/openai dotenv --legacy-peer-deps
23+
npm install --save-dev tsx
24+
```
25+
26+
## Configuration
27+
28+
Create a `.env` file in the root directory:
29+
30+
```env
31+
ELASTICSEARCH_ENDPOINT=https://your-elasticsearch-instance.com
32+
ELASTICSEARCH_API_KEY=your-api-key
33+
OPENAI_API_KEY=your-openai-api-key
34+
```
35+
36+
## Usage
37+
38+
```bash
39+
npm start
40+
```
41+
42+
## Features
43+
44+
- 🔍 Vector search with Elasticsearch
45+
- 🤖 LLM-powered natural language selection
46+
- 👤 Human-in-the-loop workflow with LangGraph
47+
- 📊 Workflow visualization (generates `workflow_graph.png`)
48+
49+
## Workflow
50+
51+
1. **Retrieve Flights** - Search Elasticsearch with vector similarity
52+
2. **Evaluate Results** - Auto-select if 1 result, show options if multiple
53+
3. **Show Results** - Display flight options to user
54+
4. **Request User Choice** - Pause workflow for user input (HITL)
55+
5. **Disambiguate & Answer** - Use LLM to interpret selection and return final answer
56+
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { ElasticVectorSearch } from "@langchain/community/vectorstores/elasticsearch";
2+
import { OpenAIEmbeddings } from "@langchain/openai";
3+
import { Client } from "@elastic/elasticsearch";
4+
import { readFile } from "node:fs/promises";
5+
import dotenv from "dotenv";
6+
7+
dotenv.config();
8+
9+
const VECTOR_INDEX = "flights-offerings";
10+
11+
// Types
12+
export interface DocumentMetadata {
13+
from_city: string;
14+
to_city: string;
15+
airport_code: string;
16+
airport_name: string;
17+
country: string;
18+
airline: string;
19+
date: string;
20+
price: number;
21+
time_approx: string;
22+
title: string;
23+
}
24+
25+
export interface Document {
26+
pageContent: string;
27+
metadata: DocumentMetadata;
28+
}
29+
30+
interface RawDocument {
31+
pageContent?: string;
32+
text?: string;
33+
metadata?: DocumentMetadata;
34+
}
35+
36+
const esClient = new Client({
37+
node: process.env.ELASTICSEARCH_ENDPOINT!,
38+
auth: {
39+
apiKey: process.env.ELASTICSEARCH_API_KEY!,
40+
},
41+
});
42+
43+
const embeddings = new OpenAIEmbeddings({
44+
model: "text-embedding-3-small",
45+
});
46+
47+
const vectorStore = new ElasticVectorSearch(embeddings, {
48+
client: esClient,
49+
indexName: VECTOR_INDEX,
50+
});
51+
52+
/**
53+
* Load dataset from a JSON file
54+
* @param path - Path to the JSON file
55+
* @returns Array of documents with pageContent and metadata
56+
*/
57+
export async function loadDataset(path: string): Promise<Document[]> {
58+
const raw = await readFile(path, "utf-8");
59+
const data: RawDocument[] = JSON.parse(raw);
60+
61+
return data.map((d) => ({
62+
pageContent: String(d.pageContent ?? d.text ?? ""),
63+
metadata: (d.metadata ?? {}) as DocumentMetadata,
64+
}));
65+
}
66+
67+
/**
68+
* Ingest data into Elasticsearch vector store
69+
* Creates the index if it doesn't exist and loads initial dataset
70+
*/
71+
export async function ingestData(): Promise<void> {
72+
const vectorExists = await esClient.indices.exists({ index: VECTOR_INDEX });
73+
74+
if (!vectorExists) {
75+
console.log("CREATING VECTOR INDEX...");
76+
77+
await esClient.indices.create({
78+
index: VECTOR_INDEX,
79+
mappings: {
80+
properties: {
81+
text: { type: "text" },
82+
embedding: {
83+
type: "dense_vector",
84+
dims: 1536,
85+
index: true,
86+
similarity: "cosine",
87+
},
88+
metadata: {
89+
type: "object",
90+
properties: {
91+
from_city: { type: "keyword" },
92+
to_city: { type: "keyword" },
93+
airport_code: { type: "keyword" },
94+
airport_name: {
95+
type: "text",
96+
fields: {
97+
keyword: { type: "keyword" },
98+
},
99+
},
100+
country: { type: "keyword" },
101+
airline: { type: "keyword" },
102+
date: { type: "date" },
103+
price: { type: "integer" },
104+
time_approx: { type: "keyword" },
105+
title: {
106+
type: "text",
107+
fields: {
108+
keyword: { type: "keyword" },
109+
},
110+
},
111+
},
112+
},
113+
},
114+
},
115+
});
116+
}
117+
118+
const indexExists = await esClient.indices.exists({ index: VECTOR_INDEX });
119+
120+
if (indexExists) {
121+
const indexCount = await esClient.count({ index: VECTOR_INDEX });
122+
const documentCount = indexCount.count;
123+
124+
// Only ingest if index is empty
125+
if (documentCount > 0) {
126+
console.log(
127+
`Index already contains ${documentCount} documents. Skipping ingestion.`
128+
);
129+
return;
130+
}
131+
132+
console.log("INGESTING DATASET...");
133+
const datasetPath = "./dataset.json";
134+
const initialDocs = await loadDataset(datasetPath).catch(() => []);
135+
136+
await vectorStore.addDocuments(initialDocs);
137+
console.log(`✅ Successfully ingested ${initialDocs.length} documents`);
138+
}
139+
}
140+
141+
export { VECTOR_INDEX };
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
[
2+
{
3+
"pageContent": "Ticket: Medellín (MDE) → Sao Paulo (GRU). 1 stop. Airline: AndeanSky.",
4+
"metadata": {
5+
"from_city": "Medellín",
6+
"to_city": "Sao Paulo",
7+
"airport_code": "GRU",
8+
"airport_name": "Guarulhos International",
9+
"country": "Brazil",
10+
"airline": "AndeanSky",
11+
"date": "2025-10-10",
12+
"price": 480,
13+
"time_approx": "7h 30m",
14+
"title": "MDE → GRU (AndeanSky)"
15+
}
16+
},
17+
{
18+
"pageContent": "Ticket: Medellín (MDE) → Sao Paulo (CGH). 1 stop. Airline: CoffeeAir.",
19+
"metadata": {
20+
"from_city": "Medellín",
21+
"to_city": "Sao Paulo",
22+
"airport_code": "CGH",
23+
"airport_name": "Congonhas",
24+
"country": "Brazil",
25+
"airline": "CoffeeAir",
26+
"date": "2025-10-10",
27+
"price": 455,
28+
"time_approx": "8h 05m",
29+
"title": "MDE → CGH (CoffeeAir)"
30+
}
31+
},
32+
{
33+
"pageContent": "Ticket: Medellín (MDE) → Tokyo (HND). 2 stops. Airline: CondorJet.",
34+
"metadata": {
35+
"from_city": "Medellín",
36+
"to_city": "Tokyo",
37+
"airport_code": "HND",
38+
"airport_name": "Haneda",
39+
"country": "Japan",
40+
"airline": "CondorJet",
41+
"date": "2025-11-05",
42+
"price": 1290,
43+
"time_approx": "22h 10m",
44+
"title": "MDE → HND (CondorJet)"
45+
}
46+
},
47+
{
48+
"pageContent": "Ticket: Medellín (MDE) → Tokyo (NRT). 1–2 stops. Airline: CaribeWings.",
49+
"metadata": {
50+
"from_city": "Medellín",
51+
"to_city": "Tokyo",
52+
"airport_code": "NRT",
53+
"airport_name": "Narita",
54+
"country": "Japan",
55+
"airline": "CaribeWings",
56+
"date": "2025-11-05",
57+
"price": 1215,
58+
"time_approx": "23h 30m",
59+
"title": "MDE → NRT (CaribeWings)"
60+
}
61+
},
62+
{
63+
"pageContent": "Ticket: Medellín (MDE) → New York (JFK). 1 stop. Airline: AndeanSky.",
64+
"metadata": {
65+
"from_city": "Medellín",
66+
"to_city": "New York",
67+
"airport_code": "JFK",
68+
"airport_name": "John F. Kennedy International",
69+
"country": "USA",
70+
"airline": "AndeanSky",
71+
"date": "2025-12-01",
72+
"price": 340,
73+
"time_approx": "6h 40m",
74+
"title": "MDE → JFK (AndeanSky)"
75+
}
76+
},
77+
{
78+
"pageContent": "Ticket: Medellín (MDE) → New York (LGA). 1 stop. Airline: CoffeeAir.",
79+
"metadata": {
80+
"from_city": "Medellín",
81+
"to_city": "New York",
82+
"airport_code": "LGA",
83+
"airport_name": "LaGuardia",
84+
"country": "USA",
85+
"airline": "CoffeeAir",
86+
"date": "2025-12-01",
87+
"price": 325,
88+
"time_approx": "6h 55m",
89+
"title": "MDE → LGA (CoffeeAir)"
90+
}
91+
},
92+
{
93+
"pageContent": "Ticket: Medellín (MDE) → London (LHR). 1 stop. Airline: CondorJet.",
94+
"metadata": {
95+
"from_city": "Medellín",
96+
"to_city": "London",
97+
"airport_code": "LHR",
98+
"airport_name": "Heathrow",
99+
"country": "UK",
100+
"airline": "CondorJet",
101+
"date": "2026-01-15",
102+
"price": 890,
103+
"time_approx": "14h 30m",
104+
"title": "MDE → LHR (CondorJet)"
105+
}
106+
},
107+
{
108+
"pageContent": "Ticket: Medellín (MDE) → London (LGW). 1–2 stops. Airline: CaribeWings.",
109+
"metadata": {
110+
"from_city": "Medellín",
111+
"to_city": "London",
112+
"airport_code": "LGW",
113+
"airport_name": "Gatwick",
114+
"country": "UK",
115+
"airline": "CaribeWings",
116+
"date": "2026-01-15",
117+
"price": 760,
118+
"time_approx": "15h 10m",
119+
"title": "MDE → LGW (CaribeWings)"
120+
}
121+
},
122+
{
123+
"pageContent": "Ticket: Medellín (MDE) → Paris (CDG). 1 stop. Airline: CoffeeAir.",
124+
"metadata": {
125+
"from_city": "Medellín",
126+
"to_city": "Paris",
127+
"airport_code": "CDG",
128+
"airport_name": "Charles de Gaulle",
129+
"country": "France",
130+
"airline": "CoffeeAir",
131+
"date": "2025-10-22",
132+
"price": 720,
133+
"time_approx": "13h 50m",
134+
"title": "MDE → CDG (CoffeeAir)"
135+
}
136+
},
137+
{
138+
"pageContent": "Ticket: Medellín (MDE) → Paris (ORY). 1 stop. Airline: AndeanSky.",
139+
"metadata": {
140+
"from_city": "Medellín",
141+
"to_city": "Paris",
142+
"airport_code": "ORY",
143+
"airport_name": "Orly",
144+
"country": "France",
145+
"airline": "AndeanSky",
146+
"date": "2025-10-22",
147+
"price": 695,
148+
"time_approx": "13h 20m",
149+
"title": "MDE → ORY (AndeanSky)"
150+
}
151+
}
152+
]

0 commit comments

Comments
 (0)