pandion-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pandion_api/__init__.py +15 -0
- pandion_api/app.py +67 -0
- pandion_api/checks.py +92 -0
- pandion_api/credentials.py +58 -0
- pandion_api/deps.py +26 -0
- pandion_api/documents.py +206 -0
- pandion_api/errors.py +20 -0
- pandion_api/jobs.py +141 -0
- pandion_api/keycheck.py +68 -0
- pandion_api/limits.py +107 -0
- pandion_api/metrics.py +91 -0
- pandion_api/observability.py +200 -0
- pandion_api/pages.py +165 -0
- pandion_api/pipelines.py +108 -0
- pandion_api/policy.py +234 -0
- pandion_api/reports.py +116 -0
- pandion_api/routes/__init__.py +0 -0
- pandion_api/routes/ask.py +97 -0
- pandion_api/routes/chat.py +199 -0
- pandion_api/routes/conversations.py +72 -0
- pandion_api/routes/documents.py +221 -0
- pandion_api/routes/evaluations.py +330 -0
- pandion_api/routes/feedback.py +58 -0
- pandion_api/routes/health.py +15 -0
- pandion_api/routes/projects.py +213 -0
- pandion_api/routes/questions.py +486 -0
- pandion_api/routes/results.py +126 -0
- pandion_api/routes/runs.py +114 -0
- pandion_api/routes/serve_ask.py +71 -0
- pandion_api/routes/serve_health.py +46 -0
- pandion_api/routes/settings.py +47 -0
- pandion_api/routes/sort.py +38 -0
- pandion_api/routes/strategies.py +60 -0
- pandion_api/runs.py +323 -0
- pandion_api/serve.py +236 -0
- pandion_api/settings.py +27 -0
- pandion_api/version.py +3 -0
- pandion_api-0.1.0.dist-info/METADATA +206 -0
- pandion_api-0.1.0.dist-info/RECORD +41 -0
- pandion_api-0.1.0.dist-info/WHEEL +4 -0
- pandion_api-0.1.0.dist-info/licenses/LICENSE +21 -0
pandion_api/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""pandion-api: Pandion's public API over HTTP, with one Policy for a site's rules.
|
|
2
|
+
|
|
3
|
+
`create_app(ApiSettings(data_dir=...), policy)` gives a FastAPI app. Every
|
|
4
|
+
endpoint that takes a pipeline takes the pipeline file's shape as JSON.
|
|
5
|
+
`create_app(settings, policy, mode="serve", pipeline="pipeline.yaml")` instead
|
|
6
|
+
serves one fixed pipeline to a site's users. See docs/pandion-api/index.md.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pandion_api.app import create_app
|
|
10
|
+
from pandion_api.policy import Policy
|
|
11
|
+
from pandion_api.serve import ServeSettings
|
|
12
|
+
from pandion_api.settings import ApiSettings
|
|
13
|
+
from pandion_api.version import VERSION as __version__
|
|
14
|
+
|
|
15
|
+
__all__ = ["ApiSettings", "Policy", "ServeSettings", "create_app", "__version__"]
|
pandion_api/app.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""create_app: a FastAPI app over Pandion's public API, with a site's Policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
|
|
11
|
+
from pandion_api.documents import DocumentStore
|
|
12
|
+
from pandion_api.policy import Policy
|
|
13
|
+
from pandion_api.routes import ask, documents, evaluations, health, projects, questions, results, sort, strategies
|
|
14
|
+
from pandion_api.routes import runs as run_routes
|
|
15
|
+
from pandion_api.routes import settings as settings_routes
|
|
16
|
+
from pandion_api.runs import RunManager
|
|
17
|
+
from pandion_api.settings import ApiSettings
|
|
18
|
+
|
|
19
|
+
#: Every router, in the order docs/pandion-api/index.md lists them.
|
|
20
|
+
ROUTERS = (
|
|
21
|
+
health.router, strategies.router, documents.router, questions.router, run_routes.router, sort.router, ask.router,
|
|
22
|
+
evaluations.router, results.router, settings_routes.router, projects.router,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_app(
|
|
27
|
+
settings: ApiSettings,
|
|
28
|
+
policy: Policy | None = None,
|
|
29
|
+
*,
|
|
30
|
+
mode: str = "lab",
|
|
31
|
+
pipeline: str | Path | None = None,
|
|
32
|
+
conversations: Any = None,
|
|
33
|
+
serve: Any = None,
|
|
34
|
+
) -> FastAPI:
|
|
35
|
+
"""The app. `policy` holds the site's rules; the default allows everything.
|
|
36
|
+
|
|
37
|
+
`mode="lab"` (the default) takes a pipeline with each request. `mode="serve"`
|
|
38
|
+
loads the one pipeline file at `pipeline` and answers POST /chat; it keeps
|
|
39
|
+
conversations in `conversations` (a Pandion conversation store, else SQL in
|
|
40
|
+
the data folder) and takes its chat settings from `serve` (`ServeSettings`).
|
|
41
|
+
See docs/pandion-api/serve-mode.md, "Serve mode"."""
|
|
42
|
+
if mode == "serve":
|
|
43
|
+
from pandion_api.serve import create_serve_app
|
|
44
|
+
|
|
45
|
+
return create_serve_app(settings, policy, pipeline, conversations, serve)
|
|
46
|
+
if mode != "lab":
|
|
47
|
+
raise ValueError(f"mode must be 'lab' or 'serve', not '{mode}'.")
|
|
48
|
+
policy = policy or Policy()
|
|
49
|
+
manager = RunManager(limit=policy.max_active_runs)
|
|
50
|
+
|
|
51
|
+
@asynccontextmanager
|
|
52
|
+
async def lifespan(app: FastAPI):
|
|
53
|
+
await policy.on_startup(app)
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
await manager.shutdown()
|
|
58
|
+
await policy.on_shutdown(app)
|
|
59
|
+
|
|
60
|
+
app = FastAPI(title=settings.title, lifespan=lifespan)
|
|
61
|
+
app.state.settings = settings
|
|
62
|
+
app.state.policy = policy
|
|
63
|
+
app.state.runs = manager
|
|
64
|
+
app.state.documents = DocumentStore(settings.sources)
|
|
65
|
+
for router in ROUTERS:
|
|
66
|
+
app.include_router(router, prefix=settings.prefix, dependencies=policy.dependencies())
|
|
67
|
+
return app
|
pandion_api/checks.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Read a pipeline dict step by step, for a site's rules (see Policy.check_run).
|
|
2
|
+
|
|
3
|
+
Every function here reads the pipeline file's shape, as `Pipeline.to_dict`
|
|
4
|
+
gives it, and the strategies' settings as `pandion.catalog()` describes them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pandion
|
|
12
|
+
|
|
13
|
+
#: (section, key, stage) for each step that is one block.
|
|
14
|
+
SINGLE = (
|
|
15
|
+
("index", "source", "source"),
|
|
16
|
+
("index", "parse", "parse"),
|
|
17
|
+
("index", "chunk", "chunk"),
|
|
18
|
+
("index", "enrich", "enrich"),
|
|
19
|
+
("index", "store", "index"),
|
|
20
|
+
("retrieve", "rewrite", "query"),
|
|
21
|
+
("retrieve", "search", "retrieve"),
|
|
22
|
+
)
|
|
23
|
+
#: (section, key, stage) for each step that is a list of blocks.
|
|
24
|
+
LISTS = (("index", "clean", "clean"), ("retrieve", "rerank", "rerank"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _block(block: Any) -> dict[str, Any] | None:
|
|
28
|
+
return block if isinstance(block, dict) and isinstance(block.get("use"), str) else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _section(config: dict[str, Any], name: str) -> dict[str, Any]:
|
|
32
|
+
section = config.get(name)
|
|
33
|
+
return section if isinstance(section, dict) else {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def steps(config: dict[str, Any]) -> list[tuple[str, str, str, dict[str, Any]]]:
|
|
37
|
+
"""(where, stage, use, settings) for every step in `config`."""
|
|
38
|
+
found: list[tuple[str, str, str, dict[str, Any]]] = []
|
|
39
|
+
|
|
40
|
+
def add(where: str, stage: str, block: dict[str, Any]) -> None:
|
|
41
|
+
found.append((where, stage, block["use"], {k: v for k, v in block.items() if k != "use"}))
|
|
42
|
+
|
|
43
|
+
# index.embed is not visited: to_dict folds the embedder's settings into it,
|
|
44
|
+
# and no embedder has a read-only setting or a custom endpoint today.
|
|
45
|
+
for section, key, stage in SINGLE:
|
|
46
|
+
block = _block(_section(config, section).get(key))
|
|
47
|
+
if block:
|
|
48
|
+
add(f"{section}.{key}", stage, block)
|
|
49
|
+
for section, key, stage in LISTS:
|
|
50
|
+
blocks = _section(config, section).get(key)
|
|
51
|
+
for n, block in enumerate(blocks if isinstance(blocks, list) else [], start=1):
|
|
52
|
+
if _block(block):
|
|
53
|
+
add(f"{section}.{key}.{n}", stage, block)
|
|
54
|
+
answer = _block(config.get("answer"))
|
|
55
|
+
if answer:
|
|
56
|
+
add("answer", "use_case", answer)
|
|
57
|
+
return found
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _properties(catalog: dict[str, Any], stage: str, use: str) -> dict[str, Any]:
|
|
61
|
+
return catalog.get(stage, {}).get(use, {}).get("config_schema", {}).get("properties", {})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def chat_providers(catalog: dict[str, Any] | None = None) -> dict[str, str]:
|
|
65
|
+
"""{chat model id: its provider}, as the chat step's `model` setting lists them."""
|
|
66
|
+
catalog = catalog if catalog is not None else pandion.catalog()
|
|
67
|
+
return dict((_properties(catalog, "use_case", "chat").get("model") or {}).get("x-providers") or {})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def custom_endpoint_steps(config: dict[str, Any], catalog: dict[str, Any] | None = None) -> list[str]:
|
|
71
|
+
"""The steps set to call a model at an address the request chose (an
|
|
72
|
+
OpenAI-compatible endpoint): a server would send a request there."""
|
|
73
|
+
catalog = catalog if catalog is not None else pandion.catalog()
|
|
74
|
+
found = []
|
|
75
|
+
for where, stage, use, settings in steps(config):
|
|
76
|
+
field = _properties(catalog, stage, use).get("model") or {}
|
|
77
|
+
providers = field.get("x-providers") or {}
|
|
78
|
+
model = settings.get("model")
|
|
79
|
+
if isinstance(model, str) and providers.get(model) == "openai_compatible":
|
|
80
|
+
found.append(where)
|
|
81
|
+
return found
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def changed_read_only_settings(config: dict[str, Any], catalog: dict[str, Any] | None = None) -> list[str]:
|
|
85
|
+
"""Every setting marked readOnly (a prompt, a model revision) set to anything but its default."""
|
|
86
|
+
catalog = catalog if catalog is not None else pandion.catalog()
|
|
87
|
+
found = []
|
|
88
|
+
for where, stage, use, settings in steps(config):
|
|
89
|
+
for key, prop in _properties(catalog, stage, use).items():
|
|
90
|
+
if prop.get("readOnly") and key in settings and settings[key] != prop.get("default"):
|
|
91
|
+
found.append(f"{where}.{key}")
|
|
92
|
+
return found
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Model keys: which header and variable each provider's key comes in, and
|
|
2
|
+
keeping keys out of everything a run shows.
|
|
3
|
+
|
|
4
|
+
A key is never stored, echoed, hashed or logged. A run gets the keys
|
|
5
|
+
`Policy.credentials(request)` resolves, holds them only in its worker's
|
|
6
|
+
closure, and every event and result it gives passes through `redact`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import traceback
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Callable, Iterable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ProviderKey:
|
|
18
|
+
header: str # the request header a client sends a key in
|
|
19
|
+
env_var: str # the server's environment variable
|
|
20
|
+
extra: str # the key's name in Pandion's `credentials=`
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
PROVIDERS: dict[str, ProviderKey] = {
|
|
24
|
+
"anthropic": ProviderKey("X-Anthropic-Api-Key", "ANTHROPIC_API_KEY", "anthropic_api_key"),
|
|
25
|
+
"openai": ProviderKey("X-OpenAI-Api-Key", "OPENAI_API_KEY", "openai_api_key"),
|
|
26
|
+
"custom": ProviderKey("X-Custom-Api-Key", "OPENAI_COMPATIBLE_API_KEY", "custom_api_key"),
|
|
27
|
+
"openrouter": ProviderKey("X-OpenRouter-Api-Key", "OPENROUTER_API_KEY", "openrouter_api_key"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#: What a scrubbed key is replaced with.
|
|
31
|
+
REDACTED = "[redacted]"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def redact(value: Any, key: str | Iterable[str] | None) -> Any:
|
|
35
|
+
"""Replace every occurrence of `key` (or of each of several keys) in the
|
|
36
|
+
strings inside `value`."""
|
|
37
|
+
keys = [key] if isinstance(key, str) else [k for k in (key or []) if k]
|
|
38
|
+
if not keys:
|
|
39
|
+
return value
|
|
40
|
+
if isinstance(value, str):
|
|
41
|
+
for k in sorted(keys, key=len, reverse=True):
|
|
42
|
+
value = value.replace(k, REDACTED)
|
|
43
|
+
return value
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
return {k: redact(v, keys) for k, v in value.items()}
|
|
46
|
+
if isinstance(value, (list, tuple)):
|
|
47
|
+
return type(value)(redact(v, keys) for v in value)
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def guarded(keys: list[str], fn: Callable[[], Any]) -> Any:
|
|
52
|
+
"""Run `fn`; a crash is raised again with every key scrubbed from its traceback."""
|
|
53
|
+
try:
|
|
54
|
+
return fn()
|
|
55
|
+
except Exception:
|
|
56
|
+
if not keys:
|
|
57
|
+
raise
|
|
58
|
+
raise RuntimeError(redact(traceback.format_exc(), keys)) from None
|
pandion_api/deps.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Plain FastAPI dependencies: what a route gets from the app it runs in."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
|
|
7
|
+
from pandion_api.documents import DocumentStore
|
|
8
|
+
from pandion_api.policy import Policy
|
|
9
|
+
from pandion_api.runs import RunManager
|
|
10
|
+
from pandion_api.settings import ApiSettings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_policy(request: Request) -> Policy:
|
|
14
|
+
return request.app.state.policy
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_settings(request: Request) -> ApiSettings:
|
|
18
|
+
return request.app.state.settings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_runs(request: Request) -> RunManager:
|
|
22
|
+
return request.app.state.runs
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_documents(request: Request) -> DocumentStore:
|
|
26
|
+
return request.app.state.documents
|
pandion_api/documents.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Documents: each uploaded file once, by its fingerprint, with a record beside it.
|
|
2
|
+
|
|
3
|
+
A file is kept as `<sha256><suffix>` in the `sources/` folder under the
|
|
4
|
+
server's data folder, the name Pandion's own upload step looks for. Its record
|
|
5
|
+
(`sources/.meta/<sha>.json`) holds the name it came with, its size and its
|
|
6
|
+
content type, the link it was fetched from for a URL document (`origin_url`),
|
|
7
|
+
and any other key a site's Policy adds, such as who uploaded it; a client is
|
|
8
|
+
shown only the first five. A question set written for it lives in
|
|
9
|
+
`sources/questions/<sha>.json`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import threading
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import pandion
|
|
24
|
+
from pandion.extend import FORMATS, format_of
|
|
25
|
+
|
|
26
|
+
#: A document id is a sha256 in lowercase hex. Checked before an id reaches a
|
|
27
|
+
#: path, so a crafted one such as `../../x` never leaves the folder.
|
|
28
|
+
SHA = re.compile(r"^[0-9a-f]{64}\Z")
|
|
29
|
+
#: The 404 for a document that is not there, or not for this caller. It never
|
|
30
|
+
#: repeats the id the request sent.
|
|
31
|
+
NO_SUCH_DOCUMENT = "There is no document with that fingerprint."
|
|
32
|
+
META_DIR = ".meta"
|
|
33
|
+
QUESTIONS_DIR = "questions"
|
|
34
|
+
#: Held around every change to a record, here, in a Policy and in a site's own
|
|
35
|
+
#: clean-up, so two uploads of the same bytes, or an upload and a clean-up,
|
|
36
|
+
#: never lose what the other wrote.
|
|
37
|
+
SIDECAR_LOCK = threading.Lock()
|
|
38
|
+
#: The part of a request's file name that reaches a path: a plain suffix such
|
|
39
|
+
#: as `.pdf`. Anything else (a `:` that names a Windows stream, a space, a
|
|
40
|
+
#: separator) is dropped, and the file is kept under its sha alone.
|
|
41
|
+
SAFE_SUFFIX = re.compile(r"^\.[A-Za-z0-9_-]{1,16}$")
|
|
42
|
+
|
|
43
|
+
#: The refusal for a name ending in `.part`: a stored file with that suffix
|
|
44
|
+
#: would look like a half-written one, which every reader skips.
|
|
45
|
+
PART_REFUSED = "A file name ending in .part cannot be uploaded; rename it and try again."
|
|
46
|
+
|
|
47
|
+
_PUBLIC = ("sha", "filename", "size", "content_type", "origin_url")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def readable_formats() -> list[str] | None:
|
|
51
|
+
"""Every format some parser here declares it reads, such as ["pdf", "docx"],
|
|
52
|
+
or None when a parser declares none (it is not checked, so any file may do)."""
|
|
53
|
+
found: set[str] = set()
|
|
54
|
+
for parser in pandion.catalog()["parse"].values():
|
|
55
|
+
formats = (parser.get("provides") or {}).get("formats")
|
|
56
|
+
if not formats:
|
|
57
|
+
return None
|
|
58
|
+
found.update(formats)
|
|
59
|
+
return [name for name in FORMATS if name in found]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def unreadable(filename: str) -> str | None:
|
|
63
|
+
"""The 415 sentence for a file no parser here reads, or None. A name
|
|
64
|
+
whose suffix the store would not keep (see `suffix`) is not checked, the
|
|
65
|
+
same as Pandion leaves a file with no extension unchecked."""
|
|
66
|
+
formats = readable_formats()
|
|
67
|
+
if formats is None or not suffix(filename) or format_of(filename) in formats:
|
|
68
|
+
return None
|
|
69
|
+
names = [FORMATS[name][0] for name in formats]
|
|
70
|
+
listed = names[0] if len(names) == 1 else f"{', '.join(names[:-1])} and {names[-1]}"
|
|
71
|
+
return f"This server reads {listed} files, and this file is not one of them."
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def suffix(filename: str) -> str:
|
|
75
|
+
"""The suffix a file is stored with: its name's, when it is a plain one, else none."""
|
|
76
|
+
found = Path(filename).suffix
|
|
77
|
+
return found if SAFE_SUFFIX.match(found) else ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def safe_name(filename: str) -> str:
|
|
81
|
+
"""`filename` as a run names its document. Pandion keeps its own copy of a
|
|
82
|
+
document as `<sha><the name's suffix>`, so the name's suffix must be the
|
|
83
|
+
one the store kept (`suffix`). When the store dropped it (a space, a `:`
|
|
84
|
+
that names a Windows stream, too long), every dot becomes `_`, leaving the
|
|
85
|
+
name no suffix at all. A plain name such as `report.pdf` is unchanged, so
|
|
86
|
+
its result ids are the old routes' ones."""
|
|
87
|
+
return filename if Path(filename).suffix == suffix(filename) else filename.replace(".", "_")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_part(filename: str) -> bool:
|
|
91
|
+
"""Whether the name ends in `.part`, in any case (see PART_REFUSED)."""
|
|
92
|
+
return filename.lower().endswith(".part")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def read_sidecar(path: Path) -> dict[str, Any]:
|
|
96
|
+
"""A record's JSON, or {} when it is missing, unreadable, or not an object."""
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
99
|
+
except (OSError, json.JSONDecodeError):
|
|
100
|
+
return {}
|
|
101
|
+
return data if isinstance(data, dict) else {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class Document:
|
|
106
|
+
sha: str
|
|
107
|
+
filename: str
|
|
108
|
+
size: int
|
|
109
|
+
content_type: str
|
|
110
|
+
#: The link a URL document was fetched from; None for an upload.
|
|
111
|
+
origin_url: str | None = None
|
|
112
|
+
#: Every other key in the record, for the site's Policy. Never shown.
|
|
113
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
114
|
+
|
|
115
|
+
def record(self) -> dict[str, Any]:
|
|
116
|
+
"""What a client is shown: `origin_url` only for a URL document."""
|
|
117
|
+
shown = {"sha": self.sha, "filename": self.filename, "size": self.size, "content_type": self.content_type}
|
|
118
|
+
return {**shown, "origin_url": self.origin_url} if self.origin_url else shown
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def of(cls, body: dict[str, Any]) -> Document | None:
|
|
122
|
+
"""The document a record describes, or None for one without a valid id and name."""
|
|
123
|
+
sha, filename = body.get("sha"), body.get("filename")
|
|
124
|
+
if not isinstance(sha, str) or not SHA.match(sha) or not isinstance(filename, str):
|
|
125
|
+
return None
|
|
126
|
+
size, content_type, origin = body.get("size"), body.get("content_type"), body.get("origin_url")
|
|
127
|
+
return cls(
|
|
128
|
+
sha=sha,
|
|
129
|
+
filename=filename,
|
|
130
|
+
size=size if isinstance(size, int) else 0,
|
|
131
|
+
content_type=content_type if isinstance(content_type, str) else "application/octet-stream",
|
|
132
|
+
origin_url=origin if isinstance(origin, str) else None,
|
|
133
|
+
extra={k: v for k, v in body.items() if k not in _PUBLIC},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class DocumentStore:
|
|
138
|
+
lock = SIDECAR_LOCK
|
|
139
|
+
|
|
140
|
+
def __init__(self, folder: str | Path) -> None:
|
|
141
|
+
self.folder = Path(folder)
|
|
142
|
+
|
|
143
|
+
def sidecar(self, sha: str) -> Path:
|
|
144
|
+
return self.folder / META_DIR / f"{sha}.json"
|
|
145
|
+
|
|
146
|
+
def questions(self, sha: str) -> Path:
|
|
147
|
+
return self.folder / QUESTIONS_DIR / f"{sha}.json"
|
|
148
|
+
|
|
149
|
+
def save(self, data: bytes, filename: str) -> str:
|
|
150
|
+
"""Keep `data` as `<sha><suffix of filename>`, unless a file with this sha
|
|
151
|
+
is here already, under any suffix: the same bytes are never stored
|
|
152
|
+
twice, whatever they are called. Only a plain suffix comes from the
|
|
153
|
+
name (see `suffix`). The bytes go to a side file first and are renamed
|
|
154
|
+
into place, so a reader never sees half a file. Returns the sha."""
|
|
155
|
+
sha = hashlib.sha256(data).hexdigest()
|
|
156
|
+
dest = self.folder / f"{sha}{suffix(filename)}"
|
|
157
|
+
if self.file(sha) is None:
|
|
158
|
+
self.folder.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
part = dest.with_name(dest.name + ".part")
|
|
160
|
+
part.write_bytes(data)
|
|
161
|
+
os.replace(part, dest)
|
|
162
|
+
return sha
|
|
163
|
+
|
|
164
|
+
def write(
|
|
165
|
+
self, sha: str, filename: str, size: int, content_type: str, origin_url: str | None = None
|
|
166
|
+
) -> Document:
|
|
167
|
+
"""Write the record. Keys a site added stay, and so does an `origin_url`
|
|
168
|
+
an earlier fetch of the same bytes wrote. Call with `lock` held."""
|
|
169
|
+
path = self.sidecar(sha)
|
|
170
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
body = {**read_sidecar(path), "sha": sha, "filename": filename, "size": size, "content_type": content_type}
|
|
172
|
+
if origin_url:
|
|
173
|
+
body["origin_url"] = origin_url
|
|
174
|
+
path.write_text(json.dumps(body), encoding="utf-8")
|
|
175
|
+
return Document.of(body)
|
|
176
|
+
|
|
177
|
+
def get(self, sha: str) -> Document | None:
|
|
178
|
+
if not SHA.match(sha):
|
|
179
|
+
return None
|
|
180
|
+
return Document.of(read_sidecar(self.sidecar(sha)))
|
|
181
|
+
|
|
182
|
+
def all(self) -> list[Document]:
|
|
183
|
+
meta = self.folder / META_DIR
|
|
184
|
+
if not meta.is_dir():
|
|
185
|
+
return []
|
|
186
|
+
found = (Document.of(read_sidecar(p)) for p in meta.glob("*.json"))
|
|
187
|
+
return [d for d in found if d is not None]
|
|
188
|
+
|
|
189
|
+
def file(self, sha: str) -> Path | None:
|
|
190
|
+
"""The stored file, or None. A seeded file with no record is found too."""
|
|
191
|
+
if not SHA.match(sha) or not self.folder.is_dir():
|
|
192
|
+
return None
|
|
193
|
+
for p in sorted(self.folder.glob(f"{sha}*")):
|
|
194
|
+
if p.is_file() and not p.name.endswith(".part"):
|
|
195
|
+
return p
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
def remove(self, sha: str) -> None:
|
|
199
|
+
"""Delete the file, its record and its question set. Call with `lock` held."""
|
|
200
|
+
if not SHA.match(sha):
|
|
201
|
+
return
|
|
202
|
+
for p in self.folder.glob(f"{sha}*"):
|
|
203
|
+
if p.is_file():
|
|
204
|
+
p.unlink()
|
|
205
|
+
self.sidecar(sha).unlink(missing_ok=True)
|
|
206
|
+
self.questions(sha).unlink(missing_ok=True)
|
pandion_api/errors.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""What a serve caller sees when something fails: one sentence and a trace id.
|
|
2
|
+
The detail goes to the log under the same id."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("pandion_api")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def generic(trace_id: str) -> str:
|
|
13
|
+
return f"Something went wrong. Quote this trace id when you ask for help: {trace_id}."
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hide(detail: str, trace_id: str | None = None) -> str:
|
|
17
|
+
"""Log `detail` at ERROR with a trace id and return the sentence for the caller."""
|
|
18
|
+
trace_id = trace_id or uuid.uuid4().hex
|
|
19
|
+
logger.error("Serve error (trace %s): %s", trace_id, detail)
|
|
20
|
+
return generic(trace_id)
|
pandion_api/jobs.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Starting a run, and the documents it reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Annotated, Any, Callable
|
|
7
|
+
|
|
8
|
+
from fastapi import HTTPException, Request
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from pandion_api.credentials import PROVIDERS, guarded, redact
|
|
12
|
+
from pandion_api.errors import hide
|
|
13
|
+
from pandion_api.documents import NO_SUCH_DOCUMENT, SHA, safe_name
|
|
14
|
+
from pandion_api.pipelines import problems
|
|
15
|
+
|
|
16
|
+
#: What a run's work does on the worker thread: given the event callback, the
|
|
17
|
+
#: cancel event and the keys (None when there are none), it returns
|
|
18
|
+
#: (ok, cancelled, result) with a plain JSON result.
|
|
19
|
+
Work = Callable[[Callable[[dict[str, Any]], None], threading.Event, dict[str, str] | None], tuple[bool, bool, Any]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
#: One document's labels: at most 50, a name up to 2,000 characters, a value up to 10,000.
|
|
23
|
+
Labels = Annotated[dict[Annotated[str, Field(max_length=2000)], Annotated[str, Field(max_length=10000)]], Field(max_length=50)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SortedIn(BaseModel):
|
|
27
|
+
"""A request body that names documents and may say which category each
|
|
28
|
+
goes to. Both maps are keyed by the document's sha, as `documents` is."""
|
|
29
|
+
|
|
30
|
+
#: Pin a document to a category; this wins over the route.
|
|
31
|
+
categories: dict[str, str] = {}
|
|
32
|
+
#: Labels for a document, read by the route's `metadata` check.
|
|
33
|
+
metadata: dict[str, Labels] = {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def assign_to(p: Any, body: SortedIn, names: list[str], request: Request) -> None:
|
|
37
|
+
"""Give `p` the pins and labels of `body`, and the link each URL document
|
|
38
|
+
was fetched from. `names` are the document ids the pipeline knows, in the
|
|
39
|
+
order of `body.documents`. A pin or label for a document not in
|
|
40
|
+
`documents`, or a pin to an unknown category, is a 422."""
|
|
41
|
+
ids = dict(zip(body.documents, names))
|
|
42
|
+
store = request.app.state.documents
|
|
43
|
+
links = {name: d.origin_url for sha, name in ids.items() if (d := store.get(sha)) and d.origin_url}
|
|
44
|
+
for sha in [*body.categories, *body.metadata]:
|
|
45
|
+
if sha not in ids:
|
|
46
|
+
raise problems(f"categories and metadata name document {sha}, which is not one of the documents sent.")
|
|
47
|
+
try:
|
|
48
|
+
p.index.assign(
|
|
49
|
+
categories={ids[s]: c for s, c in body.categories.items()},
|
|
50
|
+
metadata={ids[s]: m for s, m in body.metadata.items()},
|
|
51
|
+
links=links,
|
|
52
|
+
)
|
|
53
|
+
except ValueError as exc:
|
|
54
|
+
raise problems(str(exc)) from None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def documents_of(shas: list[str], request: Request) -> dict[str, str]:
|
|
58
|
+
"""{document name: stored file} for a run, named as each was uploaded, so
|
|
59
|
+
the same bytes under the same name always give the same result ids. A file
|
|
60
|
+
with no record takes the name `Policy.document_name` gives it, else its
|
|
61
|
+
stored file's name."""
|
|
62
|
+
store = request.app.state.documents
|
|
63
|
+
policy = request.app.state.policy
|
|
64
|
+
out: dict[str, str] = {}
|
|
65
|
+
for sha in shas:
|
|
66
|
+
if not SHA.match(sha):
|
|
67
|
+
raise HTTPException(status_code=404, detail=NO_SUCH_DOCUMENT)
|
|
68
|
+
if not policy.can_read(sha, request):
|
|
69
|
+
raise HTTPException(status_code=403, detail=policy.not_readable)
|
|
70
|
+
path = store.file(sha)
|
|
71
|
+
if path is None:
|
|
72
|
+
raise HTTPException(status_code=404, detail=NO_SUCH_DOCUMENT)
|
|
73
|
+
document = store.get(sha)
|
|
74
|
+
given = policy.document_name(sha, request) if document is None else None
|
|
75
|
+
name = safe_name(document.filename) if document is not None else safe_name(given) if given else path.name
|
|
76
|
+
if name in out:
|
|
77
|
+
raise HTTPException(
|
|
78
|
+
status_code=400,
|
|
79
|
+
detail=f"Two documents are both named {name}. Upload one of them under another name.",
|
|
80
|
+
)
|
|
81
|
+
out[name] = str(path)
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def scrub_errors(value: Any, scrub: Callable[[str], str]) -> Any:
|
|
86
|
+
"""`value` with `scrub` applied to every string under an `error` key, at any depth."""
|
|
87
|
+
if isinstance(value, dict):
|
|
88
|
+
return {
|
|
89
|
+
k: scrub(v) if k == "error" and isinstance(v, str) else scrub_errors(v, scrub)
|
|
90
|
+
for k, v in value.items()
|
|
91
|
+
}
|
|
92
|
+
if isinstance(value, (list, tuple)):
|
|
93
|
+
return type(value)(scrub_errors(v, scrub) for v in value)
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def too_many_runs(most: int) -> str:
|
|
98
|
+
"""The 429 for an owner who already has `most` runs going."""
|
|
99
|
+
runs = "run" if most == 1 else "runs"
|
|
100
|
+
return f"You already have {most} {runs} going. Wait for one to finish, or stop one."
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def start(request: Request, kind: str, work: Work) -> dict[str, str]:
|
|
104
|
+
"""Start `work` on the app's run manager with this request's keys, as this
|
|
105
|
+
request's owner (`Policy.run_owner`). Every event and the result are
|
|
106
|
+
scrubbed of the keys, and their error text goes through `Policy.scrub`; a
|
|
107
|
+
`run_cancelled` event marks the run cancelled. An owner who already has
|
|
108
|
+
`Policy.max_runs_per_owner` runs working or waiting is refused with a 429."""
|
|
109
|
+
policy = request.app.state.policy
|
|
110
|
+
owner = policy.run_owner(request)
|
|
111
|
+
most = policy.max_runs_per_owner(request)
|
|
112
|
+
if owner is not None and most is not None:
|
|
113
|
+
going = sum(1 for s in request.app.state.runs.active() if s.owner == owner)
|
|
114
|
+
if going >= most:
|
|
115
|
+
raise HTTPException(status_code=429, detail=too_many_runs(most))
|
|
116
|
+
serving = hasattr(request.app.state, "serve") # serve mode: env keys only, generic errors
|
|
117
|
+
if serving:
|
|
118
|
+
credentials = {
|
|
119
|
+
spec.extra: key for p, spec in PROVIDERS.items() if (key := policy.server_key(p)[0])
|
|
120
|
+
}
|
|
121
|
+
else:
|
|
122
|
+
credentials = policy.credentials(request)
|
|
123
|
+
keys = list(credentials.values())
|
|
124
|
+
|
|
125
|
+
def clean(value: Any) -> Any:
|
|
126
|
+
return scrub_errors(redact(value, keys), policy.scrub)
|
|
127
|
+
|
|
128
|
+
def job(emit, cancel):
|
|
129
|
+
seen: list[bool] = []
|
|
130
|
+
|
|
131
|
+
def on_event(e: dict[str, Any]) -> None:
|
|
132
|
+
if e.get("event") == "run_cancelled":
|
|
133
|
+
seen.append(True)
|
|
134
|
+
emit(clean(e))
|
|
135
|
+
|
|
136
|
+
ok, cancelled, result = guarded(keys, lambda: work(on_event, cancel, credentials or None))
|
|
137
|
+
return ok, cancelled or bool(seen), clean(result)
|
|
138
|
+
|
|
139
|
+
scrub = (lambda text: hide(redact(text, keys), getattr(request.state, "trace_id", None))) if serving else (lambda text: policy.scrub(redact(text, keys)))
|
|
140
|
+
state = request.app.state.runs.start(kind, job, owner=owner, scrub=scrub)
|
|
141
|
+
return {"run_id": state.run_id}
|