bioai-evidence-validator 0.4.1__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bioai_evidence_validator-0.4.1.dist-info → bioai_evidence_validator-0.5.0.dist-info}/METADATA +66 -8
- {bioai_evidence_validator-0.4.1.dist-info → bioai_evidence_validator-0.5.0.dist-info}/RECORD +8 -7
- bioevidence_validator/__init__.py +6 -1
- bioevidence_validator/cli.py +22 -0
- bioevidence_validator/draft.py +250 -0
- {bioai_evidence_validator-0.4.1.dist-info → bioai_evidence_validator-0.5.0.dist-info}/WHEEL +0 -0
- {bioai_evidence_validator-0.4.1.dist-info → bioai_evidence_validator-0.5.0.dist-info}/entry_points.txt +0 -0
- {bioai_evidence_validator-0.4.1.dist-info → bioai_evidence_validator-0.5.0.dist-info}/licenses/LICENSE +0 -0
{bioai_evidence_validator-0.4.1.dist-info → bioai_evidence_validator-0.5.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bioai-evidence-validator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Standards-aligned evidence policy validation for AI-assisted biological curation
|
|
5
5
|
Project-URL: Homepage, https://github.com/NingyuSUN/bioai-evidence-validator
|
|
6
6
|
Project-URL: Documentation, https://github.com/NingyuSUN/bioai-evidence-validator/tree/main/docs
|
|
@@ -34,6 +34,7 @@ Description-Content-Type: text/markdown
|
|
|
34
34
|
[](https://pypi.org/project/bioai-evidence-validator/)
|
|
35
35
|
[](https://github.com/NingyuSUN/bioai-evidence-validator/blob/main/pyproject.toml)
|
|
36
36
|
[](https://github.com/NingyuSUN/bioai-evidence-validator/blob/main/LICENSE)
|
|
37
|
+
[](https://colab.research.google.com/github/NingyuSUN/bioai-evidence-validator/blob/main/examples/quickstart.ipynb)
|
|
37
38
|
|
|
38
39
|
**Stop AI-extracted biological claims from entering your knowledge base or
|
|
39
40
|
training set before their evidence is good enough for that use.**
|
|
@@ -49,6 +50,9 @@ each requested use.
|
|
|
49
50
|
pip install bioai-evidence-validator
|
|
50
51
|
```
|
|
51
52
|
|
|
53
|
+
Or try it in the browser, nothing to install:
|
|
54
|
+
[quickstart notebook on Colab](https://colab.research.google.com/github/NingyuSUN/bioai-evidence-validator/blob/main/examples/quickstart.ipynb).
|
|
55
|
+
|
|
52
56
|
## 30-second example
|
|
53
57
|
|
|
54
58
|
The two records below are identical except for one field: how the supporting
|
|
@@ -105,13 +109,45 @@ injected faults; the full validator admitted **0/160**.
|
|
|
105
109
|
|
|
106
110
|
## Use it
|
|
107
111
|
|
|
112
|
+
### Write a draft, not a full record
|
|
113
|
+
|
|
114
|
+
A full record spells out identifiers, evidence lines and hashes. A draft states each fact
|
|
115
|
+
once; `bioevidence build` derives the rest and hashes local source files:
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
profile: literature-claim
|
|
119
|
+
uses: [research_summary]
|
|
120
|
+
statement:
|
|
121
|
+
subject: {id: "SYN:GENE_A", label: Synthetic gene A, type: gene}
|
|
122
|
+
predicate: associated_with
|
|
123
|
+
object: {id: "SYN:PHENOTYPE_A", label: Synthetic phenotype A, type: phenotype}
|
|
124
|
+
scope: ["taxon:synthetic"]
|
|
125
|
+
sources:
|
|
126
|
+
- {id: paper, title: Synthetic paper, type: publication, version: v1,
|
|
127
|
+
retrieved_at: "2026-09-21T00:00:00Z", file: synthetic_paper.txt}
|
|
128
|
+
evidence:
|
|
129
|
+
- {source: paper, locator: Table 2, type: publication_result,
|
|
130
|
+
method: llm_extraction, scope: ["taxon:synthetic"]}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
bioevidence build examples/drafts/llm_claim.yaml --output record.json
|
|
135
|
+
bioevidence validate record.json --profile literature-claim # exit 2: review required
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Building never fills in scope, extraction method, retrieval time or review decisions for
|
|
139
|
+
you. For LLM pipelines, `bioevidence draft-schema --profile literature-claim` prints a JSON
|
|
140
|
+
Schema for structured output. See the [draft format](https://github.com/NingyuSUN/bioai-evidence-validator/blob/main/docs/DRAFTS.md).
|
|
141
|
+
|
|
108
142
|
### Command line
|
|
109
143
|
|
|
110
144
|
```bash
|
|
111
145
|
bioevidence profiles # list built-in profiles and their use contracts
|
|
146
|
+
bioevidence build draft.yaml --output record.json # expand a compact draft
|
|
112
147
|
bioevidence validate record.json --profile literature-claim
|
|
113
148
|
bioevidence validate record.json --profile my_profile.yaml --output report.json
|
|
114
|
-
bioevidence
|
|
149
|
+
bioevidence draft-schema --profile literature-claim # JSON Schema for drafts (e.g. LLM output)
|
|
150
|
+
bioevidence generate-schema --output record.schema.json # JSON Schema for full records
|
|
115
151
|
```
|
|
116
152
|
|
|
117
153
|
Exit codes: **0** admitted, **1** rejected, **2** review required, **3** input or configuration error.
|
|
@@ -119,12 +155,9 @@ Exit codes: **0** admitted, **1** rejected, **2** review required, **3** input o
|
|
|
119
155
|
### Python
|
|
120
156
|
|
|
121
157
|
```python
|
|
122
|
-
import
|
|
123
|
-
from pathlib import Path
|
|
124
|
-
|
|
125
|
-
from bioevidence_validator.engine import validate_record
|
|
158
|
+
from bioevidence_validator import build_record, load_draft, validate_record
|
|
126
159
|
|
|
127
|
-
record =
|
|
160
|
+
record = build_record(load_draft("draft.yaml"), base_dir=".") # or load a full record JSON
|
|
128
161
|
report = validate_record(record, profile="literature-claim")
|
|
129
162
|
|
|
130
163
|
for decision in report["use_decisions"]:
|
|
@@ -133,6 +166,30 @@ for decision in report["use_decisions"]:
|
|
|
133
166
|
|
|
134
167
|
`profile` accepts a built-in name or a path to your own YAML profile.
|
|
135
168
|
|
|
169
|
+
### Check records in CI
|
|
170
|
+
|
|
171
|
+
Validate every record or draft in a pull request, with a summary table and inline annotations:
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
# .github/workflows/evidence.yml
|
|
175
|
+
on: pull_request
|
|
176
|
+
jobs:
|
|
177
|
+
evidence:
|
|
178
|
+
runs-on: ubuntu-latest
|
|
179
|
+
steps:
|
|
180
|
+
- uses: actions/checkout@v4
|
|
181
|
+
- uses: NingyuSUN/bioai-evidence-validator@v0.5.0
|
|
182
|
+
with:
|
|
183
|
+
files: records/**/*.yaml # whitespace-separated globs
|
|
184
|
+
format: draft # or: record (default)
|
|
185
|
+
profile: literature-claim # built-in name or path to your profile YAML
|
|
186
|
+
fail-on: review # or: rejected
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
With `fail-on: review` (default) the job fails unless every file is admitted; with
|
|
190
|
+
`fail-on: rejected` it fails only on rejected files or files that cannot be read. The
|
|
191
|
+
action's outputs `admitted`, `review_required`, `rejected` and `error` hold the counts.
|
|
192
|
+
|
|
136
193
|
## How it works
|
|
137
194
|
|
|
138
195
|
```mermaid
|
|
@@ -222,7 +279,7 @@ projection. External source truth and cohort independence require upstream verif
|
|
|
222
279
|
|
|
223
280
|
## Versions and branches
|
|
224
281
|
|
|
225
|
-
`main` is the domain-neutral framework (0.
|
|
282
|
+
`main` is the domain-neutral framework (0.5.0). The complete canine implementation
|
|
226
283
|
and SQLite adapter from 0.3 live on the
|
|
227
284
|
[`canine-breed` branch](https://github.com/NingyuSUN/bioai-evidence-validator/tree/canine-breed);
|
|
228
285
|
see the [0.4 migration guide](https://github.com/NingyuSUN/bioai-evidence-validator/blob/main/docs/MIGRATION-0.4.md) and
|
|
@@ -235,6 +292,7 @@ If you use this toolkit in research, please cite it using the metadata in
|
|
|
235
292
|
(GitHub's "Cite this repository" button generates APA and BibTeX).
|
|
236
293
|
|
|
237
294
|
[Create a profile](https://github.com/NingyuSUN/bioai-evidence-validator/blob/main/docs/PROFILES.md) ·
|
|
295
|
+
[Draft format](https://github.com/NingyuSUN/bioai-evidence-validator/blob/main/docs/DRAFTS.md) ·
|
|
238
296
|
[Engineering contract](https://github.com/NingyuSUN/bioai-evidence-validator/blob/main/docs/ENGINEERING.md) ·
|
|
239
297
|
[Design case study](https://github.com/NingyuSUN/bioai-evidence-validator/blob/main/docs/CASE_STUDY.md) ·
|
|
240
298
|
[Architecture decision](https://github.com/NingyuSUN/bioai-evidence-validator/blob/main/docs/ADR-002-domain-neutral-main.md) ·
|
{bioai_evidence_validator-0.4.1.dist-info → bioai_evidence_validator-0.5.0.dist-info}/RECORD
RENAMED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
bioevidence_validator/__init__.py,sha256=
|
|
2
|
-
bioevidence_validator/cli.py,sha256=
|
|
1
|
+
bioevidence_validator/__init__.py,sha256=O0lprvrrJN270yZxpMRi7jF6CTawvgc-f36QbAm0HgU,312
|
|
2
|
+
bioevidence_validator/cli.py,sha256=50z5Aj6y6L3L9bwI_KZLMMYvtNHpWfwWY2O_XXGNVNI,5442
|
|
3
3
|
bioevidence_validator/config.py,sha256=Ahc9eRPNR_ArqnbnJkdZsPFq9yERTYied0oGwUkANYI,1110
|
|
4
|
+
bioevidence_validator/draft.py,sha256=8m5Yh68advpWrDb3m6ivkWa0Cq2SEM8CCbevBqXoRec,13552
|
|
4
5
|
bioevidence_validator/engine.py,sha256=fm1gIOySsLiBbI3SklbEStiMD93RrhR3lA0vqdLJJXQ,15003
|
|
5
6
|
bioevidence_validator/profiles/dataset-label.yaml,sha256=4utz9YcFFF2UnaLjIb0ATb-27hF8Smw7zmv0sBTaQdM,843
|
|
6
7
|
bioevidence_validator/profiles/general.yaml,sha256=i9fJCWovJZ2e2d4rbn0Bx7mrhumNb2zjS9TOarkBWZw,778
|
|
7
8
|
bioevidence_validator/profiles/literature-claim.yaml,sha256=7l82gX3oGGyYFq9B29-1qGXeL4LRcMhJi8Y9fieVE0c,612
|
|
8
9
|
bioevidence_validator/schema/bioevidence_core.yaml,sha256=18MMngua0jlu4VnAbpb7SfI3numnCdjO986bZP6-Xn4,6700
|
|
9
|
-
bioai_evidence_validator-0.
|
|
10
|
-
bioai_evidence_validator-0.
|
|
11
|
-
bioai_evidence_validator-0.
|
|
12
|
-
bioai_evidence_validator-0.
|
|
13
|
-
bioai_evidence_validator-0.
|
|
10
|
+
bioai_evidence_validator-0.5.0.dist-info/METADATA,sha256=1gG5J1j0CxRTbQJDYGBYz7vhMMzqqy7K_uqLE6cQ9ds,15360
|
|
11
|
+
bioai_evidence_validator-0.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
bioai_evidence_validator-0.5.0.dist-info/entry_points.txt,sha256=6oufzT2x1nIhTqM5B6y4aWubExKAoFb_wXXTdz3mBQQ,63
|
|
13
|
+
bioai_evidence_validator-0.5.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
14
|
+
bioai_evidence_validator-0.5.0.dist-info/RECORD,,
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
"""Evidence validation primitives and domain policy runners."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.5.0"
|
|
4
|
+
|
|
5
|
+
from .draft import build_record, draft_json_schema, load_draft # noqa: E402
|
|
6
|
+
from .engine import validate_record # noqa: E402
|
|
7
|
+
|
|
8
|
+
__all__ = ["__version__", "build_record", "draft_json_schema", "load_draft", "validate_record"]
|
bioevidence_validator/cli.py
CHANGED
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
import yaml
|
|
11
11
|
|
|
12
|
+
from .draft import build_record, draft_json_schema, load_draft
|
|
12
13
|
from .engine import default_schema_path, generate_json_schema, validate_record, profile_path, list_profiles
|
|
13
14
|
|
|
14
15
|
|
|
@@ -20,6 +21,12 @@ def parser() -> argparse.ArgumentParser:
|
|
|
20
21
|
validate.add_argument("--output", type=Path)
|
|
21
22
|
validate.add_argument("--schema", type=Path, default=default_schema_path())
|
|
22
23
|
validate.add_argument("--profile", default="general", help="Built-in profile name or YAML file path")
|
|
24
|
+
build = commands.add_parser("build", help="Expand a compact YAML/JSON draft into a full record")
|
|
25
|
+
build.add_argument("draft", type=Path)
|
|
26
|
+
build.add_argument("--output", type=Path)
|
|
27
|
+
draft_schema = commands.add_parser("draft-schema", help="JSON Schema for drafts under one profile")
|
|
28
|
+
draft_schema.add_argument("--profile", default="general", help="Built-in profile name or YAML file path")
|
|
29
|
+
draft_schema.add_argument("--output", type=Path)
|
|
23
30
|
generate = commands.add_parser("generate-schema", help="Generate JSON Schema from LinkML")
|
|
24
31
|
generate.add_argument("--output", type=Path, required=True)
|
|
25
32
|
generate.add_argument("--schema", type=Path, default=default_schema_path())
|
|
@@ -81,6 +88,21 @@ def _run(args) -> int:
|
|
|
81
88
|
print(json.dumps(list_profiles(), indent=2))
|
|
82
89
|
return 0
|
|
83
90
|
|
|
91
|
+
if args.command in {"build", "draft-schema"}:
|
|
92
|
+
if args.command == "build":
|
|
93
|
+
if args.output and args.output.resolve() == args.draft.resolve():
|
|
94
|
+
raise ValueError("Output must not overwrite the draft")
|
|
95
|
+
draft = load_draft(args.draft)
|
|
96
|
+
_check_depth(draft)
|
|
97
|
+
result = build_record(draft, base_dir=args.draft.parent)
|
|
98
|
+
else:
|
|
99
|
+
result = draft_json_schema(args.profile)
|
|
100
|
+
if args.output:
|
|
101
|
+
_write_json(args.output, result)
|
|
102
|
+
else:
|
|
103
|
+
print(json.dumps(result, indent=2))
|
|
104
|
+
return 0
|
|
105
|
+
|
|
84
106
|
selected_profile = profile_path(args.profile)
|
|
85
107
|
protected = [args.input, args.schema, selected_profile, default_schema_path()]
|
|
86
108
|
if args.output and args.output.resolve() in {p.resolve() for p in protected}:
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Compact drafts: a short, strict authoring format that expands to a full evidence record.
|
|
2
|
+
|
|
3
|
+
A draft names each fact once. Building it derives identifiers, groups evidence items into
|
|
4
|
+
evidence lines by direction, and hashes local source files. It never fills in facts the
|
|
5
|
+
caller did not state: scopes, extraction methods, retrieval times and reviewer decisions
|
|
6
|
+
must be written explicitly, and the resulting record still goes through full validation.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .config import load_mapping, nonblank
|
|
17
|
+
from .engine import load_profile, profile_path
|
|
18
|
+
|
|
19
|
+
DRAFT_KEYS = {"required": {"profile", "uses", "statement", "sources", "evidence"},
|
|
20
|
+
"optional": {"id", "status", "reviews"}}
|
|
21
|
+
STATEMENT_KEYS = {"required": {"subject", "predicate", "object", "scope"}, "optional": set()}
|
|
22
|
+
ENTITY_KEYS = {"required": {"id", "label", "type"}, "optional": set()}
|
|
23
|
+
SOURCE_KEYS = {"required": {"id", "title", "type", "version", "retrieved_at"},
|
|
24
|
+
"optional": {"uri", "sha256", "file"}}
|
|
25
|
+
EVIDENCE_KEYS = {"required": {"source", "locator", "type", "method", "scope"},
|
|
26
|
+
"optional": {"text", "direction"}}
|
|
27
|
+
REVIEW_KEYS = {"required": {"reviewer", "decision", "uses", "rationale", "decided_at"}, "optional": set()}
|
|
28
|
+
REVIEWER_KEYS = {"required": {"id", "type"}, "optional": {"name"}}
|
|
29
|
+
|
|
30
|
+
STATUSES = ["proposed", "accepted", "rejected", "superseded"]
|
|
31
|
+
SOURCE_TYPES = ["ontology_snapshot", "registry_snapshot", "dataset_snapshot", "publication", "web_page", "local_file"]
|
|
32
|
+
METHODS = ["deterministic_parser", "manual_curation", "normalized_string_match", "llm_extraction"]
|
|
33
|
+
DIRECTIONS = ["supports", "contradicts", "neutral"]
|
|
34
|
+
DECISIONS = ["accept", "reject", "defer"]
|
|
35
|
+
AGENT_TYPES = ["human", "software", "organization"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fields(value: Any, keys: dict, path: str) -> dict:
|
|
39
|
+
if not isinstance(value, dict):
|
|
40
|
+
raise ValueError(f"{path} must be a mapping")
|
|
41
|
+
missing = keys["required"] - value.keys()
|
|
42
|
+
unknown = value.keys() - keys["required"] - keys["optional"]
|
|
43
|
+
if missing:
|
|
44
|
+
raise ValueError(f"{path} is missing: {', '.join(sorted(missing))}")
|
|
45
|
+
if unknown:
|
|
46
|
+
raise ValueError(f"{path} has unknown fields: {', '.join(sorted(map(str, unknown)))}")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _text(value: Any, path: str, choices: list[str] | None = None) -> str:
|
|
51
|
+
if isinstance(value, (dt.date, dt.datetime)):
|
|
52
|
+
value = value.isoformat() # YAML loads unquoted timestamps as datetime objects.
|
|
53
|
+
if not nonblank(value):
|
|
54
|
+
raise ValueError(f"{path} must be a nonblank string (quote numbers and versions in YAML)")
|
|
55
|
+
if choices is not None and value not in choices:
|
|
56
|
+
raise ValueError(f"{path} must be one of: {', '.join(choices)}")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _texts(value: Any, path: str) -> list[str]:
|
|
61
|
+
if not isinstance(value, list) or not value:
|
|
62
|
+
raise ValueError(f"{path} must be a nonempty list")
|
|
63
|
+
items = [_text(item, f"{path}[{index}]") for index, item in enumerate(value)]
|
|
64
|
+
if len(set(items)) != len(items):
|
|
65
|
+
raise ValueError(f"{path} must not repeat values")
|
|
66
|
+
return items
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _rows(value: Any, path: str, required: bool = True) -> list:
|
|
70
|
+
if not isinstance(value, list) or (required and not value):
|
|
71
|
+
raise ValueError(f"{path} must be a {'nonempty ' if required else ''}list")
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _entity(value: Any, path: str) -> dict:
|
|
76
|
+
entity = _fields(value, ENTITY_KEYS, path)
|
|
77
|
+
return {"id": _text(entity["id"], path + ".id"), "label": _text(entity["label"], path + ".label"),
|
|
78
|
+
"entity_type": _text(entity["type"], path + ".type")}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _source(value: Any, path: str, base_dir: Path) -> tuple[str, dict]:
|
|
82
|
+
source = _fields(value, SOURCE_KEYS, path)
|
|
83
|
+
if "sha256" not in source and "file" not in source:
|
|
84
|
+
raise ValueError(f"{path} needs sha256, file, or both")
|
|
85
|
+
artifact = {"title": _text(source["title"], path + ".title"),
|
|
86
|
+
"source_type": _text(source["type"], path + ".type", SOURCE_TYPES)}
|
|
87
|
+
if "uri" in source:
|
|
88
|
+
artifact["uri"] = _text(source["uri"], path + ".uri")
|
|
89
|
+
artifact.update({"version": _text(source["version"], path + ".version"),
|
|
90
|
+
"retrieved_at": _text(source["retrieved_at"], path + ".retrieved_at")})
|
|
91
|
+
observed = None
|
|
92
|
+
if "file" in source:
|
|
93
|
+
file = base_dir / _text(source["file"], path + ".file")
|
|
94
|
+
if not file.is_file():
|
|
95
|
+
raise ValueError(f"{path}.file does not exist: {file}")
|
|
96
|
+
observed = hashlib.sha256(file.read_bytes()).hexdigest()
|
|
97
|
+
# A stated hash is the frozen reference; a file hash is what was observed now.
|
|
98
|
+
# With both, validation reports a mismatch (BEV002) instead of trusting either.
|
|
99
|
+
artifact["sha256"] = _text(source["sha256"], path + ".sha256").lower() if "sha256" in source else observed
|
|
100
|
+
if "sha256" in source and observed is not None:
|
|
101
|
+
artifact["observed_sha256"] = observed
|
|
102
|
+
return _text(source["id"], path + ".id"), artifact
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_draft(path: Path) -> dict:
|
|
106
|
+
"""Read a YAML or JSON draft with duplicate-key rejection."""
|
|
107
|
+
return load_mapping(Path(path).read_bytes(), "Draft")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_record(draft: dict, *, base_dir: Path | str = ".") -> dict[str, Any]:
|
|
111
|
+
"""Expand a compact draft into a full record. Raises ValueError on any malformed field."""
|
|
112
|
+
draft = _fields(draft, DRAFT_KEYS, "draft")
|
|
113
|
+
base_dir = Path(base_dir)
|
|
114
|
+
profile = _text(draft["profile"], "draft.profile")
|
|
115
|
+
uses = _texts(draft["uses"], "draft.uses")
|
|
116
|
+
statement = _fields(draft["statement"], STATEMENT_KEYS, "draft.statement")
|
|
117
|
+
scope = _texts(statement["scope"], "draft.statement.scope")
|
|
118
|
+
|
|
119
|
+
record_id = _text(draft["id"], "draft.id") if "id" in draft else None
|
|
120
|
+
if record_id is None:
|
|
121
|
+
canonical = json.dumps(draft, sort_keys=True, separators=(",", ":"), default=str).encode()
|
|
122
|
+
record_id = "bioev:draft-" + hashlib.sha256(canonical).hexdigest()[:16]
|
|
123
|
+
|
|
124
|
+
sources, local_ids = [], {}
|
|
125
|
+
for index, row in enumerate(_rows(draft["sources"], "draft.sources")):
|
|
126
|
+
name, artifact = _source(row, f"draft.sources[{index}]", base_dir)
|
|
127
|
+
if name in local_ids:
|
|
128
|
+
raise ValueError(f"draft.sources[{index}].id repeats {name!r}")
|
|
129
|
+
local_ids[name] = f"{record_id}/source/{name}"
|
|
130
|
+
sources.append({"id": local_ids[name], **artifact})
|
|
131
|
+
|
|
132
|
+
items, lines = [], {}
|
|
133
|
+
for index, row in enumerate(_rows(draft["evidence"], "draft.evidence")):
|
|
134
|
+
path = f"draft.evidence[{index}]"
|
|
135
|
+
evidence = _fields(row, EVIDENCE_KEYS, path)
|
|
136
|
+
source = _text(evidence["source"], path + ".source")
|
|
137
|
+
if source not in local_ids:
|
|
138
|
+
raise ValueError(f"{path}.source {source!r} is not a declared source id")
|
|
139
|
+
item = {"id": f"{record_id}/evidence/{index + 1}", "source_artifact_id": local_ids[source],
|
|
140
|
+
"locator": _text(evidence["locator"], path + ".locator")}
|
|
141
|
+
if "text" in evidence:
|
|
142
|
+
item["extracted_text"] = _text(evidence["text"], path + ".text")
|
|
143
|
+
item.update({"evidence_type": _text(evidence["type"], path + ".type"),
|
|
144
|
+
"extraction_method": _text(evidence["method"], path + ".method", METHODS),
|
|
145
|
+
"scope": _texts(evidence["scope"], path + ".scope")})
|
|
146
|
+
items.append(item)
|
|
147
|
+
direction = _text(evidence.get("direction", "supports"), path + ".direction", DIRECTIONS)
|
|
148
|
+
lines.setdefault(direction, []).append(item["id"])
|
|
149
|
+
|
|
150
|
+
statement_id = f"{record_id}/statement"
|
|
151
|
+
adjudications = []
|
|
152
|
+
for index, row in enumerate(_rows(draft.get("reviews", []), "draft.reviews", required=False)):
|
|
153
|
+
path = f"draft.reviews[{index}]"
|
|
154
|
+
review = _fields(row, REVIEW_KEYS, path)
|
|
155
|
+
reviewer = _fields(review["reviewer"], REVIEWER_KEYS, path + ".reviewer")
|
|
156
|
+
agent = {"id": _text(reviewer["id"], path + ".reviewer.id")}
|
|
157
|
+
if "name" in reviewer:
|
|
158
|
+
agent["name"] = _text(reviewer["name"], path + ".reviewer.name")
|
|
159
|
+
agent["agent_type"] = _text(reviewer["type"], path + ".reviewer.type", AGENT_TYPES)
|
|
160
|
+
adjudications.append({"id": f"{record_id}/review/{index + 1}", "statement_id": statement_id,
|
|
161
|
+
"applies_to_uses": _texts(review["uses"], path + ".uses"),
|
|
162
|
+
"decision": _text(review["decision"], path + ".decision", DECISIONS),
|
|
163
|
+
"reviewer": agent, "rationale": _text(review["rationale"], path + ".rationale"),
|
|
164
|
+
"decided_at": _text(review["decided_at"], path + ".decided_at")})
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
"record_id": record_id,
|
|
168
|
+
"profile_id": profile,
|
|
169
|
+
"statement": {
|
|
170
|
+
"id": statement_id,
|
|
171
|
+
"subject": _entity(statement["subject"], "draft.statement.subject"),
|
|
172
|
+
"predicate": _text(statement["predicate"], "draft.statement.predicate"),
|
|
173
|
+
"object": _entity(statement["object"], "draft.statement.object"),
|
|
174
|
+
"scope": scope,
|
|
175
|
+
"evidence_lines": [{"id": f"{record_id}/line/{direction}", "direction": direction,
|
|
176
|
+
"evidence_item_ids": ids} for direction, ids in lines.items()],
|
|
177
|
+
"statement_status": _text(draft.get("status", "proposed"), "draft.status", STATUSES),
|
|
178
|
+
},
|
|
179
|
+
"source_artifacts": sources,
|
|
180
|
+
"evidence_items": items,
|
|
181
|
+
"adjudications": adjudications,
|
|
182
|
+
"requested_uses": uses,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _object(keys: dict, properties: dict, description: str | None = None) -> dict:
|
|
187
|
+
if properties.keys() != keys["required"] | keys["optional"]:
|
|
188
|
+
raise RuntimeError("Draft schema and parser fields diverged")
|
|
189
|
+
schema = {"type": "object", "additionalProperties": False,
|
|
190
|
+
"required": sorted(keys["required"]), "properties": properties}
|
|
191
|
+
return {"description": description, **schema} if description else schema
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _choice(values: list[str], description: str | None = None) -> dict:
|
|
195
|
+
if not values:
|
|
196
|
+
return {"type": "string", "minLength": 1, **({"description": description} if description else {})}
|
|
197
|
+
return {"type": "string", "enum": list(values), **({"description": description} if description else {})}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def draft_json_schema(profile: str | Path = "general") -> dict[str, Any]:
|
|
201
|
+
"""JSON Schema for drafts under one profile, e.g. for LLM structured output.
|
|
202
|
+
|
|
203
|
+
Profile allowlists become enums. The schema guides authoring only; build_record and
|
|
204
|
+
validation remain authoritative.
|
|
205
|
+
"""
|
|
206
|
+
contract = load_profile(profile_path(profile).read_bytes())
|
|
207
|
+
evidence_types = sorted({kind for use in contract["uses"].values() for kind in use["required_evidence_types"]})
|
|
208
|
+
text = {"type": "string", "minLength": 1}
|
|
209
|
+
tokens = {"type": "array", "minItems": 1, "uniqueItems": True, "items": text}
|
|
210
|
+
|
|
211
|
+
def entity(types):
|
|
212
|
+
return _object(ENTITY_KEYS, {"id": {**text, "description": "Stable identifier, e.g. a CURIE."},
|
|
213
|
+
"label": text, "type": _choice(types)})
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
217
|
+
"title": f"BioAI evidence draft ({contract['id']} profile {contract['version']})",
|
|
218
|
+
**_object(DRAFT_KEYS, {
|
|
219
|
+
"id": {**text, "description": "Optional record id; derived from the draft content when omitted."},
|
|
220
|
+
"profile": {"const": contract["id"]},
|
|
221
|
+
"uses": {"type": "array", "minItems": 1, "uniqueItems": True,
|
|
222
|
+
"items": _choice(sorted(contract["uses"]))},
|
|
223
|
+
"status": _choice(STATUSES, "Defaults to proposed."),
|
|
224
|
+
"statement": _object(STATEMENT_KEYS, {
|
|
225
|
+
"subject": entity(contract["subject_types"]),
|
|
226
|
+
"predicate": _choice(contract["predicates"]),
|
|
227
|
+
"object": entity(contract["object_types"]),
|
|
228
|
+
"scope": {**tokens, "description": "Explicit scope tokens, e.g. taxon:9606."}}),
|
|
229
|
+
"sources": {"type": "array", "minItems": 1, "items": _object(SOURCE_KEYS, {
|
|
230
|
+
"id": {**text, "description": "Local name referenced by evidence[].source."},
|
|
231
|
+
"title": text, "type": _choice(SOURCE_TYPES), "uri": text, "version": text,
|
|
232
|
+
"retrieved_at": {"type": "string", "format": "date-time"},
|
|
233
|
+
"sha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$",
|
|
234
|
+
"description": "Frozen source hash."},
|
|
235
|
+
"file": {**text, "description": "Local file hashed at build time, relative to the draft."}},
|
|
236
|
+
"Provide sha256, file, or both.")},
|
|
237
|
+
"evidence": {"type": "array", "minItems": 1, "items": _object(EVIDENCE_KEYS, {
|
|
238
|
+
"source": text, "locator": {**text, "description": "Where in the source, e.g. 'Table 2'."},
|
|
239
|
+
"text": text, "type": _choice(evidence_types),
|
|
240
|
+
"method": _choice(METHODS, "Set by the pipeline that produced this item, not by a model."),
|
|
241
|
+
"scope": {**tokens, "description": "Scope the source actually supports."},
|
|
242
|
+
"direction": _choice(DIRECTIONS, "Defaults to supports.")})},
|
|
243
|
+
"reviews": {"type": "array", "items": _object(REVIEW_KEYS, {
|
|
244
|
+
"reviewer": _object(REVIEWER_KEYS, {"id": text, "name": text, "type": _choice(AGENT_TYPES)}),
|
|
245
|
+
"decision": _choice(DECISIONS),
|
|
246
|
+
"uses": {"type": "array", "minItems": 1, "uniqueItems": True,
|
|
247
|
+
"items": _choice(sorted(contract["uses"]))},
|
|
248
|
+
"rationale": text, "decided_at": {"type": "string", "format": "date-time"}})},
|
|
249
|
+
}),
|
|
250
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|