dean-research-tools 0.1.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.
@@ -0,0 +1,7 @@
1
+ Copyright 2026 Dean MacGregor
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: dean_research_tools
3
+ Version: 0.1.0
4
+ Summary: Retriever tools package for research task and content lookup
5
+ Author-email: Dean MacGregor <powertrading121@gmail.com>
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: cohere>=5.0.0
18
+ Requires-Dist: langchain-core>=0.3.0
19
+ Requires-Dist: orjson>=3.9.0
20
+ Requires-Dist: pgvector>=0.3.0
21
+ Requires-Dist: psycopg[binary]>=3.2.0
22
+ Requires-Dist: pydantic>=2.7.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2.2; extra == "dev"
25
+ Requires-Dist: pytest>=8.2.0; extra == "dev"
26
+ Requires-Dist: twine>=5.1.1; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # dean_research_tools
30
+
31
+ I made this library so I could use it in different environments without copying and pasting. I'm not intending to have it be used by a lot of people.
32
+
33
+ If a lot of people want to use it then that's fine, there's no secret sauce, but you're on your own.
@@ -0,0 +1,5 @@
1
+ # dean_research_tools
2
+
3
+ I made this library so I could use it in different environments without copying and pasting. I'm not intending to have it be used by a lot of people.
4
+
5
+ If a lot of people want to use it then that's fine, there's no secret sauce, but you're on your own.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dean_research_tools"
7
+ version = "0.1.0"
8
+ description = "Retriever tools package for research task and content lookup"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Dean MacGregor", email="powertrading121@gmail.com"}
14
+ ]
15
+
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Software Development :: Libraries",
24
+ ]
25
+ dependencies = [
26
+ "cohere>=5.0.0",
27
+ "langchain-core>=0.3.0",
28
+ "orjson>=3.9.0",
29
+ "pgvector>=0.3.0",
30
+ "psycopg[binary]>=3.2.0",
31
+ "pydantic>=2.7.0",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "build>=1.2.2",
37
+ "pytest>=8.2.0",
38
+ "twine>=5.1.1",
39
+ ]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+
44
+ [tool.setuptools]
45
+ package-dir = {"" = "src"}
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """Retriever tools package."""
2
+
3
+ from dean_research_tools.config import load_settings
4
+ from dean_research_tools.retriever import PGTools
5
+
6
+ __all__ = ["load_settings", "PGTools"]
@@ -0,0 +1,94 @@
1
+ """Configuration for the researcher tools package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from pydantic import BaseModel, ConfigDict, SecretStr
8
+
9
+
10
+ class Settings(BaseModel):
11
+ """Environment-backed settings for DB and Azure OpenAI."""
12
+
13
+ model_config = ConfigDict(frozen=True)
14
+
15
+ db_host: str
16
+ db_port: int
17
+ db_name: str
18
+ db_user: str
19
+ db_password: str
20
+ azure_openai_api_key: SecretStr
21
+ azure_embedding_endpoint: str
22
+ azure_openai_embedding_deployment: str
23
+
24
+ @property
25
+ def db_dsn(self) -> str:
26
+ return (
27
+ f"host={self.db_host} "
28
+ f"port={self.db_port} "
29
+ f"dbname={self.db_name} "
30
+ f"user={self.db_user} "
31
+ f"password={self.db_password} "
32
+ f"connect_timeout=300 "
33
+ f"keepalives=1 "
34
+ f"keepalives_idle=30 "
35
+ f"keepalives_interval=10 "
36
+ f"keepalives_count=5"
37
+ )
38
+
39
+
40
+ def _require_env(name: str) -> str:
41
+ value = os.getenv(name, "").strip()
42
+ if not value:
43
+ raise ValueError(f"Missing required environment variable: {name}")
44
+ return value
45
+
46
+
47
+ def _research_env(name: str) -> str:
48
+ return f"RESEARCH_{name.upper()}"
49
+
50
+
51
+ def load_settings(
52
+ *,
53
+ db_host: str | None = None,
54
+ db_port: int | None = None,
55
+ db_name: str | None = None,
56
+ db_user: str | None = None,
57
+ db_password: str | None = None,
58
+ azure_openai_api_key: str | SecretStr | None = None,
59
+ azure_embedding_endpoint: str | None = None,
60
+ azure_openai_embedding_deployment: str | None = None,
61
+ ) -> Settings:
62
+ """Load settings from keyword overrides or RESEARCH_* environment variables."""
63
+
64
+ resolved_db_host = db_host or _require_env(_research_env("db_host"))
65
+ resolved_db_port = db_port or int(os.getenv(_research_env("db_port"), "5432"))
66
+ resolved_db_name = db_name or _require_env(_research_env("db_name"))
67
+ resolved_db_user = db_user or _require_env(_research_env("db_user"))
68
+ resolved_db_password = db_password or _require_env(_research_env("db_password"))
69
+ resolved_embedding_endpoint = azure_embedding_endpoint or _require_env(
70
+ _research_env("azure_embedding_endpoint")
71
+ )
72
+ resolved_embedding_deployment = azure_openai_embedding_deployment or _require_env(
73
+ _research_env("azure_openai_embedding_deployment")
74
+ )
75
+
76
+ if azure_openai_api_key is None:
77
+ resolved_api_key = SecretStr(
78
+ _require_env(_research_env("azure_openai_api_key"))
79
+ )
80
+ elif isinstance(azure_openai_api_key, SecretStr):
81
+ resolved_api_key = azure_openai_api_key
82
+ else:
83
+ resolved_api_key = SecretStr(azure_openai_api_key)
84
+
85
+ return Settings(
86
+ db_host=resolved_db_host,
87
+ db_port=resolved_db_port,
88
+ db_name=resolved_db_name,
89
+ db_user=resolved_db_user,
90
+ db_password=resolved_db_password,
91
+ azure_openai_api_key=resolved_api_key,
92
+ azure_embedding_endpoint=resolved_embedding_endpoint,
93
+ azure_openai_embedding_deployment=resolved_embedding_deployment,
94
+ )
@@ -0,0 +1,42 @@
1
+ """Embedding model construction for retrieval queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from cohere import AsyncClientV2
8
+
9
+ from dean_research_tools.config import Settings
10
+
11
+
12
+ class EmbeddingsModel:
13
+ def __init__(self, settings: Settings):
14
+ self.settings = settings
15
+ self.co_client = AsyncClientV2(
16
+ api_key=self.settings.azure_openai_api_key.get_secret_value(),
17
+ base_url=self.settings.azure_embedding_endpoint,
18
+ )
19
+
20
+ async def embed_texts(
21
+ self,
22
+ texts: list[str],
23
+ input_type: Literal["search_document", "search_query"] = "search_query",
24
+ ) -> list[list[float]] | None:
25
+ results = []
26
+ chunked = self.chunk_list(texts)
27
+ for chunk in chunked:
28
+ result = await self.co_client.embed(
29
+ model=self.settings.azure_openai_embedding_deployment,
30
+ texts=chunk,
31
+ input_type=input_type,
32
+ embedding_types=["float"],
33
+ )
34
+ flt = result.embeddings.float_
35
+ if flt is None:
36
+ return None
37
+ results.extend(flt)
38
+ return results
39
+
40
+ @staticmethod
41
+ def chunk_list(lst, size=96):
42
+ return [lst[i : i + size] for i in range(0, len(lst), size)]
@@ -0,0 +1,90 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class SemanticContentSearchInput(BaseModel):
5
+ query: str = Field(
6
+ description="The search query string to be embedded with cohere and used for vector similarity search on text content."
7
+ )
8
+ task_id: int | None = Field(
9
+ default=None,
10
+ description=(
11
+ "Optional task scope. Omit this for corpus-wide searches. "
12
+ "Use it when the question is restricted to a browser task."
13
+ ),
14
+ )
15
+ url_part: str | None = Field(
16
+ default=None,
17
+ description=(
18
+ "Optional URL scope. Omit this for corpus-wide searches. "
19
+ "It will filter for content whose URL contains this substring."
20
+ ),
21
+ )
22
+ doc_id: int | None = Field(
23
+ default=None,
24
+ description="Optional doc_id to filter the search results. If provided, only content from this document will be considered.",
25
+ )
26
+ top_k: int = Field(
27
+ default=5,
28
+ description="The number of top results to return (default is 5).",
29
+ )
30
+ min_score: float = Field(
31
+ default=0,
32
+ description="Minimum score threshold for filtering results (between 0.0 and 1.0)",
33
+ )
34
+
35
+
36
+ class SemanticTaskSearchInput(BaseModel):
37
+ query: str = Field(
38
+ description="The search query string to be embedded with cohere and used for vector similarity search on previous browser tasks."
39
+ )
40
+ top_k: int = Field(
41
+ default=5,
42
+ description="The number of top results to return (default is 5).",
43
+ )
44
+ min_score: float = Field(
45
+ default=0,
46
+ description="Minimum score threshold for filtering results (between 0.0 and 1.0)",
47
+ )
48
+
49
+
50
+ class KeywordContentSearchInput(BaseModel):
51
+ keywords: list[str] = Field(
52
+ description="The keywords to search for in the text_chunk of the browser_content table. The search will be case-insensitive and will match any text_chunk that contains any of the keywords."
53
+ )
54
+ task_id: int | None = Field(
55
+ default=None,
56
+ description="The task_id by which to filter the search results. If this isn't provided then url_part must be provided.",
57
+ )
58
+ url_part: str | None = Field(
59
+ default=None,
60
+ description=(
61
+ "Optional URL scope. Omit this for corpus-wide searches. "
62
+ "It will filter for content whose URL contains this substring."
63
+ ),
64
+ )
65
+ doc_id: int | None = Field(
66
+ default=None,
67
+ description="Optional doc_id to further filter the search results.",
68
+ )
69
+ pages: list[int] | None = Field(
70
+ default=None,
71
+ description="Optional list of page numbers to filter the search results. Can only be provided if doc_id is also provided.",
72
+ )
73
+ top_k: int = Field(
74
+ default=5,
75
+ description="The number of top results to return (default is 5).",
76
+ )
77
+
78
+
79
+ class GetTaskInput(BaseModel):
80
+ task_id: int = Field(
81
+ description="The task_id from which to retrieve the associated task details."
82
+ )
83
+
84
+
85
+ class BrowserUseInput(BaseModel):
86
+ objective: str = Field(
87
+ description="""Invoke an agent using the browser_use library to use a real browser to collect content. The agent needs to be given a detailed objective of what content
88
+ it needs to ingest. The agent will then use the browser to collect content and ingest it into the database. The agent will return a task_id which can be used
89
+ to retrieve the content with your existing tools."""
90
+ )
@@ -0,0 +1,346 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ from inspect import ismethod
5
+ from typing import TYPE_CHECKING, Literal, Sequence, get_args
6
+ from weakref import WeakSet
7
+
8
+ import orjson
9
+ from langchain_core.tools import StructuredTool
10
+ from pgvector import Vector
11
+ from pgvector.psycopg import register_vector_async
12
+ from psycopg import AsyncConnection
13
+ from psycopg.rows import dict_row
14
+ from psycopg.sql import SQL
15
+
16
+ import dean_research_tools.models as models
17
+ from dean_research_tools.config import Settings, load_settings
18
+ from dean_research_tools.embeddings import EmbeddingsModel
19
+
20
+ _registered_pgvector = WeakSet()
21
+
22
+
23
+ def _to_pascal_inputs(s: str) -> str:
24
+ return "".join(word.capitalize() for word in s.split("_")) + "Input"
25
+
26
+
27
+ async def ensure_pgvector_registered(conn: AsyncConnection) -> None:
28
+ if conn in _registered_pgvector:
29
+ return
30
+ await register_vector_async(conn)
31
+ _registered_pgvector.add(conn)
32
+
33
+
34
+ if TYPE_CHECKING:
35
+ from psycopg_pool import AsyncConnectionPool
36
+
37
+
38
+ AVAILABLE_TOOLS = Literal[
39
+ "semantic_content_search",
40
+ "keyword_content_search",
41
+ "get_task",
42
+ "semantic_task_search",
43
+ "browser_use",
44
+ ]
45
+
46
+
47
+ class PGTools:
48
+ def __init__(
49
+ self,
50
+ *,
51
+ include: Sequence[AVAILABLE_TOOLS] | None = None,
52
+ exclude: Sequence[AVAILABLE_TOOLS] | None = None,
53
+ settings: Settings | None = None,
54
+ conn: AsyncConnection | None = None,
55
+ pool: AsyncConnectionPool | None = None,
56
+ ):
57
+ self.settings = settings or load_settings()
58
+ self.include = include
59
+ self.exclude = exclude
60
+ if conn is not None and pool is not None:
61
+ raise ValueError("Cannot specify both conn and pool")
62
+ self.conn = conn
63
+ self.pool = pool
64
+ self._close_conn = conn is None
65
+ self.embeddings = EmbeddingsModel(self.settings)
66
+
67
+ @asynccontextmanager
68
+ async def _get_cur(self):
69
+ if self.conn is not None:
70
+ await ensure_pgvector_registered(self.conn)
71
+ async with self.conn.cursor(row_factory=dict_row) as cur:
72
+ yield cur
73
+ elif self.pool is not None:
74
+ async with self.pool.connection() as conn:
75
+ await ensure_pgvector_registered(conn)
76
+ async with conn.cursor(row_factory=dict_row) as cur:
77
+ yield cur
78
+ else:
79
+ raise ValueError("No connection or pool provided")
80
+
81
+ def _get_all_tools(self) -> list[StructuredTool]:
82
+ tools = []
83
+ for tool_name in dir(self):
84
+ if tool_name[0] == "_" or not ismethod(getattr(self, tool_name)):
85
+ continue
86
+ kwargs = {
87
+ "coroutine": getattr(self, tool_name),
88
+ "args_schema": getattr(models, _to_pascal_inputs(tool_name)),
89
+ "description": getattr(self, tool_name).__doc__.replace(
90
+ "RETURN_DIRECT", ""
91
+ ),
92
+ }
93
+ if "RETURN_DIRECT" in getattr(self, tool_name).__doc__:
94
+ kwargs["return_direct"] = True
95
+ tools.append(StructuredTool.from_function(**kwargs))
96
+ return tools
97
+
98
+ def _get_tools(
99
+ self,
100
+ ) -> list[StructuredTool]:
101
+ tools_to_get: tuple[AVAILABLE_TOOLS] = tuple([])
102
+ if self.include is not None and self.exclude is not None:
103
+ raise ValueError("Cannot specify both include and exclude")
104
+ elif self.include is None and self.exclude is None:
105
+ tools_to_get: tuple[AVAILABLE_TOOLS] = get_args(AVAILABLE_TOOLS)
106
+ elif self.include is not None:
107
+ tools_to_get = tuple(
108
+ x for x in get_args(AVAILABLE_TOOLS) if x in self.include
109
+ )
110
+ elif self.exclude is not None:
111
+ tools_to_get = tuple(
112
+ t for t in get_args(AVAILABLE_TOOLS) if t not in self.exclude
113
+ )
114
+
115
+ return [t for t in self._get_all_tools() if t.name in tools_to_get]
116
+
117
+ async def __aenter__(self) -> list[StructuredTool]:
118
+ if self.conn is None and self.pool is None:
119
+ self.conn = await AsyncConnection.connect(self.settings.db_dsn)
120
+ await ensure_pgvector_registered(self.conn)
121
+ await self.conn.set_autocommit(True)
122
+
123
+ return self._get_tools()
124
+
125
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
126
+ if self.conn is not None and self._close_conn:
127
+ await self.conn.close()
128
+ self.conn = None
129
+
130
+ async def semantic_content_search(
131
+ self,
132
+ query: str,
133
+ task_id: int | None = None,
134
+ url_part: str | None = None,
135
+ doc_id: int | None = None,
136
+ top_k: int = 5,
137
+ min_score: float = 0,
138
+ ) -> str:
139
+ """Search for relevant content in the browser_content table using vector similarity."""
140
+ if self.conn is None:
141
+ raise RuntimeError("PGTools must be used as an async context manager")
142
+ if not query.strip():
143
+ raise ValueError("query must not be empty")
144
+ if top_k < 1:
145
+ raise ValueError("top_k must be >= 1")
146
+ if not (0.0 <= min_score <= 1.0):
147
+ raise ValueError("min_score must be between 0.0 and 1.0")
148
+
149
+ query_embedding = await self.embeddings.embed_texts(
150
+ [query], input_type="search_query"
151
+ )
152
+ if query_embedding is None:
153
+ raise ValueError("Failed to generate query embedding")
154
+ query_embedding = query_embedding[0]
155
+
156
+ select = SQL("""
157
+ WITH emb AS (
158
+ SELECT %s::vector AS embedding
159
+ )
160
+ SELECT
161
+ bt.id as task_id,
162
+ bc.chunk_id,
163
+ bc.doc_id,
164
+ (bc.chunk_meta->>'page')::int AS page,
165
+ (bc.chunk_meta->>'chunk_index')::int AS chunk_index,
166
+ bc.text_chunk,
167
+ COALESCE(bd.url, '') AS url,
168
+ COALESCE(bd.title, '') AS title,
169
+ (1 - (bc.embedding <=> emb.embedding))::double precision AS score
170
+ FROM ai_proj.browser_content bc
171
+ INNER JOIN ai_proj.browser_docs bd using (doc_id)
172
+ INNER JOIN ai_proj.browser_tasks bt using (id)
173
+ CROSS JOIN emb
174
+ """)
175
+ values: list[Vector | int | str] = [Vector(query_embedding)]
176
+ wheres = [
177
+ SQL("""
178
+ WHERE bc.embedding IS NOT NULL
179
+ AND bc.text_chunk IS NOT NULL
180
+ """)
181
+ ]
182
+
183
+ if task_id is not None:
184
+ wheres.append(SQL("AND bt.id = %s "))
185
+ values.append(task_id)
186
+
187
+ if doc_id is not None:
188
+ wheres.append(SQL("AND bc.doc_id = %s "))
189
+ values.append(doc_id)
190
+
191
+ if url_part is not None:
192
+ wheres.append(SQL("AND bd.url ILIKE '%%' || %s || '%%'"))
193
+ values.append(url_part)
194
+
195
+ limit = SQL("""
196
+ ORDER BY bc.embedding <=> emb.embedding
197
+ LIMIT %s
198
+ """)
199
+ values.append(top_k)
200
+ sql = select + SQL(" ").join(wheres) + limit
201
+ async with self.conn.cursor(row_factory=dict_row) as cur:
202
+ await cur.execute(sql, values)
203
+ res = await cur.fetchall()
204
+ return orjson.dumps(res).decode("utf-8")
205
+
206
+ async def keyword_content_search(
207
+ self,
208
+ keywords: list[str],
209
+ task_id: int | None = None,
210
+ url_part: str | None = None,
211
+ doc_id: int | None = None,
212
+ pages: list[int] | None = None,
213
+ top_k: int = 5,
214
+ ) -> str:
215
+ """Search for relevant content in the browser_content table using keywords, it is case-insensitive.
216
+ Must provide either a task_id or url_part to filter results."""
217
+ if not keywords:
218
+ raise ValueError("keywords must not be empty")
219
+ if not task_id and not url_part:
220
+ raise ValueError("Either task_id or url_part must be provided")
221
+ if top_k < 1:
222
+ raise ValueError("top_k must be >= 1")
223
+ if pages and not doc_id:
224
+ raise ValueError("doc_id must be provided if pages are specified")
225
+
226
+ select = SQL("""
227
+ SELECT
228
+ bt.id as task_id,
229
+ bc.chunk_id,
230
+ bc.doc_id,
231
+ (bc.chunk_meta->>'page')::int AS page,
232
+ (bc.chunk_meta->>'chunk_index')::int AS chunk_index,
233
+ bc.text_chunk,
234
+ COALESCE(bd.url, '') AS url,
235
+ COALESCE(bd.title, '') AS title
236
+ FROM ai_proj.browser_content bc
237
+ INNER JOIN ai_proj.browser_docs bd using (doc_id)
238
+ INNER JOIN ai_proj.browser_tasks bt using (id)
239
+ """)
240
+
241
+ wheres = [
242
+ SQL("""
243
+ WHERE bc.text_chunk IS NOT NULL
244
+ AND EXISTS (
245
+ SELECT 1
246
+ FROM unnest(%s::text[]) AS kw
247
+ WHERE bc.text_chunk ILIKE '%%' || kw || '%%'
248
+ )
249
+ """)
250
+ ]
251
+ values: list[str | int | list[str] | list[int]] = [keywords]
252
+
253
+ if task_id is not None:
254
+ wheres.append(SQL("AND bt.id = %s"))
255
+ values.append(task_id)
256
+ if doc_id is not None:
257
+ wheres.append(SQL("AND bc.doc_id = %s"))
258
+ values.append(doc_id)
259
+ if pages:
260
+ wheres.append(SQL("AND (bc.chunk_meta->>'page')::int = ANY(%s)"))
261
+ values.append(pages)
262
+ if url_part is not None:
263
+ wheres.append(SQL("AND bd.url ILIKE '%%' || %s || '%%'"))
264
+ values.append(url_part)
265
+
266
+ limit = SQL("""
267
+ ORDER BY doc_id, (bc.chunk_meta->>'page')::int, (bc.chunk_meta->>'chunk_index')::int
268
+ LIMIT %s
269
+ """)
270
+ values.append(top_k)
271
+ sql = select + SQL(" ").join(wheres) + limit
272
+
273
+ async with self._get_cur() as cur:
274
+ await cur.execute(sql, values)
275
+ res = await cur.fetchall()
276
+ return orjson.dumps(res).decode("utf-8")
277
+
278
+ async def get_task(self, task_id: int) -> str:
279
+ """Retrieve the starting_task for a given task_id from the browser_tasks table.
280
+ This is the same info from semantic_task_search."""
281
+
282
+ sql = """
283
+ SELECT starting_task
284
+ FROM ai_proj.browser_tasks
285
+ WHERE id = %s
286
+ """
287
+ async with self._get_cur() as cur:
288
+ await cur.execute(sql, [task_id])
289
+ res = await cur.fetchone()
290
+ return orjson.dumps(res).decode("utf-8")
291
+
292
+ async def semantic_task_search(
293
+ self,
294
+ query: str,
295
+ top_k: int = 5,
296
+ min_score: float = 0,
297
+ ) -> str:
298
+ """Search for relevant tasks in the browser_tasks table using vector similarity."""
299
+ if not query.strip():
300
+ raise ValueError("query must not be empty")
301
+ if top_k < 1:
302
+ raise ValueError("top_k must be >= 1")
303
+ if not (0.0 <= min_score <= 1.0):
304
+ raise ValueError("min_score must be between 0.0 and 1.0")
305
+
306
+ query_embedding = await self.embeddings.embed_texts(
307
+ [query], input_type="search_query"
308
+ )
309
+ if query_embedding is None:
310
+ raise ValueError("Failed to generate query embedding")
311
+ query_embedding = query_embedding[0]
312
+
313
+ sql = SQL("""
314
+ WITH emb AS (
315
+ SELECT %s::vector AS embedding
316
+ )
317
+ SELECT
318
+ bt.id as task_id,
319
+ bt.starting_task,
320
+ (1 - (bt.embedding <=> emb.embedding))::double precision AS score
321
+ FROM ai_proj.browser_tasks bt
322
+ CROSS JOIN emb
323
+ ORDER BY bt.embedding <=> emb.embedding
324
+ LIMIT %s
325
+ """)
326
+ values = [Vector(query_embedding), top_k]
327
+ async with self._get_cur() as cur:
328
+ await cur.execute(sql, values)
329
+ res = await cur.fetchall()
330
+ return orjson.dumps(res).decode("utf-8")
331
+
332
+ async def browser_use(self, objective: str) -> str:
333
+ """Use a real browser to collect content and ingest it into the database.RETURN_DIRECT"""
334
+
335
+ sql = SQL("""
336
+ INSERT INTO ai_proj.browser_tasks (starting_task)
337
+ VALUES (%s)
338
+ RETURNING id as task_id
339
+ """)
340
+ values = [objective]
341
+ # TODO invoking this needs to pause the research agent until the task is completed and then
342
+ # return the results of the task.
343
+ async with self._get_cur() as cur:
344
+ await cur.execute(sql, values)
345
+ res = await cur.fetchall()
346
+ return orjson.dumps(res).decode("utf-8")
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: dean_research_tools
3
+ Version: 0.1.0
4
+ Summary: Retriever tools package for research task and content lookup
5
+ Author-email: Dean MacGregor <powertrading121@gmail.com>
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: cohere>=5.0.0
18
+ Requires-Dist: langchain-core>=0.3.0
19
+ Requires-Dist: orjson>=3.9.0
20
+ Requires-Dist: pgvector>=0.3.0
21
+ Requires-Dist: psycopg[binary]>=3.2.0
22
+ Requires-Dist: pydantic>=2.7.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2.2; extra == "dev"
25
+ Requires-Dist: pytest>=8.2.0; extra == "dev"
26
+ Requires-Dist: twine>=5.1.1; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # dean_research_tools
30
+
31
+ I made this library so I could use it in different environments without copying and pasting. I'm not intending to have it be used by a lot of people.
32
+
33
+ If a lot of people want to use it then that's fine, there's no secret sauce, but you're on your own.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/dean_research_tools/__init__.py
5
+ src/dean_research_tools/config.py
6
+ src/dean_research_tools/embeddings.py
7
+ src/dean_research_tools/models.py
8
+ src/dean_research_tools/retriever.py
9
+ src/dean_research_tools.egg-info/PKG-INFO
10
+ src/dean_research_tools.egg-info/SOURCES.txt
11
+ src/dean_research_tools.egg-info/dependency_links.txt
12
+ src/dean_research_tools.egg-info/requires.txt
13
+ src/dean_research_tools.egg-info/top_level.txt
14
+ tests/test_func_args_sync.py
@@ -0,0 +1,11 @@
1
+ cohere>=5.0.0
2
+ langchain-core>=0.3.0
3
+ orjson>=3.9.0
4
+ pgvector>=0.3.0
5
+ psycopg[binary]>=3.2.0
6
+ pydantic>=2.7.0
7
+
8
+ [dev]
9
+ build>=1.2.2
10
+ pytest>=8.2.0
11
+ twine>=5.1.1
@@ -0,0 +1 @@
1
+ dean_research_tools
@@ -0,0 +1,141 @@
1
+ from inspect import isfunction, signature
2
+ from typing import Any, cast, get_args
3
+
4
+ from langchain_core.tools import ArgsSchema
5
+ from pydantic import SecretStr
6
+
7
+ from dean_research_tools import PGTools
8
+ from dean_research_tools.config import Settings
9
+ from dean_research_tools.retriever import AVAILABLE_TOOLS
10
+
11
+
12
+ def _safe_schema(model: ArgsSchema) -> dict[str, Any]:
13
+ if isinstance(model, dict):
14
+ return model
15
+ attr_names = ["model_json_schema", "schema"]
16
+ for attr_name in attr_names:
17
+ if hasattr(model, attr_name):
18
+ return getattr(model, attr_name)()
19
+
20
+ raise ValueError(
21
+ f"Model {model} does not have a schema method or model_json_schema method"
22
+ )
23
+
24
+
25
+ TYPE_MAP = {
26
+ "string": "str",
27
+ "null": "None",
28
+ "integer": "int",
29
+ "number": "float",
30
+ "boolean": "bool",
31
+ "array": "list",
32
+ "object": "dict",
33
+ }
34
+
35
+
36
+ def _parse_model_arg(model_args: dict[str, Any], arg: str) -> set[str]:
37
+ if arg not in model_args:
38
+ raise ValueError(f"Argument {arg} not found in model args")
39
+ arg_info = model_args[arg]
40
+ return _parse_arg_info(arg_info)
41
+
42
+
43
+ def _parse_arg_info(arg_info: dict[str, Any]) -> set[str]:
44
+ inner_types = ""
45
+ if "items" in arg_info:
46
+ inner_types_raw = sorted(list(_parse_arg_info(arg_info["items"])))
47
+ inner_types = "[" + ",".join(inner_types_raw) + "]"
48
+ if "type" in arg_info:
49
+ arg_type = TYPE_MAP[arg_info["type"]]
50
+ return {arg_type + inner_types}
51
+ if "anyOf" in arg_info:
52
+ types = set()
53
+ for option in arg_info["anyOf"]:
54
+ types.update(_parse_arg_info(option))
55
+ return types
56
+ raise ValueError(f"Argument info {arg_info} does not have a type or anyOf field")
57
+
58
+
59
+ def _alphabatise_inner(annotation: str) -> str:
60
+ open_indx = annotation.find("[")
61
+ while open_indx > 0:
62
+ close_indx = annotation.find("]", open_indx)
63
+ inner = annotation[open_indx + 1 : close_indx]
64
+ inner_parts = [p.strip() for p in inner.split(",")]
65
+ inner_parts.sort()
66
+ inner_sorted = ",".join(inner_parts)
67
+ annotation = (
68
+ annotation[: open_indx + 1] + inner_sorted + annotation[close_indx:]
69
+ )
70
+ open_indx = annotation.find("[", close_indx)
71
+ return annotation
72
+
73
+
74
+ def _parse_func_arg(annotation: str) -> set[str]:
75
+ annotation = _alphabatise_inner(annotation)
76
+ split_chars = [",", "|"]
77
+ for char in split_chars:
78
+ if char in annotation:
79
+ parts = [p.strip() for p in annotation.split(char)]
80
+ return {p for p in parts}
81
+
82
+ return {annotation}
83
+
84
+
85
+ blank = Settings(
86
+ db_host="a",
87
+ db_port=1,
88
+ db_name="b",
89
+ db_user="c",
90
+ db_password="d",
91
+ azure_openai_api_key=SecretStr("e"),
92
+ azure_embedding_endpoint="f",
93
+ azure_openai_embedding_deployment="g",
94
+ )
95
+
96
+
97
+ def test_get_all_tools():
98
+ """Tests that the functions returned by _get_all_tools match the functions defined in the PGTools class."""
99
+ funcs_in_all = set(x.name for x in PGTools(settings=blank)._get_all_tools())
100
+
101
+ funcs_in_class = set(
102
+ x for x in dir(PGTools) if x[0] != "_" and isfunction(getattr(PGTools, x))
103
+ )
104
+
105
+ if funcs_in_class != funcs_in_all:
106
+ missing_from_class = funcs_in_all - funcs_in_class
107
+ missing_from_function = funcs_in_class - funcs_in_all
108
+ raise ValueError(
109
+ f"Mismatch between PGTools class and _get_all_tools. Missing from class: {missing_from_class}. Missing from function: {missing_from_function}"
110
+ )
111
+
112
+
113
+ def test_available_tools():
114
+ """Tests that the functions defined in the PGTools class match the functions listed in AVAILABLE_TOOLS."""
115
+ funcs_in_class = set(
116
+ x for x in dir(PGTools) if x[0] != "_" and isfunction(getattr(PGTools, x))
117
+ )
118
+
119
+ funcs_in_literal = set(get_args(AVAILABLE_TOOLS))
120
+
121
+ if funcs_in_class != funcs_in_literal:
122
+ missing_from_class = funcs_in_literal - funcs_in_class
123
+ missing_from_literal = funcs_in_class - funcs_in_literal
124
+ raise ValueError(
125
+ f"Mismatch between PGTools class and AVAILABLE_TOOLS literal. Missing from class: {missing_from_class}. Missing from literal: {missing_from_literal}"
126
+ )
127
+
128
+
129
+ def test_arg_schema_sync():
130
+ """Tests that the function arguments defined in the PGTools class match the arguments defined in the ArgsSchema for each tool."""
131
+ for struct_tool in PGTools(settings=blank)._get_all_tools():
132
+ func_args = signature(getattr(PGTools, struct_tool.name)).parameters
133
+ model_args = _safe_schema(struct_tool.args_schema)["properties"]
134
+ for arg in func_args:
135
+ if arg == "self":
136
+ continue
137
+ func_annot = str(func_args[arg].annotation)
138
+
139
+ model_types = _parse_model_arg(model_args, arg)
140
+ func_types = _parse_func_arg(func_annot)
141
+ assert func_types == model_types