arms-agent 1.0.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.
- arms_agent-1.0.0/.gitignore +12 -0
- arms_agent-1.0.0/LICENSE +26 -0
- arms_agent-1.0.0/PKG-INFO +149 -0
- arms_agent-1.0.0/README.md +118 -0
- arms_agent-1.0.0/pyproject.toml +93 -0
- arms_agent-1.0.0/src/arms_agent/__init__.py +13 -0
- arms_agent-1.0.0/src/arms_agent/__main__.py +127 -0
- arms_agent-1.0.0/src/arms_agent/agent.py +138 -0
- arms_agent-1.0.0/src/arms_agent/cache.py +218 -0
- arms_agent-1.0.0/src/arms_agent/logging_config.py +99 -0
- arms_agent-1.0.0/src/arms_agent/prompts.py +181 -0
- arms_agent-1.0.0/src/arms_agent/schema.py +145 -0
- arms_agent-1.0.0/src/arms_agent/state.py +24 -0
- arms_agent-1.0.0/src/arms_agent/token_tracker/__init__.py +33 -0
- arms_agent-1.0.0/src/arms_agent/token_tracker/pricing.py +133 -0
- arms_agent-1.0.0/src/arms_agent/token_tracker/tracker.py +67 -0
- arms_agent-1.0.0/src/arms_agent/token_tracker/usage.py +107 -0
- arms_agent-1.0.0/src/arms_agent/tools.py +214 -0
- arms_agent-1.0.0/src/arms_agent/tracing/__init__.py +53 -0
- arms_agent-1.0.0/src/arms_agent/tracing/client.py +76 -0
- arms_agent-1.0.0/src/arms_agent/tracing/instrumentation.py +52 -0
- arms_agent-1.0.0/src/arms_agent/tracing/observations.py +101 -0
- arms_agent-1.0.0/src/arms_agent/tracing/spans.py +33 -0
- arms_agent-1.0.0/src/arms_agent/utils.py +241 -0
- arms_agent-1.0.0/src/arms_agent/workflow.py +43 -0
- arms_agent-1.0.0/tests/conftest.py +51 -0
- arms_agent-1.0.0/tests/test_agent.py +19 -0
- arms_agent-1.0.0/tests/test_cache.py +121 -0
- arms_agent-1.0.0/tests/test_migrate_cli.py +160 -0
- arms_agent-1.0.0/tests/test_prompts.py +46 -0
- arms_agent-1.0.0/tests/test_response_format.py +300 -0
- arms_agent-1.0.0/tests/test_schema.py +375 -0
- arms_agent-1.0.0/tests/test_token_tracker.py +454 -0
- arms_agent-1.0.0/tests/test_tools.py +246 -0
- arms_agent-1.0.0/tests/test_tracing.py +356 -0
- arms_agent-1.0.0/tests/test_utils.py +245 -0
- arms_agent-1.0.0/tests/test_version.py +40 -0
- arms_agent-1.0.0/tests/test_workflow.py +21 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Package-local ignores. Hatchling ships the nearest VCS ignore file inside the
|
|
2
|
+
# sdist and reads it when choosing files, so this file keeps build artifacts out
|
|
3
|
+
# of the wheel and keeps the workspace's own ignore list from being published.
|
|
4
|
+
__pycache__/
|
|
5
|
+
*.py[cod]
|
|
6
|
+
*$py.class
|
|
7
|
+
*.egg-info/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.coverage
|
arms_agent-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Copyright (c) 2026, The Board of Trustees of Leland Stanford Junior University
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without modification, are
|
|
5
|
+
permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
1. Redistributions of source code must retain the above copyright notice, this list of
|
|
8
|
+
conditions and the following disclaimer.
|
|
9
|
+
|
|
10
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list
|
|
11
|
+
of conditions and the following disclaimer in the documentation and/or other materials
|
|
12
|
+
provided with the distribution.
|
|
13
|
+
|
|
14
|
+
THIS SOFTWARE IS PROVIDED BY The Board of Trustees of Leland Stanford Junior University
|
|
15
|
+
''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
16
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
|
|
17
|
+
EVENT SHALL The Board of Trustees of Leland Stanford Junior University OR CONTRIBUTORS BE
|
|
18
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
19
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
20
|
+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
|
21
|
+
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
|
|
22
|
+
IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
23
|
+
|
|
24
|
+
The views and conclusions contained in the software and documentation are those of the
|
|
25
|
+
authors and should not be interpreted as representing official policies, either expressed
|
|
26
|
+
or implied, of The Board of Trustees of Leland Stanford Junior University.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: arms-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: An ontology-constrained LLM agent that standardizes legacy biomedical metadata into CEDAR templates
|
|
5
|
+
Project-URL: Homepage, https://github.com/musen-lab/metadata-standardization-agent
|
|
6
|
+
Project-URL: Repository, https://github.com/musen-lab/metadata-standardization-agent
|
|
7
|
+
Project-URL: Issues, https://github.com/musen-lab/metadata-standardization-agent/issues
|
|
8
|
+
Project-URL: Paper, https://arxiv.org/abs/2604.08552
|
|
9
|
+
Author: Josef Hardi
|
|
10
|
+
License-Expression: BSD-2-Clause
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: biomedical,cedar,langgraph,llm-agent,metadata,ontology
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: cedar-mcp>=1.3.0
|
|
22
|
+
Requires-Dist: langchain-core<2.0,>=1.2
|
|
23
|
+
Requires-Dist: langchain-openai<2.0,>=1.0
|
|
24
|
+
Requires-Dist: langchain<2.0,>=1.2
|
|
25
|
+
Requires-Dist: langgraph<2.0,>=1.0
|
|
26
|
+
Requires-Dist: pydantic<3.0,>=2.0
|
|
27
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
28
|
+
Provides-Extra: tracing
|
|
29
|
+
Requires-Dist: langfuse<5.0,>=4.14; extra == 'tracing'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# ARMS Agent
|
|
33
|
+
|
|
34
|
+
An LLM agent that standardizes legacy biomedical metadata records into the [CEDAR](https://metadatacenter.org/) template format.
|
|
35
|
+
|
|
36
|
+
It fetches the live CEDAR template and queries BioPortal for canonical terms through [Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) tools, so the constraints it applies are the ones the template holds right now.
|
|
37
|
+
|
|
38
|
+
This is the agent described in *Automated Standardization of Legacy Biomedical Metadata Using an Ontology-Constrained LLM Agent* ([arXiv:2604.08552](https://arxiv.org/abs/2604.08552)). The evaluation harness, the 839-record dataset, and the code for every figure in the paper live in the [project repository](https://github.com/musen-lab/metadata-standardization-agent).
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install arms-agent
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configure
|
|
47
|
+
|
|
48
|
+
Three keys are required. Put them in the environment, or in a `.env` file in the directory you run from:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
OPENAI_API_KEY=... # LLM calls
|
|
52
|
+
CEDAR_API_KEY=... # fetching CEDAR templates
|
|
53
|
+
BIOPORTAL_API_KEY=... # ontology term lookups
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Optional: set `OPENAI_BASE_URL` to route LLM calls through an OpenAI-compatible gateway.
|
|
57
|
+
|
|
58
|
+
To trace each LLM call, tool call, and agent step to [Langfuse](https://langfuse.com/), install the extra and
|
|
59
|
+
set both keys:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install 'arms-agent[tracing]'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
LANGFUSE_PUBLIC_KEY=...
|
|
67
|
+
LANGFUSE_SECRET_KEY=...
|
|
68
|
+
LANGFUSE_HOST=... # optional, defaults to Langfuse Cloud
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Tracing stays off until both keys are set, and `LANGFUSE_TRACING_ENABLED=false` switches it off while leaving
|
|
72
|
+
the keys in place.
|
|
73
|
+
|
|
74
|
+
## Command line
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
arms-migrate \
|
|
78
|
+
--input legacy-record.json \
|
|
79
|
+
--target-schema https://repo.metadatacenter.org/templates/dd5e8653-81cf-470b-b71b-15cab421bb84 \
|
|
80
|
+
--output migrated.json \
|
|
81
|
+
--model gpt-5-mini
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`--output` takes a file or a directory. Given a directory, the filename comes from the input; left out, the
|
|
85
|
+
result lands in your temp directory. `--model` defaults to `gpt-5.6-terra`. Add `--debug` for step-by-step
|
|
86
|
+
logging on stderr.
|
|
87
|
+
|
|
88
|
+
## Python
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import asyncio, json
|
|
92
|
+
|
|
93
|
+
from langchain_core.messages import HumanMessage
|
|
94
|
+
|
|
95
|
+
from arms_agent.agent import build_migration_agent, build_response_format
|
|
96
|
+
from arms_agent.prompts import SYSTEM_PROMPT
|
|
97
|
+
from arms_agent.tools import all_tools
|
|
98
|
+
from arms_agent.workflow import build_workflow
|
|
99
|
+
|
|
100
|
+
template_iri = "https://repo.metadatacenter.org/templates/dd5e8653-81cf-470b-b71b-15cab421bb84"
|
|
101
|
+
legacy = json.load(open("legacy-record.json"))
|
|
102
|
+
|
|
103
|
+
agent = build_migration_agent(
|
|
104
|
+
model="gpt-5-mini",
|
|
105
|
+
system_prompt=SYSTEM_PROMPT,
|
|
106
|
+
response_format=build_response_format(template_iri),
|
|
107
|
+
tools=all_tools,
|
|
108
|
+
reasoning_effort="high",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
result = asyncio.run(
|
|
112
|
+
build_workflow(agent).ainvoke(
|
|
113
|
+
{
|
|
114
|
+
"messages": [
|
|
115
|
+
HumanMessage(
|
|
116
|
+
content=(
|
|
117
|
+
"Migrate the following legacy metadata record to the CEDAR template.\n\n"
|
|
118
|
+
f"CEDAR Template IRI: {template_iri}\n\n"
|
|
119
|
+
f"Legacy metadata:\n```json\n{json.dumps(legacy, indent=2)}\n```"
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
],
|
|
123
|
+
"cedar_template_iri": template_iri,
|
|
124
|
+
},
|
|
125
|
+
config={"recursion_limit": 30},
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
print(json.dumps(result["metadata"], indent=2))
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The agent answers against a JSON schema built from the template, so the result conforms to the template's field structure. When a model answers without a validated object, a fixed extraction step parses the text into one.
|
|
132
|
+
|
|
133
|
+
## Caching
|
|
134
|
+
|
|
135
|
+
CEDAR template and BioPortal term responses are cached in SQLite for 24 hours, to keep repeated runs fast and off the rate limits. Override with `ARMS_CACHE_DIR` and `ARMS_CACHE_TTL_SECONDS`.
|
|
136
|
+
|
|
137
|
+
## Other settings
|
|
138
|
+
|
|
139
|
+
| Variable | Default | What it does |
|
|
140
|
+
| --- | --- | --- |
|
|
141
|
+
| `OPENAI_EXTRACTION_MODEL` | `gpt-4.1-mini` | The model that parses a reply into an object when the main model answers without one. |
|
|
142
|
+
| `OPENAI_COST_MULTIPLIER` | `1.0` | Scales the reported cost when your endpoint charges a fraction of OpenAI's list prices. |
|
|
143
|
+
| `OPENAI_COST_CACHE_DISCOUNT` | `true` | Whether the endpoint discounts cached input tokens. |
|
|
144
|
+
|
|
145
|
+
Costs are local estimates from provider-reported token counts, not billed amounts.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
BSD 2-Clause.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# ARMS Agent
|
|
2
|
+
|
|
3
|
+
An LLM agent that standardizes legacy biomedical metadata records into the [CEDAR](https://metadatacenter.org/) template format.
|
|
4
|
+
|
|
5
|
+
It fetches the live CEDAR template and queries BioPortal for canonical terms through [Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) tools, so the constraints it applies are the ones the template holds right now.
|
|
6
|
+
|
|
7
|
+
This is the agent described in *Automated Standardization of Legacy Biomedical Metadata Using an Ontology-Constrained LLM Agent* ([arXiv:2604.08552](https://arxiv.org/abs/2604.08552)). The evaluation harness, the 839-record dataset, and the code for every figure in the paper live in the [project repository](https://github.com/musen-lab/metadata-standardization-agent).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install arms-agent
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Configure
|
|
16
|
+
|
|
17
|
+
Three keys are required. Put them in the environment, or in a `.env` file in the directory you run from:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
OPENAI_API_KEY=... # LLM calls
|
|
21
|
+
CEDAR_API_KEY=... # fetching CEDAR templates
|
|
22
|
+
BIOPORTAL_API_KEY=... # ontology term lookups
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Optional: set `OPENAI_BASE_URL` to route LLM calls through an OpenAI-compatible gateway.
|
|
26
|
+
|
|
27
|
+
To trace each LLM call, tool call, and agent step to [Langfuse](https://langfuse.com/), install the extra and
|
|
28
|
+
set both keys:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install 'arms-agent[tracing]'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
LANGFUSE_PUBLIC_KEY=...
|
|
36
|
+
LANGFUSE_SECRET_KEY=...
|
|
37
|
+
LANGFUSE_HOST=... # optional, defaults to Langfuse Cloud
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Tracing stays off until both keys are set, and `LANGFUSE_TRACING_ENABLED=false` switches it off while leaving
|
|
41
|
+
the keys in place.
|
|
42
|
+
|
|
43
|
+
## Command line
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
arms-migrate \
|
|
47
|
+
--input legacy-record.json \
|
|
48
|
+
--target-schema https://repo.metadatacenter.org/templates/dd5e8653-81cf-470b-b71b-15cab421bb84 \
|
|
49
|
+
--output migrated.json \
|
|
50
|
+
--model gpt-5-mini
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`--output` takes a file or a directory. Given a directory, the filename comes from the input; left out, the
|
|
54
|
+
result lands in your temp directory. `--model` defaults to `gpt-5.6-terra`. Add `--debug` for step-by-step
|
|
55
|
+
logging on stderr.
|
|
56
|
+
|
|
57
|
+
## Python
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio, json
|
|
61
|
+
|
|
62
|
+
from langchain_core.messages import HumanMessage
|
|
63
|
+
|
|
64
|
+
from arms_agent.agent import build_migration_agent, build_response_format
|
|
65
|
+
from arms_agent.prompts import SYSTEM_PROMPT
|
|
66
|
+
from arms_agent.tools import all_tools
|
|
67
|
+
from arms_agent.workflow import build_workflow
|
|
68
|
+
|
|
69
|
+
template_iri = "https://repo.metadatacenter.org/templates/dd5e8653-81cf-470b-b71b-15cab421bb84"
|
|
70
|
+
legacy = json.load(open("legacy-record.json"))
|
|
71
|
+
|
|
72
|
+
agent = build_migration_agent(
|
|
73
|
+
model="gpt-5-mini",
|
|
74
|
+
system_prompt=SYSTEM_PROMPT,
|
|
75
|
+
response_format=build_response_format(template_iri),
|
|
76
|
+
tools=all_tools,
|
|
77
|
+
reasoning_effort="high",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
result = asyncio.run(
|
|
81
|
+
build_workflow(agent).ainvoke(
|
|
82
|
+
{
|
|
83
|
+
"messages": [
|
|
84
|
+
HumanMessage(
|
|
85
|
+
content=(
|
|
86
|
+
"Migrate the following legacy metadata record to the CEDAR template.\n\n"
|
|
87
|
+
f"CEDAR Template IRI: {template_iri}\n\n"
|
|
88
|
+
f"Legacy metadata:\n```json\n{json.dumps(legacy, indent=2)}\n```"
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
],
|
|
92
|
+
"cedar_template_iri": template_iri,
|
|
93
|
+
},
|
|
94
|
+
config={"recursion_limit": 30},
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
print(json.dumps(result["metadata"], indent=2))
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The agent answers against a JSON schema built from the template, so the result conforms to the template's field structure. When a model answers without a validated object, a fixed extraction step parses the text into one.
|
|
101
|
+
|
|
102
|
+
## Caching
|
|
103
|
+
|
|
104
|
+
CEDAR template and BioPortal term responses are cached in SQLite for 24 hours, to keep repeated runs fast and off the rate limits. Override with `ARMS_CACHE_DIR` and `ARMS_CACHE_TTL_SECONDS`.
|
|
105
|
+
|
|
106
|
+
## Other settings
|
|
107
|
+
|
|
108
|
+
| Variable | Default | What it does |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| `OPENAI_EXTRACTION_MODEL` | `gpt-4.1-mini` | The model that parses a reply into an object when the main model answers without one. |
|
|
111
|
+
| `OPENAI_COST_MULTIPLIER` | `1.0` | Scales the reported cost when your endpoint charges a fraction of OpenAI's list prices. |
|
|
112
|
+
| `OPENAI_COST_CACHE_DISCOUNT` | `true` | Whether the endpoint discounts cached input tokens. |
|
|
113
|
+
|
|
114
|
+
Costs are local estimates from provider-reported token counts, not billed amounts.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
BSD 2-Clause.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "arms-agent"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "An ontology-constrained LLM agent that standardizes legacy biomedical metadata into CEDAR templates"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "BSD-2-Clause"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Josef Hardi" },
|
|
11
|
+
]
|
|
12
|
+
keywords = ["metadata", "cedar", "ontology", "biomedical", "langgraph", "llm-agent"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 5 - Production/Stable",
|
|
15
|
+
"Intended Audience :: Science/Research",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
dependencies = [
|
|
24
|
+
"langchain>=1.2,<2.0",
|
|
25
|
+
"langgraph>=1.0,<2.0",
|
|
26
|
+
"langchain-openai>=1.0,<2.0",
|
|
27
|
+
"langchain-core>=1.2,<2.0",
|
|
28
|
+
"pydantic>=2.0,<3.0",
|
|
29
|
+
"cedar-mcp>=1.3.0",
|
|
30
|
+
"python-dotenv>=1.2.1",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
# Every Langfuse import is function-local and gated on tracing_enabled(), so the
|
|
35
|
+
# agent runs without it. Only projects that trace pay for the SDK.
|
|
36
|
+
tracing = [
|
|
37
|
+
"langfuse>=4.14,<5.0",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/musen-lab/metadata-standardization-agent"
|
|
42
|
+
Repository = "https://github.com/musen-lab/metadata-standardization-agent"
|
|
43
|
+
Issues = "https://github.com/musen-lab/metadata-standardization-agent/issues"
|
|
44
|
+
Paper = "https://arxiv.org/abs/2604.08552"
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
arms-migrate = "arms_agent.__main__:main"
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["hatchling"]
|
|
51
|
+
build-backend = "hatchling.build"
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["src/arms_agent"]
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.sdist]
|
|
57
|
+
include = ["src/arms_agent", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
58
|
+
# Hatchling always ships the nearest VCS ignore file and cannot be told not to,
|
|
59
|
+
# so arms-agent/.gitignore exists to be that file. Without it the workspace's own
|
|
60
|
+
# ignore list is published, naming internal paths no installer needs.
|
|
61
|
+
|
|
62
|
+
# These rules mirror the workspace root, kept inline rather than extended from it.
|
|
63
|
+
# The sdist ships this file without its parent, so an `extend` there resolves to
|
|
64
|
+
# nothing and every ruff run inside an unpacked sdist fails.
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
target-version = "py312"
|
|
67
|
+
line-length = 120
|
|
68
|
+
src = ["src", "tests"]
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint]
|
|
71
|
+
select = [
|
|
72
|
+
"E", # pycodestyle errors
|
|
73
|
+
"W", # pycodestyle warnings
|
|
74
|
+
"F", # pyflakes
|
|
75
|
+
"I", # isort
|
|
76
|
+
"N", # pep8-naming
|
|
77
|
+
"UP", # pyupgrade
|
|
78
|
+
"B", # flake8-bugbear
|
|
79
|
+
"SIM", # flake8-simplify
|
|
80
|
+
"TCH", # flake8-type-checking
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
[tool.ruff.lint.isort]
|
|
84
|
+
known-first-party = ["arms_agent"]
|
|
85
|
+
|
|
86
|
+
[tool.ruff.lint.per-file-ignores]
|
|
87
|
+
# The system prompt is authored as Markdown; long lines and intentional
|
|
88
|
+
# trailing-space line breaks are part of the prompt text.
|
|
89
|
+
"src/arms_agent/prompts.py" = ["E501", "W291"]
|
|
90
|
+
|
|
91
|
+
[tool.pytest.ini_options]
|
|
92
|
+
testpaths = ["tests"]
|
|
93
|
+
addopts = "-v --tb=short"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Metadata Standardization Agent: A LangGraph agent for migrating legacy metadata to CEDAR template format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
# pyproject.toml is the only place the version is written. Reading it back
|
|
9
|
+
# from the installed distribution keeps a second copy from drifting.
|
|
10
|
+
__version__ = version("arms-agent")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
# A source tree that was never installed still has to import.
|
|
13
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Entry point for running the metadata standardization agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from dotenv import find_dotenv, load_dotenv
|
|
14
|
+
from langchain_core.messages import HumanMessage
|
|
15
|
+
|
|
16
|
+
from arms_agent.agent import build_migration_agent, build_response_format
|
|
17
|
+
from arms_agent.logging_config import configure_logging
|
|
18
|
+
from arms_agent.prompts import SYSTEM_PROMPT
|
|
19
|
+
from arms_agent.token_tracker import TokenUsageTracker
|
|
20
|
+
from arms_agent.tools import all_tools
|
|
21
|
+
from arms_agent.tracing import flush_tracing, instrument, traced_run
|
|
22
|
+
from arms_agent.workflow import build_workflow
|
|
23
|
+
|
|
24
|
+
# Load environment variables from the nearest .env, searching upward from the working
|
|
25
|
+
# directory. Counting parent directories from this file would only work inside a source
|
|
26
|
+
# checkout; installed from a wheel, the package sits in site-packages and has no project
|
|
27
|
+
# root above it. find_dotenv returns "" when there is no .env, and load_dotenv("") is a
|
|
28
|
+
# no-op, so a run without one is fine.
|
|
29
|
+
load_dotenv(find_dotenv(usecwd=True), override=True)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("arms_agent.__main__")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> None:
|
|
35
|
+
"""Run the migration agent with a legacy metadata record and CEDAR template IRI."""
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
description="Migrate a legacy metadata record to a CEDAR template format.",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("--input", required=True, help="Path to the legacy metadata JSON file.")
|
|
40
|
+
parser.add_argument("--target-schema", required=True, help="IRI of the CEDAR template to migrate to.")
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--output",
|
|
43
|
+
help="Output file path or directory. If a directory, the output filename is derived from the input. "
|
|
44
|
+
f"(default: {Path(tempfile.gettempdir()) / 'migrated-metadata.json'})",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--model",
|
|
48
|
+
default="gpt-5.6-terra",
|
|
49
|
+
help="LLM model identifier (default: gpt-5.6-terra).",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug logging to stderr.")
|
|
52
|
+
args = parser.parse_args()
|
|
53
|
+
|
|
54
|
+
configure_logging(args.debug)
|
|
55
|
+
|
|
56
|
+
logger.debug("Legacy metadata file: %s", args.input)
|
|
57
|
+
logger.debug("CEDAR template IRI: %s", args.target_schema)
|
|
58
|
+
|
|
59
|
+
with open(args.input) as f:
|
|
60
|
+
legacy_metadata = json.load(f)
|
|
61
|
+
|
|
62
|
+
user_message = (
|
|
63
|
+
f"Migrate the following legacy metadata record to the CEDAR template.\n\n"
|
|
64
|
+
f"CEDAR Template IRI: {args.target_schema}\n\n"
|
|
65
|
+
f"Legacy metadata:\n```json\n{json.dumps(legacy_metadata, indent=2)}\n```"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
agent = build_migration_agent(
|
|
69
|
+
model=args.model,
|
|
70
|
+
system_prompt=SYSTEM_PROMPT,
|
|
71
|
+
response_format=build_response_format(args.target_schema),
|
|
72
|
+
tools=all_tools,
|
|
73
|
+
reasoning_effort="high",
|
|
74
|
+
reasoning_mode="standard",
|
|
75
|
+
)
|
|
76
|
+
workflow = build_workflow(agent)
|
|
77
|
+
tracker = TokenUsageTracker()
|
|
78
|
+
input_stem = Path(args.input).stem
|
|
79
|
+
run_metadata = {
|
|
80
|
+
"input_file": Path(args.input).name,
|
|
81
|
+
"template_iri": args.target_schema,
|
|
82
|
+
"model": args.model,
|
|
83
|
+
}
|
|
84
|
+
run_config = instrument(
|
|
85
|
+
{
|
|
86
|
+
"recursion_limit": 30,
|
|
87
|
+
"callbacks": [tracker],
|
|
88
|
+
"run_name": f"migrate-{input_stem}",
|
|
89
|
+
"tags": ["cli", "migrate"],
|
|
90
|
+
"metadata": run_metadata,
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
start = time.perf_counter()
|
|
94
|
+
try:
|
|
95
|
+
with traced_run(f"migrate-{input_stem}", run_metadata):
|
|
96
|
+
result = asyncio.run(
|
|
97
|
+
workflow.ainvoke(
|
|
98
|
+
{
|
|
99
|
+
"messages": [HumanMessage(content=user_message)],
|
|
100
|
+
"cedar_template_iri": args.target_schema,
|
|
101
|
+
},
|
|
102
|
+
config=run_config,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
finally:
|
|
106
|
+
# Traces of a failed run are the ones worth keeping, so flush either way.
|
|
107
|
+
flush_tracing()
|
|
108
|
+
elapsed = time.perf_counter() - start
|
|
109
|
+
if args.output is None:
|
|
110
|
+
output_path = Path(tempfile.gettempdir()) / "migrated-metadata.json"
|
|
111
|
+
elif Path(args.output).is_dir():
|
|
112
|
+
output_path = Path(args.output) / f"{input_stem}.json"
|
|
113
|
+
else:
|
|
114
|
+
output_path = Path(args.output)
|
|
115
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
output_path.write_text(json.dumps(result["metadata"], indent=2) + "\n")
|
|
117
|
+
print(f"Output written to: {output_path}")
|
|
118
|
+
|
|
119
|
+
decisions_path = output_path.with_suffix(".decisions.json")
|
|
120
|
+
decisions_path.write_text(json.dumps(result.get("decisions") or [], indent=2) + "\n")
|
|
121
|
+
print(f"Processing log written to: {decisions_path}")
|
|
122
|
+
print(f"Execution time: {elapsed:.2f}s")
|
|
123
|
+
print(tracker.usage_summary())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""LangGraph agent for metadata migration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
9
|
+
|
|
10
|
+
from langchain.agents import create_agent
|
|
11
|
+
from langchain.agents.structured_output import ProviderStrategy
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
from langchain_core.tools import BaseTool
|
|
17
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
18
|
+
|
|
19
|
+
from arms_agent.schema import build_response_model
|
|
20
|
+
from arms_agent.state import AgentState
|
|
21
|
+
from arms_agent.tools import get_cedar_template
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# OpenAI o-series reasoning models don't support parallel_tool_calls
|
|
26
|
+
_O_SERIES = re.compile(r"^o\d")
|
|
27
|
+
|
|
28
|
+
# Which models take reasoning settings. Not gpt-5.6 alone: the o-series and the whole
|
|
29
|
+
# gpt-5 family accept them, and a non-reasoning model such as gpt-4.1 rejects the
|
|
30
|
+
# request outright with "'reasoning.effort' is not supported with this model".
|
|
31
|
+
_REASONING_MODELS = re.compile(r"^(o\d|gpt-5)")
|
|
32
|
+
|
|
33
|
+
# The values the API accepts, as it reports them when given anything else.
|
|
34
|
+
ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
|
35
|
+
ReasoningMode = Literal["standard", "pro"]
|
|
36
|
+
|
|
37
|
+
# Where the OpenAI-compatible API lives. ``OPENAI_BASE_URL`` is the OpenAI SDK's own
|
|
38
|
+
# name for this, so a gateway configured for any other OpenAI client works here
|
|
39
|
+
# unchanged; ``OPENAI_API_BASE`` is accepted as well because langchain reads that one.
|
|
40
|
+
_BASE_URL_VARS = ("OPENAI_BASE_URL", "OPENAI_API_BASE")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_base_url() -> str | None:
|
|
44
|
+
"""Return the API endpoint to call, or ``None`` for OpenAI's own.
|
|
45
|
+
|
|
46
|
+
Set one of the variables in :data:`_BASE_URL_VARS` to route every call through a
|
|
47
|
+
gateway, such as the Stanford API Gateway, instead of ``api.openai.com``. The key
|
|
48
|
+
in ``OPENAI_API_KEY`` then has to be the one that gateway issues.
|
|
49
|
+
"""
|
|
50
|
+
for name in _BASE_URL_VARS:
|
|
51
|
+
value = os.environ.get(name, "").strip()
|
|
52
|
+
if value:
|
|
53
|
+
return value
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _reasoning_kwargs(
|
|
58
|
+
model: str,
|
|
59
|
+
reasoning_effort: ReasoningEffort,
|
|
60
|
+
reasoning_mode: ReasoningMode,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""Return the reasoning settings to hand ``ChatOpenAI``.
|
|
63
|
+
|
|
64
|
+
A model that does not reason gets nothing, since it rejects the request outright.
|
|
65
|
+
"""
|
|
66
|
+
if not _REASONING_MODELS.match(model):
|
|
67
|
+
return {}
|
|
68
|
+
return {"reasoning": {"effort": reasoning_effort, "mode": reasoning_mode}}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_response_format(template_iri: str) -> ProviderStrategy:
|
|
72
|
+
"""Return the provider-enforced response format for *template_iri*.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ValueError: If the template cannot be fetched, which would otherwise bind an
|
|
76
|
+
empty schema and silently produce empty records for a whole run.
|
|
77
|
+
"""
|
|
78
|
+
template_dict = get_cedar_template.invoke({"template_id": template_iri})
|
|
79
|
+
if "error" in template_dict or not template_dict.get("children"):
|
|
80
|
+
msg = f"Cannot build a response schema for {template_iri}: {template_dict.get('error', 'no fields returned')}"
|
|
81
|
+
raise ValueError(msg)
|
|
82
|
+
return ProviderStrategy(build_response_model(template_dict), strict=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_migration_agent(
|
|
86
|
+
model: str,
|
|
87
|
+
system_prompt: str,
|
|
88
|
+
response_format: ProviderStrategy | None,
|
|
89
|
+
tools: Sequence[BaseTool],
|
|
90
|
+
reasoning_effort: ReasoningEffort = "low",
|
|
91
|
+
reasoning_mode: ReasoningMode = "standard",
|
|
92
|
+
) -> CompiledStateGraph:
|
|
93
|
+
"""Build the agent that performs the migration.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
model: The OpenAI model identifier to use.
|
|
97
|
+
system_prompt: The system prompt carrying the agent's policy.
|
|
98
|
+
response_format: The schema the final answer is bound to.
|
|
99
|
+
``None`` leaves the answer unconstrained, as free text the extraction node then has to parse.
|
|
100
|
+
tools: The tools this agent may call.
|
|
101
|
+
reasoning_effort: How much reasoning the model spends before answering.
|
|
102
|
+
Ignored by models that do not reason.
|
|
103
|
+
reasoning_mode: Which reasoning behaviour to use. Ignored by models that
|
|
104
|
+
do not reason.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
A compiled agent graph.
|
|
108
|
+
"""
|
|
109
|
+
from langchain_openai import ChatOpenAI
|
|
110
|
+
|
|
111
|
+
base_url = resolve_base_url()
|
|
112
|
+
reasoning_kwargs = _reasoning_kwargs(model, reasoning_effort, reasoning_mode)
|
|
113
|
+
|
|
114
|
+
logger.info(
|
|
115
|
+
"Building migration agent with model=%s, tools=%d, structured_output=%s, reasoning=%s, endpoint=%s",
|
|
116
|
+
model,
|
|
117
|
+
len(tools),
|
|
118
|
+
response_format is not None,
|
|
119
|
+
reasoning_kwargs or None,
|
|
120
|
+
base_url or "api.openai.com",
|
|
121
|
+
)
|
|
122
|
+
model_kwargs: dict[str, Any] = {}
|
|
123
|
+
if tools and not _O_SERIES.match(model):
|
|
124
|
+
model_kwargs["parallel_tool_calls"] = True
|
|
125
|
+
llm = ChatOpenAI(
|
|
126
|
+
base_url=base_url,
|
|
127
|
+
model=model,
|
|
128
|
+
temperature=0,
|
|
129
|
+
model_kwargs=model_kwargs,
|
|
130
|
+
**reasoning_kwargs,
|
|
131
|
+
)
|
|
132
|
+
return create_agent(
|
|
133
|
+
llm,
|
|
134
|
+
tools=tools,
|
|
135
|
+
system_prompt=system_prompt,
|
|
136
|
+
state_schema=AgentState,
|
|
137
|
+
response_format=response_format,
|
|
138
|
+
)
|