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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bioai-evidence-validator
3
- Version: 0.4.1
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
  [![PyPI](https://img.shields.io/pypi/v/bioai-evidence-validator)](https://pypi.org/project/bioai-evidence-validator/)
35
35
  [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://github.com/NingyuSUN/bioai-evidence-validator/blob/main/pyproject.toml)
36
36
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/NingyuSUN/bioai-evidence-validator/blob/main/LICENSE)
37
+ [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](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 generate-schema --output record.schema.json # JSON Schema for the input format
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 json
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 = json.loads(Path("record.json").read_text(encoding="utf-8"))
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.4.1). The complete canine implementation
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) ·
@@ -1,13 +1,14 @@
1
- bioevidence_validator/__init__.py,sha256=w-OLV_MserELKh9U9FrLM8ZICJqNoWNOlqMwglL1lIk,87
2
- bioevidence_validator/cli.py,sha256=fknYldtjN50OvguGMcp_kmAky5PxZ0i5WRUF4HEQVkA,4326
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.4.1.dist-info/METADATA,sha256=JkN3mmJrD-cx86fBjdrjrTaxadrM1SdzbTrtHv81uO0,12563
10
- bioai_evidence_validator-0.4.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
- bioai_evidence_validator-0.4.1.dist-info/entry_points.txt,sha256=6oufzT2x1nIhTqM5B6y4aWubExKAoFb_wXXTdz3mBQQ,63
12
- bioai_evidence_validator-0.4.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
13
- bioai_evidence_validator-0.4.1.dist-info/RECORD,,
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.4.1"
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"]
@@ -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
+ }