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.
Files changed (46) hide show
  1. ragfabric_server-0.3.1/.gitignore +49 -0
  2. ragfabric_server-0.3.1/PKG-INFO +27 -0
  3. ragfabric_server-0.3.1/README.md +3 -0
  4. ragfabric_server-0.3.1/pyproject.toml +36 -0
  5. ragfabric_server-0.3.1/src/ragfabric_server/__init__.py +1 -0
  6. ragfabric_server-0.3.1/src/ragfabric_server/api/__init__.py +0 -0
  7. ragfabric_server-0.3.1/src/ragfabric_server/api/routes/__init__.py +0 -0
  8. ragfabric_server-0.3.1/src/ragfabric_server/api/routes/access.py +218 -0
  9. ragfabric_server-0.3.1/src/ragfabric_server/api/routes/admin.py +254 -0
  10. ragfabric_server-0.3.1/src/ragfabric_server/api/routes/analytics.py +92 -0
  11. ragfabric_server-0.3.1/src/ragfabric_server/api/routes/ask.py +477 -0
  12. ragfabric_server-0.3.1/src/ragfabric_server/api/routes/auth.py +74 -0
  13. ragfabric_server-0.3.1/src/ragfabric_server/api/routes/collections.py +167 -0
  14. ragfabric_server-0.3.1/src/ragfabric_server/api/routes/documents.py +356 -0
  15. ragfabric_server-0.3.1/src/ragfabric_server/api/routes/providers.py +101 -0
  16. ragfabric_server-0.3.1/src/ragfabric_server/api/routes/runs.py +52 -0
  17. ragfabric_server-0.3.1/src/ragfabric_server/api/routes/search.py +642 -0
  18. ragfabric_server-0.3.1/src/ragfabric_server/deps.py +238 -0
  19. ragfabric_server-0.3.1/src/ragfabric_server/main.py +135 -0
  20. ragfabric_server-0.3.1/src/ragfabric_server/provider_config.py +140 -0
  21. ragfabric_server-0.3.1/src/ragfabric_server/schemas/__init__.py +0 -0
  22. ragfabric_server-0.3.1/src/ragfabric_server/schemas/access.py +94 -0
  23. ragfabric_server-0.3.1/src/ragfabric_server/schemas/ask.py +27 -0
  24. ragfabric_server-0.3.1/src/ragfabric_server/schemas/document.py +64 -0
  25. ragfabric_server-0.3.1/src/ragfabric_server/schemas/providers.py +90 -0
  26. ragfabric_server-0.3.1/src/ragfabric_server/schemas/runs.py +53 -0
  27. ragfabric_server-0.3.1/src/ragfabric_server/schemas/search.py +249 -0
  28. ragfabric_server-0.3.1/src/ragfabric_server/schemas/user.py +44 -0
  29. ragfabric_server-0.3.1/tests/conftest.py +273 -0
  30. ragfabric_server-0.3.1/tests/test_access_api.py +116 -0
  31. ragfabric_server-0.3.1/tests/test_access_enforcement.py +360 -0
  32. ragfabric_server-0.3.1/tests/test_admin_user_crud.py +160 -0
  33. ragfabric_server-0.3.1/tests/test_agentic_generation.py +250 -0
  34. ragfabric_server-0.3.1/tests/test_agentic_routes.py +238 -0
  35. ragfabric_server-0.3.1/tests/test_api.py +724 -0
  36. ragfabric_server-0.3.1/tests/test_ask_sse.py +208 -0
  37. ragfabric_server-0.3.1/tests/test_document_move.py +55 -0
  38. ragfabric_server-0.3.1/tests/test_graph_citations.py +380 -0
  39. ragfabric_server-0.3.1/tests/test_graph_routes.py +850 -0
  40. ragfabric_server-0.3.1/tests/test_group_crud.py +132 -0
  41. ragfabric_server-0.3.1/tests/test_ingestion_runs.py +89 -0
  42. ragfabric_server-0.3.1/tests/test_overrides.py +232 -0
  43. ragfabric_server-0.3.1/tests/test_parsers.py +174 -0
  44. ragfabric_server-0.3.1/tests/test_providers_route.py +174 -0
  45. ragfabric_server-0.3.1/tests/test_search_real_path.py +184 -0
  46. 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,3 @@
1
+ # ragfabric-server
2
+
3
+ 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."""
@@ -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
+ }