paperllm 0.1.0__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.
- paperllm-0.1.0/LICENSE +21 -0
- paperllm-0.1.0/PKG-INFO +214 -0
- paperllm-0.1.0/README.md +190 -0
- paperllm-0.1.0/paperllm/__init__.py +21 -0
- paperllm-0.1.0/paperllm/caller.py +102 -0
- paperllm-0.1.0/paperllm/client.py +946 -0
- paperllm-0.1.0/paperllm/config.py +64 -0
- paperllm-0.1.0/paperllm/db.py +33 -0
- paperllm-0.1.0/paperllm/migrations/versions/0001_llm_call.py +124 -0
- paperllm-0.1.0/paperllm/models.py +143 -0
- paperllm-0.1.0/paperllm/record.py +141 -0
- paperllm-0.1.0/paperllm.egg-info/PKG-INFO +214 -0
- paperllm-0.1.0/paperllm.egg-info/SOURCES.txt +16 -0
- paperllm-0.1.0/paperllm.egg-info/dependency_links.txt +1 -0
- paperllm-0.1.0/paperllm.egg-info/requires.txt +9 -0
- paperllm-0.1.0/paperllm.egg-info/top_level.txt +1 -0
- paperllm-0.1.0/pyproject.toml +84 -0
- paperllm-0.1.0/setup.cfg +4 -0
paperllm-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bilal Zonjy, MD
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
paperllm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paperllm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Calling a local model about a paper, and keeping what the call cost: tool-submitted answers, truncation discipline, and a row per call.
|
|
5
|
+
Author: Bilal Zonjy, MD
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Source, https://github.com/BilalZonjy/paperllm
|
|
8
|
+
Keywords: ollama,llm,pubmed,extraction,observability
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: ollama<1,>=0.5.1
|
|
17
|
+
Requires-Dist: pydantic<3,>=2
|
|
18
|
+
Requires-Dist: sqlalchemy<3,>=2
|
|
19
|
+
Provides-Extra: migrations
|
|
20
|
+
Requires-Dist: alembic<2,>=1.13; extra == "migrations"
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# paperllm
|
|
26
|
+
|
|
27
|
+
Calling a local model about a paper, and keeping what the call cost.
|
|
28
|
+
|
|
29
|
+
- **Call shapes that survive a small model** — an agentic call whose answer arrives through a tool
|
|
30
|
+
rather than a `format=` grammar, a truncation check at every point a reply is returned, and a
|
|
31
|
+
thinking-versus-content split that finds the answer where a reasoning model actually put it.
|
|
32
|
+
- **A row per call** — tokens in and out, the window and budget it ran under, why generation
|
|
33
|
+
stopped, how long it took, and whether it worked.
|
|
34
|
+
|
|
35
|
+
Extracted from a SUDEP literature-review pipeline, where it runs a three-call extraction cascade over
|
|
36
|
+
~2,500 papers. **v0.1.0, and honestly 0.x**: one consumer so far, which is why the public surface is
|
|
37
|
+
deliberately small (see *What it does not do*). Expect the API to move before 1.0; pin a version.
|
|
38
|
+
|
|
39
|
+
## Two boundaries worth stating before you read further
|
|
40
|
+
|
|
41
|
+
**Ollama-shaped.** `done_reason`, the thinking channel, the retry semantics and the shape of a tool
|
|
42
|
+
round are Ollama's. The name says `llm`; the code says Ollama. A second backend would need more than
|
|
43
|
+
a new client object, and pretending otherwise here would be the kind of premature generality this
|
|
44
|
+
library was extracted specifically to avoid.
|
|
45
|
+
|
|
46
|
+
**Paper-scoped.** `llm_call.pmid` is a PubMed identifier, not a generic subject key. That is
|
|
47
|
+
deliberate — a sibling to `pubmedcorpus`, not a general-purpose LLM client.
|
|
48
|
+
|
|
49
|
+
## Installing
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install paperllm
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Take the extra if you intend to run the migration that creates the call log's tables:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install "paperllm[migrations]"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**No database driver is installed for you.** Nothing here is Postgres-specific, so which driver to
|
|
62
|
+
use is your decision — but read the `now()` note under *The recorder* before choosing a primary-key
|
|
63
|
+
strategy on Postgres.
|
|
64
|
+
|
|
65
|
+
## Using it
|
|
66
|
+
|
|
67
|
+
Everything the library needs to know about your deployment is one object:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from paperllm.caller import Caller
|
|
71
|
+
from paperllm.config import CallConfig
|
|
72
|
+
|
|
73
|
+
caller = Caller(CallConfig(
|
|
74
|
+
host="http://localhost:11434",
|
|
75
|
+
# (model, purpose) -> context window. A judgement about *your* ensemble: this model's VRAM
|
|
76
|
+
# ceiling, this kind of call's needs. The library only needs to be able to ask.
|
|
77
|
+
num_ctx_for=lambda model, purpose: 32768,
|
|
78
|
+
# Whether this model may emit reasoning tokens.
|
|
79
|
+
think_for=lambda model: True,
|
|
80
|
+
# Optional. Without it, nothing is written down and no database is needed.
|
|
81
|
+
recorder=None,
|
|
82
|
+
))
|
|
83
|
+
|
|
84
|
+
answer = caller.extract(model, messages, MySchema, num_predict=16384)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`purpose` is one of `paperllm.config.EXTRACTION | PROSE | AGENTIC` — the three kinds of call this
|
|
88
|
+
library makes. It is **opaque to the library**: handed to `num_ctx_for`, stored on the call record,
|
|
89
|
+
never branched on. You decide what each is worth in context tokens.
|
|
90
|
+
|
|
91
|
+
### The calls
|
|
92
|
+
|
|
93
|
+
| | |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `chat_raw` | one constrained-decoding call → raw content |
|
|
96
|
+
| `classify` / `extract` | the same, parsed and validated into a Pydantic model |
|
|
97
|
+
| `chat_prose` | unconstrained generation |
|
|
98
|
+
| `chat_agentic` | the model may call tools, you run them, it continues |
|
|
99
|
+
| `reason` | one agentic call whose answer arrives through a tool; returns `(scratchpad, was_truncated)` |
|
|
100
|
+
|
|
101
|
+
Two exceptions, and they are **siblings, not a hierarchy**: `ExtractionError` means the model
|
|
102
|
+
produced nothing usable; `Truncated` means generation stopped on `num_predict`. A caller that
|
|
103
|
+
swallows the first must decide about the second separately, because a budget bug filed as a
|
|
104
|
+
judgement about a paper is how a number ends up in the record as evidence.
|
|
105
|
+
|
|
106
|
+
`Truncated` carries both `content` and `thinking`. On a reasoning model the second is usually the
|
|
107
|
+
only one with anything in it — measured over one run, 31 of 48 tool-calling turns had empty
|
|
108
|
+
`content` — so a cut-off call is a shorter scratchpad rather than a lost paper.
|
|
109
|
+
|
|
110
|
+
### Budgets are yours
|
|
111
|
+
|
|
112
|
+
`num_predict` defaults to `MIN_NUM_PREDICT` (2048), which is a **floor, not a working budget**.
|
|
113
|
+
Thinking tokens count against the budget, so a number sized for the answer alone leaves a reasoning
|
|
114
|
+
model with nothing left to answer with — 2048 is what "you forgot to choose" looks like, small
|
|
115
|
+
enough to catch.
|
|
116
|
+
|
|
117
|
+
The right number depends on how your prompt was built and how much room it reserved for the reply.
|
|
118
|
+
Only you know both. The pipeline this came from keeps its measured budgets in
|
|
119
|
+
`sudep/analysis/budgets.py`; every one of them cost a failed run to learn.
|
|
120
|
+
|
|
121
|
+
## The call log
|
|
122
|
+
|
|
123
|
+
Two tables. `llm_call` is one row per call, append-only; `call_stage` is the vocabulary naming which
|
|
124
|
+
call in a cascade a row is.
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
pmid model stage created_at <- the key
|
|
128
|
+
purpose label <- what kind of call, and which step
|
|
129
|
+
success error_type error_message
|
|
130
|
+
num_ctx num_predict <- what it ran under
|
|
131
|
+
prompt_tokens eval_tokens <- what it used
|
|
132
|
+
done_reason duration_ms
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**`stage` names which call this was, not how the paper ended.** A paper that fails call 1 and
|
|
136
|
+
succeeds on call 2 has no single outcome, but each of its calls has one. The questions worth asking
|
|
137
|
+
are `stage × success`, and a pmid with no successful row at any stage is the failed paper. Seed
|
|
138
|
+
`call_stage` with your own cascade; the shipped rows describe a three-call one.
|
|
139
|
+
|
|
140
|
+
`success` means the call produced what its stage was asked for — a well-formed reply carrying
|
|
141
|
+
unusable JSON is a failure, because the cascade treats it as one and the log has to agree with the
|
|
142
|
+
cascade rather than with HTTP.
|
|
143
|
+
|
|
144
|
+
### The recorder, and the one trap
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
def recorder(call):
|
|
148
|
+
with your_own_session_scope() as session: # NOT the caller's session
|
|
149
|
+
paperllm.record.record(session, call)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**It must not join the transaction of the work it is describing.** A failed extraction rolls back,
|
|
153
|
+
and a record of the failure written in that transaction rolls back with it — leaving a log that
|
|
154
|
+
holds exactly the calls that went well. Open a short-lived session per record and commit it.
|
|
155
|
+
|
|
156
|
+
That also makes the primary key safe: Postgres's `now()` is the *transaction* timestamp, so one row
|
|
157
|
+
per transaction is what keeps `created_at` distinct. If you batch, use `clock_timestamp()`.
|
|
158
|
+
|
|
159
|
+
A recorder that raises does not break the call — `record.emit` swallows it and logs at WARNING. A run
|
|
160
|
+
that finishes with an incomplete log beats a run that died protecting its bookkeeping.
|
|
161
|
+
|
|
162
|
+
## It grows, and nothing prunes it
|
|
163
|
+
|
|
164
|
+
One row per call, and calls are never updated or replaced — a full pass over a few thousand papers
|
|
165
|
+
with a handful of models is tens of thousands of rows. **There is no retention policy, no TTL and no
|
|
166
|
+
cleanup job, deliberately.** A log that deletes on its own is a log you cannot trust to answer a
|
|
167
|
+
question about last month, and the only honest default for "how long is a call worth keeping" is
|
|
168
|
+
however long its answer stays interesting.
|
|
169
|
+
|
|
170
|
+
So deletion is an operator's decision, taken deliberately:
|
|
171
|
+
|
|
172
|
+
```sql
|
|
173
|
+
-- What you are about to remove, before removing it.
|
|
174
|
+
SELECT date_trunc('month', created_at) AS month, count(*)
|
|
175
|
+
FROM llm_call GROUP BY 1 ORDER BY 1;
|
|
176
|
+
|
|
177
|
+
DELETE FROM llm_call WHERE created_at < '2026-01-01';
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`ix_llm_call_created_at` exists for exactly that scan.
|
|
181
|
+
|
|
182
|
+
**Age is the only axis available, and that is worth knowing before you rely on it.** The table
|
|
183
|
+
carries no schema or prompt version, so "delete everything from before the prompt changed" has to be
|
|
184
|
+
expressed as a date — look up when the version bumped and cut there. Storing the versions here was
|
|
185
|
+
rejected on the grounds that a call log should not have to be told what question the caller was
|
|
186
|
+
asking; if that turns out to be wrong, it is an added column and a migration, not a redesign.
|
|
187
|
+
|
|
188
|
+
Nothing downstream reads this table, so a delete cannot break a pipeline — only an analysis you had
|
|
189
|
+
not run yet.
|
|
190
|
+
|
|
191
|
+
## Migrations
|
|
192
|
+
|
|
193
|
+
The library owns its schema and ships an Alembic branch labelled `paperllm`, creating both tables and
|
|
194
|
+
seeding the vocabulary in one revision — an unseeded lookup table would make the foreign key reject
|
|
195
|
+
every insert. Add it to your `version_locations`, then `alembic upgrade heads` (**`heads`**, not
|
|
196
|
+
`head`: with more than one branch, `head` is ambiguous and errors).
|
|
197
|
+
|
|
198
|
+
`paperllm.db.Base` has its own `MetaData`, deliberately: nothing here has a foreign key crossing into
|
|
199
|
+
your schema — `llm_call.pmid` names a pmid and carries none, so a deleted paper does not take the
|
|
200
|
+
record of its failures with it.
|
|
201
|
+
|
|
202
|
+
## What it does not do
|
|
203
|
+
|
|
204
|
+
- **No CLI.** Deciding which environment variables must be present before touching a database is the
|
|
205
|
+
application's call.
|
|
206
|
+
- **No sessions.** `record` takes one you opened.
|
|
207
|
+
- **No environment reading.** Everything arrives on `CallConfig`.
|
|
208
|
+
- **No re-exports** from `__init__.py`. Import submodules, so the public surface stays small enough
|
|
209
|
+
to reshape once a second consumer shows where the joints actually belong.
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
Offline by construction: no Ollama, no database. `_ollama` is the single seam every call goes
|
|
213
|
+
through, and `CallConfig` is built in the test rather than patched onto a global — which is the point
|
|
214
|
+
of the window and thinking policies being callables.
|
paperllm-0.1.0/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# paperllm
|
|
2
|
+
|
|
3
|
+
Calling a local model about a paper, and keeping what the call cost.
|
|
4
|
+
|
|
5
|
+
- **Call shapes that survive a small model** — an agentic call whose answer arrives through a tool
|
|
6
|
+
rather than a `format=` grammar, a truncation check at every point a reply is returned, and a
|
|
7
|
+
thinking-versus-content split that finds the answer where a reasoning model actually put it.
|
|
8
|
+
- **A row per call** — tokens in and out, the window and budget it ran under, why generation
|
|
9
|
+
stopped, how long it took, and whether it worked.
|
|
10
|
+
|
|
11
|
+
Extracted from a SUDEP literature-review pipeline, where it runs a three-call extraction cascade over
|
|
12
|
+
~2,500 papers. **v0.1.0, and honestly 0.x**: one consumer so far, which is why the public surface is
|
|
13
|
+
deliberately small (see *What it does not do*). Expect the API to move before 1.0; pin a version.
|
|
14
|
+
|
|
15
|
+
## Two boundaries worth stating before you read further
|
|
16
|
+
|
|
17
|
+
**Ollama-shaped.** `done_reason`, the thinking channel, the retry semantics and the shape of a tool
|
|
18
|
+
round are Ollama's. The name says `llm`; the code says Ollama. A second backend would need more than
|
|
19
|
+
a new client object, and pretending otherwise here would be the kind of premature generality this
|
|
20
|
+
library was extracted specifically to avoid.
|
|
21
|
+
|
|
22
|
+
**Paper-scoped.** `llm_call.pmid` is a PubMed identifier, not a generic subject key. That is
|
|
23
|
+
deliberate — a sibling to `pubmedcorpus`, not a general-purpose LLM client.
|
|
24
|
+
|
|
25
|
+
## Installing
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install paperllm
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Take the extra if you intend to run the migration that creates the call log's tables:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install "paperllm[migrations]"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**No database driver is installed for you.** Nothing here is Postgres-specific, so which driver to
|
|
38
|
+
use is your decision — but read the `now()` note under *The recorder* before choosing a primary-key
|
|
39
|
+
strategy on Postgres.
|
|
40
|
+
|
|
41
|
+
## Using it
|
|
42
|
+
|
|
43
|
+
Everything the library needs to know about your deployment is one object:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from paperllm.caller import Caller
|
|
47
|
+
from paperllm.config import CallConfig
|
|
48
|
+
|
|
49
|
+
caller = Caller(CallConfig(
|
|
50
|
+
host="http://localhost:11434",
|
|
51
|
+
# (model, purpose) -> context window. A judgement about *your* ensemble: this model's VRAM
|
|
52
|
+
# ceiling, this kind of call's needs. The library only needs to be able to ask.
|
|
53
|
+
num_ctx_for=lambda model, purpose: 32768,
|
|
54
|
+
# Whether this model may emit reasoning tokens.
|
|
55
|
+
think_for=lambda model: True,
|
|
56
|
+
# Optional. Without it, nothing is written down and no database is needed.
|
|
57
|
+
recorder=None,
|
|
58
|
+
))
|
|
59
|
+
|
|
60
|
+
answer = caller.extract(model, messages, MySchema, num_predict=16384)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`purpose` is one of `paperllm.config.EXTRACTION | PROSE | AGENTIC` — the three kinds of call this
|
|
64
|
+
library makes. It is **opaque to the library**: handed to `num_ctx_for`, stored on the call record,
|
|
65
|
+
never branched on. You decide what each is worth in context tokens.
|
|
66
|
+
|
|
67
|
+
### The calls
|
|
68
|
+
|
|
69
|
+
| | |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `chat_raw` | one constrained-decoding call → raw content |
|
|
72
|
+
| `classify` / `extract` | the same, parsed and validated into a Pydantic model |
|
|
73
|
+
| `chat_prose` | unconstrained generation |
|
|
74
|
+
| `chat_agentic` | the model may call tools, you run them, it continues |
|
|
75
|
+
| `reason` | one agentic call whose answer arrives through a tool; returns `(scratchpad, was_truncated)` |
|
|
76
|
+
|
|
77
|
+
Two exceptions, and they are **siblings, not a hierarchy**: `ExtractionError` means the model
|
|
78
|
+
produced nothing usable; `Truncated` means generation stopped on `num_predict`. A caller that
|
|
79
|
+
swallows the first must decide about the second separately, because a budget bug filed as a
|
|
80
|
+
judgement about a paper is how a number ends up in the record as evidence.
|
|
81
|
+
|
|
82
|
+
`Truncated` carries both `content` and `thinking`. On a reasoning model the second is usually the
|
|
83
|
+
only one with anything in it — measured over one run, 31 of 48 tool-calling turns had empty
|
|
84
|
+
`content` — so a cut-off call is a shorter scratchpad rather than a lost paper.
|
|
85
|
+
|
|
86
|
+
### Budgets are yours
|
|
87
|
+
|
|
88
|
+
`num_predict` defaults to `MIN_NUM_PREDICT` (2048), which is a **floor, not a working budget**.
|
|
89
|
+
Thinking tokens count against the budget, so a number sized for the answer alone leaves a reasoning
|
|
90
|
+
model with nothing left to answer with — 2048 is what "you forgot to choose" looks like, small
|
|
91
|
+
enough to catch.
|
|
92
|
+
|
|
93
|
+
The right number depends on how your prompt was built and how much room it reserved for the reply.
|
|
94
|
+
Only you know both. The pipeline this came from keeps its measured budgets in
|
|
95
|
+
`sudep/analysis/budgets.py`; every one of them cost a failed run to learn.
|
|
96
|
+
|
|
97
|
+
## The call log
|
|
98
|
+
|
|
99
|
+
Two tables. `llm_call` is one row per call, append-only; `call_stage` is the vocabulary naming which
|
|
100
|
+
call in a cascade a row is.
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
pmid model stage created_at <- the key
|
|
104
|
+
purpose label <- what kind of call, and which step
|
|
105
|
+
success error_type error_message
|
|
106
|
+
num_ctx num_predict <- what it ran under
|
|
107
|
+
prompt_tokens eval_tokens <- what it used
|
|
108
|
+
done_reason duration_ms
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**`stage` names which call this was, not how the paper ended.** A paper that fails call 1 and
|
|
112
|
+
succeeds on call 2 has no single outcome, but each of its calls has one. The questions worth asking
|
|
113
|
+
are `stage × success`, and a pmid with no successful row at any stage is the failed paper. Seed
|
|
114
|
+
`call_stage` with your own cascade; the shipped rows describe a three-call one.
|
|
115
|
+
|
|
116
|
+
`success` means the call produced what its stage was asked for — a well-formed reply carrying
|
|
117
|
+
unusable JSON is a failure, because the cascade treats it as one and the log has to agree with the
|
|
118
|
+
cascade rather than with HTTP.
|
|
119
|
+
|
|
120
|
+
### The recorder, and the one trap
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
def recorder(call):
|
|
124
|
+
with your_own_session_scope() as session: # NOT the caller's session
|
|
125
|
+
paperllm.record.record(session, call)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**It must not join the transaction of the work it is describing.** A failed extraction rolls back,
|
|
129
|
+
and a record of the failure written in that transaction rolls back with it — leaving a log that
|
|
130
|
+
holds exactly the calls that went well. Open a short-lived session per record and commit it.
|
|
131
|
+
|
|
132
|
+
That also makes the primary key safe: Postgres's `now()` is the *transaction* timestamp, so one row
|
|
133
|
+
per transaction is what keeps `created_at` distinct. If you batch, use `clock_timestamp()`.
|
|
134
|
+
|
|
135
|
+
A recorder that raises does not break the call — `record.emit` swallows it and logs at WARNING. A run
|
|
136
|
+
that finishes with an incomplete log beats a run that died protecting its bookkeeping.
|
|
137
|
+
|
|
138
|
+
## It grows, and nothing prunes it
|
|
139
|
+
|
|
140
|
+
One row per call, and calls are never updated or replaced — a full pass over a few thousand papers
|
|
141
|
+
with a handful of models is tens of thousands of rows. **There is no retention policy, no TTL and no
|
|
142
|
+
cleanup job, deliberately.** A log that deletes on its own is a log you cannot trust to answer a
|
|
143
|
+
question about last month, and the only honest default for "how long is a call worth keeping" is
|
|
144
|
+
however long its answer stays interesting.
|
|
145
|
+
|
|
146
|
+
So deletion is an operator's decision, taken deliberately:
|
|
147
|
+
|
|
148
|
+
```sql
|
|
149
|
+
-- What you are about to remove, before removing it.
|
|
150
|
+
SELECT date_trunc('month', created_at) AS month, count(*)
|
|
151
|
+
FROM llm_call GROUP BY 1 ORDER BY 1;
|
|
152
|
+
|
|
153
|
+
DELETE FROM llm_call WHERE created_at < '2026-01-01';
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`ix_llm_call_created_at` exists for exactly that scan.
|
|
157
|
+
|
|
158
|
+
**Age is the only axis available, and that is worth knowing before you rely on it.** The table
|
|
159
|
+
carries no schema or prompt version, so "delete everything from before the prompt changed" has to be
|
|
160
|
+
expressed as a date — look up when the version bumped and cut there. Storing the versions here was
|
|
161
|
+
rejected on the grounds that a call log should not have to be told what question the caller was
|
|
162
|
+
asking; if that turns out to be wrong, it is an added column and a migration, not a redesign.
|
|
163
|
+
|
|
164
|
+
Nothing downstream reads this table, so a delete cannot break a pipeline — only an analysis you had
|
|
165
|
+
not run yet.
|
|
166
|
+
|
|
167
|
+
## Migrations
|
|
168
|
+
|
|
169
|
+
The library owns its schema and ships an Alembic branch labelled `paperllm`, creating both tables and
|
|
170
|
+
seeding the vocabulary in one revision — an unseeded lookup table would make the foreign key reject
|
|
171
|
+
every insert. Add it to your `version_locations`, then `alembic upgrade heads` (**`heads`**, not
|
|
172
|
+
`head`: with more than one branch, `head` is ambiguous and errors).
|
|
173
|
+
|
|
174
|
+
`paperllm.db.Base` has its own `MetaData`, deliberately: nothing here has a foreign key crossing into
|
|
175
|
+
your schema — `llm_call.pmid` names a pmid and carries none, so a deleted paper does not take the
|
|
176
|
+
record of its failures with it.
|
|
177
|
+
|
|
178
|
+
## What it does not do
|
|
179
|
+
|
|
180
|
+
- **No CLI.** Deciding which environment variables must be present before touching a database is the
|
|
181
|
+
application's call.
|
|
182
|
+
- **No sessions.** `record` takes one you opened.
|
|
183
|
+
- **No environment reading.** Everything arrives on `CallConfig`.
|
|
184
|
+
- **No re-exports** from `__init__.py`. Import submodules, so the public surface stays small enough
|
|
185
|
+
to reshape once a second consumer shows where the joints actually belong.
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
Offline by construction: no Ollama, no database. `_ollama` is the single seam every call goes
|
|
189
|
+
through, and `CallConfig` is built in the test rather than patched onto a global — which is the point
|
|
190
|
+
of the window and thinking policies being callables.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Local-model call discipline for reading papers, and a durable record of every call.
|
|
2
|
+
|
|
3
|
+
**Being extracted from the SUDEP review pipeline it was written for**, one step at a time, and it
|
|
4
|
+
lives inside `backend/` while that happens: `PYTHONPATH` is already `backend/`, so `import paperllm`
|
|
5
|
+
resolves with no install step. Same route `pubmedcorpus` took.
|
|
6
|
+
|
|
7
|
+
What it is for: the call shapes that survive a local model — an agentic call whose answer arrives
|
|
8
|
+
through a tool rather than a `format=` grammar, a truncation check at every point a reply is returned,
|
|
9
|
+
the thinking-versus-content split that decides where a reasoning model actually put its work — and a
|
|
10
|
+
table that keeps what each call cost, so budget and window questions are settled by measurement rather
|
|
11
|
+
than by arithmetic.
|
|
12
|
+
|
|
13
|
+
**Ollama-shaped, despite the name.** The retry semantics, `done_reason`, and the thinking channel are
|
|
14
|
+
Ollama's. A second backend would need more than a new client object.
|
|
15
|
+
|
|
16
|
+
**No re-exports here, deliberately.** Callers import submodules — `from paperllm import record` — so
|
|
17
|
+
the public surface stays small enough to reshape once a second consumer shows where the joints
|
|
18
|
+
actually belong.
|
|
19
|
+
|
|
20
|
+
**Postgres-only**, like its sibling: the recorder's write path is Postgres-specific.
|
|
21
|
+
"""
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The object a consumer actually holds.
|
|
2
|
+
|
|
3
|
+
`client.py` is written as functions taking a `CallConfig` first, because that is what makes each one
|
|
4
|
+
testable on its own with a config built in the test. This binds one config to all of them, so a
|
|
5
|
+
caller constructs it once and passes *it* around instead of threading a config through every function
|
|
6
|
+
between the entry point and the model call.
|
|
7
|
+
|
|
8
|
+
Same shape as `pubmedcorpus.NCBIClient(config)`, and for the same reason: the configuration of a
|
|
9
|
+
long-lived service is stated once per run, and everything downstream reads it from the object rather
|
|
10
|
+
than assembling its own.
|
|
11
|
+
|
|
12
|
+
**Thin on purpose.** Every method is a one-line delegation, and the behaviour stays in `client.py`.
|
|
13
|
+
The alternative — moving the bodies in here — would bury four hundred lines of hard-won comments
|
|
14
|
+
inside a class for no gain, and would make each call path harder to exercise in isolation.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import Type, TypeVar
|
|
18
|
+
|
|
19
|
+
from paperllm import client
|
|
20
|
+
from paperllm.config import CallConfig
|
|
21
|
+
from paperllm.record import CallContext
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
M = TypeVar("M", bound=BaseModel)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Caller:
|
|
28
|
+
"""One Ollama endpoint, one window policy, one place calls are recorded.
|
|
29
|
+
|
|
30
|
+
Construct it where the application knows its own configuration — for a CLI, once per command —
|
|
31
|
+
and hand it down. Cheap to build: the underlying `ollama.Client` is created lazily, per host, on
|
|
32
|
+
the first call that needs it.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: CallConfig):
|
|
36
|
+
self.config = config
|
|
37
|
+
|
|
38
|
+
# --- availability ---------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def available_models(self) -> set[str]:
|
|
41
|
+
return client.available_models(self.config)
|
|
42
|
+
|
|
43
|
+
def require_models(self, models: list[str]) -> None:
|
|
44
|
+
return client.require_models(self.config, models)
|
|
45
|
+
|
|
46
|
+
# --- embeddings -----------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def embed(self, model: str, texts: list[str]) -> list[list[float]]:
|
|
49
|
+
return client.embed(self.config, model, texts)
|
|
50
|
+
|
|
51
|
+
# --- constrained calls ----------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def chat_raw(
|
|
54
|
+
self, model: str, messages: list[dict], schema: dict,
|
|
55
|
+
num_predict: int = client.MIN_NUM_PREDICT,
|
|
56
|
+
) -> str:
|
|
57
|
+
return client.chat_raw(self.config, model, messages, schema, num_predict=num_predict)
|
|
58
|
+
|
|
59
|
+
def classify(
|
|
60
|
+
self, model: str, messages: list[dict], schema: dict, model_cls: Type[M],
|
|
61
|
+
num_predict: int = client.MIN_NUM_PREDICT, call: CallContext | None = None,
|
|
62
|
+
) -> M:
|
|
63
|
+
return client.classify(self.config, model, messages, schema, model_cls,
|
|
64
|
+
num_predict=num_predict, call=call)
|
|
65
|
+
|
|
66
|
+
def extract(
|
|
67
|
+
self, model: str, messages: list[dict], model_cls: Type[M],
|
|
68
|
+
num_predict: int = client.MIN_NUM_PREDICT, call: CallContext | None = None,
|
|
69
|
+
) -> M:
|
|
70
|
+
return client.extract(self.config, model, messages, model_cls,
|
|
71
|
+
num_predict=num_predict, call=call)
|
|
72
|
+
|
|
73
|
+
# --- unconstrained calls --------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def chat_prose(
|
|
76
|
+
self, model: str, messages: list[dict], *,
|
|
77
|
+
num_predict: int = client.MIN_NUM_PREDICT, temperature: float = 0.4,
|
|
78
|
+
) -> str:
|
|
79
|
+
return client.chat_prose(self.config, model, messages,
|
|
80
|
+
num_predict=num_predict, temperature=temperature)
|
|
81
|
+
|
|
82
|
+
def chat_agentic(
|
|
83
|
+
self, model: str, messages: list[dict], tools: list[dict], dispatch, *,
|
|
84
|
+
num_predict: int = client.MIN_NUM_PREDICT, temperature: float = 0.4,
|
|
85
|
+
max_calls: int = 8, stop_when=None, on_response=None,
|
|
86
|
+
call: CallContext | None = None,
|
|
87
|
+
) -> str:
|
|
88
|
+
return client.chat_agentic(
|
|
89
|
+
self.config, model, messages, tools, dispatch, num_predict=num_predict,
|
|
90
|
+
temperature=temperature, max_calls=max_calls, stop_when=stop_when,
|
|
91
|
+
on_response=on_response, call=call,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def reason(
|
|
95
|
+
self, model: str, messages: list[dict], tools: list[dict], dispatch, stop_when=None,
|
|
96
|
+
num_predict: int = client.MIN_NUM_PREDICT, on_response=None,
|
|
97
|
+
call: CallContext | None = None,
|
|
98
|
+
) -> tuple[str | None, bool]:
|
|
99
|
+
return client.reason(
|
|
100
|
+
self.config, model, messages, tools, dispatch, stop_when=stop_when,
|
|
101
|
+
num_predict=num_predict, on_response=on_response, call=call,
|
|
102
|
+
)
|