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.
- dean_research_tools-0.1.0/LICENSE +7 -0
- dean_research_tools-0.1.0/PKG-INFO +33 -0
- dean_research_tools-0.1.0/README.md +5 -0
- dean_research_tools-0.1.0/pyproject.toml +48 -0
- dean_research_tools-0.1.0/setup.cfg +4 -0
- dean_research_tools-0.1.0/src/dean_research_tools/__init__.py +6 -0
- dean_research_tools-0.1.0/src/dean_research_tools/config.py +94 -0
- dean_research_tools-0.1.0/src/dean_research_tools/embeddings.py +42 -0
- dean_research_tools-0.1.0/src/dean_research_tools/models.py +90 -0
- dean_research_tools-0.1.0/src/dean_research_tools/retriever.py +346 -0
- dean_research_tools-0.1.0/src/dean_research_tools.egg-info/PKG-INFO +33 -0
- dean_research_tools-0.1.0/src/dean_research_tools.egg-info/SOURCES.txt +14 -0
- dean_research_tools-0.1.0/src/dean_research_tools.egg-info/dependency_links.txt +1 -0
- dean_research_tools-0.1.0/src/dean_research_tools.egg-info/requires.txt +11 -0
- dean_research_tools-0.1.0/src/dean_research_tools.egg-info/top_level.txt +1 -0
- dean_research_tools-0.1.0/tests/test_func_args_sync.py +141 -0
|
@@ -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,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 @@
|
|
|
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
|