ragfabric-server 0.3.1__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.
- ragfabric_server-0.3.1/.gitignore +49 -0
- ragfabric_server-0.3.1/PKG-INFO +27 -0
- ragfabric_server-0.3.1/README.md +3 -0
- ragfabric_server-0.3.1/pyproject.toml +36 -0
- ragfabric_server-0.3.1/src/ragfabric_server/__init__.py +1 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/__init__.py +0 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/routes/__init__.py +0 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/routes/access.py +218 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/routes/admin.py +254 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/routes/analytics.py +92 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/routes/ask.py +477 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/routes/auth.py +74 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/routes/collections.py +167 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/routes/documents.py +356 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/routes/providers.py +101 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/routes/runs.py +52 -0
- ragfabric_server-0.3.1/src/ragfabric_server/api/routes/search.py +642 -0
- ragfabric_server-0.3.1/src/ragfabric_server/deps.py +238 -0
- ragfabric_server-0.3.1/src/ragfabric_server/main.py +135 -0
- ragfabric_server-0.3.1/src/ragfabric_server/provider_config.py +140 -0
- ragfabric_server-0.3.1/src/ragfabric_server/schemas/__init__.py +0 -0
- ragfabric_server-0.3.1/src/ragfabric_server/schemas/access.py +94 -0
- ragfabric_server-0.3.1/src/ragfabric_server/schemas/ask.py +27 -0
- ragfabric_server-0.3.1/src/ragfabric_server/schemas/document.py +64 -0
- ragfabric_server-0.3.1/src/ragfabric_server/schemas/providers.py +90 -0
- ragfabric_server-0.3.1/src/ragfabric_server/schemas/runs.py +53 -0
- ragfabric_server-0.3.1/src/ragfabric_server/schemas/search.py +249 -0
- ragfabric_server-0.3.1/src/ragfabric_server/schemas/user.py +44 -0
- ragfabric_server-0.3.1/tests/conftest.py +273 -0
- ragfabric_server-0.3.1/tests/test_access_api.py +116 -0
- ragfabric_server-0.3.1/tests/test_access_enforcement.py +360 -0
- ragfabric_server-0.3.1/tests/test_admin_user_crud.py +160 -0
- ragfabric_server-0.3.1/tests/test_agentic_generation.py +250 -0
- ragfabric_server-0.3.1/tests/test_agentic_routes.py +238 -0
- ragfabric_server-0.3.1/tests/test_api.py +724 -0
- ragfabric_server-0.3.1/tests/test_ask_sse.py +208 -0
- ragfabric_server-0.3.1/tests/test_document_move.py +55 -0
- ragfabric_server-0.3.1/tests/test_graph_citations.py +380 -0
- ragfabric_server-0.3.1/tests/test_graph_routes.py +850 -0
- ragfabric_server-0.3.1/tests/test_group_crud.py +132 -0
- ragfabric_server-0.3.1/tests/test_ingestion_runs.py +89 -0
- ragfabric_server-0.3.1/tests/test_overrides.py +232 -0
- ragfabric_server-0.3.1/tests/test_parsers.py +174 -0
- ragfabric_server-0.3.1/tests/test_providers_route.py +174 -0
- ragfabric_server-0.3.1/tests/test_search_real_path.py +184 -0
- ragfabric_server-0.3.1/tests/test_vectorless_routes.py +140 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
|
|
6
|
+
# local configuration (copy of ragfabric.example.yaml)
|
|
7
|
+
ragfabric.yaml
|
|
8
|
+
|
|
9
|
+
# OS
|
|
10
|
+
.DS_Store
|
|
11
|
+
|
|
12
|
+
# Python
|
|
13
|
+
__pycache__/
|
|
14
|
+
*.pyc
|
|
15
|
+
*.pyo
|
|
16
|
+
.venv/
|
|
17
|
+
venv/
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
|
|
21
|
+
# Local data / databases
|
|
22
|
+
*.db
|
|
23
|
+
backend/data/uploads/
|
|
24
|
+
|
|
25
|
+
# Node / Angular
|
|
26
|
+
node_modules/
|
|
27
|
+
dist/
|
|
28
|
+
.angular/
|
|
29
|
+
npm-debug.log*
|
|
30
|
+
|
|
31
|
+
# Editors
|
|
32
|
+
.idea/
|
|
33
|
+
.vscode/
|
|
34
|
+
|
|
35
|
+
# Frontend test artifacts
|
|
36
|
+
coverage/
|
|
37
|
+
out-tsc/
|
|
38
|
+
|
|
39
|
+
# Local planning notes and scratch (never committed)
|
|
40
|
+
MEMORY*.md
|
|
41
|
+
*.local.md
|
|
42
|
+
.notes/
|
|
43
|
+
.scratch/
|
|
44
|
+
|
|
45
|
+
# Secrets and keys (belt and braces; .env already excluded above)
|
|
46
|
+
*.pem
|
|
47
|
+
*.key
|
|
48
|
+
*.p12
|
|
49
|
+
secrets/
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ragfabric-server
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: RagFabric HTTP API (FastAPI).
|
|
5
|
+
Project-URL: Homepage, https://github.com/ranjan-del/ragfabric
|
|
6
|
+
Project-URL: Repository, https://github.com/ranjan-del/ragfabric
|
|
7
|
+
Project-URL: Documentation, https://github.com/ranjan-del/ragfabric/tree/main/docs
|
|
8
|
+
Project-URL: Changelog, https://github.com/ranjan-del/ragfabric/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/ranjan-del/ragfabric/issues
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
18
|
+
Requires-Python: <3.14,>=3.13
|
|
19
|
+
Requires-Dist: fastapi>=0.141
|
|
20
|
+
Requires-Dist: python-multipart>=0.0.32
|
|
21
|
+
Requires-Dist: ragfabric-core==0.3.1
|
|
22
|
+
Requires-Dist: uvicorn[standard]>=0.52
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# ragfabric-server
|
|
26
|
+
|
|
27
|
+
The RagFabric HTTP API. See the repository README.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ragfabric-server"
|
|
3
|
+
version = "0.3.1"
|
|
4
|
+
description = "RagFabric HTTP API (FastAPI)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.13,<3.14"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"Operating System :: OS Independent",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.13",
|
|
14
|
+
"Framework :: FastAPI",
|
|
15
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"ragfabric-core==0.3.1",
|
|
19
|
+
"fastapi>=0.141",
|
|
20
|
+
"uvicorn[standard]>=0.52",
|
|
21
|
+
"python-multipart>=0.0.32",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/ranjan-del/ragfabric"
|
|
26
|
+
Repository = "https://github.com/ranjan-del/ragfabric"
|
|
27
|
+
Documentation = "https://github.com/ranjan-del/ragfabric/tree/main/docs"
|
|
28
|
+
Changelog = "https://github.com/ranjan-del/ragfabric/blob/main/CHANGELOG.md"
|
|
29
|
+
Issues = "https://github.com/ranjan-del/ragfabric/issues"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling>=1.27"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/ragfabric_server"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""RagFabric HTTP API."""
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Admin API for groups, grants, overrides and API keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from ragfabric_core.auth import service
|
|
9
|
+
from ragfabric_core.auth.api_keys import create_api_key
|
|
10
|
+
from ragfabric_core.db.session import get_db
|
|
11
|
+
from ragfabric_core.models.access import (
|
|
12
|
+
ApiKey,
|
|
13
|
+
CollectionGrant,
|
|
14
|
+
DocumentOverride,
|
|
15
|
+
Group,
|
|
16
|
+
GroupMember,
|
|
17
|
+
)
|
|
18
|
+
from ragfabric_core.models.document import Collection, Document
|
|
19
|
+
from ragfabric_core.models.user import User
|
|
20
|
+
from ragfabric_server.deps import require_role
|
|
21
|
+
from ragfabric_server.schemas.access import (
|
|
22
|
+
ApiKeyCreate,
|
|
23
|
+
ApiKeyCreated,
|
|
24
|
+
ApiKeyOut,
|
|
25
|
+
GrantCreate,
|
|
26
|
+
GrantOut,
|
|
27
|
+
GroupCreate,
|
|
28
|
+
GroupOut,
|
|
29
|
+
GroupUpdate,
|
|
30
|
+
MemberAdd,
|
|
31
|
+
OverrideCreate,
|
|
32
|
+
OverrideOut,
|
|
33
|
+
)
|
|
34
|
+
from ragfabric_server.schemas.user import UserOut
|
|
35
|
+
|
|
36
|
+
router = APIRouter(dependencies=[Depends(require_role("admin"))])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_or_404(db: Session, model, ident, name: str):
|
|
40
|
+
obj = db.get(model, ident)
|
|
41
|
+
if obj is None:
|
|
42
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{name} not found.")
|
|
43
|
+
return obj
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.post("/groups", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
|
47
|
+
def create_group(payload: GroupCreate, db: Session = Depends(get_db)) -> Group:
|
|
48
|
+
if db.query(Group).filter(Group.name == payload.name).first() is not None:
|
|
49
|
+
raise HTTPException(
|
|
50
|
+
status_code=status.HTTP_409_CONFLICT, detail="Group name already exists."
|
|
51
|
+
)
|
|
52
|
+
group = service.create_group(db, payload.name, payload.description)
|
|
53
|
+
db.commit()
|
|
54
|
+
db.refresh(group)
|
|
55
|
+
return group
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/groups", response_model=list[GroupOut])
|
|
59
|
+
def list_groups(db: Session = Depends(get_db)) -> list[Group]:
|
|
60
|
+
return db.query(Group).order_by(Group.name).all()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.put("/groups/{group_id}", response_model=GroupOut)
|
|
64
|
+
def update_group(group_id: int, payload: GroupUpdate, db: Session = Depends(get_db)) -> Group:
|
|
65
|
+
"""Rename a group or change its description.
|
|
66
|
+
|
|
67
|
+
A group's name is the handle an operator uses everywhere else in the
|
|
68
|
+
console, so it has to be editable without recreating the group and
|
|
69
|
+
re-adding every member and grant.
|
|
70
|
+
"""
|
|
71
|
+
group = _get_or_404(db, Group, group_id, "Group")
|
|
72
|
+
if payload.name is not None and payload.name != group.name:
|
|
73
|
+
clash = db.query(Group).filter(Group.name == payload.name).first()
|
|
74
|
+
if clash is not None:
|
|
75
|
+
raise HTTPException(
|
|
76
|
+
status_code=status.HTTP_409_CONFLICT, detail="Group name already exists."
|
|
77
|
+
)
|
|
78
|
+
group.name = payload.name
|
|
79
|
+
if payload.description is not None:
|
|
80
|
+
group.description = payload.description
|
|
81
|
+
db.commit()
|
|
82
|
+
db.refresh(group)
|
|
83
|
+
return group
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.delete("/groups/{group_id}", status_code=status.HTTP_200_OK)
|
|
87
|
+
def delete_group(group_id: int, db: Session = Depends(get_db)) -> dict:
|
|
88
|
+
"""Delete a group. Its memberships and grants go with it; its users do not.
|
|
89
|
+
|
|
90
|
+
Every foreign key pointing at ``groups.id`` is already declared
|
|
91
|
+
``ON DELETE CASCADE``: ``group_members.group_id``,
|
|
92
|
+
``collection_grants.group_id`` and ``document_overrides.group_id``. That
|
|
93
|
+
is the right choice for all three and it is left in place rather than
|
|
94
|
+
reimplemented here. A membership, a grant or an override belonging to a
|
|
95
|
+
group that no longer exists is not a fact about anything, and keeping any
|
|
96
|
+
of them would leave access rows the console can no longer show or revoke.
|
|
97
|
+
|
|
98
|
+
What must NOT cascade is the users themselves. ``group_members`` is the
|
|
99
|
+
join table, so deleting the group removes the membership rows and leaves
|
|
100
|
+
every user account untouched, which is what the test of this route
|
|
101
|
+
asserts directly.
|
|
102
|
+
"""
|
|
103
|
+
group = _get_or_404(db, Group, group_id, "Group")
|
|
104
|
+
db.delete(group)
|
|
105
|
+
db.commit()
|
|
106
|
+
return {"detail": "Group deleted.", "id": group_id}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@router.get("/groups/{group_id}/members", response_model=list[UserOut])
|
|
110
|
+
def list_members(group_id: int, db: Session = Depends(get_db)) -> list[User]:
|
|
111
|
+
"""List the users in a group.
|
|
112
|
+
|
|
113
|
+
Membership could be written but never read back, so the console had no
|
|
114
|
+
way to show who is in a group, which is the only question anyone asks of
|
|
115
|
+
one.
|
|
116
|
+
"""
|
|
117
|
+
_get_or_404(db, Group, group_id, "Group")
|
|
118
|
+
return (
|
|
119
|
+
db.query(User)
|
|
120
|
+
.join(GroupMember, GroupMember.user_id == User.id)
|
|
121
|
+
.filter(GroupMember.group_id == group_id)
|
|
122
|
+
.order_by(User.email)
|
|
123
|
+
.all()
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@router.post("/groups/{group_id}/members", status_code=status.HTTP_200_OK)
|
|
128
|
+
def add_member(group_id: int, payload: MemberAdd, db: Session = Depends(get_db)) -> dict:
|
|
129
|
+
_get_or_404(db, Group, group_id, "Group")
|
|
130
|
+
_get_or_404(db, User, payload.user_id, "User")
|
|
131
|
+
service.add_member(db, group_id, payload.user_id)
|
|
132
|
+
db.commit()
|
|
133
|
+
return {"detail": "Member added.", "group_id": group_id, "user_id": payload.user_id}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@router.delete("/groups/{group_id}/members/{user_id}", status_code=status.HTTP_200_OK)
|
|
137
|
+
def remove_member(group_id: int, user_id: int, db: Session = Depends(get_db)) -> dict:
|
|
138
|
+
service.remove_member(db, group_id, user_id)
|
|
139
|
+
db.commit()
|
|
140
|
+
return {"detail": "Member removed.", "group_id": group_id, "user_id": user_id}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@router.post("/grants", response_model=GrantOut, status_code=status.HTTP_201_CREATED)
|
|
144
|
+
def create_grant(payload: GrantCreate, db: Session = Depends(get_db)) -> CollectionGrant:
|
|
145
|
+
_get_or_404(db, Group, payload.group_id, "Group")
|
|
146
|
+
_get_or_404(db, Collection, payload.collection_id, "Collection")
|
|
147
|
+
grant = service.grant_collection(
|
|
148
|
+
db, payload.group_id, payload.collection_id, payload.permission
|
|
149
|
+
)
|
|
150
|
+
db.commit()
|
|
151
|
+
db.refresh(grant)
|
|
152
|
+
return grant
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.get("/grants", response_model=list[GrantOut])
|
|
156
|
+
def list_grants(db: Session = Depends(get_db)) -> list[CollectionGrant]:
|
|
157
|
+
return db.query(CollectionGrant).order_by(CollectionGrant.id).all()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.delete("/grants/{grant_id}", status_code=status.HTTP_200_OK)
|
|
161
|
+
def delete_grant(grant_id: int, db: Session = Depends(get_db)) -> dict:
|
|
162
|
+
grant = _get_or_404(db, CollectionGrant, grant_id, "Grant")
|
|
163
|
+
db.delete(grant)
|
|
164
|
+
db.commit()
|
|
165
|
+
return {"detail": "Grant removed.", "id": grant_id}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.post("/overrides", response_model=OverrideOut, status_code=status.HTTP_201_CREATED)
|
|
169
|
+
def create_override(payload: OverrideCreate, db: Session = Depends(get_db)) -> DocumentOverride:
|
|
170
|
+
_get_or_404(db, Document, payload.document_id, "Document")
|
|
171
|
+
try:
|
|
172
|
+
override = service.set_document_override(
|
|
173
|
+
db,
|
|
174
|
+
payload.document_id,
|
|
175
|
+
group_id=payload.group_id,
|
|
176
|
+
user_id=payload.user_id,
|
|
177
|
+
permission=payload.permission,
|
|
178
|
+
)
|
|
179
|
+
except ValueError as exc:
|
|
180
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
181
|
+
db.commit()
|
|
182
|
+
db.refresh(override)
|
|
183
|
+
return override
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@router.get("/overrides", response_model=list[OverrideOut])
|
|
187
|
+
def list_overrides(db: Session = Depends(get_db)) -> list[DocumentOverride]:
|
|
188
|
+
return db.query(DocumentOverride).order_by(DocumentOverride.id).all()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@router.post("/keys", response_model=ApiKeyCreated, status_code=status.HTTP_201_CREATED)
|
|
192
|
+
def create_key(payload: ApiKeyCreate, db: Session = Depends(get_db)) -> ApiKeyCreated:
|
|
193
|
+
_get_or_404(db, User, payload.user_id, "User")
|
|
194
|
+
key, plaintext = create_api_key(
|
|
195
|
+
db,
|
|
196
|
+
name=payload.name,
|
|
197
|
+
user_id=payload.user_id,
|
|
198
|
+
collection_ids=payload.collection_ids,
|
|
199
|
+
strategies=payload.strategies,
|
|
200
|
+
rate_limit_per_minute=payload.rate_limit_per_minute,
|
|
201
|
+
expires_at=payload.expires_at,
|
|
202
|
+
)
|
|
203
|
+
db.commit()
|
|
204
|
+
db.refresh(key)
|
|
205
|
+
return ApiKeyCreated(**ApiKeyOut.model_validate(key).model_dump(), key=plaintext)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@router.get("/keys", response_model=list[ApiKeyOut])
|
|
209
|
+
def list_keys(db: Session = Depends(get_db)) -> list[ApiKey]:
|
|
210
|
+
return db.query(ApiKey).order_by(ApiKey.id).all()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@router.delete("/keys/{key_id}", status_code=status.HTTP_200_OK)
|
|
214
|
+
def revoke_key(key_id: int, db: Session = Depends(get_db)) -> dict:
|
|
215
|
+
key = _get_or_404(db, ApiKey, key_id, "API key")
|
|
216
|
+
key.is_active = False
|
|
217
|
+
db.commit()
|
|
218
|
+
return {"detail": "API key revoked.", "id": key_id}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Admin routes (admin role required).
|
|
2
|
+
|
|
3
|
+
Covers the admin controls from MEMORY.md: list and manage users' roles/activation,
|
|
4
|
+
bump document versions, and hard-delete any document regardless of owner.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
|
10
|
+
from sqlalchemy.orm import Session
|
|
11
|
+
|
|
12
|
+
from ragfabric_core.db.session import get_db
|
|
13
|
+
from ragfabric_core.graph.extract import detach_documents
|
|
14
|
+
from ragfabric_core.ingest.parser import SUPPORTED_FORMATS
|
|
15
|
+
from ragfabric_core.ingest.pipeline import reingest_document
|
|
16
|
+
from ragfabric_core.ingest.storage import get_storage
|
|
17
|
+
from ragfabric_core.models.access import ApiKey, AuditLog
|
|
18
|
+
from ragfabric_core.models.document import Collection, Document, QueryLog
|
|
19
|
+
from ragfabric_core.models.runs import Conversation, RetrievalRun
|
|
20
|
+
from ragfabric_core.models.user import Role, User
|
|
21
|
+
from ragfabric_core.runtime import get_config, get_session_factory
|
|
22
|
+
from ragfabric_core.security import hash_password
|
|
23
|
+
from ragfabric_core.stores.registry import build_lexical_store, build_vector_store
|
|
24
|
+
from ragfabric_server.deps import require_role
|
|
25
|
+
from ragfabric_server.schemas.document import DocumentOut
|
|
26
|
+
from ragfabric_server.schemas.user import AdminUserCreate, PermissionUpdate, UserOut
|
|
27
|
+
|
|
28
|
+
router = APIRouter()
|
|
29
|
+
|
|
30
|
+
_VALID_ROLES = {Role.ADMIN.value, Role.USER.value}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/users", response_model=list[UserOut])
|
|
34
|
+
def list_users(
|
|
35
|
+
db: Session = Depends(get_db),
|
|
36
|
+
_: User = Depends(require_role("admin")),
|
|
37
|
+
) -> list[User]:
|
|
38
|
+
"""List all users (admin only)."""
|
|
39
|
+
return db.query(User).order_by(User.created_at.desc()).all()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.put("/users/{user_id}/permissions", response_model=UserOut)
|
|
43
|
+
def set_permissions(
|
|
44
|
+
user_id: int,
|
|
45
|
+
payload: PermissionUpdate,
|
|
46
|
+
db: Session = Depends(get_db),
|
|
47
|
+
_: User = Depends(require_role("admin")),
|
|
48
|
+
) -> User:
|
|
49
|
+
"""Update a user's role and/or active status (admin only)."""
|
|
50
|
+
user = db.get(User, user_id)
|
|
51
|
+
if user is None:
|
|
52
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
|
53
|
+
if payload.role is not None:
|
|
54
|
+
if payload.role not in _VALID_ROLES:
|
|
55
|
+
raise HTTPException(
|
|
56
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
57
|
+
detail=f"Invalid role. Allowed: {', '.join(sorted(_VALID_ROLES))}.",
|
|
58
|
+
)
|
|
59
|
+
user.role = payload.role
|
|
60
|
+
if payload.is_active is not None:
|
|
61
|
+
user.is_active = payload.is_active
|
|
62
|
+
db.commit()
|
|
63
|
+
db.refresh(user)
|
|
64
|
+
return user
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
|
68
|
+
def create_user(
|
|
69
|
+
payload: AdminUserCreate,
|
|
70
|
+
db: Session = Depends(get_db),
|
|
71
|
+
_: User = Depends(require_role("admin")),
|
|
72
|
+
) -> User:
|
|
73
|
+
"""Create a user outright (admin only).
|
|
74
|
+
|
|
75
|
+
``/api/auth/register`` exists for self service and hardcodes the ``user``
|
|
76
|
+
role, which is what stops anyone signing themselves up as an admin. This
|
|
77
|
+
route is behind the admin guard, so it may name the role and the initial
|
|
78
|
+
active state, and it is the reason an operator no longer has to insert a
|
|
79
|
+
row by hand to onboard somebody.
|
|
80
|
+
"""
|
|
81
|
+
if payload.role not in _VALID_ROLES:
|
|
82
|
+
raise HTTPException(
|
|
83
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
84
|
+
detail=f"Invalid role. Allowed: {', '.join(sorted(_VALID_ROLES))}.",
|
|
85
|
+
)
|
|
86
|
+
email = payload.email.lower()
|
|
87
|
+
if db.query(User).filter(User.email == email).first() is not None:
|
|
88
|
+
raise HTTPException(
|
|
89
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
90
|
+
detail="A user with that email already exists.",
|
|
91
|
+
)
|
|
92
|
+
user = User(
|
|
93
|
+
email=email,
|
|
94
|
+
hashed_password=hash_password(payload.password),
|
|
95
|
+
role=payload.role,
|
|
96
|
+
is_active=payload.is_active,
|
|
97
|
+
)
|
|
98
|
+
db.add(user)
|
|
99
|
+
db.commit()
|
|
100
|
+
db.refresh(user)
|
|
101
|
+
return user
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@router.delete("/users/{user_id}", status_code=status.HTTP_200_OK)
|
|
105
|
+
def delete_user(
|
|
106
|
+
user_id: int,
|
|
107
|
+
db: Session = Depends(get_db),
|
|
108
|
+
admin: User = Depends(require_role("admin")),
|
|
109
|
+
) -> dict:
|
|
110
|
+
"""Delete a user, settling every row that references them (admin only).
|
|
111
|
+
|
|
112
|
+
Nine tables carry a foreign key to ``users.id`` and each one is decided
|
|
113
|
+
here rather than left to whatever the database does by default. Two of
|
|
114
|
+
them already declare ``ON DELETE CASCADE`` on the column and are left to
|
|
115
|
+
it; the other seven are nullable with no cascade, which on PostgreSQL
|
|
116
|
+
(and on SQLite, where this project turns foreign keys on) means the
|
|
117
|
+
delete is REFUSED until they are settled. They are settled as follows.
|
|
118
|
+
|
|
119
|
+
Cascade, because the row means nothing without the user:
|
|
120
|
+
|
|
121
|
+
- ``group_members.user_id``: a membership of a deleted user is not a fact
|
|
122
|
+
about anything.
|
|
123
|
+
- ``document_overrides.user_id``: a per user grant or denial likewise.
|
|
124
|
+
|
|
125
|
+
Revoke and detach, because the row must not keep working but must not
|
|
126
|
+
vanish either:
|
|
127
|
+
|
|
128
|
+
- ``api_keys.principal_user_id``: the key is deactivated AND detached.
|
|
129
|
+
Deactivating alone would leave a live foreign key and the delete would
|
|
130
|
+
fail; deleting the key instead would break ``audit_log.api_key_id`` and
|
|
131
|
+
erase the record of what that key did. A deactivated, detached key
|
|
132
|
+
authenticates nobody and still anchors its own audit trail.
|
|
133
|
+
|
|
134
|
+
Preserve and anonymise, because the history is the product:
|
|
135
|
+
|
|
136
|
+
- ``audit_log.principal_user_id``, ``retrieval_runs.user_id``,
|
|
137
|
+
``query_logs.user_id``, ``conversations.user_id``: measurement and
|
|
138
|
+
audit history outlives the account. The rows survive with the
|
|
139
|
+
reference cleared.
|
|
140
|
+
|
|
141
|
+
Preserve and disown, because the content belongs to the organisation:
|
|
142
|
+
|
|
143
|
+
- ``collections.owner_id``, ``documents.owner_id``: deleting a person
|
|
144
|
+
must never delete the corpus. Ownership is cleared and an admin can
|
|
145
|
+
reassign it.
|
|
146
|
+
|
|
147
|
+
Refusing to delete yourself is not a cascade decision, it is an
|
|
148
|
+
availability one: an admin who deletes their own account can lock the
|
|
149
|
+
last administrator out of the console.
|
|
150
|
+
"""
|
|
151
|
+
user = db.get(User, user_id)
|
|
152
|
+
if user is None:
|
|
153
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
|
154
|
+
if user.id == admin.id:
|
|
155
|
+
raise HTTPException(
|
|
156
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
157
|
+
detail="You cannot delete your own account.",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
db.query(ApiKey).filter(ApiKey.principal_user_id == user_id).update(
|
|
161
|
+
{"is_active": False, "principal_user_id": None}, synchronize_session=False
|
|
162
|
+
)
|
|
163
|
+
for model, column in (
|
|
164
|
+
(AuditLog, AuditLog.principal_user_id),
|
|
165
|
+
(RetrievalRun, RetrievalRun.user_id),
|
|
166
|
+
(Conversation, Conversation.user_id),
|
|
167
|
+
(QueryLog, QueryLog.user_id),
|
|
168
|
+
(Collection, Collection.owner_id),
|
|
169
|
+
(Document, Document.owner_id),
|
|
170
|
+
):
|
|
171
|
+
db.query(model).filter(column == user_id).update(
|
|
172
|
+
{column.key: None}, synchronize_session=False
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
db.delete(user)
|
|
176
|
+
db.commit()
|
|
177
|
+
return {"detail": "User deleted.", "id": user_id}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@router.post("/documents/{document_id}/versions", response_model=DocumentOut)
|
|
181
|
+
async def create_version(
|
|
182
|
+
document_id: int,
|
|
183
|
+
file: UploadFile | None = File(default=None),
|
|
184
|
+
db: Session = Depends(get_db),
|
|
185
|
+
_: User = Depends(require_role("admin")),
|
|
186
|
+
) -> Document:
|
|
187
|
+
"""Publish a new version of a document (admin only).
|
|
188
|
+
|
|
189
|
+
With a ``file``, the document's content is REPLACED: the old chunks are
|
|
190
|
+
dropped from the database and the vector index, the new file is re-ingested
|
|
191
|
+
under the same document id, and the version counter advances. Keeping the id
|
|
192
|
+
means collection membership and any stored citation still resolve.
|
|
193
|
+
|
|
194
|
+
Without a file, this only advances the counter, which is the "mark a
|
|
195
|
+
reviewed revision" case where the content has not changed.
|
|
196
|
+
"""
|
|
197
|
+
document = db.get(Document, document_id)
|
|
198
|
+
if document is None:
|
|
199
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found.")
|
|
200
|
+
|
|
201
|
+
if file is None:
|
|
202
|
+
document.version += 1
|
|
203
|
+
db.commit()
|
|
204
|
+
db.refresh(document)
|
|
205
|
+
return document
|
|
206
|
+
|
|
207
|
+
filename = file.filename or document.filename
|
|
208
|
+
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
|
209
|
+
if ext not in SUPPORTED_FORMATS:
|
|
210
|
+
raise HTTPException(
|
|
211
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
212
|
+
detail=(
|
|
213
|
+
f"Unsupported format '{ext or filename}'. "
|
|
214
|
+
f"Supported: {', '.join(SUPPORTED_FORMATS)}."
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
data = await file.read()
|
|
218
|
+
if not data:
|
|
219
|
+
raise HTTPException(
|
|
220
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Uploaded file is empty."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return reingest_document(
|
|
224
|
+
db,
|
|
225
|
+
document,
|
|
226
|
+
filename=filename,
|
|
227
|
+
data=data,
|
|
228
|
+
content_type=file.content_type or "",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@router.delete("/documents/{document_id}", status_code=status.HTTP_200_OK)
|
|
233
|
+
def admin_delete_document(
|
|
234
|
+
document_id: int,
|
|
235
|
+
db: Session = Depends(get_db),
|
|
236
|
+
_: User = Depends(require_role("admin")),
|
|
237
|
+
) -> dict:
|
|
238
|
+
"""Hard-delete any document and its vectors (admin override)."""
|
|
239
|
+
document = db.get(Document, document_id)
|
|
240
|
+
if document is None:
|
|
241
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found.")
|
|
242
|
+
detach_documents(db, [document.id]) # recompute the graph first (R42)
|
|
243
|
+
db.delete(document) # cascades to chunks
|
|
244
|
+
db.commit()
|
|
245
|
+
# As with the owner-facing delete route, the relational cascade does not
|
|
246
|
+
# reach the configured vector/lexical stores (SQLite does not enforce the
|
|
247
|
+
# chunk_embeddings/chunk_search foreign keys, and Chroma has none at all),
|
|
248
|
+
# so those rows must be dropped explicitly or they accumulate as orphans.
|
|
249
|
+
cfg = get_config()
|
|
250
|
+
sf = get_session_factory()
|
|
251
|
+
build_vector_store(cfg.vector_store, sf).delete_document(document_id)
|
|
252
|
+
build_lexical_store(cfg.lexical_store, sf).delete_document(document_id)
|
|
253
|
+
get_storage().delete(document_id)
|
|
254
|
+
return {"detail": "Document deleted.", "id": document_id}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Analytics routes powering the dashboard tiles and usage view."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends
|
|
6
|
+
from sqlalchemy import func
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from ragfabric_core.db.session import get_db
|
|
10
|
+
from ragfabric_core.models.document import Chunk, Collection, Document, QueryLog
|
|
11
|
+
from ragfabric_core.models.user import User
|
|
12
|
+
from ragfabric_core.runtime import get_config, get_session_factory
|
|
13
|
+
from ragfabric_core.stores.registry import build_vector_store
|
|
14
|
+
from ragfabric_server.deps import get_current_user
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/overview")
|
|
20
|
+
def overview(
|
|
21
|
+
db: Session = Depends(get_db),
|
|
22
|
+
current_user: User = Depends(get_current_user),
|
|
23
|
+
) -> dict:
|
|
24
|
+
"""Aggregate counts for the dashboard summary tiles."""
|
|
25
|
+
return {
|
|
26
|
+
"documents": db.query(Document).count(),
|
|
27
|
+
"collections": db.query(Collection).count(),
|
|
28
|
+
"chunks": db.query(Chunk).count(),
|
|
29
|
+
"users": db.query(User).count(),
|
|
30
|
+
"queries": db.query(QueryLog).count(),
|
|
31
|
+
"ready_documents": db.query(Document).filter(Document.status == "ready").count(),
|
|
32
|
+
# Live vector count from the configured store. Comparing this against
|
|
33
|
+
# ``chunks`` is the quickest way to spot the vector index drifting out
|
|
34
|
+
# of sync with the database.
|
|
35
|
+
"indexed_vectors": build_vector_store(
|
|
36
|
+
get_config().vector_store, get_session_factory()
|
|
37
|
+
).count(),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.get("/usage")
|
|
42
|
+
def usage(
|
|
43
|
+
db: Session = Depends(get_db),
|
|
44
|
+
current_user: User = Depends(get_current_user),
|
|
45
|
+
) -> dict:
|
|
46
|
+
"""Recent questions, most-indexed documents, and most-cited documents."""
|
|
47
|
+
recent = db.query(QueryLog).order_by(QueryLog.created_at.desc()).limit(10).all()
|
|
48
|
+
recent_queries = [
|
|
49
|
+
{
|
|
50
|
+
"question": log.question,
|
|
51
|
+
"confidence": round(float(log.confidence), 4),
|
|
52
|
+
"created_at": log.created_at.isoformat(),
|
|
53
|
+
}
|
|
54
|
+
for log in recent
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
top_docs = (
|
|
58
|
+
db.query(Document.id, Document.filename, func.count(Chunk.id).label("chunks"))
|
|
59
|
+
.join(Chunk, Chunk.document_id == Document.id)
|
|
60
|
+
.group_by(Document.id, Document.filename)
|
|
61
|
+
.order_by(func.count(Chunk.id).desc())
|
|
62
|
+
.limit(5)
|
|
63
|
+
.all()
|
|
64
|
+
)
|
|
65
|
+
top_documents = [
|
|
66
|
+
{"document_id": doc_id, "filename": filename, "chunks": chunks}
|
|
67
|
+
for doc_id, filename, chunks in top_docs
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
# Citation counts are tallied in Python rather than SQL: the cited ids live
|
|
71
|
+
# in a JSON column and JSON aggregation is not portable between SQLite and
|
|
72
|
+
# PostgreSQL, both of which this app has to run on.
|
|
73
|
+
counts: dict[int, int] = {}
|
|
74
|
+
for (cited,) in db.query(QueryLog.cited_document_ids).all():
|
|
75
|
+
for document_id in cited or []:
|
|
76
|
+
counts[document_id] = counts.get(document_id, 0) + 1
|
|
77
|
+
|
|
78
|
+
names = dict(db.query(Document.id, Document.filename).all())
|
|
79
|
+
most_cited = [
|
|
80
|
+
{
|
|
81
|
+
"document_id": document_id,
|
|
82
|
+
"filename": names.get(document_id, "(deleted)"),
|
|
83
|
+
"citations": count,
|
|
84
|
+
}
|
|
85
|
+
for document_id, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:5]
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
"recent_queries": recent_queries,
|
|
90
|
+
"top_documents": top_documents,
|
|
91
|
+
"most_cited_documents": most_cited,
|
|
92
|
+
}
|