langchain-ladybug 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langchain_ladybug-0.3.0/.gitignore +35 -0
- langchain_ladybug-0.3.0/CHANGELOG.md +20 -0
- langchain_ladybug-0.3.0/Makefile +23 -0
- langchain_ladybug-0.3.0/PKG-INFO +107 -0
- langchain_ladybug-0.3.0/README.md +79 -0
- langchain_ladybug-0.3.0/langchain_ladybug/__init__.py +9 -0
- langchain_ladybug-0.3.0/langchain_ladybug/chains/__init__.py +13 -0
- langchain_ladybug-0.3.0/langchain_ladybug/chains/ladybug_qa.py +217 -0
- langchain_ladybug-0.3.0/langchain_ladybug/chains/prompts.py +53 -0
- langchain_ladybug-0.3.0/langchain_ladybug/graphs/__init__.py +11 -0
- langchain_ladybug-0.3.0/langchain_ladybug/graphs/ladybug_graph.py +258 -0
- langchain_ladybug-0.3.0/pyproject.toml +68 -0
- langchain_ladybug-0.3.0/tests/__init__.py +0 -0
- langchain_ladybug-0.3.0/tests/integration_tests/__init__.py +0 -0
- langchain_ladybug-0.3.0/tests/integration_tests/graphs/__init__.py +0 -0
- langchain_ladybug-0.3.0/tests/integration_tests/graphs/test_ladybug.py +74 -0
- langchain_ladybug-0.3.0/tests/unit_tests/__init__.py +0 -0
- langchain_ladybug-0.3.0/tests/unit_tests/test_imports.py +31 -0
- langchain_ladybug-0.3.0/tests/unit_tests/test_ladybug_qa_chain.py +50 -0
- langchain_ladybug-0.3.0/tests/unit_tests/test_prompts.py +29 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Virtual environments
|
|
2
|
+
venv*/
|
|
3
|
+
.venv*/
|
|
4
|
+
env/
|
|
5
|
+
.env/
|
|
6
|
+
|
|
7
|
+
# Build artifacts
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
|
|
12
|
+
# Byte-compiled / cache
|
|
13
|
+
__pycache__/
|
|
14
|
+
*.py[cod]
|
|
15
|
+
*.pyo
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.benchmarks/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
|
|
21
|
+
# Coverage
|
|
22
|
+
.coverage
|
|
23
|
+
htmlcov/
|
|
24
|
+
|
|
25
|
+
# Editor / OS
|
|
26
|
+
.idea/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.swp
|
|
29
|
+
.DS_Store
|
|
30
|
+
Thumbs.db
|
|
31
|
+
|
|
32
|
+
# Cursor IDE
|
|
33
|
+
.cursor/
|
|
34
|
+
memory-bank/
|
|
35
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.3.0] - 2026-05-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release of `langchain-ladybug`, based on [@adsharma](https://github.com/adsharma)'s port of the Kuzu LangChain integration to Ladybug ([langchain-community PR #438](https://github.com/langchain-ai/langchain-community/pull/438)).
|
|
13
|
+
- `LadybugGraph` — LangChain graph wrapper for the [Ladybug](https://github.com/ladybug-ai/ladybug) graph database with support for:
|
|
14
|
+
- Schema introspection via `refresh_schema` and `get_schema`.
|
|
15
|
+
- Cypher query execution via `query`.
|
|
16
|
+
- Document ingestion via `add_graph_documents`, including optional source chunk tracking with `MENTIONS` relationships.
|
|
17
|
+
- `LadybugQAChain` — question-answering chain that translates natural language to Ladybug-dialect Cypher and returns grounded answers from the graph.
|
|
18
|
+
- `LADYBUG_GENERATION_PROMPT` — prompt template for Ladybug-dialect Cypher generation, enforcing correct relationship pattern syntax (`()-[]->()`) and clean output.
|
|
19
|
+
- `CYPHER_QA_PROMPT` — prompt template for producing human-readable answers from Cypher query results.
|
|
20
|
+
- Support for separate `cypher_llm` and `qa_llm` models in `LadybugQAChain.from_llm`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
.PHONY: all lint format test help
|
|
2
|
+
|
|
3
|
+
all: help
|
|
4
|
+
|
|
5
|
+
help:
|
|
6
|
+
@echo "Available targets:"
|
|
7
|
+
@echo " lint - Run ruff linter"
|
|
8
|
+
@echo " format - Run ruff formatter"
|
|
9
|
+
@echo " test - Run unit tests (no network)"
|
|
10
|
+
@echo " typing - Run mypy type checking"
|
|
11
|
+
|
|
12
|
+
lint:
|
|
13
|
+
uv run --group lint ruff check .
|
|
14
|
+
|
|
15
|
+
format:
|
|
16
|
+
uv run --group lint ruff format .
|
|
17
|
+
uv run --group lint ruff check --fix .
|
|
18
|
+
|
|
19
|
+
test:
|
|
20
|
+
uv run --group test pytest tests/unit_tests -v
|
|
21
|
+
|
|
22
|
+
typing:
|
|
23
|
+
uv run --group typing mypy langchain_ladybug
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: langchain-ladybug
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: LangChain integration for the Ladybug graph database.
|
|
5
|
+
Project-URL: Homepage, https://github.com/stevereiner/langchain-ladybug
|
|
6
|
+
Project-URL: Source, https://github.com/stevereiner/langchain-ladybug
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Python: <4.0.0,>=3.10.0
|
|
9
|
+
Requires-Dist: ladybug>=0.16.1
|
|
10
|
+
Requires-Dist: langchain-classic<2.0.0,>=1.0.0
|
|
11
|
+
Requires-Dist: langchain-community<1.0.0,>=0.4.1
|
|
12
|
+
Requires-Dist: langchain-core<2.0.0,>=1.0.1
|
|
13
|
+
Requires-Dist: pydantic<3.0.0,>=2.0.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: langchain-core; extra == 'dev'
|
|
16
|
+
Provides-Extra: lint
|
|
17
|
+
Requires-Dist: ruff<1.0.0,>=0.13.1; extra == 'lint'
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: langchain-tests<2.0.0,>=1.0.0; extra == 'test'
|
|
20
|
+
Requires-Dist: pytest-asyncio<1.0.0,>=0.20.3; extra == 'test'
|
|
21
|
+
Requires-Dist: pytest-cov<7.0.0,>=6.2.1; extra == 'test'
|
|
22
|
+
Requires-Dist: pytest-mock<4.0.0,>=3.10.0; extra == 'test'
|
|
23
|
+
Requires-Dist: pytest<9.0.0,>=8.4.1; extra == 'test'
|
|
24
|
+
Provides-Extra: typing
|
|
25
|
+
Requires-Dist: langchain-core; extra == 'typing'
|
|
26
|
+
Requires-Dist: mypy<2.0.0,>=1.17.1; extra == 'typing'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# langchain-ladybug
|
|
30
|
+
|
|
31
|
+
LangChain integration for the [Ladybug](https://github.com/ladybug-ai/ladybug) graph database.
|
|
32
|
+
|
|
33
|
+
This package provides:
|
|
34
|
+
|
|
35
|
+
- **`LadybugGraph`** — a LangChain graph wrapper for Ladybug that supports schema introspection, Cypher queries, and document ingestion via `add_graph_documents`.
|
|
36
|
+
- **`LadybugQAChain`** — a question-answering chain that generates Ladybug-dialect Cypher queries from natural language and returns answers grounded in the graph.
|
|
37
|
+
- Prompt templates (`LADYBUG_GENERATION_PROMPT`, `CYPHER_QA_PROMPT`) tuned for the Ladybug Cypher dialect.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv pip install langchain-ladybug
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The `ladybug` Python package must also be installed:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv pip install ladybug
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import ladybug as lb
|
|
55
|
+
from langchain_ladybug import LadybugGraph, LadybugQAChain
|
|
56
|
+
from langchain_openai import ChatOpenAI
|
|
57
|
+
|
|
58
|
+
db = lb.Database("/path/to/my.db")
|
|
59
|
+
|
|
60
|
+
graph = LadybugGraph(db)
|
|
61
|
+
|
|
62
|
+
llm = ChatOpenAI(model="gpt-4o", temperature=0)
|
|
63
|
+
|
|
64
|
+
chain = LadybugQAChain.from_llm(
|
|
65
|
+
llm=llm,
|
|
66
|
+
graph=graph,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
answer = chain.invoke({"query": "Who acted in The Godfather?"})
|
|
70
|
+
print(answer["result"])
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Install dependencies
|
|
77
|
+
uv sync
|
|
78
|
+
|
|
79
|
+
# Run unit tests
|
|
80
|
+
make test
|
|
81
|
+
|
|
82
|
+
# Lint
|
|
83
|
+
make lint
|
|
84
|
+
|
|
85
|
+
# Format
|
|
86
|
+
make format
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Acknowledgements
|
|
90
|
+
|
|
91
|
+
Started from the Kuzu → Ladybug LangChain support port by [@adsharma](https://github.com/adsharma) ([PR #438](https://github.com/langchain-ai/langchain-community/pull/438)) — a proposed LadybugDB (formerly Kuzu) integration into the upstream langchain-community repo
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
## Project Structure
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
langchain_ladybug/
|
|
98
|
+
├── graphs/
|
|
99
|
+
│ ├── graph_document.py # Node, Relationship, GraphDocument data classes
|
|
100
|
+
│ └── ladybug_graph.py # LadybugGraph wrapper
|
|
101
|
+
└── chains/
|
|
102
|
+
├── prompts.py # Ladybug-dialect Cypher prompt templates
|
|
103
|
+
└── ladybug_qa.py # LadybugQAChain
|
|
104
|
+
tests/
|
|
105
|
+
├── unit_tests/ # No network calls
|
|
106
|
+
└── integration_tests/ # Requires ladybug
|
|
107
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# langchain-ladybug
|
|
2
|
+
|
|
3
|
+
LangChain integration for the [Ladybug](https://github.com/ladybug-ai/ladybug) graph database.
|
|
4
|
+
|
|
5
|
+
This package provides:
|
|
6
|
+
|
|
7
|
+
- **`LadybugGraph`** — a LangChain graph wrapper for Ladybug that supports schema introspection, Cypher queries, and document ingestion via `add_graph_documents`.
|
|
8
|
+
- **`LadybugQAChain`** — a question-answering chain that generates Ladybug-dialect Cypher queries from natural language and returns answers grounded in the graph.
|
|
9
|
+
- Prompt templates (`LADYBUG_GENERATION_PROMPT`, `CYPHER_QA_PROMPT`) tuned for the Ladybug Cypher dialect.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv pip install langchain-ladybug
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The `ladybug` Python package must also be installed:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv pip install ladybug
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import ladybug as lb
|
|
27
|
+
from langchain_ladybug import LadybugGraph, LadybugQAChain
|
|
28
|
+
from langchain_openai import ChatOpenAI
|
|
29
|
+
|
|
30
|
+
db = lb.Database("/path/to/my.db")
|
|
31
|
+
|
|
32
|
+
graph = LadybugGraph(db)
|
|
33
|
+
|
|
34
|
+
llm = ChatOpenAI(model="gpt-4o", temperature=0)
|
|
35
|
+
|
|
36
|
+
chain = LadybugQAChain.from_llm(
|
|
37
|
+
llm=llm,
|
|
38
|
+
graph=graph,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
answer = chain.invoke({"query": "Who acted in The Godfather?"})
|
|
42
|
+
print(answer["result"])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Development
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Install dependencies
|
|
49
|
+
uv sync
|
|
50
|
+
|
|
51
|
+
# Run unit tests
|
|
52
|
+
make test
|
|
53
|
+
|
|
54
|
+
# Lint
|
|
55
|
+
make lint
|
|
56
|
+
|
|
57
|
+
# Format
|
|
58
|
+
make format
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Acknowledgements
|
|
62
|
+
|
|
63
|
+
Started from the Kuzu → Ladybug LangChain support port by [@adsharma](https://github.com/adsharma) ([PR #438](https://github.com/langchain-ai/langchain-community/pull/438)) — a proposed LadybugDB (formerly Kuzu) integration into the upstream langchain-community repo
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
## Project Structure
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
langchain_ladybug/
|
|
70
|
+
├── graphs/
|
|
71
|
+
│ ├── graph_document.py # Node, Relationship, GraphDocument data classes
|
|
72
|
+
│ └── ladybug_graph.py # LadybugGraph wrapper
|
|
73
|
+
└── chains/
|
|
74
|
+
├── prompts.py # Ladybug-dialect Cypher prompt templates
|
|
75
|
+
└── ladybug_qa.py # LadybugQAChain
|
|
76
|
+
tests/
|
|
77
|
+
├── unit_tests/ # No network calls
|
|
78
|
+
└── integration_tests/ # Requires ladybug
|
|
79
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""langchain-ladybug: LangChain integration for the Ladybug graph database."""
|
|
2
|
+
|
|
3
|
+
from langchain_ladybug.chains.ladybug_qa import LadybugQAChain
|
|
4
|
+
from langchain_ladybug.graphs.ladybug_graph import LadybugGraph
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"LadybugGraph",
|
|
8
|
+
"LadybugQAChain",
|
|
9
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Chain and prompt classes for the Ladybug integration."""
|
|
2
|
+
|
|
3
|
+
from langchain_ladybug.chains.ladybug_qa import LadybugQAChain
|
|
4
|
+
from langchain_ladybug.chains.prompts import (
|
|
5
|
+
CYPHER_QA_PROMPT,
|
|
6
|
+
LADYBUG_GENERATION_PROMPT,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"LadybugQAChain",
|
|
11
|
+
"CYPHER_QA_PROMPT",
|
|
12
|
+
"LADYBUG_GENERATION_PROMPT",
|
|
13
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Question answering over a graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from langchain_classic.chains.base import Chain
|
|
9
|
+
from langchain_classic.chains.llm import LLMChain
|
|
10
|
+
from langchain_core.callbacks import CallbackManagerForChainRun
|
|
11
|
+
from langchain_core.language_models import BaseLanguageModel
|
|
12
|
+
from langchain_core.prompts import BasePromptTemplate
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from langchain_ladybug.chains.prompts import CYPHER_QA_PROMPT, LADYBUG_GENERATION_PROMPT
|
|
16
|
+
from langchain_ladybug.graphs.ladybug_graph import LadybugGraph
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def remove_prefix(text: str, prefix: str) -> str:
|
|
20
|
+
"""Remove a prefix from a text string.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
text: Text to remove the prefix from.
|
|
24
|
+
prefix: Prefix to remove from the text.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Text with the prefix removed, or the original text if it does not start
|
|
28
|
+
with the prefix.
|
|
29
|
+
"""
|
|
30
|
+
if text.startswith(prefix):
|
|
31
|
+
return text[len(prefix):]
|
|
32
|
+
return text
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_cypher(text: str) -> str:
|
|
36
|
+
"""Extract Cypher code from a text string.
|
|
37
|
+
|
|
38
|
+
Looks for Cypher code enclosed in triple backticks.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
text: Text to extract Cypher code from.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Cypher code extracted from the first triple-backtick block, or the
|
|
45
|
+
original text if no such block is found.
|
|
46
|
+
"""
|
|
47
|
+
pattern = r"```(.*?)```"
|
|
48
|
+
matches = re.findall(pattern, text, re.DOTALL)
|
|
49
|
+
return matches[0] if matches else text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LadybugQAChain(Chain):
|
|
53
|
+
"""Question-answering against a graph by generating Cypher statements for Ladybug.
|
|
54
|
+
|
|
55
|
+
*Security note*: Make sure that the database connection uses credentials
|
|
56
|
+
that are narrowly-scoped to only include necessary permissions.
|
|
57
|
+
Failure to do so may result in data corruption or loss, since the calling
|
|
58
|
+
code may attempt commands that would result in deletion, mutation
|
|
59
|
+
of data if appropriately prompted or reading sensitive data if such
|
|
60
|
+
data is present in the database.
|
|
61
|
+
The best way to guard against such negative outcomes is to (as appropriate)
|
|
62
|
+
limit the permissions granted to the credentials used with this tool.
|
|
63
|
+
|
|
64
|
+
See https://python.langchain.com/docs/security for more information.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
graph: LadybugGraph = Field(exclude=True)
|
|
68
|
+
cypher_generation_chain: LLMChain
|
|
69
|
+
qa_chain: LLMChain
|
|
70
|
+
input_key: str = "query"
|
|
71
|
+
output_key: str = "result"
|
|
72
|
+
|
|
73
|
+
allow_dangerous_requests: bool = False
|
|
74
|
+
"""Forced user opt-in to acknowledge that the chain can make dangerous requests.
|
|
75
|
+
|
|
76
|
+
*Security note*: Make sure that the database connection uses credentials
|
|
77
|
+
that are narrowly-scoped to only include necessary permissions.
|
|
78
|
+
Failure to do so may result in data corruption or loss, since the calling
|
|
79
|
+
code may attempt commands that would result in deletion, mutation
|
|
80
|
+
of data if appropriately prompted or reading sensitive data if such
|
|
81
|
+
data is present in the database.
|
|
82
|
+
The best way to guard against such negative outcomes is to (as appropriate)
|
|
83
|
+
limit the permissions granted to the credentials used with this tool.
|
|
84
|
+
|
|
85
|
+
See https://python.langchain.com/docs/security for more information.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
89
|
+
"""Initialize the chain.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: If `allow_dangerous_requests` is not `True`.
|
|
93
|
+
"""
|
|
94
|
+
if not kwargs.get("allow_dangerous_requests"):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"In order to use this chain, you must acknowledge that it can make "
|
|
97
|
+
"dangerous requests by setting `allow_dangerous_requests` to `True`. "
|
|
98
|
+
"You must narrowly scope the permissions of the database connection "
|
|
99
|
+
"to only include necessary permissions. Failure to do so may result "
|
|
100
|
+
"in data corruption or loss or reading sensitive data if such data is "
|
|
101
|
+
"present in the database. "
|
|
102
|
+
"Only use this chain if you understand the risks and have taken the "
|
|
103
|
+
"necessary precautions. "
|
|
104
|
+
"See https://python.langchain.com/docs/security for more information."
|
|
105
|
+
)
|
|
106
|
+
super().__init__(**kwargs)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def input_keys(self) -> List[str]:
|
|
110
|
+
"""Return the input keys."""
|
|
111
|
+
return [self.input_key]
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def output_keys(self) -> List[str]:
|
|
115
|
+
"""Return the output keys."""
|
|
116
|
+
return [self.output_key]
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_llm(
|
|
120
|
+
cls,
|
|
121
|
+
llm: Optional[BaseLanguageModel] = None,
|
|
122
|
+
*,
|
|
123
|
+
qa_prompt: BasePromptTemplate = CYPHER_QA_PROMPT,
|
|
124
|
+
cypher_prompt: BasePromptTemplate = LADYBUG_GENERATION_PROMPT,
|
|
125
|
+
cypher_llm: Optional[BaseLanguageModel] = None,
|
|
126
|
+
qa_llm: Optional[BaseLanguageModel] = None,
|
|
127
|
+
**kwargs: Any,
|
|
128
|
+
) -> LadybugQAChain:
|
|
129
|
+
"""Initialize from an LLM.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
llm: A language model to use for both Cypher generation and QA when
|
|
133
|
+
separate models are not provided.
|
|
134
|
+
qa_prompt: Prompt template for the QA step.
|
|
135
|
+
cypher_prompt: Prompt template for the Cypher generation step.
|
|
136
|
+
cypher_llm: A language model dedicated to Cypher generation. If
|
|
137
|
+
provided, `llm` is used only for QA unless `qa_llm` is also given.
|
|
138
|
+
qa_llm: A language model dedicated to the QA step. If provided,
|
|
139
|
+
`llm` is used only for Cypher generation unless `cypher_llm` is
|
|
140
|
+
also given.
|
|
141
|
+
**kwargs: Additional keyword arguments passed to the chain constructor.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
A configured `LadybugQAChain` instance.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: If neither `llm` nor `cypher_llm` is provided, neither
|
|
148
|
+
`llm` nor `qa_llm` is provided, or all three of `llm`,
|
|
149
|
+
`cypher_llm`, and `qa_llm` are provided simultaneously.
|
|
150
|
+
"""
|
|
151
|
+
if not cypher_llm and not llm:
|
|
152
|
+
raise ValueError("Either `llm` or `cypher_llm` parameters must be provided")
|
|
153
|
+
if not qa_llm and not llm:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"Either `llm` or `qa_llm` parameters must be provided along with"
|
|
156
|
+
" `cypher_llm`"
|
|
157
|
+
)
|
|
158
|
+
if cypher_llm and qa_llm and llm:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"You can specify up to two of 'cypher_llm', 'qa_llm'"
|
|
161
|
+
", and 'llm', but not all three simultaneously."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
qa_chain = LLMChain(
|
|
165
|
+
llm=qa_llm or llm, # type: ignore[arg-type]
|
|
166
|
+
prompt=qa_prompt,
|
|
167
|
+
)
|
|
168
|
+
cypher_generation_chain = LLMChain(
|
|
169
|
+
llm=cypher_llm or llm, # type: ignore[arg-type]
|
|
170
|
+
prompt=cypher_prompt,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return cls(
|
|
174
|
+
qa_chain=qa_chain,
|
|
175
|
+
cypher_generation_chain=cypher_generation_chain,
|
|
176
|
+
**kwargs,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _call(
|
|
180
|
+
self,
|
|
181
|
+
inputs: Dict[str, Any],
|
|
182
|
+
run_manager: Optional[CallbackManagerForChainRun] = None,
|
|
183
|
+
) -> Dict[str, str]:
|
|
184
|
+
"""Generate a Cypher statement, query the graph, and answer the question.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
inputs: Dictionary containing the input question under `input_key`.
|
|
188
|
+
run_manager: Optional callback manager for tracing and logging.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Dictionary with the answer under `output_key`.
|
|
192
|
+
"""
|
|
193
|
+
_run_manager = run_manager or CallbackManagerForChainRun.get_noop_manager()
|
|
194
|
+
callbacks = _run_manager.get_child()
|
|
195
|
+
question = inputs[self.input_key]
|
|
196
|
+
|
|
197
|
+
generated_cypher = self.cypher_generation_chain.run(
|
|
198
|
+
{"question": question, "schema": self.graph.get_schema}, callbacks=callbacks
|
|
199
|
+
)
|
|
200
|
+
generated_cypher = remove_prefix(extract_cypher(generated_cypher), "cypher")
|
|
201
|
+
|
|
202
|
+
_run_manager.on_text("Generated Cypher:", end="\n", verbose=self.verbose)
|
|
203
|
+
_run_manager.on_text(
|
|
204
|
+
generated_cypher, color="green", end="\n", verbose=self.verbose
|
|
205
|
+
)
|
|
206
|
+
context = self.graph.query(generated_cypher)
|
|
207
|
+
|
|
208
|
+
_run_manager.on_text("Full Context:", end="\n", verbose=self.verbose)
|
|
209
|
+
_run_manager.on_text(
|
|
210
|
+
str(context), color="green", end="\n", verbose=self.verbose
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
result = self.qa_chain(
|
|
214
|
+
{"question": question, "context": context},
|
|
215
|
+
callbacks=callbacks,
|
|
216
|
+
)
|
|
217
|
+
return {self.output_key: result[self.qa_chain.output_key]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# flake8: noqa
|
|
2
|
+
from langchain_core.prompts.prompt import PromptTemplate
|
|
3
|
+
|
|
4
|
+
CYPHER_GENERATION_TEMPLATE = """Task:Generate Cypher statement to query a graph database.
|
|
5
|
+
Instructions:
|
|
6
|
+
Use only the provided relationship types and properties in the schema.
|
|
7
|
+
Do not use any other relationship types or properties that are not provided.
|
|
8
|
+
Schema:
|
|
9
|
+
{schema}
|
|
10
|
+
Note: Do not include any explanations or apologies in your responses.
|
|
11
|
+
Do not respond to any questions that might ask anything else than for you to construct a Cypher statement.
|
|
12
|
+
Do not include any text except the generated Cypher statement.
|
|
13
|
+
|
|
14
|
+
The question is:
|
|
15
|
+
{question}"""
|
|
16
|
+
|
|
17
|
+
LADYBUG_EXTRA_INSTRUCTIONS = """
|
|
18
|
+
Instructions:
|
|
19
|
+
Generate the Ladybug dialect of Cypher with the following rules in mind:
|
|
20
|
+
1. Do not omit the relationship pattern. Always use `()-[]->()` instead of `()->()`.
|
|
21
|
+
2. Do not include triple backticks ``` in your response. Return only Cypher.
|
|
22
|
+
3. Do not return any notes or comments in your response.
|
|
23
|
+
\n"""
|
|
24
|
+
|
|
25
|
+
LADYBUG_GENERATION_TEMPLATE = CYPHER_GENERATION_TEMPLATE.replace(
|
|
26
|
+
"Generate Cypher", "Generate Ladybug Cypher"
|
|
27
|
+
).replace("Instructions:", LADYBUG_EXTRA_INSTRUCTIONS)
|
|
28
|
+
|
|
29
|
+
LADYBUG_GENERATION_PROMPT = PromptTemplate(
|
|
30
|
+
input_variables=["schema", "question"], template=LADYBUG_GENERATION_TEMPLATE
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
CYPHER_QA_TEMPLATE = """You are an assistant that helps to form nice and human understandable answers.
|
|
34
|
+
The information part contains the provided information that you must use to construct an answer.
|
|
35
|
+
The provided information is authoritative, you must never doubt it or try to use your internal knowledge to correct it.
|
|
36
|
+
Make the answer sound as a response to the question. Do not mention that you based the result on the given information.
|
|
37
|
+
Here is an example:
|
|
38
|
+
|
|
39
|
+
Question: Which managers own Neo4j stocks?
|
|
40
|
+
Context:[manager:CTL LLC, manager:JANE STREET GROUP LLC]
|
|
41
|
+
Helpful Answer: CTL LLC, JANE STREET GROUP LLC owns Neo4j stocks.
|
|
42
|
+
|
|
43
|
+
Follow this example when generating answers.
|
|
44
|
+
If the provided information is empty, say that you don't know the answer.
|
|
45
|
+
Information:
|
|
46
|
+
{context}
|
|
47
|
+
|
|
48
|
+
Question: {question}
|
|
49
|
+
Helpful Answer:"""
|
|
50
|
+
|
|
51
|
+
CYPHER_QA_PROMPT = PromptTemplate(
|
|
52
|
+
input_variables=["context", "question"], template=CYPHER_QA_TEMPLATE
|
|
53
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Graph classes for the Ladybug integration."""
|
|
2
|
+
|
|
3
|
+
from langchain_community.graphs.graph_document import GraphDocument, Node, Relationship
|
|
4
|
+
from langchain_ladybug.graphs.ladybug_graph import LadybugGraph
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"LadybugGraph",
|
|
8
|
+
"GraphDocument",
|
|
9
|
+
"Node",
|
|
10
|
+
"Relationship",
|
|
11
|
+
]
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from hashlib import md5
|
|
2
|
+
from typing import Any, Dict, List, Tuple
|
|
3
|
+
|
|
4
|
+
from langchain_community.graphs.graph_document import GraphDocument, Relationship
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LadybugGraph:
|
|
8
|
+
"""Ladybug wrapper for graph operations.
|
|
9
|
+
|
|
10
|
+
*Security note*: Make sure that the database connection uses credentials
|
|
11
|
+
that are narrowly-scoped to only include necessary permissions.
|
|
12
|
+
Failure to do so may result in data corruption or loss, since the calling
|
|
13
|
+
code may attempt commands that would result in deletion, mutation
|
|
14
|
+
of data if appropriately prompted or reading sensitive data if such
|
|
15
|
+
data is present in the database.
|
|
16
|
+
The best way to guard against such negative outcomes is to (as appropriate)
|
|
17
|
+
limit the permissions granted to the credentials used with this tool.
|
|
18
|
+
|
|
19
|
+
See https://python.langchain.com/docs/security for more information.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self, db: Any, database: str = "ladybug", allow_dangerous_requests: bool = False
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Initializes the Ladybug graph database connection.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
db: The Ladybug database instance.
|
|
29
|
+
database: The name of the database.
|
|
30
|
+
allow_dangerous_requests: Must be set to `True` to acknowledge that
|
|
31
|
+
this class can execute arbitrary queries on the database.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: If `allow_dangerous_requests` is not `True`, or if the
|
|
35
|
+
`ladybug` package is not installed.
|
|
36
|
+
"""
|
|
37
|
+
if allow_dangerous_requests is not True:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"The LadybugGraph class is a powerful tool that can be used to execute "
|
|
40
|
+
"arbitrary queries on the database. To enable this functionality, "
|
|
41
|
+
"set the `allow_dangerous_requests` parameter to `True` when "
|
|
42
|
+
"constructing the LadybugGraph object."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
import ladybug as lb
|
|
47
|
+
except ImportError as e:
|
|
48
|
+
raise ImportError(
|
|
49
|
+
"Could not import Ladybug python package. "
|
|
50
|
+
"Please install Ladybug with `pip install ladybug`."
|
|
51
|
+
) from e
|
|
52
|
+
|
|
53
|
+
self.db = db
|
|
54
|
+
self.conn = lb.Connection(self.db)
|
|
55
|
+
self.database = database
|
|
56
|
+
self.refresh_schema()
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def get_schema(self) -> str:
|
|
60
|
+
"""Returns the schema of the Ladybug database."""
|
|
61
|
+
return self.schema
|
|
62
|
+
|
|
63
|
+
def query(self, query: str, params: dict = {}) -> List[Dict[str, Any]]:
|
|
64
|
+
"""Query the Ladybug database.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
query: The Cypher query string to execute.
|
|
68
|
+
params: Optional parameter bindings for the query.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A list of result rows, each represented as a dict of column name to value.
|
|
72
|
+
"""
|
|
73
|
+
result = self.conn.execute(query, params)
|
|
74
|
+
column_names = result.get_column_names()
|
|
75
|
+
return_list = []
|
|
76
|
+
while result.has_next():
|
|
77
|
+
row = result.get_next()
|
|
78
|
+
return_list.append(dict(zip(column_names, row)))
|
|
79
|
+
return return_list
|
|
80
|
+
|
|
81
|
+
def refresh_schema(self) -> None:
|
|
82
|
+
"""Refreshes the Ladybug graph schema information."""
|
|
83
|
+
node_properties = []
|
|
84
|
+
node_table_names = self.conn._get_node_table_names()
|
|
85
|
+
for table_name in node_table_names:
|
|
86
|
+
current_table_schema = {"properties": [], "label": table_name}
|
|
87
|
+
properties = self.conn._get_node_property_names(table_name)
|
|
88
|
+
for property_name in properties:
|
|
89
|
+
property_type = properties[property_name]["type"]
|
|
90
|
+
list_type_flag = ""
|
|
91
|
+
if properties[property_name]["dimension"] > 0:
|
|
92
|
+
if "shape" in properties[property_name]:
|
|
93
|
+
for s in properties[property_name]["shape"]:
|
|
94
|
+
list_type_flag += f"[{s}]"
|
|
95
|
+
else:
|
|
96
|
+
for i in range(properties[property_name]["dimension"]):
|
|
97
|
+
list_type_flag += "[]"
|
|
98
|
+
property_type += list_type_flag
|
|
99
|
+
current_table_schema["properties"].append(
|
|
100
|
+
(
|
|
101
|
+
property_name,
|
|
102
|
+
property_type,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
node_properties.append(current_table_schema)
|
|
106
|
+
|
|
107
|
+
relationships = []
|
|
108
|
+
rel_tables = self.conn._get_rel_table_names()
|
|
109
|
+
for table in rel_tables:
|
|
110
|
+
relationships.append(
|
|
111
|
+
f"(:{table['src']})-[:{table['name']}]->(:{table['dst']})"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
rel_properties = []
|
|
115
|
+
for table in rel_tables:
|
|
116
|
+
table_name = table["name"]
|
|
117
|
+
current_table_schema = {"properties": [], "label": table_name}
|
|
118
|
+
query_result = self.conn.execute(
|
|
119
|
+
f"CALL table_info('{table_name}') RETURN *;"
|
|
120
|
+
)
|
|
121
|
+
while query_result.has_next():
|
|
122
|
+
row = query_result.get_next()
|
|
123
|
+
prop_name = row[1]
|
|
124
|
+
prop_type = row[2]
|
|
125
|
+
current_table_schema["properties"].append((prop_name, prop_type))
|
|
126
|
+
rel_properties.append(current_table_schema)
|
|
127
|
+
|
|
128
|
+
self.schema = (
|
|
129
|
+
f"Node properties: {node_properties}\n"
|
|
130
|
+
f"Relationships properties: {rel_properties}\n"
|
|
131
|
+
f"Relationships: {relationships}\n"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _create_chunk_node_table(self) -> None:
|
|
135
|
+
self.conn.execute(
|
|
136
|
+
"""
|
|
137
|
+
CREATE NODE TABLE IF NOT EXISTS Chunk (
|
|
138
|
+
id STRING,
|
|
139
|
+
text STRING,
|
|
140
|
+
type STRING,
|
|
141
|
+
PRIMARY KEY(id)
|
|
142
|
+
);
|
|
143
|
+
"""
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _create_entity_node_table(self, node_label: str) -> None:
|
|
147
|
+
self.conn.execute(
|
|
148
|
+
f"""
|
|
149
|
+
CREATE NODE TABLE IF NOT EXISTS {node_label} (
|
|
150
|
+
id STRING,
|
|
151
|
+
type STRING,
|
|
152
|
+
PRIMARY KEY(id)
|
|
153
|
+
);
|
|
154
|
+
"""
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _create_entity_relationship_table(self, rel: Relationship) -> None:
|
|
158
|
+
self.conn.execute(
|
|
159
|
+
f"""
|
|
160
|
+
CREATE REL TABLE IF NOT EXISTS {rel.type} (
|
|
161
|
+
FROM {rel.source.type} TO {rel.target.type}
|
|
162
|
+
);
|
|
163
|
+
"""
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def add_graph_documents(
|
|
167
|
+
self,
|
|
168
|
+
graph_documents: List[GraphDocument],
|
|
169
|
+
allowed_relationships: List[Tuple[str, str, str]],
|
|
170
|
+
include_source: bool = False,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Add a list of `GraphDocument` objects to the Ladybug graph.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
graph_documents: A list of `GraphDocument` objects that contain the
|
|
176
|
+
nodes and relationships to be added to the graph.
|
|
177
|
+
allowed_relationships: A list of allowed relationships in the graph.
|
|
178
|
+
Each tuple contains ``(source_node_type, relationship_type,
|
|
179
|
+
target_node_type)``. Required for Ladybug because the relationship
|
|
180
|
+
table names must pre-exist and are derived from these tuples.
|
|
181
|
+
include_source: If `True`, stores the source document and links it to
|
|
182
|
+
nodes in the graph via the `MENTIONS` relationship. Merges source
|
|
183
|
+
documents by the `id` field in source metadata when available;
|
|
184
|
+
otherwise uses the MD5 hash of `page_content`.
|
|
185
|
+
"""
|
|
186
|
+
node_labels = list(
|
|
187
|
+
{node.type for document in graph_documents for node in document.nodes}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
for document in graph_documents:
|
|
191
|
+
if include_source:
|
|
192
|
+
self._create_chunk_node_table()
|
|
193
|
+
if not document.source.metadata.get("id"):
|
|
194
|
+
document.source.metadata["id"] = md5(
|
|
195
|
+
document.source.page_content.encode("utf-8")
|
|
196
|
+
).hexdigest()
|
|
197
|
+
|
|
198
|
+
self.conn.execute(
|
|
199
|
+
f"""
|
|
200
|
+
MERGE (c:Chunk {{id: $id}})
|
|
201
|
+
SET c.text = $text,
|
|
202
|
+
c.type = "text_chunk"
|
|
203
|
+
""", # noqa: F541
|
|
204
|
+
parameters={
|
|
205
|
+
"id": document.source.metadata["id"],
|
|
206
|
+
"text": document.source.page_content,
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
for node_label in node_labels:
|
|
211
|
+
self._create_entity_node_table(node_label)
|
|
212
|
+
|
|
213
|
+
for node in document.nodes:
|
|
214
|
+
self.conn.execute(
|
|
215
|
+
f"""
|
|
216
|
+
MERGE (e:{node.type} {{id: $id}})
|
|
217
|
+
SET e.type = "entity"
|
|
218
|
+
""",
|
|
219
|
+
parameters={"id": node.id},
|
|
220
|
+
)
|
|
221
|
+
if include_source:
|
|
222
|
+
self._create_chunk_node_table()
|
|
223
|
+
ddl = "CREATE REL TABLE GROUP IF NOT EXISTS MENTIONS ("
|
|
224
|
+
table_names = list(
|
|
225
|
+
{f"FROM Chunk TO {label}" for label in node_labels}
|
|
226
|
+
)
|
|
227
|
+
ddl += ", ".join(table_names)
|
|
228
|
+
ddl += ", label STRING, triplet_source_id STRING)"
|
|
229
|
+
if ddl:
|
|
230
|
+
self.conn.execute(ddl)
|
|
231
|
+
|
|
232
|
+
if node.type in node_labels:
|
|
233
|
+
self.conn.execute(
|
|
234
|
+
f"""
|
|
235
|
+
MATCH (c:Chunk {{id: $id}}),
|
|
236
|
+
(e:{node.type} {{id: $node_id}})
|
|
237
|
+
MERGE (c)-[m:MENTIONS]->(e)
|
|
238
|
+
SET m.triplet_source_id = $id
|
|
239
|
+
""",
|
|
240
|
+
parameters={
|
|
241
|
+
"id": document.source.metadata["id"],
|
|
242
|
+
"node_id": node.id,
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
for rel in document.relationships:
|
|
247
|
+
self._create_entity_relationship_table(rel)
|
|
248
|
+
self.conn.execute(
|
|
249
|
+
f"""
|
|
250
|
+
MATCH (e1:{rel.source.type} {{id: $source_id}}),
|
|
251
|
+
(e2:{rel.target.type} {{id: $target_id}})
|
|
252
|
+
MERGE (e1)-[:{rel.type}]->(e2)
|
|
253
|
+
""",
|
|
254
|
+
parameters={
|
|
255
|
+
"source_id": rel.source.id,
|
|
256
|
+
"target_id": rel.target.id,
|
|
257
|
+
},
|
|
258
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-ladybug"
|
|
7
|
+
description = "LangChain integration for the Ladybug graph database."
|
|
8
|
+
license = { text = "MIT" }
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = []
|
|
11
|
+
|
|
12
|
+
version = "0.3.0"
|
|
13
|
+
requires-python = ">=3.10.0,<4.0.0"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"langchain-core>=1.0.1,<2.0.0",
|
|
16
|
+
"langchain-classic>=1.0.0,<2.0.0",
|
|
17
|
+
"langchain-community>=0.4.1,<1.0.0",
|
|
18
|
+
"pydantic>=2.0.0,<3.0.0",
|
|
19
|
+
"ladybug>=0.16.1",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/stevereiner/langchain-ladybug"
|
|
24
|
+
Source = "https://github.com/stevereiner/langchain-ladybug"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
test = [
|
|
28
|
+
"pytest>=8.4.1,<9.0.0",
|
|
29
|
+
"pytest-cov>=6.2.1,<7.0.0",
|
|
30
|
+
"pytest-asyncio>=0.20.3,<1.0.0",
|
|
31
|
+
"pytest-mock>=3.10.0,<4.0.0",
|
|
32
|
+
"langchain-tests>=1.0.0,<2.0.0",
|
|
33
|
+
]
|
|
34
|
+
lint = [
|
|
35
|
+
"ruff>=0.13.1,<1.0.0",
|
|
36
|
+
]
|
|
37
|
+
typing = [
|
|
38
|
+
"mypy>=1.17.1,<2.0.0",
|
|
39
|
+
"langchain-core",
|
|
40
|
+
]
|
|
41
|
+
dev = [
|
|
42
|
+
"langchain-core",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
exclude = [
|
|
47
|
+
"venv*/",
|
|
48
|
+
".venv*/",
|
|
49
|
+
"dist/",
|
|
50
|
+
".benchmarks/",
|
|
51
|
+
".pytest_cache/",
|
|
52
|
+
".mypy_cache/",
|
|
53
|
+
".ruff_cache/",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
target-version = "py310"
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
select = ["E", "F", "I", "T201", "UP"]
|
|
61
|
+
|
|
62
|
+
[tool.mypy]
|
|
63
|
+
ignore_missing_imports = true
|
|
64
|
+
disallow_untyped_defs = true
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
addopts = "--strict-markers --strict-config"
|
|
68
|
+
asyncio_mode = "auto"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Integration tests for LadybugGraph.
|
|
2
|
+
|
|
3
|
+
These tests require the `ladybug` package and a live Ladybug database.
|
|
4
|
+
Run with: uv run --group test pytest tests/integration_tests/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
|
|
12
|
+
from langchain_ladybug.graphs import LadybugGraph
|
|
13
|
+
|
|
14
|
+
EXPECTED_SCHEMA = """Node properties: [{'properties': [('name', 'STRING')], 'label': 'Movie'}, {'properties': [('name', 'STRING'), ('birthDate', 'STRING')], 'label': 'Person'}]
|
|
15
|
+
Relationships properties: [{'properties': [], 'label': 'ActedIn'}]
|
|
16
|
+
Relationships: ['(:Person)-[:ActedIn]->(:Movie)']
|
|
17
|
+
""" # noqa: E501
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestLadybug(unittest.TestCase):
|
|
21
|
+
def setUp(self) -> None:
|
|
22
|
+
try:
|
|
23
|
+
import ladybug as lb
|
|
24
|
+
except ImportError as e:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
"Cannot import Python package ladybug. Please install it by running "
|
|
27
|
+
"`pip install ladybug`."
|
|
28
|
+
) from e
|
|
29
|
+
|
|
30
|
+
self.tmpdir = tempfile.mkdtemp()
|
|
31
|
+
self.db_path = os.path.join(self.tmpdir, "test.db")
|
|
32
|
+
self.ladybug_database = lb.Database(self.db_path)
|
|
33
|
+
self.conn = lb.Connection(self.ladybug_database)
|
|
34
|
+
self.conn.execute("CREATE NODE TABLE Movie (name STRING, PRIMARY KEY(name))")
|
|
35
|
+
self.conn.execute("CREATE (:Movie {name: 'The Godfather'})")
|
|
36
|
+
self.conn.execute("CREATE (:Movie {name: 'The Godfather: Part II'})")
|
|
37
|
+
self.conn.execute(
|
|
38
|
+
"CREATE (:Movie {name: 'The Godfather Coda: The Death of Michael Corleone'})"
|
|
39
|
+
)
|
|
40
|
+
self.ladybug_graph = LadybugGraph(
|
|
41
|
+
self.ladybug_database, allow_dangerous_requests=True
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def tearDown(self) -> None:
|
|
45
|
+
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
|
46
|
+
|
|
47
|
+
def test_query_no_params(self) -> None:
|
|
48
|
+
result = self.ladybug_graph.query("MATCH (n:Movie) RETURN n.name ORDER BY n.name")
|
|
49
|
+
expected_result = [
|
|
50
|
+
{"n.name": "The Godfather"},
|
|
51
|
+
{"n.name": "The Godfather Coda: The Death of Michael Corleone"},
|
|
52
|
+
{"n.name": "The Godfather: Part II"},
|
|
53
|
+
]
|
|
54
|
+
self.assertEqual(result, expected_result)
|
|
55
|
+
|
|
56
|
+
def test_query_params(self) -> None:
|
|
57
|
+
result = self.ladybug_graph.query(
|
|
58
|
+
query="MATCH (n:Movie) WHERE n.name = $name RETURN n.name",
|
|
59
|
+
params={"name": "The Godfather"},
|
|
60
|
+
)
|
|
61
|
+
expected_result = [
|
|
62
|
+
{"n.name": "The Godfather"},
|
|
63
|
+
]
|
|
64
|
+
self.assertEqual(result, expected_result)
|
|
65
|
+
|
|
66
|
+
def test_refresh_schema(self) -> None:
|
|
67
|
+
self.conn.execute(
|
|
68
|
+
"CREATE NODE TABLE Person (name STRING, birthDate STRING, PRIMARY "
|
|
69
|
+
"KEY(name))"
|
|
70
|
+
)
|
|
71
|
+
self.conn.execute("CREATE REL TABLE ActedIn (FROM Person TO Movie)")
|
|
72
|
+
self.ladybug_graph.refresh_schema()
|
|
73
|
+
schema = self.ladybug_graph.get_schema
|
|
74
|
+
self.assertEqual(schema, EXPECTED_SCHEMA)
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Verify that all public symbols can be imported from the package."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_ladybug_graph_importable() -> None:
|
|
5
|
+
from langchain_ladybug.graphs import LadybugGraph # noqa: F401
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_ladybug_qa_chain_importable() -> None:
|
|
9
|
+
from langchain_ladybug.chains import LadybugQAChain # noqa: F401
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_graph_document_importable() -> None:
|
|
13
|
+
from langchain_community.graphs.graph_document import ( # noqa: F401
|
|
14
|
+
GraphDocument,
|
|
15
|
+
Node,
|
|
16
|
+
Relationship,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_prompts_importable() -> None:
|
|
21
|
+
from langchain_ladybug.chains import ( # noqa: F401
|
|
22
|
+
CYPHER_QA_PROMPT,
|
|
23
|
+
LADYBUG_GENERATION_PROMPT,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_top_level_importable() -> None:
|
|
28
|
+
import langchain_ladybug # noqa: F401
|
|
29
|
+
|
|
30
|
+
assert hasattr(langchain_ladybug, "LadybugGraph")
|
|
31
|
+
assert hasattr(langchain_ladybug, "LadybugQAChain")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Unit tests for LadybugQAChain."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from langchain_ladybug.chains.ladybug_qa import extract_cypher, remove_prefix
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_remove_prefix_removes_when_present() -> None:
|
|
9
|
+
assert remove_prefix("cypher MATCH (n) RETURN n", "cypher") == " MATCH (n) RETURN n"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_remove_prefix_unchanged_when_absent() -> None:
|
|
13
|
+
assert remove_prefix("MATCH (n) RETURN n", "cypher") == "MATCH (n) RETURN n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_remove_prefix_empty_string() -> None:
|
|
17
|
+
assert remove_prefix("", "cypher") == ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_extract_cypher_with_backticks() -> None:
|
|
21
|
+
text = "Here is the query:\n```MATCH (n) RETURN n```"
|
|
22
|
+
assert extract_cypher(text) == "MATCH (n) RETURN n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_extract_cypher_without_backticks() -> None:
|
|
26
|
+
text = "MATCH (n) RETURN n"
|
|
27
|
+
assert extract_cypher(text) == "MATCH (n) RETURN n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_extract_cypher_returns_first_match() -> None:
|
|
31
|
+
text = "```first```\n```second```"
|
|
32
|
+
assert extract_cypher(text) == "first"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_ladybug_qa_chain_requires_dangerous_requests() -> None:
|
|
36
|
+
"""Constructing LadybugQAChain without opt-in should raise ValueError."""
|
|
37
|
+
from unittest.mock import MagicMock
|
|
38
|
+
|
|
39
|
+
from langchain_ladybug.chains.ladybug_qa import LadybugQAChain
|
|
40
|
+
|
|
41
|
+
mock_graph = MagicMock()
|
|
42
|
+
mock_chain = MagicMock()
|
|
43
|
+
|
|
44
|
+
with pytest.raises(ValueError, match="allow_dangerous_requests"):
|
|
45
|
+
LadybugQAChain(
|
|
46
|
+
graph=mock_graph,
|
|
47
|
+
cypher_generation_chain=mock_chain,
|
|
48
|
+
qa_chain=mock_chain,
|
|
49
|
+
allow_dangerous_requests=False,
|
|
50
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Unit tests for Ladybug prompt templates."""
|
|
2
|
+
|
|
3
|
+
from langchain_ladybug.chains.prompts import (
|
|
4
|
+
CYPHER_QA_PROMPT,
|
|
5
|
+
LADYBUG_GENERATION_PROMPT,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_ladybug_generation_prompt_variables() -> None:
|
|
10
|
+
assert set(LADYBUG_GENERATION_PROMPT.input_variables) == {"schema", "question"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_ladybug_generation_prompt_contains_rules() -> None:
|
|
14
|
+
template = LADYBUG_GENERATION_PROMPT.template
|
|
15
|
+
assert "()-[]->()" in template
|
|
16
|
+
assert "triple backticks" in template
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_cypher_qa_prompt_variables() -> None:
|
|
20
|
+
assert set(CYPHER_QA_PROMPT.input_variables) == {"context", "question"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_cypher_qa_prompt_format() -> None:
|
|
24
|
+
result = CYPHER_QA_PROMPT.format(
|
|
25
|
+
context="[manager:CTL LLC]",
|
|
26
|
+
question="Which managers own Neo4j stocks?",
|
|
27
|
+
)
|
|
28
|
+
assert "CTL LLC" in result
|
|
29
|
+
assert "Neo4j stocks" in result
|