pennsieve-ai-utils 0.2.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.
- pennsieve_ai_utils-0.2.0/LICENSE +21 -0
- pennsieve_ai_utils-0.2.0/PKG-INFO +166 -0
- pennsieve_ai_utils-0.2.0/README.md +127 -0
- pennsieve_ai_utils-0.2.0/pyproject.toml +46 -0
- pennsieve_ai_utils-0.2.0/setup.cfg +4 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/__init__.py +27 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/agents.py +167 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/blackboard/__init__.py +22 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/blackboard/migrations.py +61 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/blackboard/schema.py +247 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/llm.py +436 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/models.py +613 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/processor.py +89 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/__init__.py +1 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/crossref.py +86 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/datacite.py +118 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/doi_lookup.py +81 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/embeddings.py +67 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/jats_chunker.py +133 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/markdown_chunker.py +86 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/rag_store.py +436 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/reranker.py +61 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/workflow.py +324 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils.egg-info/PKG-INFO +166 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils.egg-info/SOURCES.txt +33 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils.egg-info/dependency_links.txt +1 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils.egg-info/requires.txt +8 -0
- pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils.egg-info/top_level.txt +1 -0
- pennsieve_ai_utils-0.2.0/tests/test_agents.py +85 -0
- pennsieve_ai_utils-0.2.0/tests/test_blackboard_contract.py +98 -0
- pennsieve_ai_utils-0.2.0/tests/test_llm.py +292 -0
- pennsieve_ai_utils-0.2.0/tests/test_models.py +408 -0
- pennsieve_ai_utils-0.2.0/tests/test_processor.py +41 -0
- pennsieve_ai_utils-0.2.0/tests/test_rag_store.py +37 -0
- pennsieve_ai_utils-0.2.0/tests/test_workflow.py +48 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pennsieve
|
|
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.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pennsieve-ai-utils
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Shared blackboard contract, governor LLM client, and literature tools for the Pennsieve AI Co-Scientist stages.
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Pennsieve
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Repository, https://github.com/Pennsieve/pennsieve-ai-utils
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: pennsieve-llm<0.7,>=0.6.1
|
|
32
|
+
Requires-Dist: httpx>=0.27
|
|
33
|
+
Requires-Dist: json-repair>=0.30
|
|
34
|
+
Requires-Dist: numpy>=1.26
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
37
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# pennsieve-ai-utils
|
|
41
|
+
|
|
42
|
+
Shared utilities for the Pennsieve AI Co-Scientist stages:
|
|
43
|
+
|
|
44
|
+
1. `pennsieve-ai-scout`
|
|
45
|
+
2. `pennsieve-ai-hypothesize`
|
|
46
|
+
3. `pennsieve-ai-analyze`
|
|
47
|
+
4. `pennsieve-ai-write`
|
|
48
|
+
|
|
49
|
+
Each stage used to carry its own copy of the blackboard schema, the governor
|
|
50
|
+
LLM client, the model-role resolver, the agent helpers and the literature
|
|
51
|
+
tools. They now import them from here. The package is published to PyPI as
|
|
52
|
+
`pennsieve-ai-utils` (same release flow as `pennsieve-llm`: push a `vX.Y.Z`
|
|
53
|
+
tag), so the Pennsieve App Store build of each stage can `pip install` it
|
|
54
|
+
without GitHub credentials.
|
|
55
|
+
|
|
56
|
+
See [RUNBOOK.md](RUNBOOK.md) for local stage supervision, stopping, review
|
|
57
|
+
bundles and the remaining Pennsieve Test deployment work.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install pennsieve-ai-utils
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## What's here
|
|
64
|
+
|
|
65
|
+
| Module | Owns | Used by |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `pennsieve_ai_utils.blackboard` | Canonical `Blackboard` JSON contract, `schema_version` migrations, unknown-field preservation | all stages |
|
|
68
|
+
| `pennsieve_ai_utils.llm` | `LLMClient`: governor transport via `pennsieve-llm`, role-based model selection, preflight, self-healing on 403/404, `usage.jsonl` logging | all stages |
|
|
69
|
+
| `pennsieve_ai_utils.models` | Role table (`reasoning`, `synthesis`, `bulk`), capability ranking, allow-list discovery, operator pins | `llm` |
|
|
70
|
+
| `pennsieve_ai_utils.agents` | `call_json`, `extract_json`, `first_text`, `format_datasets`, attack-tag helpers, `load_prompt` | every agent |
|
|
71
|
+
| `pennsieve_ai_utils.processor` | Pennsieve processor env contract (`INPUT_DIR`/`OUTPUT_DIR`/run IDs), SIGTERM handling, upstream blackboard loading, output persistence | every `main.py` |
|
|
72
|
+
| `pennsieve_ai_utils.tools` | CrossRef, DataCite, DOI lookup, JATS/Markdown chunkers, Bedrock embeddings + reranker, RAG store reader | scout, hypothesize, write |
|
|
73
|
+
| `pennsieve_ai_utils.workflow` | Local four-stage supervisor, handoff gates, review bundles (`python -m pennsieve_ai_utils.workflow`) | operators |
|
|
74
|
+
|
|
75
|
+
Stage-specific science — pipelines, prompts, Discover/DANDI/OpenNeuro
|
|
76
|
+
inspection, notebook execution, manuscript rendering — stays in each app.
|
|
77
|
+
|
|
78
|
+
## Blackboard contract
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from pennsieve_ai_utils import Blackboard
|
|
82
|
+
|
|
83
|
+
bb = Blackboard.load("blackboard.json")
|
|
84
|
+
bb.record_model_assignment(
|
|
85
|
+
stage="hypothesize",
|
|
86
|
+
role="reasoning",
|
|
87
|
+
assignment={"model_id": "us.anthropic.claude-sonnet-4-6"},
|
|
88
|
+
)
|
|
89
|
+
bb.save("blackboard.json")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- One superset schema carries fields produced by every stage.
|
|
93
|
+
- `schema_version` enables explicit migrations; documents without a version
|
|
94
|
+
are treated as legacy version 0.
|
|
95
|
+
- Unknown fields are preserved at their original object level on a
|
|
96
|
+
load/save round trip.
|
|
97
|
+
- Model provenance is cumulative by stage (`model_assignments[stage][role]`)
|
|
98
|
+
rather than overwritten by the next application.
|
|
99
|
+
|
|
100
|
+
## LLM client
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from pennsieve_ai_utils.llm import LLMClient
|
|
104
|
+
from pennsieve_ai_utils.agents import call_json
|
|
105
|
+
|
|
106
|
+
client = LLMClient(stage="hypothesize", roles=("reasoning", "synthesis"))
|
|
107
|
+
assignments = client.preflight() # raises ModelNotAvailable if a floor can't be met
|
|
108
|
+
for role, assignment in assignments.items():
|
|
109
|
+
bb.record_model_assignment(stage="hypothesize", role=role, assignment=assignment)
|
|
110
|
+
|
|
111
|
+
verdict = call_json(client, agent="critic", system=..., user=..., run_id=bb.run_id,
|
|
112
|
+
model="reasoning")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Agents address models by **role**, never by vendor tier. The legacy names
|
|
116
|
+
`opus` / `sonnet` / `haiku` still resolve as aliases of
|
|
117
|
+
`reasoning` / `synthesis` / `bulk`, and `VP_MODEL_<ROLE>` pins keep working.
|
|
118
|
+
|
|
119
|
+
Preflight asks the governor's `GET /v1/models` for the allow-list first and
|
|
120
|
+
falls back to tripping a `model_not_allowed` 403 on older governors. Each
|
|
121
|
+
assigned model is then verified with a one-token call so an IAM block or a
|
|
122
|
+
retired Bedrock model fails at startup rather than twenty minutes in.
|
|
123
|
+
|
|
124
|
+
The client needs `LLM_GOVERNOR_FUNCTION_NAME` (platform-injected) or
|
|
125
|
+
`PENNSIEVE_LLM_MOCK=1` for offline tests — see
|
|
126
|
+
[LLM access on compute nodes](https://docs.pennsieve.io/docs/llm-access-on-pennsieve-compute-nodes).
|
|
127
|
+
|
|
128
|
+
## Processor helpers
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from pennsieve_ai_utils.processor import (
|
|
132
|
+
ProcessorEnv, install_sigterm_handler, load_upstream_blackboard, write_outputs,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
install_sigterm_handler()
|
|
136
|
+
env = ProcessorEnv.from_environ()
|
|
137
|
+
bb = load_upstream_blackboard(env.input_dir / "blackboard.json",
|
|
138
|
+
stage="analyze", upstream="Hypothesize",
|
|
139
|
+
run_id=env.execution_run_id)
|
|
140
|
+
...
|
|
141
|
+
write_outputs(bb, env.output_dir, client.usage_log)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
These follow the
|
|
145
|
+
[Pennsieve processor contract](https://docs.pennsieve.io/docs/pennsieve-processors):
|
|
146
|
+
read from `INPUT_DIR`, write to `OUTPUT_DIR`, exit non-zero on failure.
|
|
147
|
+
|
|
148
|
+
## Releasing
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
# bump version in pyproject.toml, commit, then:
|
|
152
|
+
git tag v0.2.0 && git push origin v0.2.0
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`.github/workflows/publish-pypi.yml` builds and publishes via PyPI Trusted
|
|
156
|
+
Publishing. One-time setup on pypi.org: add this repository as a trusted
|
|
157
|
+
publisher for the `pennsieve-ai-utils` project (GitHub environment `pypi`).
|
|
158
|
+
Apps pin `pennsieve-ai-utils>=0.2,<0.3` and pick up patch releases on rebuild.
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
python -m pip install -e ".[dev]"
|
|
164
|
+
python -m ruff check src tests
|
|
165
|
+
python -m pytest
|
|
166
|
+
```
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# pennsieve-ai-utils
|
|
2
|
+
|
|
3
|
+
Shared utilities for the Pennsieve AI Co-Scientist stages:
|
|
4
|
+
|
|
5
|
+
1. `pennsieve-ai-scout`
|
|
6
|
+
2. `pennsieve-ai-hypothesize`
|
|
7
|
+
3. `pennsieve-ai-analyze`
|
|
8
|
+
4. `pennsieve-ai-write`
|
|
9
|
+
|
|
10
|
+
Each stage used to carry its own copy of the blackboard schema, the governor
|
|
11
|
+
LLM client, the model-role resolver, the agent helpers and the literature
|
|
12
|
+
tools. They now import them from here. The package is published to PyPI as
|
|
13
|
+
`pennsieve-ai-utils` (same release flow as `pennsieve-llm`: push a `vX.Y.Z`
|
|
14
|
+
tag), so the Pennsieve App Store build of each stage can `pip install` it
|
|
15
|
+
without GitHub credentials.
|
|
16
|
+
|
|
17
|
+
See [RUNBOOK.md](RUNBOOK.md) for local stage supervision, stopping, review
|
|
18
|
+
bundles and the remaining Pennsieve Test deployment work.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install pennsieve-ai-utils
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What's here
|
|
25
|
+
|
|
26
|
+
| Module | Owns | Used by |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `pennsieve_ai_utils.blackboard` | Canonical `Blackboard` JSON contract, `schema_version` migrations, unknown-field preservation | all stages |
|
|
29
|
+
| `pennsieve_ai_utils.llm` | `LLMClient`: governor transport via `pennsieve-llm`, role-based model selection, preflight, self-healing on 403/404, `usage.jsonl` logging | all stages |
|
|
30
|
+
| `pennsieve_ai_utils.models` | Role table (`reasoning`, `synthesis`, `bulk`), capability ranking, allow-list discovery, operator pins | `llm` |
|
|
31
|
+
| `pennsieve_ai_utils.agents` | `call_json`, `extract_json`, `first_text`, `format_datasets`, attack-tag helpers, `load_prompt` | every agent |
|
|
32
|
+
| `pennsieve_ai_utils.processor` | Pennsieve processor env contract (`INPUT_DIR`/`OUTPUT_DIR`/run IDs), SIGTERM handling, upstream blackboard loading, output persistence | every `main.py` |
|
|
33
|
+
| `pennsieve_ai_utils.tools` | CrossRef, DataCite, DOI lookup, JATS/Markdown chunkers, Bedrock embeddings + reranker, RAG store reader | scout, hypothesize, write |
|
|
34
|
+
| `pennsieve_ai_utils.workflow` | Local four-stage supervisor, handoff gates, review bundles (`python -m pennsieve_ai_utils.workflow`) | operators |
|
|
35
|
+
|
|
36
|
+
Stage-specific science — pipelines, prompts, Discover/DANDI/OpenNeuro
|
|
37
|
+
inspection, notebook execution, manuscript rendering — stays in each app.
|
|
38
|
+
|
|
39
|
+
## Blackboard contract
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from pennsieve_ai_utils import Blackboard
|
|
43
|
+
|
|
44
|
+
bb = Blackboard.load("blackboard.json")
|
|
45
|
+
bb.record_model_assignment(
|
|
46
|
+
stage="hypothesize",
|
|
47
|
+
role="reasoning",
|
|
48
|
+
assignment={"model_id": "us.anthropic.claude-sonnet-4-6"},
|
|
49
|
+
)
|
|
50
|
+
bb.save("blackboard.json")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- One superset schema carries fields produced by every stage.
|
|
54
|
+
- `schema_version` enables explicit migrations; documents without a version
|
|
55
|
+
are treated as legacy version 0.
|
|
56
|
+
- Unknown fields are preserved at their original object level on a
|
|
57
|
+
load/save round trip.
|
|
58
|
+
- Model provenance is cumulative by stage (`model_assignments[stage][role]`)
|
|
59
|
+
rather than overwritten by the next application.
|
|
60
|
+
|
|
61
|
+
## LLM client
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from pennsieve_ai_utils.llm import LLMClient
|
|
65
|
+
from pennsieve_ai_utils.agents import call_json
|
|
66
|
+
|
|
67
|
+
client = LLMClient(stage="hypothesize", roles=("reasoning", "synthesis"))
|
|
68
|
+
assignments = client.preflight() # raises ModelNotAvailable if a floor can't be met
|
|
69
|
+
for role, assignment in assignments.items():
|
|
70
|
+
bb.record_model_assignment(stage="hypothesize", role=role, assignment=assignment)
|
|
71
|
+
|
|
72
|
+
verdict = call_json(client, agent="critic", system=..., user=..., run_id=bb.run_id,
|
|
73
|
+
model="reasoning")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Agents address models by **role**, never by vendor tier. The legacy names
|
|
77
|
+
`opus` / `sonnet` / `haiku` still resolve as aliases of
|
|
78
|
+
`reasoning` / `synthesis` / `bulk`, and `VP_MODEL_<ROLE>` pins keep working.
|
|
79
|
+
|
|
80
|
+
Preflight asks the governor's `GET /v1/models` for the allow-list first and
|
|
81
|
+
falls back to tripping a `model_not_allowed` 403 on older governors. Each
|
|
82
|
+
assigned model is then verified with a one-token call so an IAM block or a
|
|
83
|
+
retired Bedrock model fails at startup rather than twenty minutes in.
|
|
84
|
+
|
|
85
|
+
The client needs `LLM_GOVERNOR_FUNCTION_NAME` (platform-injected) or
|
|
86
|
+
`PENNSIEVE_LLM_MOCK=1` for offline tests — see
|
|
87
|
+
[LLM access on compute nodes](https://docs.pennsieve.io/docs/llm-access-on-pennsieve-compute-nodes).
|
|
88
|
+
|
|
89
|
+
## Processor helpers
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from pennsieve_ai_utils.processor import (
|
|
93
|
+
ProcessorEnv, install_sigterm_handler, load_upstream_blackboard, write_outputs,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
install_sigterm_handler()
|
|
97
|
+
env = ProcessorEnv.from_environ()
|
|
98
|
+
bb = load_upstream_blackboard(env.input_dir / "blackboard.json",
|
|
99
|
+
stage="analyze", upstream="Hypothesize",
|
|
100
|
+
run_id=env.execution_run_id)
|
|
101
|
+
...
|
|
102
|
+
write_outputs(bb, env.output_dir, client.usage_log)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
These follow the
|
|
106
|
+
[Pennsieve processor contract](https://docs.pennsieve.io/docs/pennsieve-processors):
|
|
107
|
+
read from `INPUT_DIR`, write to `OUTPUT_DIR`, exit non-zero on failure.
|
|
108
|
+
|
|
109
|
+
## Releasing
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# bump version in pyproject.toml, commit, then:
|
|
113
|
+
git tag v0.2.0 && git push origin v0.2.0
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`.github/workflows/publish-pypi.yml` builds and publishes via PyPI Trusted
|
|
117
|
+
Publishing. One-time setup on pypi.org: add this repository as a trusted
|
|
118
|
+
publisher for the `pennsieve-ai-utils` project (GitHub environment `pypi`).
|
|
119
|
+
Apps pin `pennsieve-ai-utils>=0.2,<0.3` and pick up patch releases on rebuild.
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
python -m pip install -e ".[dev]"
|
|
125
|
+
python -m ruff check src tests
|
|
126
|
+
python -m pytest
|
|
127
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pennsieve-ai-utils"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Shared blackboard contract, governor LLM client, and literature tools for the Pennsieve AI Co-Scientist stages."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {file = "LICENSE"}
|
|
12
|
+
dependencies = [
|
|
13
|
+
# Governor transport: returns a configured anthropic.Anthropic (pulls in
|
|
14
|
+
# anthropic, httpx2 and boto3).
|
|
15
|
+
"pennsieve-llm>=0.6.1,<0.7",
|
|
16
|
+
# Direct HTTP for CrossRef / DataCite. Declared explicitly because
|
|
17
|
+
# pennsieve-llm depends on httpx2, not httpx.
|
|
18
|
+
"httpx>=0.27",
|
|
19
|
+
# Second-chance parsing of LLM JSON output.
|
|
20
|
+
"json-repair>=0.30",
|
|
21
|
+
# Vector math for the RAG store reader.
|
|
22
|
+
"numpy>=1.26",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/Pennsieve/pennsieve-ai-utils"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
"ruff>=0.5",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
pythonpath = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
target-version = "py310"
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
# Classic ruff defaults; newer ruff releases widen the default set.
|
|
46
|
+
select = ["E4", "E7", "E9", "F"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Shared utilities for the Pennsieve AI Co-Scientist applications."""
|
|
2
|
+
|
|
3
|
+
from .blackboard import (
|
|
4
|
+
CURRENT_SCHEMA_VERSION,
|
|
5
|
+
Blackboard,
|
|
6
|
+
Claim,
|
|
7
|
+
CriticReview,
|
|
8
|
+
DatasetSummary,
|
|
9
|
+
DirectorBrief,
|
|
10
|
+
Hypothesis,
|
|
11
|
+
)
|
|
12
|
+
from .models import LEGACY_ALIASES, ROLES, ModelNotAvailable, Role, degraded_ok
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CURRENT_SCHEMA_VERSION",
|
|
16
|
+
"Blackboard",
|
|
17
|
+
"Claim",
|
|
18
|
+
"CriticReview",
|
|
19
|
+
"DatasetSummary",
|
|
20
|
+
"DirectorBrief",
|
|
21
|
+
"Hypothesis",
|
|
22
|
+
"LEGACY_ALIASES",
|
|
23
|
+
"ROLES",
|
|
24
|
+
"ModelNotAvailable",
|
|
25
|
+
"Role",
|
|
26
|
+
"degraded_ok",
|
|
27
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Helpers every LLM agent in the Co-Scientist stages uses: prompt loading,
|
|
2
|
+
JSON extraction, prompt formatting of shared blackboard state, and the
|
|
3
|
+
role-addressed `call_json` round trip."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pennsieve_ai_utils.blackboard import Blackboard
|
|
13
|
+
from pennsieve_ai_utils.llm import LLMClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_prompt(prompts_dir: Path, name: str) -> str:
|
|
17
|
+
"""Read `<prompts_dir>/<name>.md`. Each stage keeps its own prompts."""
|
|
18
|
+
return (Path(prompts_dir) / f"{name}.md").read_text()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_json(text: str) -> dict:
|
|
22
|
+
"""Extract a JSON object from LLM output that may have surrounding prose or fences.
|
|
23
|
+
|
|
24
|
+
Two-stage parsing:
|
|
25
|
+
1. `json.loads(strict=False)` — handles literal newlines/tabs inside strings
|
|
26
|
+
(which LLMs commonly emit in free-text fields).
|
|
27
|
+
2. Fall back to `json_repair` for unescaped quotes, trailing commas, and
|
|
28
|
+
other common LLM JSON foibles.
|
|
29
|
+
"""
|
|
30
|
+
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
|
31
|
+
if fence:
|
|
32
|
+
candidate = fence.group(1)
|
|
33
|
+
else:
|
|
34
|
+
brace = re.search(r"\{.*\}", text, re.DOTALL)
|
|
35
|
+
if not brace:
|
|
36
|
+
raise ValueError(f"No JSON object found in model output:\n{text[:500]}")
|
|
37
|
+
candidate = brace.group(0)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
return json.loads(candidate, strict=False)
|
|
41
|
+
except json.JSONDecodeError as strict_err:
|
|
42
|
+
try:
|
|
43
|
+
from json_repair import repair_json
|
|
44
|
+
except ImportError:
|
|
45
|
+
raise strict_err
|
|
46
|
+
try:
|
|
47
|
+
repaired = repair_json(candidate, return_objects=True)
|
|
48
|
+
except Exception:
|
|
49
|
+
raise strict_err
|
|
50
|
+
if isinstance(repaired, dict):
|
|
51
|
+
return repaired
|
|
52
|
+
raise ValueError(f"json_repair returned non-dict ({type(repaired).__name__})") from strict_err
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_ATTACK_TAGS = ("[CONSTRAINT]", "[REASONING]", "[ALIGNMENT]")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def attack_kind(attack: str) -> str:
|
|
59
|
+
"""Return 'constraint' | 'reasoning' | 'alignment' | 'untagged' for a Critic attack string."""
|
|
60
|
+
s = (attack or "").strip()
|
|
61
|
+
for tag in _ATTACK_TAGS:
|
|
62
|
+
if s.startswith(tag):
|
|
63
|
+
return tag[1:-1].lower()
|
|
64
|
+
return "untagged"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def strip_attack_tag(attack: str) -> str:
|
|
68
|
+
"""Remove the leading [TAG] prefix if present, for readable display."""
|
|
69
|
+
s = (attack or "").strip()
|
|
70
|
+
for tag in _ATTACK_TAGS:
|
|
71
|
+
if s.startswith(tag):
|
|
72
|
+
return s[len(tag):].strip()
|
|
73
|
+
return s
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def format_datasets(bb: "Blackboard") -> str:
|
|
77
|
+
"""Render dataset summaries with depositor README, file-type histogram,
|
|
78
|
+
metadata-record previews, and linked publications. Used by every stage so
|
|
79
|
+
all agents reason from the same picture of what the data is."""
|
|
80
|
+
chunks = []
|
|
81
|
+
for d in bb.dataset_summaries:
|
|
82
|
+
head = (
|
|
83
|
+
f"[{d.id}] {d.title}\n"
|
|
84
|
+
f" Species: {d.species}, n={d.n_subjects}\n"
|
|
85
|
+
f" Measurements: {', '.join(str(m) for m in d.measurements)}\n"
|
|
86
|
+
f" {d.description}"
|
|
87
|
+
)
|
|
88
|
+
extras = []
|
|
89
|
+
if d.bucket_uri:
|
|
90
|
+
extras.append(f" Bucket URI: {d.bucket_uri} (access: {d.access_type or 'unknown'})")
|
|
91
|
+
if d.readme:
|
|
92
|
+
extras.append(f" README: {d.readme[:1500].strip()}")
|
|
93
|
+
if d.file_types:
|
|
94
|
+
ft = ", ".join(f"{k}={v}" for k, v in list(d.file_types.items())[:5])
|
|
95
|
+
extras.append(f" File types: {ft}")
|
|
96
|
+
if d.file_tree:
|
|
97
|
+
tree_block = "\n".join(" " + line for line in d.file_tree.split("\n"))
|
|
98
|
+
extras.append(f" File tree (from manifest.json — these are the ONLY paths that exist):\n{tree_block}")
|
|
99
|
+
if d.record_tables:
|
|
100
|
+
for fn, info in d.record_tables.items():
|
|
101
|
+
cols = ", ".join(info.get("columns", [])[:8])
|
|
102
|
+
more = " ..." if len(info.get("columns", [])) > 8 else ""
|
|
103
|
+
preview = info.get("head", [])[:3]
|
|
104
|
+
preview_str = "; ".join(
|
|
105
|
+
"{" + ", ".join(f"{k}={v}" for k, v in row.items()) + "}"
|
|
106
|
+
for row in preview
|
|
107
|
+
)
|
|
108
|
+
extras.append(
|
|
109
|
+
f" Record table `{fn}` ({info.get('n_rows', 0)} rows): "
|
|
110
|
+
f"columns=[{cols}{more}]; first rows: {preview_str[:400]}"
|
|
111
|
+
)
|
|
112
|
+
if d.external_publications:
|
|
113
|
+
pubs = ", ".join(p.get("doi", "?") for p in d.external_publications[:3])
|
|
114
|
+
extras.append(f" Linked publications: {pubs}")
|
|
115
|
+
chunks.append(head + ("\n" + "\n".join(extras) if extras else ""))
|
|
116
|
+
return "\n\n".join(chunks)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def call_json(
|
|
120
|
+
client: "LLMClient",
|
|
121
|
+
*,
|
|
122
|
+
agent: str,
|
|
123
|
+
system: str,
|
|
124
|
+
user: str,
|
|
125
|
+
run_id: str,
|
|
126
|
+
model: str = "reasoning",
|
|
127
|
+
max_tokens: int = 16384,
|
|
128
|
+
) -> dict:
|
|
129
|
+
"""Call the model serving `model` (a pipeline role) and parse JSON from
|
|
130
|
+
its response.
|
|
131
|
+
|
|
132
|
+
Where the target model supports it we use the canonical "prefill `{`"
|
|
133
|
+
trick to force JSON output. Claude 4.6 and newer reject an
|
|
134
|
+
assistant-message prefill, so for those we skip it and rely on
|
|
135
|
+
`extract_json` to recover JSON from whatever comes back.
|
|
136
|
+
"""
|
|
137
|
+
# Ask the client rather than keying off the role name: a role can be
|
|
138
|
+
# served by a different concrete model than its first choice, and prefill
|
|
139
|
+
# rules differ by model.
|
|
140
|
+
prefill_ok = client.supports_prefill(model)
|
|
141
|
+
msg = client.messages_create(
|
|
142
|
+
model=model,
|
|
143
|
+
system=system,
|
|
144
|
+
messages=[{"role": "user", "content": user}],
|
|
145
|
+
prefill="{" if prefill_ok else None,
|
|
146
|
+
max_tokens=max_tokens,
|
|
147
|
+
run_id=run_id,
|
|
148
|
+
agent=agent,
|
|
149
|
+
)
|
|
150
|
+
# Re-ask after the call: a role rejected mid-call is re-resolved onto
|
|
151
|
+
# another model, which may have different prefill rules.
|
|
152
|
+
prefilled = prefill_ok and client.supports_prefill(model)
|
|
153
|
+
return extract_json(("{" if prefilled else "") + first_text(msg))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def first_text(msg) -> str:
|
|
157
|
+
"""The first text block's text.
|
|
158
|
+
|
|
159
|
+
Never index `content[0]` directly: models with thinking enabled put a
|
|
160
|
+
`thinking` block first, so a role pinned or degraded onto one would
|
|
161
|
+
otherwise raise AttributeError on a `ThinkingBlock`.
|
|
162
|
+
"""
|
|
163
|
+
for block in msg.content:
|
|
164
|
+
if getattr(block, "type", None) == "text":
|
|
165
|
+
return block.text
|
|
166
|
+
kinds = ", ".join(getattr(b, "type", "?") for b in msg.content) or "(empty)"
|
|
167
|
+
raise ValueError(f"No text block in model response; got: {kinds}")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Canonical blackboard schema and compatibility helpers."""
|
|
2
|
+
|
|
3
|
+
from .migrations import CURRENT_SCHEMA_VERSION
|
|
4
|
+
from .schema import (
|
|
5
|
+
Blackboard,
|
|
6
|
+
Claim,
|
|
7
|
+
CriticReview,
|
|
8
|
+
DatasetSummary,
|
|
9
|
+
DirectorBrief,
|
|
10
|
+
Hypothesis,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CURRENT_SCHEMA_VERSION",
|
|
15
|
+
"Blackboard",
|
|
16
|
+
"Claim",
|
|
17
|
+
"CriticReview",
|
|
18
|
+
"DatasetSummary",
|
|
19
|
+
"DirectorBrief",
|
|
20
|
+
"Hypothesis",
|
|
21
|
+
]
|
|
22
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Pure JSON-document migrations for the shared blackboard."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CURRENT_SCHEMA_VERSION = 1
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvalidBlackboard(ValueError):
|
|
12
|
+
"""The serialized blackboard is not a JSON object or has an invalid version."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def migrate_document(document: Mapping[str, Any]) -> dict[str, Any]:
|
|
16
|
+
"""Return a migrated copy without mutating the caller's document.
|
|
17
|
+
|
|
18
|
+
Future versions are retained as-is. The serializer preserves unknown
|
|
19
|
+
fields, allowing an older reader to inspect and re-emit newer documents
|
|
20
|
+
without silently deleting data it does not yet understand.
|
|
21
|
+
"""
|
|
22
|
+
if not isinstance(document, Mapping):
|
|
23
|
+
raise InvalidBlackboard("blackboard JSON must contain an object at the top level")
|
|
24
|
+
|
|
25
|
+
migrated = deepcopy(dict(document))
|
|
26
|
+
raw_version = migrated.get("schema_version", 0)
|
|
27
|
+
if isinstance(raw_version, bool) or not isinstance(raw_version, int) or raw_version < 0:
|
|
28
|
+
raise InvalidBlackboard(f"invalid blackboard schema_version: {raw_version!r}")
|
|
29
|
+
|
|
30
|
+
version = raw_version
|
|
31
|
+
while version < CURRENT_SCHEMA_VERSION:
|
|
32
|
+
if version == 0:
|
|
33
|
+
migrated = _migrate_v0_to_v1(migrated)
|
|
34
|
+
else: # pragma: no cover - every supported version has a branch
|
|
35
|
+
raise InvalidBlackboard(f"no migration registered for schema version {version}")
|
|
36
|
+
version += 1
|
|
37
|
+
|
|
38
|
+
return migrated
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _migrate_v0_to_v1(document: dict[str, Any]) -> dict[str, Any]:
|
|
42
|
+
document["schema_version"] = 1
|
|
43
|
+
document.setdefault("degraded", [])
|
|
44
|
+
|
|
45
|
+
assignments = document.get("model_assignments")
|
|
46
|
+
if not isinstance(assignments, dict):
|
|
47
|
+
document["model_assignments"] = {}
|
|
48
|
+
elif assignments and _looks_like_flat_assignments(assignments):
|
|
49
|
+
# Legacy apps keyed assignments directly by role and overwrote the
|
|
50
|
+
# prior stage. Preserve that provenance under an explicit namespace.
|
|
51
|
+
document["model_assignments"] = {"legacy": assignments}
|
|
52
|
+
|
|
53
|
+
return document
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _looks_like_flat_assignments(assignments: dict[str, Any]) -> bool:
|
|
57
|
+
values = list(assignments.values())
|
|
58
|
+
return bool(values) and all(
|
|
59
|
+
isinstance(value, dict) and "model_id" in value for value in values
|
|
60
|
+
)
|
|
61
|
+
|