knowledge-grove 0.1.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.
- knowledge_grove/__init__.py +18 -0
- knowledge_grove/cli.py +268 -0
- knowledge_grove/constants.py +34 -0
- knowledge_grove/crud.py +431 -0
- knowledge_grove/db.py +23 -0
- knowledge_grove/mcp_server.py +501 -0
- knowledge_grove/migrations/__init__.py +0 -0
- knowledge_grove/migrations/env.py +85 -0
- knowledge_grove/migrations/versions/initial_schema.py +295 -0
- knowledge_grove/models.py +209 -0
- knowledge_grove/search.py +174 -0
- knowledge_grove/utils/__init__.py +0 -0
- knowledge_grove/utils/chunking.py +128 -0
- knowledge_grove/utils/embedding.py +26 -0
- knowledge_grove/utils/hashing.py +12 -0
- knowledge_grove/utils/input_output.py +78 -0
- knowledge_grove-0.1.0.dist-info/METADATA +169 -0
- knowledge_grove-0.1.0.dist-info/RECORD +22 -0
- knowledge_grove-0.1.0.dist-info/WHEEL +5 -0
- knowledge_grove-0.1.0.dist-info/entry_points.txt +2 -0
- knowledge_grove-0.1.0.dist-info/licenses/LICENSE +21 -0
- knowledge_grove-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from knowledge_grove.db import get_engine, get_session
|
|
2
|
+
from knowledge_grove.models import (
|
|
3
|
+
Document,
|
|
4
|
+
DocumentTag,
|
|
5
|
+
Edge,
|
|
6
|
+
DocumentAccess,
|
|
7
|
+
RetrievalFeedback,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"get_engine",
|
|
12
|
+
"get_session",
|
|
13
|
+
"Document",
|
|
14
|
+
"DocumentTag",
|
|
15
|
+
"Edge",
|
|
16
|
+
"DocumentAccess",
|
|
17
|
+
"RetrievalFeedback",
|
|
18
|
+
]
|
knowledge_grove/cli.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""CLI commands: bootstrapping (`init-db`, `create-agent-role`, §14 of the
|
|
2
|
+
design doc, ops-facing one-time-per-database/per-agent setup) and content
|
|
3
|
+
ingestion (`ingest`, a thin wrapper over add_file_as_document/§13 dedup for
|
|
4
|
+
use from a shell rather than another agent's own code).
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import getpass
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from alembic import command
|
|
13
|
+
from alembic.config import Config
|
|
14
|
+
from psycopg import sql
|
|
15
|
+
from sqlalchemy import create_engine, func, select, text
|
|
16
|
+
from sqlalchemy.engine import make_url
|
|
17
|
+
|
|
18
|
+
import knowledge_grove
|
|
19
|
+
from knowledge_grove.constants import ContentType, SHARED_READER
|
|
20
|
+
from knowledge_grove.db import get_engine, get_session
|
|
21
|
+
from knowledge_grove.models import Document
|
|
22
|
+
from knowledge_grove.utils.input_output import add_file_as_document, detect_content_type
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _alembic_config(dsn: str) -> Config:
|
|
26
|
+
"""Build an Alembic Config pointing at this package's own bundled
|
|
27
|
+
migrations directly, rather than relying on alembic.ini being present on
|
|
28
|
+
disk -- that file lives at the repo root, outside the installed package,
|
|
29
|
+
so it won't exist for a real (non-editable) install of knowledge-grove
|
|
30
|
+
used from another project.
|
|
31
|
+
"""
|
|
32
|
+
migrations_dir = Path(knowledge_grove.__file__).parent / "migrations"
|
|
33
|
+
cfg = Config()
|
|
34
|
+
cfg.set_main_option("script_location", str(migrations_dir))
|
|
35
|
+
cfg.set_main_option("sqlalchemy.url", dsn)
|
|
36
|
+
return cfg
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def init_db(dsn: str) -> None:
|
|
40
|
+
"""Run every bundled migration up to head against `dsn`.
|
|
41
|
+
|
|
42
|
+
Must be run by a role with CREATE EXTENSION / CREATE POLICY / table-owner
|
|
43
|
+
privileges -- never the role an ordinary agent connects as, since table
|
|
44
|
+
owners bypass RLS by default (see the initial migration's own docstring).
|
|
45
|
+
"""
|
|
46
|
+
command.upgrade(_alembic_config(dsn), "head")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_agent_role(dsn: str, role_name: str, password: str) -> str:
|
|
50
|
+
"""Provision a new agent's Postgres role: LOGIN credentials, the base
|
|
51
|
+
grants every agent needs, and membership in `shared_reader` so it can
|
|
52
|
+
read whatever's been shared into that group by default.
|
|
53
|
+
|
|
54
|
+
`dsn` must belong to a role with privileges to create roles and grant
|
|
55
|
+
table access (the same setup-only role `init_db` requires), not an
|
|
56
|
+
ordinary agent role. Returns the DSN the new agent should connect with.
|
|
57
|
+
|
|
58
|
+
Role names and the password can't be passed as ordinary bind parameters
|
|
59
|
+
-- CREATE ROLE / GRANT are utility statements, not DML, so Postgres
|
|
60
|
+
doesn't accept protocol-level placeholders for them. `psycopg.sql`
|
|
61
|
+
composes them safely instead (proper identifier quoting for the role
|
|
62
|
+
name, proper literal escaping for the password).
|
|
63
|
+
"""
|
|
64
|
+
engine = create_engine(dsn)
|
|
65
|
+
try:
|
|
66
|
+
with engine.begin() as conn:
|
|
67
|
+
cur = conn.connection.dbapi_connection.cursor()
|
|
68
|
+
cur.execute(
|
|
69
|
+
sql.SQL("CREATE ROLE {} LOGIN PASSWORD {}").format(
|
|
70
|
+
sql.Identifier(role_name), sql.Literal(password)
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
cur.execute(
|
|
74
|
+
sql.SQL(
|
|
75
|
+
"GRANT SELECT, INSERT, UPDATE, DELETE ON "
|
|
76
|
+
"documents, document_tags, edges, document_access TO {}"
|
|
77
|
+
).format(sql.Identifier(role_name))
|
|
78
|
+
)
|
|
79
|
+
cur.execute(
|
|
80
|
+
sql.SQL("GRANT SELECT, INSERT ON retrieval_feedback TO {}").format(
|
|
81
|
+
sql.Identifier(role_name)
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
cur.execute(
|
|
85
|
+
sql.SQL("GRANT {} TO {}").format(
|
|
86
|
+
sql.Identifier(SHARED_READER), sql.Identifier(role_name)
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
finally:
|
|
90
|
+
engine.dispose()
|
|
91
|
+
|
|
92
|
+
agent_url = make_url(dsn).set(username=role_name, password=password)
|
|
93
|
+
return agent_url.render_as_string(hide_password=False)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _current_agent(session) -> str:
|
|
97
|
+
"""The Postgres role this connection is actually authenticated as -- see
|
|
98
|
+
mcp_server.py's identical helper for why document ownership always comes
|
|
99
|
+
from the connection itself, never a caller-supplied argument.
|
|
100
|
+
"""
|
|
101
|
+
return session.execute(text("SELECT current_user")).scalar()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _active_document_count(session, source_url: str) -> int:
|
|
105
|
+
return session.scalar(
|
|
106
|
+
select(func.count()).select_from(Document).where(
|
|
107
|
+
Document.source_url == source_url, Document.deprecated.is_(False)
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def ingest_files(
|
|
113
|
+
dsn: str,
|
|
114
|
+
file_paths: list[str],
|
|
115
|
+
source_urls: list[str | None] | None = None,
|
|
116
|
+
content_types: list[str | None] | None = None,
|
|
117
|
+
roles: dict[str, list[str]] | None = None,
|
|
118
|
+
assume_yes: bool = False,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Ingest one or more files as documents, chunking each and computing
|
|
121
|
+
embeddings (add_file_as_document). `dsn` should be an ordinary agent's
|
|
122
|
+
connection string, not the admin/setup one `init_db`/`create_agent_role`
|
|
123
|
+
need -- ownership is derived from whichever role `dsn` authenticates as.
|
|
124
|
+
|
|
125
|
+
Each file's `source_url` defaults to its own filename (not a real URL,
|
|
126
|
+
just a stable identifier re-ingesting the same file later will match
|
|
127
|
+
again -- see add_raw_document's §13 reconciliation). If a source_url
|
|
128
|
+
already has existing documents, this asks for confirmation before
|
|
129
|
+
proceeding (unless `assume_yes`): re-using an existing source_url for a
|
|
130
|
+
genuinely different file would make add_raw_document treat it as a
|
|
131
|
+
changed revision and deprecate that unrelated content.
|
|
132
|
+
|
|
133
|
+
Each file's `content_type` (which chunker to use) defaults to a guess
|
|
134
|
+
from its extension (detect_content_type) -- `.py` -> python, `.sql` ->
|
|
135
|
+
sql, everything else -> markdown. Pass `content_types` to override that
|
|
136
|
+
per file, matched by position to `file_paths`.
|
|
137
|
+
|
|
138
|
+
`roles` grants other roles access to every document ingested in this
|
|
139
|
+
call (see add_document); if omitted, defaults to {"shared_reader":
|
|
140
|
+
["read"]} -- readable by every agent in the shared_reader group. Pass
|
|
141
|
+
{} to keep everything ingested here private to the owner only.
|
|
142
|
+
"""
|
|
143
|
+
resolved_source_urls = [
|
|
144
|
+
source_urls[i] if source_urls and source_urls[i] else Path(file_paths[i]).name
|
|
145
|
+
for i in range(len(file_paths))
|
|
146
|
+
]
|
|
147
|
+
resolved_content_types = [
|
|
148
|
+
content_types[i] if content_types else None
|
|
149
|
+
for i in range(len(file_paths))
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
engine = get_engine(dsn)
|
|
153
|
+
session = get_session(engine)
|
|
154
|
+
try:
|
|
155
|
+
owner_agent = _current_agent(session)
|
|
156
|
+
|
|
157
|
+
for path, source_url, content_type in zip(file_paths, resolved_source_urls, resolved_content_types):
|
|
158
|
+
existing_count = _active_document_count(session, source_url)
|
|
159
|
+
if existing_count > 0 and not assume_yes:
|
|
160
|
+
print(
|
|
161
|
+
f"Warning: {existing_count} existing document(s) already use "
|
|
162
|
+
f"source_url '{source_url}'. Continuing will treat '{path}' as a "
|
|
163
|
+
f"new revision of that same source: identical content is a "
|
|
164
|
+
f"no-op, but different content will deprecate the existing "
|
|
165
|
+
f"chunks. If '{path}' is not actually a revision of that source, "
|
|
166
|
+
f"answer no and re-run with a different --source-url."
|
|
167
|
+
)
|
|
168
|
+
answer = input("Proceed? [y/N] ").strip().lower()
|
|
169
|
+
if answer != "y":
|
|
170
|
+
print(f"Skipped '{path}'.")
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
docs = add_file_as_document(
|
|
174
|
+
session, path, owner_agent=owner_agent, source_url=source_url,
|
|
175
|
+
content_type=content_type, roles=roles,
|
|
176
|
+
)
|
|
177
|
+
session.commit()
|
|
178
|
+
used_type = content_type or detect_content_type(path)
|
|
179
|
+
print(f"Ingested '{path}' as {len(docs)} chunk(s) ({used_type}) under source_url '{source_url}'.")
|
|
180
|
+
finally:
|
|
181
|
+
session.close()
|
|
182
|
+
engine.dispose()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main() -> None:
|
|
186
|
+
parser = argparse.ArgumentParser(prog="knowledge-grove")
|
|
187
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
188
|
+
|
|
189
|
+
subparsers.add_parser(
|
|
190
|
+
"init-db", help="Run the bundled migrations against KNOWLEDGE_GROVE_DSN."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
role_parser = subparsers.add_parser(
|
|
194
|
+
"create-agent-role", help="Provision a new agent's Postgres role."
|
|
195
|
+
)
|
|
196
|
+
role_parser.add_argument("role_name")
|
|
197
|
+
role_parser.add_argument(
|
|
198
|
+
"--password", help="If omitted, you'll be prompted (not echoed)."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
ingest_parser = subparsers.add_parser(
|
|
202
|
+
"ingest", help="Add one or more files as documents, chunking each and computing embeddings."
|
|
203
|
+
)
|
|
204
|
+
ingest_parser.add_argument("files", nargs="+", help="Path(s) to the file(s) to ingest.")
|
|
205
|
+
ingest_parser.add_argument(
|
|
206
|
+
"--source-url", action="append", default=None,
|
|
207
|
+
help=(
|
|
208
|
+
"Source identifier, given once per file in the same order as `files`. "
|
|
209
|
+
"Defaults to each file's own filename (not a real URL -- just a stable "
|
|
210
|
+
"identifier so re-ingesting the same file later is recognized as an "
|
|
211
|
+
"update rather than a new, unrelated document)."
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
ingest_parser.add_argument(
|
|
215
|
+
"--content-type", action="append", default=None, choices=list(ContentType),
|
|
216
|
+
help=(
|
|
217
|
+
"Chunker to use, given once per file in the same order as `files`. "
|
|
218
|
+
"Defaults to a guess from each file's extension (.py -> python, "
|
|
219
|
+
".sql -> sql, everything else -> markdown)."
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
ingest_parser.add_argument(
|
|
223
|
+
"--roles", default=None,
|
|
224
|
+
help=(
|
|
225
|
+
"JSON object granting other roles access to every document ingested "
|
|
226
|
+
"in this call, e.g. '{\"shared_reader\": [\"read\"]}'. Applies to the "
|
|
227
|
+
"whole call, not per file. Defaults to {\"shared_reader\": [\"read\"]} "
|
|
228
|
+
"if omitted; pass '{}' to keep everything private to the owner."
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
ingest_parser.add_argument(
|
|
232
|
+
"-y", "--yes", action="store_true",
|
|
233
|
+
help="Don't prompt for confirmation when a source_url already has existing documents.",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
args = parser.parse_args()
|
|
237
|
+
|
|
238
|
+
dsn = os.environ.get("KNOWLEDGE_GROVE_DSN")
|
|
239
|
+
if not dsn:
|
|
240
|
+
parser.error("KNOWLEDGE_GROVE_DSN must be set to a connection string")
|
|
241
|
+
|
|
242
|
+
if args.command == "init-db":
|
|
243
|
+
init_db(dsn)
|
|
244
|
+
print("Migrations applied.")
|
|
245
|
+
elif args.command == "create-agent-role":
|
|
246
|
+
password = args.password or getpass.getpass(f"Password for {args.role_name}: ")
|
|
247
|
+
agent_dsn = create_agent_role(dsn, args.role_name, password)
|
|
248
|
+
print(f"Role '{args.role_name}' created.")
|
|
249
|
+
print(f"Agent DSN: {agent_dsn}")
|
|
250
|
+
elif args.command == "ingest":
|
|
251
|
+
if args.source_url and len(args.source_url) != len(args.files):
|
|
252
|
+
parser.error("--source-url must be given once per file, or omitted entirely")
|
|
253
|
+
if args.content_type and len(args.content_type) != len(args.files):
|
|
254
|
+
parser.error("--content-type must be given once per file, or omitted entirely")
|
|
255
|
+
roles = None
|
|
256
|
+
if args.roles is not None:
|
|
257
|
+
try:
|
|
258
|
+
roles = json.loads(args.roles)
|
|
259
|
+
except json.JSONDecodeError as e:
|
|
260
|
+
parser.error(f"--roles must be valid JSON: {e}")
|
|
261
|
+
ingest_files(
|
|
262
|
+
dsn, args.files, source_urls=args.source_url,
|
|
263
|
+
content_types=args.content_type, roles=roles, assume_yes=args.yes,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
main()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
|
|
4
|
+
# Dimension of the `embedding` / `summary_embedding` vector columns.
|
|
5
|
+
# Baked into the schema at migration time (pgvector requires a fixed size per
|
|
6
|
+
# column), so changing this after tables exist requires a migration that
|
|
7
|
+
# rebuilds the vector columns and re-embeds existing content.
|
|
8
|
+
#
|
|
9
|
+
# Default matches intfloat/e5-base-v2 (768-dim, open source, MIT licensed).
|
|
10
|
+
EMBEDDING_DIM = int(os.environ.get("KNOWLEDGE_GROVE_EMBEDDING_DIM", 768))
|
|
11
|
+
|
|
12
|
+
# Postgres enum values. Defined once here so models.py (SQLAlchemy Enum
|
|
13
|
+
# types) and the initial migration (raw CREATE TYPE ... AS ENUM statements)
|
|
14
|
+
# can't drift apart from each other.
|
|
15
|
+
SOURCE_METHODS = ("vector", "tags", "fulltext", "ilike", "id")
|
|
16
|
+
JUDGED_BY_VALUES = ("explicit_llm", "implicit_usage")
|
|
17
|
+
SHARED_READER = "shared_reader"
|
|
18
|
+
|
|
19
|
+
class EdgeType(StrEnum):
|
|
20
|
+
NEXT = "next"
|
|
21
|
+
PREV = "prev"
|
|
22
|
+
SOURCE = "source"
|
|
23
|
+
TOOL = "tool"
|
|
24
|
+
RELATED = "related"
|
|
25
|
+
SUPERSEDES = "supersedes"
|
|
26
|
+
|
|
27
|
+
class PERMISSIONS(StrEnum):
|
|
28
|
+
READ = "read"
|
|
29
|
+
WRITE = "write"
|
|
30
|
+
|
|
31
|
+
class ContentType(StrEnum):
|
|
32
|
+
MARKDOWN = "markdown"
|
|
33
|
+
PYTHON = "python"
|
|
34
|
+
SQL = "sql"
|