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.
Files changed (35) hide show
  1. pennsieve_ai_utils-0.2.0/LICENSE +21 -0
  2. pennsieve_ai_utils-0.2.0/PKG-INFO +166 -0
  3. pennsieve_ai_utils-0.2.0/README.md +127 -0
  4. pennsieve_ai_utils-0.2.0/pyproject.toml +46 -0
  5. pennsieve_ai_utils-0.2.0/setup.cfg +4 -0
  6. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/__init__.py +27 -0
  7. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/agents.py +167 -0
  8. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/blackboard/__init__.py +22 -0
  9. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/blackboard/migrations.py +61 -0
  10. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/blackboard/schema.py +247 -0
  11. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/llm.py +436 -0
  12. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/models.py +613 -0
  13. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/processor.py +89 -0
  14. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/__init__.py +1 -0
  15. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/crossref.py +86 -0
  16. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/datacite.py +118 -0
  17. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/doi_lookup.py +81 -0
  18. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/embeddings.py +67 -0
  19. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/jats_chunker.py +133 -0
  20. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/markdown_chunker.py +86 -0
  21. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/rag_store.py +436 -0
  22. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/tools/reranker.py +61 -0
  23. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils/workflow.py +324 -0
  24. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils.egg-info/PKG-INFO +166 -0
  25. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils.egg-info/SOURCES.txt +33 -0
  26. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils.egg-info/dependency_links.txt +1 -0
  27. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils.egg-info/requires.txt +8 -0
  28. pennsieve_ai_utils-0.2.0/src/pennsieve_ai_utils.egg-info/top_level.txt +1 -0
  29. pennsieve_ai_utils-0.2.0/tests/test_agents.py +85 -0
  30. pennsieve_ai_utils-0.2.0/tests/test_blackboard_contract.py +98 -0
  31. pennsieve_ai_utils-0.2.0/tests/test_llm.py +292 -0
  32. pennsieve_ai_utils-0.2.0/tests/test_models.py +408 -0
  33. pennsieve_ai_utils-0.2.0/tests/test_processor.py +41 -0
  34. pennsieve_ai_utils-0.2.0/tests/test_rag_store.py +37 -0
  35. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+