minder-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- minder/__init__.py +12 -0
- minder/api/routers/prompts.py +177 -0
- minder/application/__init__.py +1 -0
- minder/application/admin/__init__.py +11 -0
- minder/application/admin/dto.py +453 -0
- minder/application/admin/jobs.py +327 -0
- minder/application/admin/use_cases.py +1895 -0
- minder/auth/__init__.py +12 -0
- minder/auth/context.py +26 -0
- minder/auth/middleware.py +70 -0
- minder/auth/principal.py +59 -0
- minder/auth/rate_limiter.py +89 -0
- minder/auth/rbac.py +60 -0
- minder/auth/service.py +541 -0
- minder/bootstrap/__init__.py +9 -0
- minder/bootstrap/providers.py +109 -0
- minder/bootstrap/transport.py +807 -0
- minder/cache/__init__.py +10 -0
- minder/cache/providers.py +140 -0
- minder/chunking/__init__.py +4 -0
- minder/chunking/code_splitter.py +184 -0
- minder/chunking/splitter.py +136 -0
- minder/cli.py +1542 -0
- minder/config.py +179 -0
- minder/continuity.py +363 -0
- minder/dev.py +160 -0
- minder/embedding/__init__.py +9 -0
- minder/embedding/base.py +7 -0
- minder/embedding/local.py +65 -0
- minder/embedding/openai.py +7 -0
- minder/graph/__init__.py +11 -0
- minder/graph/edges.py +13 -0
- minder/graph/executor.py +127 -0
- minder/graph/graph.py +263 -0
- minder/graph/nodes/__init__.py +27 -0
- minder/graph/nodes/evaluator.py +21 -0
- minder/graph/nodes/guard.py +64 -0
- minder/graph/nodes/llm.py +59 -0
- minder/graph/nodes/planning.py +30 -0
- minder/graph/nodes/reasoning.py +87 -0
- minder/graph/nodes/reranker.py +141 -0
- minder/graph/nodes/retriever.py +86 -0
- minder/graph/nodes/verification.py +230 -0
- minder/graph/nodes/workflow_planner.py +250 -0
- minder/graph/runtime.py +15 -0
- minder/graph/state.py +26 -0
- minder/llm/__init__.py +5 -0
- minder/llm/base.py +14 -0
- minder/llm/local.py +381 -0
- minder/llm/openai.py +89 -0
- minder/models/__init__.py +109 -0
- minder/models/base.py +10 -0
- minder/models/client.py +137 -0
- minder/models/document.py +34 -0
- minder/models/error.py +32 -0
- minder/models/graph.py +114 -0
- minder/models/history.py +32 -0
- minder/models/job.py +62 -0
- minder/models/prompt.py +41 -0
- minder/models/repository.py +62 -0
- minder/models/rule.py +68 -0
- minder/models/session.py +51 -0
- minder/models/skill.py +52 -0
- minder/models/user.py +41 -0
- minder/models/workflow.py +35 -0
- minder/observability/__init__.py +57 -0
- minder/observability/audit.py +243 -0
- minder/observability/logging.py +253 -0
- minder/observability/metrics.py +448 -0
- minder/observability/tracing.py +215 -0
- minder/presentation/__init__.py +1 -0
- minder/presentation/http/__init__.py +1 -0
- minder/presentation/http/admin/__init__.py +3 -0
- minder/presentation/http/admin/api.py +1309 -0
- minder/presentation/http/admin/context.py +94 -0
- minder/presentation/http/admin/dashboard.py +111 -0
- minder/presentation/http/admin/jobs.py +208 -0
- minder/presentation/http/admin/memories.py +185 -0
- minder/presentation/http/admin/prompts.py +219 -0
- minder/presentation/http/admin/routes.py +127 -0
- minder/presentation/http/admin/runtime.py +650 -0
- minder/presentation/http/admin/search.py +368 -0
- minder/presentation/http/admin/skills.py +230 -0
- minder/prompts/__init__.py +646 -0
- minder/prompts/formatter.py +142 -0
- minder/resources/__init__.py +318 -0
- minder/retrieval/__init__.py +5 -0
- minder/retrieval/hybrid.py +178 -0
- minder/retrieval/mmr.py +116 -0
- minder/retrieval/multi_hop.py +115 -0
- minder/runtime.py +15 -0
- minder/server.py +145 -0
- minder/store/__init__.py +64 -0
- minder/store/document.py +115 -0
- minder/store/error.py +82 -0
- minder/store/feedback.py +114 -0
- minder/store/graph.py +588 -0
- minder/store/history.py +57 -0
- minder/store/interfaces.py +512 -0
- minder/store/milvus/__init__.py +11 -0
- minder/store/milvus/client.py +26 -0
- minder/store/milvus/collections.py +15 -0
- minder/store/milvus/vector_store.py +232 -0
- minder/store/mongodb/__init__.py +11 -0
- minder/store/mongodb/client.py +49 -0
- minder/store/mongodb/indexes.py +90 -0
- minder/store/mongodb/operational_store.py +993 -0
- minder/store/relational.py +1087 -0
- minder/store/repo_state.py +58 -0
- minder/store/rule.py +93 -0
- minder/store/vector.py +79 -0
- minder/tools/__init__.py +47 -0
- minder/tools/auth.py +94 -0
- minder/tools/graph.py +839 -0
- minder/tools/ingest.py +353 -0
- minder/tools/memory.py +381 -0
- minder/tools/query.py +307 -0
- minder/tools/registry.py +269 -0
- minder/tools/repo_scanner.py +1266 -0
- minder/tools/search.py +15 -0
- minder/tools/session.py +316 -0
- minder/tools/skills.py +899 -0
- minder/tools/workflow.py +215 -0
- minder/transport/__init__.py +4 -0
- minder/transport/base.py +286 -0
- minder/transport/sse.py +252 -0
- minder/transport/stdio.py +29 -0
- minder_cli-0.2.0.dist-info/METADATA +318 -0
- minder_cli-0.2.0.dist-info/RECORD +132 -0
- minder_cli-0.2.0.dist-info/WHEEL +4 -0
- minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
- minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
minder/store/feedback.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FeedbackStore — async SQLAlchemy CRUD for the Feedback domain model.
|
|
3
|
+
|
|
4
|
+
Supports per-entity listing and rating aggregation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from typing import AsyncGenerator
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import delete, func, select, update
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
15
|
+
|
|
16
|
+
from minder.models import Base, Feedback
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FeedbackStore:
|
|
20
|
+
"""Async store for :class:`~minder.models.Feedback` entities."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, db_url: str, echo: bool = False) -> None:
|
|
23
|
+
self._engine = create_async_engine(db_url, echo=echo)
|
|
24
|
+
self._session_factory = async_sessionmaker(
|
|
25
|
+
self._engine,
|
|
26
|
+
expire_on_commit=False,
|
|
27
|
+
class_=AsyncSession,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# Lifecycle
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
async def init_db(self) -> None:
|
|
35
|
+
async with self._engine.begin() as conn:
|
|
36
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
37
|
+
|
|
38
|
+
async def dispose(self) -> None:
|
|
39
|
+
await self._engine.dispose()
|
|
40
|
+
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def _session(self) -> AsyncGenerator[AsyncSession, None]:
|
|
43
|
+
async with self._session_factory() as sess:
|
|
44
|
+
try:
|
|
45
|
+
yield sess
|
|
46
|
+
await sess.commit()
|
|
47
|
+
except Exception:
|
|
48
|
+
await sess.rollback()
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# CRUD
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
async def create_feedback(self, **kwargs) -> Feedback:
|
|
56
|
+
async with self._session() as sess:
|
|
57
|
+
fb = Feedback(**kwargs)
|
|
58
|
+
sess.add(fb)
|
|
59
|
+
await sess.flush()
|
|
60
|
+
await sess.refresh(fb)
|
|
61
|
+
return fb
|
|
62
|
+
|
|
63
|
+
async def get_feedback_by_id(self, feedback_id: uuid.UUID) -> Feedback | None:
|
|
64
|
+
async with self._session() as sess:
|
|
65
|
+
result = await sess.execute(
|
|
66
|
+
select(Feedback).where(Feedback.id == feedback_id)
|
|
67
|
+
)
|
|
68
|
+
return result.scalar_one_or_none()
|
|
69
|
+
|
|
70
|
+
async def list_feedback(self) -> list[Feedback]:
|
|
71
|
+
async with self._session() as sess:
|
|
72
|
+
result = await sess.execute(select(Feedback))
|
|
73
|
+
return list(result.scalars().all())
|
|
74
|
+
|
|
75
|
+
async def list_by_entity(
|
|
76
|
+
self, entity_type: str, entity_id: uuid.UUID
|
|
77
|
+
) -> list[Feedback]:
|
|
78
|
+
"""Return all feedback for a specific entity."""
|
|
79
|
+
async with self._session() as sess:
|
|
80
|
+
result = await sess.execute(
|
|
81
|
+
select(Feedback).where(
|
|
82
|
+
Feedback.entity_type == entity_type,
|
|
83
|
+
Feedback.entity_id == entity_id,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return list(result.scalars().all())
|
|
87
|
+
|
|
88
|
+
async def average_rating(self, entity_id: uuid.UUID) -> float | None:
|
|
89
|
+
"""
|
|
90
|
+
Return the average rating across all feedback for ``entity_id``,
|
|
91
|
+
or ``None`` if no feedback exists.
|
|
92
|
+
"""
|
|
93
|
+
async with self._session() as sess:
|
|
94
|
+
result = await sess.execute(
|
|
95
|
+
select(func.avg(Feedback.rating)).where(
|
|
96
|
+
Feedback.entity_id == entity_id
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
avg = result.scalar_one_or_none()
|
|
100
|
+
return float(avg) if avg is not None else None
|
|
101
|
+
|
|
102
|
+
async def update_feedback(self, feedback_id: uuid.UUID, **kwargs) -> Feedback | None:
|
|
103
|
+
async with self._session() as sess:
|
|
104
|
+
await sess.execute(
|
|
105
|
+
update(Feedback).where(Feedback.id == feedback_id).values(**kwargs)
|
|
106
|
+
)
|
|
107
|
+
result = await sess.execute(
|
|
108
|
+
select(Feedback).where(Feedback.id == feedback_id)
|
|
109
|
+
)
|
|
110
|
+
return result.scalar_one_or_none()
|
|
111
|
+
|
|
112
|
+
async def delete_feedback(self, feedback_id: uuid.UUID) -> None:
|
|
113
|
+
async with self._session() as sess:
|
|
114
|
+
await sess.execute(delete(Feedback).where(Feedback.id == feedback_id))
|
minder/store/graph.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KnowledgeGraphStore — SQLAlchemy-backed graph store for module/service/owner
|
|
3
|
+
relationships.
|
|
4
|
+
|
|
5
|
+
Nodes represent named entities (module, file, service, owner).
|
|
6
|
+
Edges represent directed relationships (depends_on, imports, calls, owns).
|
|
7
|
+
|
|
8
|
+
v2 adds repo_id + branch columns so nodes from different repos/branches
|
|
9
|
+
never collide. KnowledgeGraphStore.init_db() runs _migrate_graph_v2()
|
|
10
|
+
automatically on first boot when the columns are absent.
|
|
11
|
+
|
|
12
|
+
Backed by SQLite (dev) or PostgreSQL (prod) via the shared async engine.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import uuid
|
|
19
|
+
from collections import deque
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
from typing import Any, AsyncGenerator
|
|
22
|
+
|
|
23
|
+
from sqlalchemy import delete, select, text, update
|
|
24
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
25
|
+
|
|
26
|
+
from minder.models import Base, GraphEdge, GraphNode
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class KnowledgeGraphStore:
|
|
32
|
+
"""Async graph store. Supports node/edge CRUD + BFS traversal."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, db_url: str, echo: bool = False) -> None:
|
|
35
|
+
self._engine = create_async_engine(db_url, echo=echo)
|
|
36
|
+
self._session_factory = async_sessionmaker(
|
|
37
|
+
self._engine,
|
|
38
|
+
expire_on_commit=False,
|
|
39
|
+
class_=AsyncSession,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
# Lifecycle
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
async def init_db(self) -> None:
|
|
47
|
+
"""Create graph tables (idempotent) then run v2 migration if needed."""
|
|
48
|
+
async with self._engine.begin() as conn:
|
|
49
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
50
|
+
await self._migrate_graph_v2()
|
|
51
|
+
|
|
52
|
+
async def dispose(self) -> None:
|
|
53
|
+
await self._engine.dispose()
|
|
54
|
+
|
|
55
|
+
@asynccontextmanager
|
|
56
|
+
async def _session(self) -> AsyncGenerator[AsyncSession, None]:
|
|
57
|
+
async with self._session_factory() as sess:
|
|
58
|
+
try:
|
|
59
|
+
yield sess
|
|
60
|
+
await sess.commit()
|
|
61
|
+
except Exception:
|
|
62
|
+
await sess.rollback()
|
|
63
|
+
raise
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# v2 schema migration
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
async def _migrate_graph_v2(self) -> None:
|
|
70
|
+
"""Add repo_id + branch columns and update unique constraints if missing."""
|
|
71
|
+
async with self._engine.begin() as conn:
|
|
72
|
+
dialect = conn.dialect.name # type: ignore[attr-defined]
|
|
73
|
+
|
|
74
|
+
if dialect == "sqlite":
|
|
75
|
+
await self._migrate_graph_v2_sqlite(conn)
|
|
76
|
+
elif dialect == "postgresql":
|
|
77
|
+
await self._migrate_graph_v2_postgresql(conn)
|
|
78
|
+
# Other dialects: no-op (create_all already built the new schema)
|
|
79
|
+
|
|
80
|
+
async def _migrate_graph_v2_sqlite(self, conn: Any) -> None:
|
|
81
|
+
"""SQLite migration: recreate graph_nodes/graph_edges with new schema."""
|
|
82
|
+
result = await conn.execute(text("PRAGMA table_info(graph_nodes)"))
|
|
83
|
+
existing_cols = {row[1] for row in result.fetchall()}
|
|
84
|
+
|
|
85
|
+
if "repo_id" not in existing_cols:
|
|
86
|
+
logger.info("Migrating graph_nodes to v2 schema (SQLite)...")
|
|
87
|
+
# Recreate graph_nodes with new columns + new unique constraint
|
|
88
|
+
await conn.execute(text("""
|
|
89
|
+
CREATE TABLE IF NOT EXISTS graph_nodes_v2 (
|
|
90
|
+
id TEXT NOT NULL,
|
|
91
|
+
repo_id TEXT NOT NULL DEFAULT '',
|
|
92
|
+
branch TEXT NOT NULL DEFAULT '',
|
|
93
|
+
node_type TEXT NOT NULL,
|
|
94
|
+
name TEXT NOT NULL,
|
|
95
|
+
metadata JSON,
|
|
96
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
97
|
+
PRIMARY KEY (id),
|
|
98
|
+
UNIQUE (repo_id, branch, node_type, name)
|
|
99
|
+
)
|
|
100
|
+
"""))
|
|
101
|
+
# Migrate existing data: extract repo_id/branch from JSON metadata
|
|
102
|
+
await conn.execute(text("""
|
|
103
|
+
INSERT OR IGNORE INTO graph_nodes_v2
|
|
104
|
+
(id, repo_id, branch, node_type, name, metadata, created_at)
|
|
105
|
+
SELECT
|
|
106
|
+
id,
|
|
107
|
+
COALESCE(json_extract(metadata, '$.repo_id'), ''),
|
|
108
|
+
COALESCE(json_extract(metadata, '$.branch'), ''),
|
|
109
|
+
node_type,
|
|
110
|
+
name,
|
|
111
|
+
metadata,
|
|
112
|
+
created_at
|
|
113
|
+
FROM graph_nodes
|
|
114
|
+
"""))
|
|
115
|
+
await conn.execute(text("DROP TABLE graph_nodes"))
|
|
116
|
+
await conn.execute(text("ALTER TABLE graph_nodes_v2 RENAME TO graph_nodes"))
|
|
117
|
+
await conn.execute(text(
|
|
118
|
+
"CREATE INDEX IF NOT EXISTS ix_graph_nodes_repo_id ON graph_nodes (repo_id)"
|
|
119
|
+
))
|
|
120
|
+
await conn.execute(text(
|
|
121
|
+
"CREATE INDEX IF NOT EXISTS ix_graph_nodes_branch ON graph_nodes (branch)"
|
|
122
|
+
))
|
|
123
|
+
await conn.execute(text(
|
|
124
|
+
"CREATE INDEX IF NOT EXISTS ix_graph_nodes_node_type ON graph_nodes (node_type)"
|
|
125
|
+
))
|
|
126
|
+
logger.info("graph_nodes v2 migration complete.")
|
|
127
|
+
|
|
128
|
+
# --- graph_edges ---
|
|
129
|
+
result = await conn.execute(text("PRAGMA table_info(graph_edges)"))
|
|
130
|
+
existing_edge_cols = {row[1] for row in result.fetchall()}
|
|
131
|
+
|
|
132
|
+
if "repo_id" not in existing_edge_cols:
|
|
133
|
+
logger.info("Migrating graph_edges to v2 schema (SQLite)...")
|
|
134
|
+
await conn.execute(text("""
|
|
135
|
+
CREATE TABLE IF NOT EXISTS graph_edges_v2 (
|
|
136
|
+
id TEXT NOT NULL,
|
|
137
|
+
repo_id TEXT NOT NULL DEFAULT '',
|
|
138
|
+
source_id TEXT NOT NULL,
|
|
139
|
+
target_id TEXT NOT NULL,
|
|
140
|
+
relation TEXT NOT NULL,
|
|
141
|
+
weight REAL DEFAULT 1.0,
|
|
142
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
143
|
+
PRIMARY KEY (id),
|
|
144
|
+
UNIQUE (repo_id, source_id, target_id, relation)
|
|
145
|
+
)
|
|
146
|
+
"""))
|
|
147
|
+
await conn.execute(text("""
|
|
148
|
+
INSERT OR IGNORE INTO graph_edges_v2
|
|
149
|
+
(id, repo_id, source_id, target_id, relation, weight, created_at)
|
|
150
|
+
SELECT id, '', source_id, target_id, relation, weight, created_at
|
|
151
|
+
FROM graph_edges
|
|
152
|
+
"""))
|
|
153
|
+
await conn.execute(text("DROP TABLE graph_edges"))
|
|
154
|
+
await conn.execute(text("ALTER TABLE graph_edges_v2 RENAME TO graph_edges"))
|
|
155
|
+
await conn.execute(text(
|
|
156
|
+
"CREATE INDEX IF NOT EXISTS ix_graph_edges_repo_id ON graph_edges (repo_id)"
|
|
157
|
+
))
|
|
158
|
+
await conn.execute(text(
|
|
159
|
+
"CREATE INDEX IF NOT EXISTS ix_graph_edges_source_id ON graph_edges (source_id)"
|
|
160
|
+
))
|
|
161
|
+
await conn.execute(text(
|
|
162
|
+
"CREATE INDEX IF NOT EXISTS ix_graph_edges_target_id ON graph_edges (target_id)"
|
|
163
|
+
))
|
|
164
|
+
logger.info("graph_edges v2 migration complete.")
|
|
165
|
+
|
|
166
|
+
# Migrate tracked_branches column on repositories table if missing
|
|
167
|
+
result = await conn.execute(text("PRAGMA table_info(repositories)"))
|
|
168
|
+
repo_cols = {row[1] for row in result.fetchall()}
|
|
169
|
+
if "tracked_branches" not in repo_cols:
|
|
170
|
+
await conn.execute(text(
|
|
171
|
+
"ALTER TABLE repositories ADD COLUMN tracked_branches JSON DEFAULT '[]'"
|
|
172
|
+
))
|
|
173
|
+
logger.info("repositories.tracked_branches column added.")
|
|
174
|
+
|
|
175
|
+
async def _migrate_graph_v2_postgresql(self, conn: Any) -> None:
|
|
176
|
+
"""PostgreSQL migration: ADD COLUMN IF NOT EXISTS + constraint update."""
|
|
177
|
+
# Check if repo_id column exists
|
|
178
|
+
result = await conn.execute(text("""
|
|
179
|
+
SELECT column_name FROM information_schema.columns
|
|
180
|
+
WHERE table_name = 'graph_nodes' AND column_name = 'repo_id'
|
|
181
|
+
"""))
|
|
182
|
+
if not result.fetchone():
|
|
183
|
+
logger.info("Migrating graph_nodes to v2 schema (PostgreSQL)...")
|
|
184
|
+
await conn.execute(text(
|
|
185
|
+
"ALTER TABLE graph_nodes ADD COLUMN IF NOT EXISTS repo_id VARCHAR NOT NULL DEFAULT ''"
|
|
186
|
+
))
|
|
187
|
+
await conn.execute(text(
|
|
188
|
+
"ALTER TABLE graph_nodes ADD COLUMN IF NOT EXISTS branch VARCHAR NOT NULL DEFAULT ''"
|
|
189
|
+
))
|
|
190
|
+
# Populate from existing JSON metadata
|
|
191
|
+
await conn.execute(text("""
|
|
192
|
+
UPDATE graph_nodes
|
|
193
|
+
SET repo_id = COALESCE(metadata->>'repo_id', ''),
|
|
194
|
+
branch = COALESCE(metadata->>'branch', '')
|
|
195
|
+
WHERE repo_id = '' OR repo_id IS NULL
|
|
196
|
+
"""))
|
|
197
|
+
# Drop old constraint, add new
|
|
198
|
+
await conn.execute(text(
|
|
199
|
+
"ALTER TABLE graph_nodes DROP CONSTRAINT IF EXISTS uq_graph_node_type_name"
|
|
200
|
+
))
|
|
201
|
+
await conn.execute(text("""
|
|
202
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_graph_node_repo_branch_type_name
|
|
203
|
+
ON graph_nodes (repo_id, branch, node_type, name)
|
|
204
|
+
"""))
|
|
205
|
+
logger.info("graph_nodes v2 migration complete.")
|
|
206
|
+
|
|
207
|
+
# --- graph_edges ---
|
|
208
|
+
result = await conn.execute(text("""
|
|
209
|
+
SELECT column_name FROM information_schema.columns
|
|
210
|
+
WHERE table_name = 'graph_edges' AND column_name = 'repo_id'
|
|
211
|
+
"""))
|
|
212
|
+
if not result.fetchone():
|
|
213
|
+
logger.info("Migrating graph_edges to v2 schema (PostgreSQL)...")
|
|
214
|
+
await conn.execute(text(
|
|
215
|
+
"ALTER TABLE graph_edges ADD COLUMN IF NOT EXISTS repo_id VARCHAR NOT NULL DEFAULT ''"
|
|
216
|
+
))
|
|
217
|
+
await conn.execute(text(
|
|
218
|
+
"ALTER TABLE graph_edges DROP CONSTRAINT IF EXISTS uq_graph_edge_src_tgt_rel"
|
|
219
|
+
))
|
|
220
|
+
await conn.execute(text("""
|
|
221
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_graph_edge_repo_src_tgt_rel
|
|
222
|
+
ON graph_edges (repo_id, source_id, target_id, relation)
|
|
223
|
+
"""))
|
|
224
|
+
logger.info("graph_edges v2 migration complete.")
|
|
225
|
+
|
|
226
|
+
# tracked_branches on repositories
|
|
227
|
+
result = await conn.execute(text("""
|
|
228
|
+
SELECT column_name FROM information_schema.columns
|
|
229
|
+
WHERE table_name = 'repositories' AND column_name = 'tracked_branches'
|
|
230
|
+
"""))
|
|
231
|
+
if not result.fetchone():
|
|
232
|
+
await conn.execute(text(
|
|
233
|
+
"ALTER TABLE repositories ADD COLUMN IF NOT EXISTS tracked_branches JSON DEFAULT '[]'"
|
|
234
|
+
))
|
|
235
|
+
logger.info("repositories.tracked_branches column added.")
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
# Node operations
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
async def add_node(
|
|
242
|
+
self,
|
|
243
|
+
node_type: str,
|
|
244
|
+
name: str,
|
|
245
|
+
metadata: dict[str, Any] | None = None,
|
|
246
|
+
node_id: uuid.UUID | None = None,
|
|
247
|
+
*,
|
|
248
|
+
repo_id: str = "",
|
|
249
|
+
branch: str = "",
|
|
250
|
+
) -> GraphNode:
|
|
251
|
+
"""Insert a node. Raises on duplicate (repo_id, branch, type, name)."""
|
|
252
|
+
async with self._session() as sess:
|
|
253
|
+
node = GraphNode(
|
|
254
|
+
id=node_id or uuid.uuid4(),
|
|
255
|
+
repo_id=repo_id,
|
|
256
|
+
branch=branch,
|
|
257
|
+
node_type=node_type,
|
|
258
|
+
name=name,
|
|
259
|
+
node_metadata=metadata or {},
|
|
260
|
+
)
|
|
261
|
+
sess.add(node)
|
|
262
|
+
await sess.flush()
|
|
263
|
+
await sess.refresh(node)
|
|
264
|
+
return node
|
|
265
|
+
|
|
266
|
+
async def upsert_node(
|
|
267
|
+
self,
|
|
268
|
+
node_type: str,
|
|
269
|
+
name: str,
|
|
270
|
+
metadata: dict[str, Any] | None = None,
|
|
271
|
+
*,
|
|
272
|
+
repo_id: str = "",
|
|
273
|
+
branch: str = "",
|
|
274
|
+
) -> GraphNode:
|
|
275
|
+
"""Insert or update a node by (repo_id, branch, type, name)."""
|
|
276
|
+
async with self._session() as sess:
|
|
277
|
+
result = await sess.execute(
|
|
278
|
+
select(GraphNode).where(
|
|
279
|
+
GraphNode.repo_id == repo_id,
|
|
280
|
+
GraphNode.branch == branch,
|
|
281
|
+
GraphNode.node_type == node_type,
|
|
282
|
+
GraphNode.name == name,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
existing = result.scalar_one_or_none()
|
|
286
|
+
if existing is not None:
|
|
287
|
+
if metadata:
|
|
288
|
+
await sess.execute(
|
|
289
|
+
update(GraphNode)
|
|
290
|
+
.where(GraphNode.id == existing.id)
|
|
291
|
+
.values(node_metadata={**dict(existing.node_metadata), **metadata})
|
|
292
|
+
)
|
|
293
|
+
await sess.refresh(existing)
|
|
294
|
+
return existing
|
|
295
|
+
node = GraphNode(
|
|
296
|
+
id=uuid.uuid4(),
|
|
297
|
+
repo_id=repo_id,
|
|
298
|
+
branch=branch,
|
|
299
|
+
node_type=node_type,
|
|
300
|
+
name=name,
|
|
301
|
+
node_metadata=metadata or {},
|
|
302
|
+
)
|
|
303
|
+
sess.add(node)
|
|
304
|
+
await sess.flush()
|
|
305
|
+
await sess.refresh(node)
|
|
306
|
+
return node
|
|
307
|
+
|
|
308
|
+
async def get_node(self, node_id: uuid.UUID) -> GraphNode | None:
|
|
309
|
+
async with self._session() as sess:
|
|
310
|
+
result = await sess.execute(
|
|
311
|
+
select(GraphNode).where(GraphNode.id == node_id)
|
|
312
|
+
)
|
|
313
|
+
return result.scalar_one_or_none()
|
|
314
|
+
|
|
315
|
+
async def get_node_by_name(
|
|
316
|
+
self,
|
|
317
|
+
node_type: str,
|
|
318
|
+
name: str,
|
|
319
|
+
*,
|
|
320
|
+
repo_id: str = "",
|
|
321
|
+
branch: str = "",
|
|
322
|
+
) -> GraphNode | None:
|
|
323
|
+
async with self._session() as sess:
|
|
324
|
+
result = await sess.execute(
|
|
325
|
+
select(GraphNode).where(
|
|
326
|
+
GraphNode.repo_id == repo_id,
|
|
327
|
+
GraphNode.branch == branch,
|
|
328
|
+
GraphNode.node_type == node_type,
|
|
329
|
+
GraphNode.name == name,
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
return result.scalar_one_or_none()
|
|
333
|
+
|
|
334
|
+
async def list_nodes(self) -> list[GraphNode]:
|
|
335
|
+
"""Return ALL nodes across all repos/branches (use sparingly)."""
|
|
336
|
+
async with self._session() as sess:
|
|
337
|
+
result = await sess.execute(select(GraphNode))
|
|
338
|
+
return list(result.scalars().all())
|
|
339
|
+
|
|
340
|
+
async def list_nodes_by_scope(
|
|
341
|
+
self,
|
|
342
|
+
*,
|
|
343
|
+
repo_id: str,
|
|
344
|
+
branch: str | None = None,
|
|
345
|
+
) -> list[GraphNode]:
|
|
346
|
+
"""Return nodes scoped to a specific repo_id, optionally filtered by branch."""
|
|
347
|
+
async with self._session() as sess:
|
|
348
|
+
stmt = select(GraphNode).where(GraphNode.repo_id == repo_id)
|
|
349
|
+
if branch is not None:
|
|
350
|
+
stmt = stmt.where(GraphNode.branch == branch)
|
|
351
|
+
result = await sess.execute(stmt)
|
|
352
|
+
return list(result.scalars().all())
|
|
353
|
+
|
|
354
|
+
async def list_edges(self) -> list[GraphEdge]:
|
|
355
|
+
"""Return ALL edges across all repos (use sparingly)."""
|
|
356
|
+
async with self._session() as sess:
|
|
357
|
+
result = await sess.execute(select(GraphEdge))
|
|
358
|
+
return list(result.scalars().all())
|
|
359
|
+
|
|
360
|
+
async def list_edges_by_scope(self, *, repo_id: str) -> list[GraphEdge]:
|
|
361
|
+
"""Return edges scoped to a specific repo_id."""
|
|
362
|
+
async with self._session() as sess:
|
|
363
|
+
result = await sess.execute(
|
|
364
|
+
select(GraphEdge).where(GraphEdge.repo_id == repo_id)
|
|
365
|
+
)
|
|
366
|
+
return list(result.scalars().all())
|
|
367
|
+
|
|
368
|
+
async def query_by_type(self, node_type: str, *, repo_id: str = "") -> list[GraphNode]:
|
|
369
|
+
async with self._session() as sess:
|
|
370
|
+
stmt = select(GraphNode).where(GraphNode.node_type == node_type)
|
|
371
|
+
if repo_id:
|
|
372
|
+
stmt = stmt.where(GraphNode.repo_id == repo_id)
|
|
373
|
+
result = await sess.execute(stmt)
|
|
374
|
+
return list(result.scalars().all())
|
|
375
|
+
|
|
376
|
+
async def delete_node(self, node_id: uuid.UUID) -> None:
|
|
377
|
+
async with self._session() as sess:
|
|
378
|
+
await sess.execute(delete(GraphNode).where(GraphNode.id == node_id))
|
|
379
|
+
# Cascade: remove all edges incident to this node
|
|
380
|
+
await sess.execute(
|
|
381
|
+
delete(GraphEdge).where(
|
|
382
|
+
(GraphEdge.source_id == node_id) | (GraphEdge.target_id == node_id)
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
async def delete_nodes_by_scope(
|
|
387
|
+
self,
|
|
388
|
+
*,
|
|
389
|
+
repo_id: str,
|
|
390
|
+
branch: str | None = None,
|
|
391
|
+
paths: set[str] | None = None,
|
|
392
|
+
) -> int:
|
|
393
|
+
"""Delete nodes for a given repo/branch scope.
|
|
394
|
+
|
|
395
|
+
If *paths* is provided only nodes whose metadata.path matches are removed.
|
|
396
|
+
Returns the number of nodes deleted.
|
|
397
|
+
"""
|
|
398
|
+
nodes = await self.list_nodes_by_scope(repo_id=repo_id, branch=branch)
|
|
399
|
+
deleted = 0
|
|
400
|
+
for node in nodes:
|
|
401
|
+
meta = dict(getattr(node, "node_metadata", {}) or {})
|
|
402
|
+
if paths is not None:
|
|
403
|
+
node_path = str(meta.get("path", "") or "")
|
|
404
|
+
if node_path not in paths:
|
|
405
|
+
continue
|
|
406
|
+
await self.delete_node(node.id)
|
|
407
|
+
deleted += 1
|
|
408
|
+
return deleted
|
|
409
|
+
|
|
410
|
+
# ------------------------------------------------------------------
|
|
411
|
+
# Edge operations
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
async def add_edge(
|
|
415
|
+
self,
|
|
416
|
+
source_id: uuid.UUID,
|
|
417
|
+
target_id: uuid.UUID,
|
|
418
|
+
relation: str,
|
|
419
|
+
weight: float = 1.0,
|
|
420
|
+
edge_id: uuid.UUID | None = None,
|
|
421
|
+
*,
|
|
422
|
+
repo_id: str = "",
|
|
423
|
+
) -> GraphEdge:
|
|
424
|
+
"""Insert a directed edge. Raises on duplicate (repo_id, source, target, relation)."""
|
|
425
|
+
async with self._session() as sess:
|
|
426
|
+
edge = GraphEdge(
|
|
427
|
+
id=edge_id or uuid.uuid4(),
|
|
428
|
+
repo_id=repo_id,
|
|
429
|
+
source_id=source_id,
|
|
430
|
+
target_id=target_id,
|
|
431
|
+
relation=relation,
|
|
432
|
+
weight=weight,
|
|
433
|
+
)
|
|
434
|
+
sess.add(edge)
|
|
435
|
+
await sess.flush()
|
|
436
|
+
await sess.refresh(edge)
|
|
437
|
+
return edge
|
|
438
|
+
|
|
439
|
+
async def upsert_edge(
|
|
440
|
+
self,
|
|
441
|
+
source_id: uuid.UUID,
|
|
442
|
+
target_id: uuid.UUID,
|
|
443
|
+
relation: str,
|
|
444
|
+
weight: float = 1.0,
|
|
445
|
+
*,
|
|
446
|
+
repo_id: str = "",
|
|
447
|
+
) -> GraphEdge:
|
|
448
|
+
"""Insert or update edge by (repo_id, source, target, relation)."""
|
|
449
|
+
async with self._session() as sess:
|
|
450
|
+
result = await sess.execute(
|
|
451
|
+
select(GraphEdge).where(
|
|
452
|
+
GraphEdge.repo_id == repo_id,
|
|
453
|
+
GraphEdge.source_id == source_id,
|
|
454
|
+
GraphEdge.target_id == target_id,
|
|
455
|
+
GraphEdge.relation == relation,
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
existing = result.scalar_one_or_none()
|
|
459
|
+
if existing is not None:
|
|
460
|
+
await sess.execute(
|
|
461
|
+
update(GraphEdge)
|
|
462
|
+
.where(GraphEdge.id == existing.id)
|
|
463
|
+
.values(weight=weight)
|
|
464
|
+
)
|
|
465
|
+
await sess.refresh(existing)
|
|
466
|
+
return existing
|
|
467
|
+
edge = GraphEdge(
|
|
468
|
+
id=uuid.uuid4(),
|
|
469
|
+
repo_id=repo_id,
|
|
470
|
+
source_id=source_id,
|
|
471
|
+
target_id=target_id,
|
|
472
|
+
relation=relation,
|
|
473
|
+
weight=weight,
|
|
474
|
+
)
|
|
475
|
+
sess.add(edge)
|
|
476
|
+
await sess.flush()
|
|
477
|
+
await sess.refresh(edge)
|
|
478
|
+
return edge
|
|
479
|
+
|
|
480
|
+
async def delete_edge(self, edge_id: uuid.UUID) -> None:
|
|
481
|
+
async with self._session() as sess:
|
|
482
|
+
await sess.execute(delete(GraphEdge).where(GraphEdge.id == edge_id))
|
|
483
|
+
|
|
484
|
+
# ------------------------------------------------------------------
|
|
485
|
+
# Traversal
|
|
486
|
+
# ------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
async def get_neighbors(
|
|
489
|
+
self,
|
|
490
|
+
node_id: uuid.UUID,
|
|
491
|
+
*,
|
|
492
|
+
direction: str = "out",
|
|
493
|
+
relation: str | None = None,
|
|
494
|
+
) -> list[GraphNode]:
|
|
495
|
+
"""
|
|
496
|
+
Return neighbor nodes.
|
|
497
|
+
|
|
498
|
+
direction:
|
|
499
|
+
"out" — nodes that ``node_id`` points to (source → target)
|
|
500
|
+
"in" — nodes that point to ``node_id`` (target ← source)
|
|
501
|
+
"both" — union of out and in
|
|
502
|
+
"""
|
|
503
|
+
async with self._session() as sess:
|
|
504
|
+
neighbor_ids: list[uuid.UUID] = []
|
|
505
|
+
|
|
506
|
+
async def _out() -> list[uuid.UUID]:
|
|
507
|
+
stmt = select(GraphEdge.target_id).where(
|
|
508
|
+
GraphEdge.source_id == node_id
|
|
509
|
+
)
|
|
510
|
+
if relation:
|
|
511
|
+
stmt = stmt.where(GraphEdge.relation == relation)
|
|
512
|
+
r = await sess.execute(stmt)
|
|
513
|
+
return list(r.scalars().all())
|
|
514
|
+
|
|
515
|
+
async def _in() -> list[uuid.UUID]:
|
|
516
|
+
stmt = select(GraphEdge.source_id).where(
|
|
517
|
+
GraphEdge.target_id == node_id
|
|
518
|
+
)
|
|
519
|
+
if relation:
|
|
520
|
+
stmt = stmt.where(GraphEdge.relation == relation)
|
|
521
|
+
r = await sess.execute(stmt)
|
|
522
|
+
return list(r.scalars().all())
|
|
523
|
+
|
|
524
|
+
if direction in ("out", "both"):
|
|
525
|
+
neighbor_ids.extend(await _out())
|
|
526
|
+
if direction in ("in", "both"):
|
|
527
|
+
neighbor_ids.extend(await _in())
|
|
528
|
+
|
|
529
|
+
if not neighbor_ids:
|
|
530
|
+
return []
|
|
531
|
+
|
|
532
|
+
seen: set[uuid.UUID] = set()
|
|
533
|
+
unique_ids = [nid for nid in neighbor_ids if not (nid in seen or seen.add(nid))] # type: ignore[func-returns-value]
|
|
534
|
+
|
|
535
|
+
result = await sess.execute(
|
|
536
|
+
select(GraphNode).where(GraphNode.id.in_(unique_ids))
|
|
537
|
+
)
|
|
538
|
+
return list(result.scalars().all())
|
|
539
|
+
|
|
540
|
+
async def get_path(
|
|
541
|
+
self,
|
|
542
|
+
source_id: uuid.UUID,
|
|
543
|
+
target_id: uuid.UUID,
|
|
544
|
+
*,
|
|
545
|
+
max_depth: int = 6,
|
|
546
|
+
) -> list[GraphNode]:
|
|
547
|
+
"""
|
|
548
|
+
BFS shortest path from source to target following outgoing edges.
|
|
549
|
+
Returns ordered list of nodes including source and target, or empty
|
|
550
|
+
list if no path exists within max_depth.
|
|
551
|
+
"""
|
|
552
|
+
if source_id == target_id:
|
|
553
|
+
node = await self.get_node(source_id)
|
|
554
|
+
return [node] if node else []
|
|
555
|
+
|
|
556
|
+
visited: set[uuid.UUID] = {source_id}
|
|
557
|
+
queue: deque[tuple[uuid.UUID, list[uuid.UUID]]] = deque(
|
|
558
|
+
[(source_id, [source_id])]
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
while queue:
|
|
562
|
+
current_id, path = queue.popleft()
|
|
563
|
+
if len(path) > max_depth:
|
|
564
|
+
continue
|
|
565
|
+
async with self._session() as sess:
|
|
566
|
+
result = await sess.execute(
|
|
567
|
+
select(GraphEdge.target_id).where(GraphEdge.source_id == current_id)
|
|
568
|
+
)
|
|
569
|
+
neighbors = list(result.scalars().all())
|
|
570
|
+
|
|
571
|
+
for nid in neighbors:
|
|
572
|
+
if nid == target_id:
|
|
573
|
+
full_path = path + [target_id]
|
|
574
|
+
nodes: list[GraphNode] = []
|
|
575
|
+
async with self._session() as sess:
|
|
576
|
+
for pid in full_path:
|
|
577
|
+
r = await sess.execute(
|
|
578
|
+
select(GraphNode).where(GraphNode.id == pid)
|
|
579
|
+
)
|
|
580
|
+
n = r.scalar_one_or_none()
|
|
581
|
+
if n:
|
|
582
|
+
nodes.append(n)
|
|
583
|
+
return nodes
|
|
584
|
+
if nid not in visited:
|
|
585
|
+
visited.add(nid)
|
|
586
|
+
queue.append((nid, path + [nid]))
|
|
587
|
+
|
|
588
|
+
return []
|