Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# LangGraph + Elasticsearch Human-in-the-Loop

Flight search application using LangGraph for human-in-the-loop workflow and Elasticsearch for vector search.

## Prerequisites

- Node.js 18+
- Elasticsearch instance
- OpenAI API key

## Installation

### Quick Install

```bash
npm install
```

### Manual Install (Alternative)

```bash
npm install @elastic/elasticsearch @langchain/community @langchain/core @langchain/langgraph @langchain/openai dotenv --legacy-peer-deps
npm install --save-dev tsx
```

## Configuration

Create a `.env` file in the root directory:

```env
ELASTICSEARCH_ENDPOINT=https://your-elasticsearch-instance.com
ELASTICSEARCH_API_KEY=your-api-key
OPENAI_API_KEY=your-openai-api-key
```

## Usage

```bash
npm start
```

## Features

- 🔍 Vector search with Elasticsearch
- 🤖 LLM-powered natural language selection
- 👤 Human-in-the-loop workflow with LangGraph
- 📊 Workflow visualization (generates `workflow_graph.png`)

## Workflow

1. **Retrieve Flights** - Search Elasticsearch with vector similarity
2. **Evaluate Results** - Auto-select if 1 result, show options if multiple
3. **Show Results** - Display flight options to user
4. **Request User Choice** - Pause workflow for user input (HITL)
5. **Disambiguate & Answer** - Use LLM to interpret selection and return final answer

Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { ElasticVectorSearch } from "@langchain/community/vectorstores/elasticsearch";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Client } from "@elastic/elasticsearch";
import { readFile } from "node:fs/promises";
import dotenv from "dotenv";

dotenv.config();

const VECTOR_INDEX = "flights-offerings";

// Types
export interface DocumentMetadata {
from_city: string;
to_city: string;
airport_code: string;
airport_name: string;
country: string;
airline: string;
date: string;
price: number;
time_approx: string;
title: string;
}

export interface Document {
pageContent: string;
metadata: DocumentMetadata;
}

interface RawDocument {
pageContent?: string;
text?: string;
metadata?: DocumentMetadata;
}

const esClient = new Client({
node: process.env.ELASTICSEARCH_ENDPOINT!,
auth: {
apiKey: process.env.ELASTICSEARCH_API_KEY!,
},
});

const embeddings = new OpenAIEmbeddings({
model: "text-embedding-3-small",
});

const vectorStore = new ElasticVectorSearch(embeddings, {
client: esClient,
indexName: VECTOR_INDEX,
});

/**
* Load dataset from a JSON file
* @param path - Path to the JSON file
* @returns Array of documents with pageContent and metadata
*/
export async function loadDataset(path: string): Promise<Document[]> {
const raw = await readFile(path, "utf-8");
const data: RawDocument[] = JSON.parse(raw);

return data.map((d) => ({
pageContent: String(d.pageContent ?? d.text ?? ""),
metadata: (d.metadata ?? {}) as DocumentMetadata,
}));
}

/**
* Ingest data into Elasticsearch vector store
* Creates the index if it doesn't exist and loads initial dataset
*/
export async function ingestData(): Promise<void> {
const vectorExists = await esClient.indices.exists({ index: VECTOR_INDEX });

if (!vectorExists) {
console.log("CREATING VECTOR INDEX...");

await esClient.indices.create({
index: VECTOR_INDEX,
mappings: {
properties: {
text: { type: "text" },
embedding: {
type: "dense_vector",
dims: 1536,
index: true,
similarity: "cosine",
},
metadata: {
type: "object",
properties: {
from_city: { type: "keyword" },
to_city: { type: "keyword" },
airport_code: { type: "keyword" },
airport_name: {
type: "text",
fields: {
keyword: { type: "keyword" },
},
},
country: { type: "keyword" },
airline: { type: "keyword" },
date: { type: "date" },
price: { type: "integer" },
time_approx: { type: "keyword" },
title: {
type: "text",
fields: {
keyword: { type: "keyword" },
},
},
},
},
},
},
});
}

const indexExists = await esClient.indices.exists({ index: VECTOR_INDEX });

if (indexExists) {
const indexCount = await esClient.count({ index: VECTOR_INDEX });
const documentCount = indexCount.count;

// Only ingest if index is empty
if (documentCount > 0) {
console.log(
`Index already contains ${documentCount} documents. Skipping ingestion.`
);
return;
}

console.log("INGESTING DATASET...");
const datasetPath = "./dataset.json";
const initialDocs = await loadDataset(datasetPath).catch(() => []);

await vectorStore.addDocuments(initialDocs);
console.log(`✅ Successfully ingested ${initialDocs.length} documents`);
}
}

export { VECTOR_INDEX };
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
[
{
"pageContent": "Ticket: Medellín (MDE) → Sao Paulo (GRU). 1 stop. Airline: AndeanSky.",
"metadata": {
"from_city": "Medellín",
"to_city": "Sao Paulo",
"airport_code": "GRU",
"airport_name": "Guarulhos International",
"country": "Brazil",
"airline": "AndeanSky",
"date": "2025-10-10",
"price": 480,
"time_approx": "7h 30m",
"title": "MDE → GRU (AndeanSky)"
}
},
{
"pageContent": "Ticket: Medellín (MDE) → Sao Paulo (CGH). 1 stop. Airline: CoffeeAir.",
"metadata": {
"from_city": "Medellín",
"to_city": "Sao Paulo",
"airport_code": "CGH",
"airport_name": "Congonhas",
"country": "Brazil",
"airline": "CoffeeAir",
"date": "2025-10-10",
"price": 455,
"time_approx": "8h 05m",
"title": "MDE → CGH (CoffeeAir)"
}
},
{
"pageContent": "Ticket: Medellín (MDE) → Tokyo (HND). 2 stops. Airline: CondorJet.",
"metadata": {
"from_city": "Medellín",
"to_city": "Tokyo",
"airport_code": "HND",
"airport_name": "Haneda",
"country": "Japan",
"airline": "CondorJet",
"date": "2025-11-05",
"price": 1290,
"time_approx": "22h 10m",
"title": "MDE → HND (CondorJet)"
}
},
{
"pageContent": "Ticket: Medellín (MDE) → Tokyo (NRT). 1–2 stops. Airline: CaribeWings.",
"metadata": {
"from_city": "Medellín",
"to_city": "Tokyo",
"airport_code": "NRT",
"airport_name": "Narita",
"country": "Japan",
"airline": "CaribeWings",
"date": "2025-11-05",
"price": 1215,
"time_approx": "23h 30m",
"title": "MDE → NRT (CaribeWings)"
}
},
{
"pageContent": "Ticket: Medellín (MDE) → New York (JFK). 1 stop. Airline: AndeanSky.",
"metadata": {
"from_city": "Medellín",
"to_city": "New York",
"airport_code": "JFK",
"airport_name": "John F. Kennedy International",
"country": "USA",
"airline": "AndeanSky",
"date": "2025-12-01",
"price": 340,
"time_approx": "6h 40m",
"title": "MDE → JFK (AndeanSky)"
}
},
{
"pageContent": "Ticket: Medellín (MDE) → New York (LGA). 1 stop. Airline: CoffeeAir.",
"metadata": {
"from_city": "Medellín",
"to_city": "New York",
"airport_code": "LGA",
"airport_name": "LaGuardia",
"country": "USA",
"airline": "CoffeeAir",
"date": "2025-12-01",
"price": 325,
"time_approx": "6h 55m",
"title": "MDE → LGA (CoffeeAir)"
}
},
{
"pageContent": "Ticket: Medellín (MDE) → London (LHR). 1 stop. Airline: CondorJet.",
"metadata": {
"from_city": "Medellín",
"to_city": "London",
"airport_code": "LHR",
"airport_name": "Heathrow",
"country": "UK",
"airline": "CondorJet",
"date": "2026-01-15",
"price": 890,
"time_approx": "14h 30m",
"title": "MDE → LHR (CondorJet)"
}
},
{
"pageContent": "Ticket: Medellín (MDE) → London (LGW). 1–2 stops. Airline: CaribeWings.",
"metadata": {
"from_city": "Medellín",
"to_city": "London",
"airport_code": "LGW",
"airport_name": "Gatwick",
"country": "UK",
"airline": "CaribeWings",
"date": "2026-01-15",
"price": 760,
"time_approx": "15h 10m",
"title": "MDE → LGW (CaribeWings)"
}
},
{
"pageContent": "Ticket: Medellín (MDE) → Paris (CDG). 1 stop. Airline: CoffeeAir.",
"metadata": {
"from_city": "Medellín",
"to_city": "Paris",
"airport_code": "CDG",
"airport_name": "Charles de Gaulle",
"country": "France",
"airline": "CoffeeAir",
"date": "2025-10-22",
"price": 720,
"time_approx": "13h 50m",
"title": "MDE → CDG (CoffeeAir)"
}
},
{
"pageContent": "Ticket: Medellín (MDE) → Paris (ORY). 1 stop. Airline: AndeanSky.",
"metadata": {
"from_city": "Medellín",
"to_city": "Paris",
"airport_code": "ORY",
"airport_name": "Orly",
"country": "France",
"airline": "AndeanSky",
"date": "2025-10-22",
"price": 695,
"time_approx": "13h 20m",
"title": "MDE → ORY (AndeanSky)"
}
}
]
Loading