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.
@@ -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"