reasongraph 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,21 @@
1
+ __version__ = "0.2.0"
2
+
3
+ import os as _os
4
+
5
+ # Suppress noisy transformers/tqdm output (load reports, progress bars, pipeline hints)
6
+ # unless the user has explicitly configured verbosity.
7
+ if "TRANSFORMERS_VERBOSITY" not in _os.environ:
8
+ _os.environ["TRANSFORMERS_VERBOSITY"] = "error"
9
+ if "TQDM_DISABLE" not in _os.environ:
10
+ _os.environ["TQDM_DISABLE"] = "1"
11
+
12
+ from reasongraph._types import Node, Edge
13
+ from reasongraph._extraction import NERExtractor, GLiNER2Extractor
14
+ from reasongraph.graph import ReasonGraph
15
+ from reasongraph.datasets import load_dataset
16
+
17
+ __all__ = [
18
+ "ReasonGraph", "Node", "Edge",
19
+ "NERExtractor", "GLiNER2Extractor",
20
+ "load_dataset",
21
+ ]
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from sentence_transformers import SentenceTransformer, CrossEncoder
4
+
5
+
6
+ class EmbeddingManager:
7
+ """Manages embedding generation and cross-encoder reranking."""
8
+
9
+ DEFAULT_EMBED_MODEL = "all-MiniLM-L12-v2"
10
+ DEFAULT_RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
11
+
12
+ def __init__(
13
+ self,
14
+ embed_model: str | SentenceTransformer | None = None,
15
+ rerank_model: str | CrossEncoder | None = None,
16
+ ) -> None:
17
+ if isinstance(embed_model, SentenceTransformer):
18
+ self._embed = embed_model
19
+ else:
20
+ self._embed = SentenceTransformer(embed_model or self.DEFAULT_EMBED_MODEL)
21
+
22
+ self._rerank: CrossEncoder | None = None
23
+ self._rerank_name = rerank_model
24
+
25
+ def encode(self, text: str) -> list[float]:
26
+ """Encode a single text string to a float vector."""
27
+ return self._embed.encode(text).tolist()
28
+
29
+ def encode_batch(self, texts: list[str]) -> list[list[float]]:
30
+ """Encode multiple texts at once."""
31
+ return self._embed.encode(texts).tolist()
32
+
33
+ def rerank(
34
+ self,
35
+ query: str,
36
+ results: list[dict[str, str]],
37
+ top_k: int,
38
+ ) -> list[dict[str, str]]:
39
+ """Rerank results using a cross-encoder. Lazy-loads the model on first call."""
40
+ if not results:
41
+ return []
42
+
43
+ # Deduplicate by content
44
+ seen = {}
45
+ for r in results:
46
+ if r["content"] not in seen:
47
+ seen[r["content"]] = r
48
+ unique = list(seen.values())
49
+
50
+ if len(unique) <= 1:
51
+ return unique[:top_k]
52
+
53
+ if self._rerank is None:
54
+ model_name = (
55
+ self._rerank_name
56
+ if isinstance(self._rerank_name, str)
57
+ else self.DEFAULT_RERANK_MODEL
58
+ )
59
+ if isinstance(self._rerank_name, CrossEncoder):
60
+ self._rerank = self._rerank_name
61
+ else:
62
+ self._rerank = CrossEncoder(model_name)
63
+
64
+ pairs = [(query, r["content"]) for r in unique]
65
+ scores = self._rerank.predict(pairs)
66
+ ranked = [r for _, r in sorted(zip(scores, unique), reverse=True)]
67
+ return ranked[:top_k]
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+
5
+
6
+ class NERExtractor:
7
+ """Named entity extractor using a HuggingFace token classification model.
8
+
9
+ Default model is dslim/bert-base-NER which recognizes PER, ORG, LOC, MISC.
10
+ Lazy-loads the pipeline on first call.
11
+ """
12
+
13
+ DEFAULT_MODEL = "dslim/bert-base-NER"
14
+
15
+ def __init__(self, model: str | None = None) -> None:
16
+ self._model_name = model or self.DEFAULT_MODEL
17
+ self._pipeline = None
18
+
19
+ def _load(self):
20
+ if self._pipeline is None:
21
+ from transformers import pipeline
22
+ self._pipeline = pipeline(
23
+ "ner",
24
+ model=self._model_name,
25
+ aggregation_strategy="simple",
26
+ )
27
+
28
+ def __call__(self, text: str) -> list[str]:
29
+ """Extract entities from text. Returns deduplicated entity strings."""
30
+ self._load()
31
+ results = self._pipeline(text)
32
+ seen = set()
33
+ entities = []
34
+ for ent in results:
35
+ word = ent["word"].strip()
36
+ # Filter out WordPiece artifacts (##subword tokens) and single chars
37
+ if not word or word.startswith("##") or len(word) <= 1:
38
+ continue
39
+ if word not in seen:
40
+ seen.add(word)
41
+ entities.append(word)
42
+ return entities
43
+
44
+
45
+ class GLiNER2Extractor:
46
+ """Entity and relation extractor using GLiNER2.
47
+
48
+ Single model that handles both NER and causal relation extraction.
49
+ Lazy-loads the model on first call.
50
+
51
+ Default model: fastino/gliner2-large-v1 (340M params, DeBERTa-v3-large).
52
+ Requires: pip install reasongraph[gliner2]
53
+ """
54
+
55
+ DEFAULT_MODEL = "fastino/gliner2-large-v1"
56
+
57
+ DEFAULT_ENTITY_TYPES = ["person", "organization", "location", "event"]
58
+ DEFAULT_RELATION_TYPES = ["causes", "leads_to", "results_in"]
59
+
60
+ def __init__(
61
+ self,
62
+ model: str | None = None,
63
+ entity_types: list[str] | None = None,
64
+ relation_types: list[str] | None = None,
65
+ ) -> None:
66
+ self._model_name = model or self.DEFAULT_MODEL
67
+ self.entity_types = entity_types or self.DEFAULT_ENTITY_TYPES
68
+ self.relation_types = relation_types or self.DEFAULT_RELATION_TYPES
69
+ self._model = None
70
+
71
+ def _load(self):
72
+ if self._model is None:
73
+ try:
74
+ from gliner2 import GLiNER2
75
+ except ImportError:
76
+ raise ImportError(
77
+ "GLiNER2 not installed. Install with: pip install reasongraph[gliner2]"
78
+ )
79
+ self._model = GLiNER2.from_pretrained(self._model_name)
80
+
81
+ def __call__(self, text: str) -> list[str]:
82
+ """Extract entities from text (compatible with ExtractorFn).
83
+
84
+ Returns deduplicated entity strings.
85
+ """
86
+ self._load()
87
+ raw = self._model.extract_entities(text, self.entity_types)
88
+ # GLiNER2 returns {"entities": {"person": [...], "location": [...]}}
89
+ entities_by_type = raw.get("entities", raw)
90
+
91
+ seen = set()
92
+ entities = []
93
+ for type_key, ents in entities_by_type.items():
94
+ if not isinstance(ents, list):
95
+ continue
96
+ for ent in ents:
97
+ word = ent.strip() if isinstance(ent, str) else ""
98
+ if word and word not in seen:
99
+ seen.add(word)
100
+ entities.append(word)
101
+ return entities
102
+
103
+ def extract_causal(self, texts: list[str]) -> list[dict]:
104
+ """Extract cause-effect relations from texts (compatible with CausalExtractorFn).
105
+
106
+ Args:
107
+ texts: List of text strings to analyze.
108
+
109
+ Returns:
110
+ List of dicts (one per text) with keys:
111
+ - 'text': original text
112
+ - 'causal': bool
113
+ - 'relations': list of {'cause': str, 'effect': str}
114
+ """
115
+ self._load()
116
+ results = []
117
+ for text in texts:
118
+ raw = self._model.extract_relations(text, self.relation_types)
119
+ # GLiNER2 returns {"relation_extraction": {"causes": [("a","b")], ...}}
120
+ # or directly {"causes": [("a","b")], ...}
121
+ rel_data = raw.get("relation_extraction", raw)
122
+
123
+ relations = []
124
+ seen_pairs = set()
125
+ for rel_type, pairs in rel_data.items():
126
+ if not isinstance(pairs, list):
127
+ continue
128
+ for pair in pairs:
129
+ cause, effect = None, None
130
+ if isinstance(pair, (list, tuple)) and len(pair) >= 2:
131
+ cause, effect = str(pair[0]).strip(), str(pair[1]).strip()
132
+ elif isinstance(pair, dict):
133
+ h = pair.get("head", {})
134
+ t = pair.get("tail", {})
135
+ cause = (h.get("text", "") if isinstance(h, dict) else str(h)).strip()
136
+ effect = (t.get("text", "") if isinstance(t, dict) else str(t)).strip()
137
+ if not cause or not effect or cause == effect:
138
+ continue
139
+ pair_key = (cause, effect)
140
+ if pair_key in seen_pairs:
141
+ continue
142
+ seen_pairs.add(pair_key)
143
+ relations.append({"cause": cause, "effect": effect})
144
+
145
+ results.append({
146
+ "text": text,
147
+ "causal": len(relations) > 0,
148
+ "relations": relations,
149
+ })
150
+ return results
151
+
152
+
153
+ # Type alias for any entity extractor callable: text -> list of entity strings
154
+ ExtractorFn = Callable[[str], list[str]]
155
+
156
+ # Type alias for causal extractor: list[str] -> list[dict]
157
+ CausalExtractorFn = Callable[[list[str]], list[dict]]
reasongraph/_types.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+
6
+
7
+ @dataclass
8
+ class Node:
9
+ """A node in the reason graph."""
10
+
11
+ content: str
12
+ type: str = "text"
13
+ embedding: list[float] | None = None
14
+ created_at: datetime = field(default_factory=datetime.now)
15
+ last_accessed: datetime = field(default_factory=datetime.now)
16
+
17
+
18
+ @dataclass
19
+ class Edge:
20
+ """A directed edge between two nodes."""
21
+
22
+ from_content: str
23
+ to_content: str
24
+ last_accessed: datetime = field(default_factory=datetime.now)
@@ -0,0 +1,5 @@
1
+ from reasongraph.backends._base import Backend
2
+ from reasongraph.backends._sqlite import SqliteBackend
3
+ from reasongraph.backends._postgres import PostgresBackend
4
+
5
+ __all__ = ["Backend", "SqliteBackend", "PostgresBackend"]
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from reasongraph._types import Node, Edge
6
+
7
+
8
+ class Backend(ABC):
9
+ """Abstract base class for graph storage backends."""
10
+
11
+ @abstractmethod
12
+ async def initialize(self) -> None:
13
+ """Create tables/schema if they don't exist."""
14
+
15
+ @abstractmethod
16
+ async def close(self) -> None:
17
+ """Release resources (connections, pools, etc.)."""
18
+
19
+ @abstractmethod
20
+ async def insert_nodes(self, nodes: list[Node]) -> None:
21
+ """Insert or upsert a batch of nodes (must have embeddings set)."""
22
+
23
+ @abstractmethod
24
+ async def insert_edges(self, edges: list[Edge]) -> None:
25
+ """Insert a batch of edges, ignoring duplicates."""
26
+
27
+ @abstractmethod
28
+ async def knn_search(
29
+ self, embedding: list[float], top_k: int
30
+ ) -> list[dict[str, str]]:
31
+ """Return the top_k closest nodes as dicts with 'content' and 'type' keys."""
32
+
33
+ @abstractmethod
34
+ async def get_neighbors(self, content: str) -> list[dict[str, str]]:
35
+ """Return all direct neighbors (both directions) as dicts with 'content' and 'type'."""
36
+
37
+ @abstractmethod
38
+ async def delete_stale_nodes(self, days: int) -> int:
39
+ """Delete nodes not accessed within the given number of days. Return count deleted."""
40
+
41
+ @abstractmethod
42
+ async def get_all_nodes(self) -> list[Node]:
43
+ """Return every node in the graph."""
44
+
45
+ @abstractmethod
46
+ async def hybrid_search(
47
+ self, embedding: list[float], query_text: str, top_k: int,
48
+ rrf_k: int = 60, keyword_only: bool = False,
49
+ ) -> list[dict[str, str]]:
50
+ """Combined embedding + trigram search using Reciprocal Rank Fusion.
51
+
52
+ Each node is ranked independently by cosine similarity and by trigram
53
+ similarity. The final score is:
54
+ rrf_score(d) = 1/(rrf_k + rank_emb(d)) + 1/(rrf_k + rank_kw(d))
55
+
56
+ When keyword_only=True, nodes are ranked by trigram similarity alone
57
+ (no embedding component).
58
+
59
+ Args:
60
+ embedding: Query embedding vector.
61
+ query_text: Raw query string for trigram matching.
62
+ top_k: Number of results to return.
63
+ rrf_k: RRF smoothing constant (default 60).
64
+ keyword_only: If True, rank by trigram similarity only.
65
+
66
+ Returns:
67
+ Top-k nodes as dicts with 'content' and 'type' keys.
68
+ """
69
+
70
+ @abstractmethod
71
+ async def get_all_edges(self) -> list[Edge]:
72
+ """Return every edge in the graph."""
@@ -0,0 +1,235 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta
4
+
5
+ from reasongraph._types import Node, Edge
6
+ from reasongraph.backends._base import Backend
7
+
8
+ try:
9
+ from psycopg_pool import AsyncConnectionPool
10
+ from pgvector.psycopg import register_vector_async
11
+
12
+ _HAS_POSTGRES = True
13
+ except ImportError:
14
+ _HAS_POSTGRES = False
15
+
16
+
17
+ class PostgresBackend(Backend):
18
+ """PostgreSQL + pgvector backend for scalable vector search.
19
+
20
+ Requires: pip install reasongraph[postgres]
21
+ """
22
+
23
+ def __init__(self, database_url: str) -> None:
24
+ if not _HAS_POSTGRES:
25
+ raise ImportError(
26
+ "PostgreSQL dependencies not installed. "
27
+ "Install with: pip install reasongraph[postgres]"
28
+ )
29
+ self.database_url = database_url
30
+ self._pool: AsyncConnectionPool | None = None
31
+
32
+ async def _get_pool(self) -> AsyncConnectionPool:
33
+ if self._pool is None:
34
+ raise RuntimeError("Backend not initialized. Call initialize() first.")
35
+ return self._pool
36
+
37
+ async def initialize(self) -> None:
38
+ self._pool = AsyncConnectionPool(self.database_url, open=False)
39
+ await self._pool.open()
40
+
41
+ async with self._pool.connection() as conn:
42
+ await conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
43
+ await register_vector_async(conn)
44
+
45
+ await conn.execute("""
46
+ CREATE TABLE IF NOT EXISTS nodes (
47
+ content TEXT PRIMARY KEY,
48
+ embedding VECTOR(384) NOT NULL,
49
+ created_at TIMESTAMP DEFAULT NOW(),
50
+ last_accessed TIMESTAMP DEFAULT NOW(),
51
+ type TEXT NOT NULL CHECK (type IN ('text', 'entity'))
52
+ )
53
+ """)
54
+
55
+ await conn.execute("""
56
+ CREATE TABLE IF NOT EXISTS edges (
57
+ from_content TEXT NOT NULL REFERENCES nodes(content) ON DELETE CASCADE,
58
+ to_content TEXT NOT NULL REFERENCES nodes(content) ON DELETE CASCADE,
59
+ last_accessed TIMESTAMP DEFAULT NOW(),
60
+ UNIQUE(from_content, to_content)
61
+ )
62
+ """)
63
+
64
+ await conn.execute(
65
+ "CREATE INDEX IF NOT EXISTS edges_from_idx ON edges (from_content)"
66
+ )
67
+ await conn.execute(
68
+ "CREATE INDEX IF NOT EXISTS edges_to_idx ON edges (to_content)"
69
+ )
70
+
71
+ async def close(self) -> None:
72
+ if self._pool is not None:
73
+ await self._pool.close()
74
+ self._pool = None
75
+
76
+ async def insert_nodes(self, nodes: list[Node]) -> None:
77
+ pool = await self._get_pool()
78
+ now = datetime.now()
79
+ async with pool.connection() as conn:
80
+ async with conn.cursor() as cur:
81
+ data = [
82
+ (node.content, node.embedding, node.created_at, now, node.type)
83
+ for node in nodes
84
+ ]
85
+ await cur.executemany(
86
+ """
87
+ INSERT INTO nodes (content, embedding, created_at, last_accessed, type)
88
+ VALUES (%s, %s, %s, %s, %s)
89
+ ON CONFLICT (content) DO UPDATE SET last_accessed = NOW()
90
+ """,
91
+ data,
92
+ )
93
+
94
+ async def insert_edges(self, edges: list[Edge]) -> None:
95
+ pool = await self._get_pool()
96
+ async with pool.connection() as conn:
97
+ async with conn.cursor() as cur:
98
+ data = [(e.from_content, e.to_content) for e in edges]
99
+ await cur.executemany(
100
+ """
101
+ INSERT INTO edges (from_content, to_content)
102
+ VALUES (%s, %s)
103
+ ON CONFLICT DO NOTHING
104
+ """,
105
+ data,
106
+ )
107
+
108
+ async def knn_search(
109
+ self, embedding: list[float], top_k: int
110
+ ) -> list[dict[str, str]]:
111
+ pool = await self._get_pool()
112
+ vec_str = f"[{', '.join(map(str, embedding))}]"
113
+ async with pool.connection() as conn:
114
+ async with conn.cursor() as cur:
115
+ await cur.execute(
116
+ f"""
117
+ SELECT content, type
118
+ FROM nodes
119
+ ORDER BY embedding <=> '{vec_str}'
120
+ LIMIT {top_k}
121
+ """
122
+ )
123
+ rows = await cur.fetchall()
124
+ return [{"content": row[0], "type": row[1]} for row in rows]
125
+
126
+ async def hybrid_search(
127
+ self, embedding: list[float], query_text: str, top_k: int,
128
+ rrf_k: int = 60, keyword_only: bool = False,
129
+ ) -> list[dict[str, str]]:
130
+ pool = await self._get_pool()
131
+ vec_str = f"[{', '.join(map(str, embedding))}]"
132
+ async with pool.connection() as conn:
133
+ await conn.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
134
+ async with conn.cursor() as cur:
135
+ if keyword_only:
136
+ await cur.execute(
137
+ """
138
+ SELECT content, type
139
+ FROM nodes
140
+ ORDER BY similarity(content, %s) DESC
141
+ LIMIT %s
142
+ """,
143
+ (query_text, top_k),
144
+ )
145
+ return [
146
+ {"content": row[0], "type": row[1]}
147
+ for row in await cur.fetchall()
148
+ ]
149
+
150
+ # RRF entirely in SQL using window functions
151
+ await cur.execute(
152
+ f"""
153
+ WITH emb_ranked AS (
154
+ SELECT content, type,
155
+ ROW_NUMBER() OVER (
156
+ ORDER BY embedding <=> '{vec_str}'
157
+ ) AS rank
158
+ FROM nodes
159
+ ),
160
+ kw_ranked AS (
161
+ SELECT content,
162
+ ROW_NUMBER() OVER (
163
+ ORDER BY similarity(content, %s) DESC
164
+ ) AS rank
165
+ FROM nodes
166
+ )
167
+ SELECT e.content, e.type
168
+ FROM emb_ranked e
169
+ JOIN kw_ranked k ON e.content = k.content
170
+ ORDER BY 1.0 / (%s + e.rank) + 1.0 / (%s + k.rank) DESC
171
+ LIMIT %s
172
+ """,
173
+ (query_text, rrf_k, rrf_k, top_k),
174
+ )
175
+ return [
176
+ {"content": row[0], "type": row[1]}
177
+ for row in await cur.fetchall()
178
+ ]
179
+
180
+ async def get_neighbors(self, content: str) -> list[dict[str, str]]:
181
+ pool = await self._get_pool()
182
+ async with pool.connection() as conn:
183
+ async with conn.cursor() as cur:
184
+ await cur.execute(
185
+ """
186
+ SELECT DISTINCT n.content, n.type FROM nodes n
187
+ INNER JOIN edges e ON (e.to_content = n.content AND e.from_content = %s)
188
+ OR (e.from_content = n.content AND e.to_content = %s)
189
+ """,
190
+ (content, content),
191
+ )
192
+ return [
193
+ {"content": row[0], "type": row[1]}
194
+ for row in await cur.fetchall()
195
+ ]
196
+
197
+ async def delete_stale_nodes(self, days: int) -> int:
198
+ pool = await self._get_pool()
199
+ cutoff = datetime.now() - timedelta(days=days)
200
+ async with pool.connection() as conn:
201
+ async with conn.cursor() as cur:
202
+ await cur.execute(
203
+ "DELETE FROM nodes WHERE last_accessed < %s", (cutoff,)
204
+ )
205
+ return cur.rowcount
206
+
207
+ async def get_all_nodes(self) -> list[Node]:
208
+ pool = await self._get_pool()
209
+ async with pool.connection() as conn:
210
+ async with conn.cursor() as cur:
211
+ await cur.execute(
212
+ "SELECT content, type, embedding, created_at, last_accessed FROM nodes"
213
+ )
214
+ nodes = []
215
+ for content, node_type, emb, created_at, last_accessed in await cur.fetchall():
216
+ nodes.append(Node(
217
+ content=content,
218
+ type=node_type,
219
+ embedding=list(emb) if emb else None,
220
+ created_at=created_at,
221
+ last_accessed=last_accessed,
222
+ ))
223
+ return nodes
224
+
225
+ async def get_all_edges(self) -> list[Edge]:
226
+ pool = await self._get_pool()
227
+ async with pool.connection() as conn:
228
+ async with conn.cursor() as cur:
229
+ await cur.execute(
230
+ "SELECT from_content, to_content, last_accessed FROM edges"
231
+ )
232
+ return [
233
+ Edge(from_content=row[0], to_content=row[1], last_accessed=row[2])
234
+ for row in await cur.fetchall()
235
+ ]