paperus 0.0.8__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.
- paperus/__init__.py +5 -0
- paperus/access.py +202 -0
- paperus/api/__init__.py +1 -0
- paperus/api/admin.py +364 -0
- paperus/api/auth.py +297 -0
- paperus/api/avatars.py +60 -0
- paperus/api/backups.py +91 -0
- paperus/api/citations.py +92 -0
- paperus/api/deps.py +77 -0
- paperus/api/duplicates.py +61 -0
- paperus/api/files.py +350 -0
- paperus/api/groups.py +491 -0
- paperus/api/health.py +22 -0
- paperus/api/imports.py +120 -0
- paperus/api/items.py +749 -0
- paperus/api/notes.py +423 -0
- paperus/api/organize.py +649 -0
- paperus/api/search.py +272 -0
- paperus/api/setup.py +120 -0
- paperus/api/tokens.py +101 -0
- paperus/api/trash.py +43 -0
- paperus/api/users.py +217 -0
- paperus/app.py +176 -0
- paperus/auth.py +134 -0
- paperus/avatars.py +44 -0
- paperus/backup.py +496 -0
- paperus/changes.py +104 -0
- paperus/citations.py +337 -0
- paperus/cli.py +275 -0
- paperus/config.py +59 -0
- paperus/db.py +18 -0
- paperus/duplicates.py +183 -0
- paperus/embedding.py +116 -0
- paperus/handover.py +119 -0
- paperus/identifiers.py +118 -0
- paperus/identify.py +243 -0
- paperus/importing/__init__.py +53 -0
- paperus/importing/apply.py +444 -0
- paperus/importing/bibtex.py +303 -0
- paperus/importing/common.py +67 -0
- paperus/importing/csljson.py +55 -0
- paperus/importing/ris.py +157 -0
- paperus/importing/run.py +205 -0
- paperus/importing/zotero.py +441 -0
- paperus/integrity.py +123 -0
- paperus/jobs.py +480 -0
- paperus/library.py +247 -0
- paperus/mcp_server.py +914 -0
- paperus/merge.py +296 -0
- paperus/metadata.py +110 -0
- paperus/metrics.py +228 -0
- paperus/migrate.py +23 -0
- paperus/migrations/env.py +53 -0
- paperus/migrations/procrastinate_3.10.0.sql +599 -0
- paperus/migrations/script.py.mako +20 -0
- paperus/migrations/versions/0001_initial_schema.py +294 -0
- paperus/migrations/versions/0002_add_system_settings.py +32 -0
- paperus/migrations/versions/0003_mark_reference_lists.py +28 -0
- paperus/migrations/versions/0004_add_tags_and_collections.py +115 -0
- paperus/migrations/versions/0005_add_tag_details.py +22 -0
- paperus/migrations/versions/0006_add_groups_and_libraries.py +149 -0
- paperus/migrations/versions/0007_items_in_several_libraries.py +49 -0
- paperus/migrations/versions/0008_one_owner_per_item.py +53 -0
- paperus/migrations/versions/0009_add_seen_hints.py +29 -0
- paperus/migrations/versions/0010_recent_activity.py +44 -0
- paperus/migrations/versions/0011_user_login_and_deactivation.py +26 -0
- paperus/migrations/versions/0012_add_access_tokens.py +45 -0
- paperus/migrations/versions/0013_add_notes.py +150 -0
- paperus/migrations/versions/0014_add_imports.py +47 -0
- paperus/migrations/versions/0015_keep_embedding_model.py +34 -0
- paperus/migrations/versions/0016_add_not_duplicates.py +39 -0
- paperus/migrations/versions/0017_add_favourites_and_pinned_collections.py +63 -0
- paperus/migrations/versions/0018_add_oidc_subject.py +22 -0
- paperus/migrations/versions/0019_add_avatars.py +24 -0
- paperus/ml.py +205 -0
- paperus/models.py +769 -0
- paperus/notes.py +111 -0
- paperus/oidc.py +296 -0
- paperus/openapi.py +11 -0
- paperus/organize.py +158 -0
- paperus/processing.py +161 -0
- paperus/py.typed +0 -0
- paperus/readable.py +337 -0
- paperus/storage.py +235 -0
- paperus/system.py +217 -0
- paperus/text.py +134 -0
- paperus/tokens.py +151 -0
- paperus/trash.py +121 -0
- paperus/usage.py +89 -0
- paperus/web.py +25 -0
- paperus-0.0.8.dist-info/METADATA +57 -0
- paperus-0.0.8.dist-info/RECORD +94 -0
- paperus-0.0.8.dist-info/WHEEL +4 -0
- paperus-0.0.8.dist-info/entry_points.txt +3 -0
paperus/__init__.py
ADDED
paperus/access.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Who may read and change which library.
|
|
2
|
+
|
|
3
|
+
A person reads their own library and those of the groups they belong to,
|
|
4
|
+
including every group inside those (membership is inherited downwards, and a
|
|
5
|
+
subgroup can only raise a role). Site administrators manage users and groups
|
|
6
|
+
but read no library because of it. All of this is worked out once per
|
|
7
|
+
request (``Access.of``) from the group tree, which is small, and the person's
|
|
8
|
+
memberships; lists and searches then filter by library ID in SQL.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import ColumnElement, select
|
|
15
|
+
from sqlalchemy.orm import Session
|
|
16
|
+
|
|
17
|
+
from paperus.models import (
|
|
18
|
+
ROLE_RANK,
|
|
19
|
+
Group,
|
|
20
|
+
Item,
|
|
21
|
+
Library,
|
|
22
|
+
Membership,
|
|
23
|
+
Note,
|
|
24
|
+
Role,
|
|
25
|
+
User,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Forbidden(Exception):
|
|
30
|
+
"""The person may see this but not change it (HTTP 403)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotVisible(Exception):
|
|
34
|
+
"""The person may not see this; answered like a missing object (404)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Access:
|
|
39
|
+
user: User
|
|
40
|
+
own_library: uuid.UUID
|
|
41
|
+
roles: dict[uuid.UUID, Role] = field(default_factory=dict)
|
|
42
|
+
"""Effective role per group (direct or inherited)."""
|
|
43
|
+
group_libraries: dict[uuid.UUID, uuid.UUID] = field(default_factory=dict)
|
|
44
|
+
"""Library of each group the person belongs to."""
|
|
45
|
+
own_allowed: bool = True
|
|
46
|
+
"""False for an access token limited to some groups' libraries."""
|
|
47
|
+
site_admin: bool = False
|
|
48
|
+
"""A site administrator acting in person (never through a token, which
|
|
49
|
+
gets no administration)."""
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def of(
|
|
53
|
+
cls,
|
|
54
|
+
db: Session,
|
|
55
|
+
user: User,
|
|
56
|
+
only: list[uuid.UUID] | None = None,
|
|
57
|
+
*,
|
|
58
|
+
token: bool = False,
|
|
59
|
+
) -> "Access":
|
|
60
|
+
"""What ``user`` may do; with ``only`` (an access token's owner limit),
|
|
61
|
+
nothing outside these libraries; ``token``: through an access token."""
|
|
62
|
+
own = db.scalar(select(Library.id).where(Library.user_id == user.id))
|
|
63
|
+
if own is None: # users from before libraries existed get one now
|
|
64
|
+
library = Library(user_id=user.id)
|
|
65
|
+
db.add(library)
|
|
66
|
+
db.flush()
|
|
67
|
+
own = library.id
|
|
68
|
+
parents: dict[uuid.UUID, uuid.UUID | None] = {
|
|
69
|
+
group_id: parent
|
|
70
|
+
for group_id, parent in db.execute(select(Group.id, Group.parent_id))
|
|
71
|
+
}
|
|
72
|
+
direct = {
|
|
73
|
+
group_id: Role(role)
|
|
74
|
+
for group_id, role in db.execute(
|
|
75
|
+
select(Membership.group_id, Membership.role).where(
|
|
76
|
+
Membership.user_id == user.id
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
roles: dict[uuid.UUID, Role] = {}
|
|
81
|
+
for group_id in parents:
|
|
82
|
+
# The highest role on the way up to the top.
|
|
83
|
+
best: Role | None = None
|
|
84
|
+
current: uuid.UUID | None = group_id
|
|
85
|
+
while current is not None:
|
|
86
|
+
role = direct.get(current)
|
|
87
|
+
if role and (best is None or ROLE_RANK[role] > ROLE_RANK[best]):
|
|
88
|
+
best = role
|
|
89
|
+
current = parents.get(current)
|
|
90
|
+
if best is not None:
|
|
91
|
+
roles[group_id] = best
|
|
92
|
+
libraries = {
|
|
93
|
+
group_id: library_id
|
|
94
|
+
for group_id, library_id in db.execute(
|
|
95
|
+
select(Library.group_id, Library.id).where(Library.group_id.in_(roles))
|
|
96
|
+
)
|
|
97
|
+
if group_id is not None
|
|
98
|
+
}
|
|
99
|
+
site_admin = user.is_admin and not token
|
|
100
|
+
if only is None:
|
|
101
|
+
return cls(user, own, roles, libraries, site_admin=site_admin)
|
|
102
|
+
allowed = set(only)
|
|
103
|
+
libraries = {g: lib for g, lib in libraries.items() if lib in allowed}
|
|
104
|
+
roles = {g: r for g, r in roles.items() if g in libraries}
|
|
105
|
+
return cls(
|
|
106
|
+
user,
|
|
107
|
+
own,
|
|
108
|
+
roles,
|
|
109
|
+
libraries,
|
|
110
|
+
own_allowed=own in allowed,
|
|
111
|
+
site_admin=site_admin,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def readable(self) -> list[uuid.UUID]:
|
|
116
|
+
"""Libraries this person may read: their own and their groups'."""
|
|
117
|
+
own = [self.own_library] if self.own_allowed else []
|
|
118
|
+
return [*own, *self.group_libraries.values()]
|
|
119
|
+
|
|
120
|
+
def role_in_library(self, library_id: uuid.UUID) -> Role | None:
|
|
121
|
+
for group_id, group_library in self.group_libraries.items():
|
|
122
|
+
if group_library == library_id:
|
|
123
|
+
return self.roles[group_id]
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def can_read(self, library_id: uuid.UUID) -> bool:
|
|
127
|
+
"""The person's own library (unless a token is limited to others) or
|
|
128
|
+
one of their groups' (only those a limited token allows)."""
|
|
129
|
+
if library_id == self.own_library:
|
|
130
|
+
return self.own_allowed
|
|
131
|
+
return library_id in self.group_libraries.values()
|
|
132
|
+
|
|
133
|
+
def can_write(self, library_id: uuid.UUID) -> bool:
|
|
134
|
+
"""Add, change and delete items, tags and collections."""
|
|
135
|
+
if library_id == self.own_library:
|
|
136
|
+
return self.own_allowed
|
|
137
|
+
role = self.role_in_library(library_id)
|
|
138
|
+
return role is not None and ROLE_RANK[role] >= ROLE_RANK[Role.MEMBER]
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def writable(self) -> list[uuid.UUID]:
|
|
142
|
+
return [library for library in self.readable if self.can_write(library)]
|
|
143
|
+
|
|
144
|
+
def is_group_admin(self, group_id: uuid.UUID) -> bool:
|
|
145
|
+
return self.roles.get(group_id) == Role.ADMIN
|
|
146
|
+
|
|
147
|
+
def require_write(self, library_id: uuid.UUID) -> None:
|
|
148
|
+
if not self.can_write(library_id):
|
|
149
|
+
raise Forbidden("readers cannot change this library")
|
|
150
|
+
|
|
151
|
+
# Items have one owner (``Item.library_id``): a person or a group.
|
|
152
|
+
|
|
153
|
+
def can_read_item(self, item: Item) -> bool:
|
|
154
|
+
return self.can_read(item.library_id)
|
|
155
|
+
|
|
156
|
+
def can_write_item(self, item: Item) -> bool:
|
|
157
|
+
return self.can_write(item.library_id)
|
|
158
|
+
|
|
159
|
+
def require_write_item(self, item: Item) -> None:
|
|
160
|
+
if not self.can_write_item(item):
|
|
161
|
+
raise Forbidden("readers cannot change this item")
|
|
162
|
+
|
|
163
|
+
def item_filter(
|
|
164
|
+
self, libraries: list[uuid.UUID] | None = None
|
|
165
|
+
) -> ColumnElement[bool]:
|
|
166
|
+
"""Items owned by these (by default: everything the person reads)."""
|
|
167
|
+
return Item.library_id.in_(self.readable if libraries is None else libraries)
|
|
168
|
+
|
|
169
|
+
def may_tag(self, library_id: uuid.UUID, item: Item) -> bool:
|
|
170
|
+
"""Whether the person may put a tag of ``library_id`` on (or take it
|
|
171
|
+
off) the item: their private tags on any item they see; a group's
|
|
172
|
+
tags (they read) on any item they may change, e.g. their own papers
|
|
173
|
+
with the group's vocabulary."""
|
|
174
|
+
if library_id == self.own_library:
|
|
175
|
+
return self.own_allowed and self.can_read_item(item)
|
|
176
|
+
return self.can_read(library_id) and self.can_write_item(item)
|
|
177
|
+
|
|
178
|
+
def may_hold(self, library_id: uuid.UUID, item: Item | Note) -> bool:
|
|
179
|
+
"""Whether a collection of ``library_id`` may hold the item (or note):
|
|
180
|
+
a group's only the group's own, a person's private ones anything the
|
|
181
|
+
person can read."""
|
|
182
|
+
if library_id == self.own_library:
|
|
183
|
+
return self.own_allowed and self.can_read(item.library_id)
|
|
184
|
+
return library_id == item.library_id
|
|
185
|
+
|
|
186
|
+
def target_library(self, requested: uuid.UUID | None) -> uuid.UUID:
|
|
187
|
+
"""Where something new goes: the library asked for (if the person may
|
|
188
|
+
add to it), else their default library, else their own."""
|
|
189
|
+
if requested is not None:
|
|
190
|
+
if not self.can_read(requested):
|
|
191
|
+
raise NotVisible("library not found")
|
|
192
|
+
self.require_write(requested)
|
|
193
|
+
return requested
|
|
194
|
+
default = self.user.default_library_id
|
|
195
|
+
if default is not None and self.can_write(default):
|
|
196
|
+
return default
|
|
197
|
+
if self.own_allowed:
|
|
198
|
+
return self.own_library
|
|
199
|
+
writable = self.writable
|
|
200
|
+
if not writable:
|
|
201
|
+
raise Forbidden("this token may not add anything")
|
|
202
|
+
return writable[0]
|
paperus/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP API routes."""
|
paperus/api/admin.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Admin page: settings, the ML service, progress and jobs."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
from sqlalchemy import func, select
|
|
9
|
+
|
|
10
|
+
from paperus import auth, integrity, metrics, oidc, readable, system, usage
|
|
11
|
+
from paperus.api.auth import OIDC_CALLBACK, public_base
|
|
12
|
+
from paperus.api.deps import DB, CurrentUser, request_token
|
|
13
|
+
from paperus.jobs import (
|
|
14
|
+
queue_embedding_sweep,
|
|
15
|
+
queue_empty_trash,
|
|
16
|
+
queue_integrity_check,
|
|
17
|
+
queue_ml_configuration,
|
|
18
|
+
queue_readable_copy,
|
|
19
|
+
queue_text_sweep,
|
|
20
|
+
)
|
|
21
|
+
from paperus.ml import MLClient, MLLoading, MLRejected, MLUnavailable
|
|
22
|
+
from paperus.models import LoginSession, User
|
|
23
|
+
from paperus.processing import reread_all
|
|
24
|
+
|
|
25
|
+
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def current_admin(user: CurrentUser) -> User:
|
|
29
|
+
if not user.is_admin:
|
|
30
|
+
raise HTTPException(403, "admins only")
|
|
31
|
+
return user
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
Admin = Annotated[User, Depends(current_admin)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MLService(BaseModel):
|
|
38
|
+
url: str
|
|
39
|
+
ok: bool
|
|
40
|
+
error: str | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MLModel(BaseModel):
|
|
44
|
+
name: str
|
|
45
|
+
task: str
|
|
46
|
+
dimensions: int
|
|
47
|
+
max_tokens: int
|
|
48
|
+
loaded: bool
|
|
49
|
+
cached_bytes: int | None
|
|
50
|
+
# For choosing (older ML services do not send these).
|
|
51
|
+
description: str = ""
|
|
52
|
+
licence: str = ""
|
|
53
|
+
download_mb: int = 0
|
|
54
|
+
scores: dict[str, float] = {}
|
|
55
|
+
"""Published retrieval scores (nDCG@10, out of 100), by benchmark."""
|
|
56
|
+
benchmarks: dict[str, str] = {}
|
|
57
|
+
"""What the keys of ``scores`` mean."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MLSpeed(BaseModel):
|
|
61
|
+
model: str
|
|
62
|
+
loading: bool = False
|
|
63
|
+
"""The model is still downloading or loading: ask again in a while."""
|
|
64
|
+
passages: int = 0
|
|
65
|
+
seconds: float = 0
|
|
66
|
+
passages_per_second: float = 0
|
|
67
|
+
url: str | None = None
|
|
68
|
+
"""The ML service that measured it."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MLServiceState(BaseModel):
|
|
72
|
+
url: str
|
|
73
|
+
threads: int
|
|
74
|
+
keep: list[str]
|
|
75
|
+
cores: int
|
|
76
|
+
"""CPU cores this ML service may use."""
|
|
77
|
+
loaded: list[str]
|
|
78
|
+
loading: list[str] = []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class MLStatus(BaseModel):
|
|
82
|
+
services: list[MLService]
|
|
83
|
+
models: list[MLModel]
|
|
84
|
+
"""Empty if no service answers."""
|
|
85
|
+
service: MLServiceState | None = None
|
|
86
|
+
"""The first service's settings and cores (None for older services)."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Stats(BaseModel):
|
|
90
|
+
items: int
|
|
91
|
+
files: int
|
|
92
|
+
files_pending: int
|
|
93
|
+
"""Files whose text (and, for uploads, metadata) is still being read."""
|
|
94
|
+
chunks: int
|
|
95
|
+
"""Passages searchable by meaning (reference lists are left out)."""
|
|
96
|
+
embedded: int
|
|
97
|
+
"""Chunks with a vector from the current embedding model."""
|
|
98
|
+
embedding_model: str
|
|
99
|
+
jobs: dict[str, dict[str, int]]
|
|
100
|
+
"""Background jobs per queue and status (todo, doing, failed, …)."""
|
|
101
|
+
storage: usage.Usage
|
|
102
|
+
"""Space the library and the backups take, and what is free."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ml(request: Request) -> MLClient:
|
|
106
|
+
ml: MLClient = request.app.state.ml
|
|
107
|
+
return ml
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _models(ml: MLClient) -> list[MLModel]:
|
|
111
|
+
return [MLModel.model_validate(m) for m in ml.models()]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.get("/config")
|
|
115
|
+
def get_config(request: Request, db: DB, admin: Admin) -> system.SystemConfig:
|
|
116
|
+
return system.load_config(db, request.app.state.settings)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.put("/config")
|
|
120
|
+
def set_config(
|
|
121
|
+
body: system.SystemConfig, request: Request, db: DB, admin: Admin
|
|
122
|
+
) -> system.SystemConfig:
|
|
123
|
+
"""Save settings. A new embedding model must be one the ML service
|
|
124
|
+
offers; the library is then re-embedded in the background, while keyword
|
|
125
|
+
search keeps working and search by meaning covers more and more."""
|
|
126
|
+
current = system.load_config(db, request.app.state.settings)
|
|
127
|
+
model_changed = body.embedding_model != current.embedding_model
|
|
128
|
+
if model_changed:
|
|
129
|
+
try:
|
|
130
|
+
known = {m.name for m in _models(_ml(request)) if m.task == "embedding"}
|
|
131
|
+
except (MLUnavailable, MLRejected) as error:
|
|
132
|
+
raise HTTPException(
|
|
133
|
+
503, "ML service unreachable; cannot check the model"
|
|
134
|
+
) from error
|
|
135
|
+
if body.embedding_model not in known:
|
|
136
|
+
raise HTTPException(
|
|
137
|
+
422, f"the ML service has no model {body.embedding_model!r}"
|
|
138
|
+
)
|
|
139
|
+
system.save_config(db, body)
|
|
140
|
+
db.commit()
|
|
141
|
+
system.apply(body, request.app.state.http)
|
|
142
|
+
if model_changed:
|
|
143
|
+
queue_embedding_sweep(request.app.state.jobs)
|
|
144
|
+
copy = ("copy_enabled", "copy_template", "copy_links")
|
|
145
|
+
if body.copy_enabled and any(
|
|
146
|
+
getattr(body, name) != getattr(current, name) for name in copy
|
|
147
|
+
):
|
|
148
|
+
queue_readable_copy(request.app.state.jobs)
|
|
149
|
+
if model_changed or body.ml_threads != current.ml_threads:
|
|
150
|
+
# The worker sends it at once (first in its queue), and every minute.
|
|
151
|
+
queue_ml_configuration(request.app.state.jobs)
|
|
152
|
+
return body
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.get("/ml")
|
|
156
|
+
def ml_status(request: Request, admin: Admin) -> MLStatus:
|
|
157
|
+
ml = _ml(request)
|
|
158
|
+
services = [MLService(url=u, ok=e is None, error=e) for u, e in ml.status()]
|
|
159
|
+
try:
|
|
160
|
+
models = _models(ml)
|
|
161
|
+
except (MLUnavailable, MLRejected):
|
|
162
|
+
models = []
|
|
163
|
+
try:
|
|
164
|
+
service = MLServiceState.model_validate(ml.service_config())
|
|
165
|
+
except (MLUnavailable, MLRejected, ValueError):
|
|
166
|
+
service = None
|
|
167
|
+
return MLStatus(services=services, models=models, service=service)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@router.post("/ml/models/{name}/speed")
|
|
171
|
+
def measure_ml_speed(name: str, request: Request, admin: Admin) -> MLSpeed:
|
|
172
|
+
"""Time a model on this installation's ML service with sample passages.
|
|
173
|
+
A model not downloaded yet starts downloading: the answer then says
|
|
174
|
+
``loading``; ask again in a while."""
|
|
175
|
+
try:
|
|
176
|
+
return MLSpeed.model_validate(_ml(request).measure_speed(name))
|
|
177
|
+
except MLLoading:
|
|
178
|
+
return MLSpeed(model=name, loading=True)
|
|
179
|
+
except MLRejected as error:
|
|
180
|
+
raise HTTPException(
|
|
181
|
+
404 if error.status == 404 else 502, error.detail
|
|
182
|
+
) from error
|
|
183
|
+
except MLUnavailable as error:
|
|
184
|
+
raise HTTPException(503, f"ML service unreachable: {error}") from error
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@router.delete("/ml/models/{name}", status_code=204)
|
|
188
|
+
def delete_ml_model(name: str, request: Request, db: DB, admin: Admin) -> None:
|
|
189
|
+
"""Remove a downloaded model from the ML service (not the one in use)."""
|
|
190
|
+
if name == system.load_config(db, request.app.state.settings).embedding_model:
|
|
191
|
+
raise HTTPException(409, "this model is in use")
|
|
192
|
+
try:
|
|
193
|
+
_ml(request).delete_model(name)
|
|
194
|
+
except MLRejected as error:
|
|
195
|
+
raise HTTPException(404, error.detail) from error
|
|
196
|
+
except MLUnavailable as error:
|
|
197
|
+
raise HTTPException(503, "ML service unreachable") from error
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@router.get("/stats")
|
|
201
|
+
def stats(request: Request, db: DB, admin: Admin) -> Stats:
|
|
202
|
+
figures = metrics.library_figures(db, request.app.state.settings)
|
|
203
|
+
return Stats(
|
|
204
|
+
items=figures.items,
|
|
205
|
+
files=figures.files,
|
|
206
|
+
files_pending=figures.files_pending,
|
|
207
|
+
chunks=figures.chunks,
|
|
208
|
+
embedded=figures.embedded,
|
|
209
|
+
embedding_model=figures.embedding_model,
|
|
210
|
+
jobs=figures.jobs,
|
|
211
|
+
storage=usage.usage(db, request.app.state.settings),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@router.post("/jobs/{job}", status_code=202)
|
|
216
|
+
def run_job(job: str, request: Request, db: DB, admin: Admin) -> dict[str, Any]:
|
|
217
|
+
"""Start a sweep now: ``embeddings`` (embed what is missing), ``text``
|
|
218
|
+
(read files still pending), ``text-all`` (read every file again),
|
|
219
|
+
``trash`` (delete what is older than the trash days), ``integrity``
|
|
220
|
+
(check stored contents), ``integrity-deep`` (also their SHA-256) or
|
|
221
|
+
``integrity-clean`` (check and remove unused stored contents), ``copy``
|
|
222
|
+
(update the readable copy) or ``copy-remove`` (delete it)."""
|
|
223
|
+
jobs = request.app.state.jobs
|
|
224
|
+
sweeps = {
|
|
225
|
+
"embeddings": queue_embedding_sweep,
|
|
226
|
+
"text": queue_text_sweep,
|
|
227
|
+
"trash": queue_empty_trash,
|
|
228
|
+
"integrity": queue_integrity_check,
|
|
229
|
+
"integrity-deep": lambda app: queue_integrity_check(app, deep=True),
|
|
230
|
+
"integrity-clean": lambda app: queue_integrity_check(app, remove_unused=True),
|
|
231
|
+
"copy": queue_readable_copy,
|
|
232
|
+
"copy-remove": lambda app: queue_readable_copy(app, remove=True),
|
|
233
|
+
}
|
|
234
|
+
if job == "text-all":
|
|
235
|
+
count = reread_all(db)
|
|
236
|
+
queue_text_sweep(request.app.state.jobs)
|
|
237
|
+
return {"queued": job, "files": count}
|
|
238
|
+
if job not in sweeps:
|
|
239
|
+
raise HTTPException(404, f"unknown job {job!r}")
|
|
240
|
+
sweeps[job](jobs)
|
|
241
|
+
return {"queued": job}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@router.get("/integrity")
|
|
245
|
+
def integrity_report(db: DB, admin: Admin) -> integrity.Report | None:
|
|
246
|
+
"""The last integrity check's report (None before the first)."""
|
|
247
|
+
return integrity.last_report(db)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class Authentication(BaseModel):
|
|
251
|
+
redirect_uri: str
|
|
252
|
+
"""To register at the identity provider."""
|
|
253
|
+
secret_set: bool
|
|
254
|
+
secret_from_environment: bool
|
|
255
|
+
"""Set with ``PAPERUS_OIDC_CLIENT_SECRET`` (then not changeable here)."""
|
|
256
|
+
linked: int
|
|
257
|
+
"""Accounts that have logged in with the OIDC login."""
|
|
258
|
+
sessions: int
|
|
259
|
+
"""Devices logged in now."""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@router.get("/authentication")
|
|
263
|
+
def authentication(request: Request, db: DB, admin: Admin) -> Authentication:
|
|
264
|
+
"""What the Authentication settings show besides the settings."""
|
|
265
|
+
settings = request.app.state.settings
|
|
266
|
+
config = system.load_config(db, settings)
|
|
267
|
+
now = datetime.now(UTC)
|
|
268
|
+
return Authentication(
|
|
269
|
+
redirect_uri=public_base(request, config) + OIDC_CALLBACK,
|
|
270
|
+
secret_set=system.oidc_secret(db, settings) is not None,
|
|
271
|
+
secret_from_environment=bool(settings.oidc_client_secret),
|
|
272
|
+
linked=db.scalar(select(func.count()).where(User.oidc_subject.is_not(None)))
|
|
273
|
+
or 0,
|
|
274
|
+
sessions=db.scalar(select(func.count()).where(LoginSession.expires_at > now))
|
|
275
|
+
or 0,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Secret(BaseModel):
|
|
280
|
+
secret: str | None = Field(None, max_length=1000)
|
|
281
|
+
"""None or empty: no secret (a public client)."""
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@router.put("/authentication/secret", status_code=204)
|
|
285
|
+
def set_oidc_secret(body: Secret, db: DB, admin: Admin) -> None:
|
|
286
|
+
"""The OIDC login's client secret; never sent back."""
|
|
287
|
+
system.save_oidc_secret(db, body.secret or None)
|
|
288
|
+
db.commit()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class ProviderCheck(BaseModel):
|
|
292
|
+
issuer: str = Field(max_length=500)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class ProviderFound(BaseModel):
|
|
296
|
+
ok: bool
|
|
297
|
+
error: str | None = None
|
|
298
|
+
authorization_endpoint: str | None = None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@router.post("/authentication/check")
|
|
302
|
+
def check_provider(
|
|
303
|
+
body: ProviderCheck, request: Request, admin: Admin
|
|
304
|
+
) -> ProviderFound:
|
|
305
|
+
"""Whether the identity provider answers at this address."""
|
|
306
|
+
try:
|
|
307
|
+
provider = oidc.discover(request.app.state.http, body.issuer, fresh=True)
|
|
308
|
+
except oidc.LoginFailed as error:
|
|
309
|
+
return ProviderFound(ok=False, error=str(error))
|
|
310
|
+
return ProviderFound(
|
|
311
|
+
ok=True, authorization_endpoint=provider.authorization_endpoint
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@router.delete("/sessions")
|
|
316
|
+
def end_all_sessions(request: Request, db: DB, admin: Admin) -> dict[str, int]:
|
|
317
|
+
"""Log everyone out on every device, except this one."""
|
|
318
|
+
ended = auth.end_all_sessions(db, request_token(request) or "")
|
|
319
|
+
db.commit()
|
|
320
|
+
return {"ended": ended}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class ReadableCopy(BaseModel):
|
|
324
|
+
path: str
|
|
325
|
+
"""The folder on the server (``PAPERUS_COPY_PATH``)."""
|
|
326
|
+
free_bytes: int | None
|
|
327
|
+
report: readable.CopyReport | None
|
|
328
|
+
"""The last update."""
|
|
329
|
+
variables: dict[str, str]
|
|
330
|
+
links_possible: bool
|
|
331
|
+
"""Hard links work only with files stored on a disk."""
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@router.get("/readable-copy")
|
|
335
|
+
def readable_copy(request: Request, db: DB, admin: Admin) -> ReadableCopy:
|
|
336
|
+
settings = request.app.state.settings
|
|
337
|
+
root = settings.copy_path.resolve()
|
|
338
|
+
return ReadableCopy(
|
|
339
|
+
path=str(root),
|
|
340
|
+
free_bytes=readable.free_space(root),
|
|
341
|
+
report=readable.last_report(db),
|
|
342
|
+
variables=readable.VARIABLES,
|
|
343
|
+
links_possible=settings.storage_backend == "filesystem",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class TemplateIn(BaseModel):
|
|
348
|
+
template: str = Field(max_length=300)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class TemplatePreview(BaseModel):
|
|
352
|
+
paths: list[str]
|
|
353
|
+
"""Where some of the library's files would go."""
|
|
354
|
+
error: str | None = None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@router.post("/readable-copy/preview")
|
|
358
|
+
def preview_template(body: TemplateIn, db: DB, admin: Admin) -> TemplatePreview:
|
|
359
|
+
try:
|
|
360
|
+
template = readable.check_template(body.template)
|
|
361
|
+
except readable.TemplateError as error:
|
|
362
|
+
return TemplatePreview(paths=[], error=str(error))
|
|
363
|
+
found = readable.wanted(db, template, limit=40)
|
|
364
|
+
return TemplatePreview(paths=[w.path for w in found[:8]])
|