psyclaw 0.28.1 → 0.28.2
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.
- package/README.md +5 -5
- package/agents/recommended/catalog.json +1 -1
- package/dist/src/adapters/pi/extension.js +43 -9
- package/dist/src/adapters/pi/extension.js.map +1 -1
- package/dist/src/ars/mode-editor.d.ts +20 -0
- package/dist/src/ars/mode-editor.js +57 -0
- package/dist/src/ars/mode-editor.js.map +1 -0
- package/dist/src/ars/profile.d.ts +4 -1
- package/dist/src/ars/profile.js +23 -6
- package/dist/src/ars/profile.js.map +1 -1
- package/package.json +10 -3
- package/vendor/academic-paper-skills/LICENSE +21 -0
- package/vendor/academic-paper-skills/NOTICE.md +7 -0
- package/vendor/academic-paper-skills/PSYCLAW_SOURCE.json +14 -0
- package/vendor/academic-paper-skills/academic-paper-composer/SKILL.md +840 -0
- package/vendor/academic-paper-skills/academic-paper-composer/references/section_guides.md +675 -0
- package/vendor/academic-paper-skills/academic-paper-composer/references/writing_standards.md +629 -0
- package/vendor/academic-paper-skills/academic-paper-composer/scripts/chapter_quality_check.py +470 -0
- package/vendor/academic-paper-skills/academic-paper-composer/scripts/final_evaluation.py +550 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/SKILL.md +670 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/references/quality_standards.md +336 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/references/search_strategy.md +459 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/scripts/evaluate_samples.py +300 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/scripts/gap_analysis.py +399 -0
- package/vendor/ars/pi/wrapper.js +9 -0
- package/vendor/nature-skills/LICENSE +201 -0
- package/vendor/nature-skills/NOTICE.md +7 -0
- package/vendor/nature-skills/PSYCLAW_SOURCE.json +19 -0
- package/vendor/nature-skills/skills/nature-figure/README.md +107 -0
- package/vendor/nature-skills/skills/nature-figure/README_EN.md +107 -0
- package/vendor/nature-skills/skills/nature-figure/SKILL.md +154 -0
- package/vendor/nature-skills/skills/nature-figure/agents/openai.yaml +4 -0
- package/vendor/nature-skills/skills/nature-figure/evals/evals.json +325 -0
- package/vendor/nature-skills/skills/nature-figure/manifest.yaml +103 -0
- package/vendor/nature-skills/skills/nature-figure/references/ai-graphical-abstract-workflow.md +129 -0
- package/vendor/nature-skills/skills/nature-figure/references/api.md +576 -0
- package/vendor/nature-skills/skills/nature-figure/references/asset-adaptation.md +72 -0
- package/vendor/nature-skills/skills/nature-figure/references/backend-selection.md +118 -0
- package/vendor/nature-skills/skills/nature-figure/references/chart-types.md +348 -0
- package/vendor/nature-skills/skills/nature-figure/references/common-patterns.md +371 -0
- package/vendor/nature-skills/skills/nature-figure/references/demos.md +58 -0
- package/vendor/nature-skills/skills/nature-figure/references/design-theory.md +467 -0
- package/vendor/nature-skills/skills/nature-figure/references/figure-contract.md +122 -0
- package/vendor/nature-skills/skills/nature-figure/references/figure-legend-conventions.md +90 -0
- package/vendor/nature-skills/skills/nature-figure/references/multipanel-evidence-architecture.md +268 -0
- package/vendor/nature-skills/skills/nature-figure/references/nature-2026-observations.md +124 -0
- package/vendor/nature-skills/skills/nature-figure/references/nature-article-requirements.md +149 -0
- package/vendor/nature-skills/skills/nature-figure/references/openrouter-image-generation.md +141 -0
- package/vendor/nature-skills/skills/nature-figure/references/qa-contract.md +344 -0
- package/vendor/nature-skills/skills/nature-figure/references/r-template-index.md +66 -0
- package/vendor/nature-skills/skills/nature-figure/references/r-workflow.md +234 -0
- package/vendor/nature-skills/skills/nature-figure/references/template-catalog.md +33 -0
- package/vendor/nature-skills/skills/nature-figure/references/tutorials.md +260 -0
- package/vendor/nature-skills/skills/nature-figure/requirements.txt +1 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/audit_figure_collisions.py +742 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/audit_panel_alignment.py +933 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/audit_pdf_text.py +152 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/figure_safety.py +50 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/generate_openrouter_schematic.py +260 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/nature_figure_backend.py +93 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/panel_alignment.R +188 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/plot_templates.py +604 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/validate_figure.py +835 -0
- package/vendor/nature-skills/skills/nature-figure/static/core/contract.md +55 -0
- package/vendor/nature-skills/skills/nature-figure/static/core/stance.md +41 -0
- package/vendor/nature-skills/skills/nature-figure/static/fragments/backend/python.md +56 -0
- package/vendor/nature-skills/skills/nature-figure/static/fragments/backend/r.md +63 -0
- package/vendor/nature-skills/skills/nature-figure/tests/test_figure_safety.py +209 -0
- package/vendor/nature-skills/skills/nature-polishing/README.md +58 -0
- package/vendor/nature-skills/skills/nature-polishing/README_EN.md +58 -0
- package/vendor/nature-skills/skills/nature-polishing/SKILL.md +111 -0
- package/vendor/nature-skills/skills/nature-polishing/agents/openai.yaml +4 -0
- package/vendor/nature-skills/skills/nature-polishing/manifest.yaml +101 -0
- package/vendor/nature-skills/skills/nature-polishing/references/latex-layout.md +211 -0
- package/vendor/nature-skills/skills/nature-polishing/references/nat-comms-2025-diction.md +73 -0
- package/vendor/nature-skills/skills/nature-polishing/references/phrasebank-playbook.md +175 -0
- package/vendor/nature-skills/skills/nature-polishing/references/published-article-patterns.md +126 -0
- package/vendor/nature-skills/skills/nature-polishing/references/section-moves.md +252 -0
- package/vendor/nature-skills/skills/nature-polishing/references/style-guardrails.md +94 -0
- package/vendor/nature-skills/skills/nature-polishing/references/writing-strategy.md +160 -0
- package/vendor/nature-skills/skills/nature-polishing/static/core/failure-modes.md +29 -0
- package/vendor/nature-skills/skills/nature-polishing/static/core/output-format.md +21 -0
- package/vendor/nature-skills/skills/nature-polishing/static/core/stance.md +26 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/generic.md +17 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nat-comms.md +38 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nat-mach-intell.md +102 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nature.md +19 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/language/en.md +19 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/language/zh-to-en.md +19 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/algorithmic.md +18 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/hypothesis.md +17 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/methods.md +35 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/research.md +29 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/review.md +17 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/abstract.md +26 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/conclusion.md +15 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/discussion.md +48 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/intro.md +33 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/methods.md +26 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/results.md +61 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/title.md +17 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/README.md +44 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/README_EN.md +44 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/SKILL.md +196 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/agents/openai.yaml +4 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/manifest.yaml +19 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/references/common-patterns.md +96 -0
- package/vendor/nature-skills/skills/nature-shared/README.md +43 -0
- package/vendor/nature-skills/skills/nature-shared/README_EN.md +43 -0
- package/vendor/nature-skills/skills/nature-shared/SKILL.md +33 -0
- package/vendor/nature-skills/skills/nature-shared/agents/openai.yaml +6 -0
- package/vendor/nature-skills/skills/nature-shared/core/consistency-sweep.md +122 -0
- package/vendor/nature-skills/skills/nature-shared/core/discussion-argument-language.md +192 -0
- package/vendor/nature-skills/skills/nature-shared/core/ethics.md +88 -0
- package/vendor/nature-skills/skills/nature-shared/core/main-text-discipline.md +179 -0
- package/vendor/nature-skills/skills/nature-shared/core/nature-abstract.md +171 -0
- package/vendor/nature-skills/skills/nature-shared/core/nature-introduction.md +164 -0
- package/vendor/nature-skills/skills/nature-shared/core/nature-results-discussion.md +215 -0
- package/vendor/nature-skills/skills/nature-shared/core/paper-type-taxonomy.md +41 -0
- package/vendor/nature-skills/skills/nature-shared/core/reader-workflow.md +21 -0
- package/vendor/nature-skills/skills/nature-shared/core/research-compliance.md +192 -0
- package/vendor/nature-skills/skills/nature-shared/core/terminology-ledger.md +58 -0
- package/vendor/nature-skills/skills/nature-shared/journal-formats/nat-comms.md +139 -0
- package/vendor/nature-skills/skills/nature-shared/journal-formats/nature-machine-intelligence.md +431 -0
- package/vendor/nature-skills/skills/nature-shared/journal-formats/nature.md +313 -0
- package/vendor/nature-skills/skills/nature-shared/manifest.yaml +52 -0
- package/vendor/nature-skills/skills/nature-shared/scripts/check_consistency.py +273 -0
- package/vendor/nature-skills/skills/nature-shared/tests/test_check_consistency.py +63 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit text font sizes used by PDF content-stream ``Tf`` operators.
|
|
3
|
+
|
|
4
|
+
This dependency-free check catches reduced mathtext superscripts/subscripts and
|
|
5
|
+
other glyph runs that can fall below a journal font-size floor even when the
|
|
6
|
+
parent matplotlib ``fontsize`` is compliant. It supports plain and FlateDecode
|
|
7
|
+
content streams, which covers normal matplotlib PDF output.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import zlib
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
STREAM_START = re.compile(rb"stream\r?\n")
|
|
22
|
+
TF_OPERATOR = re.compile(
|
|
23
|
+
rb"/([^\s/<>]+)\s+([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][-+]?\d+)?)\s+Tf\b"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class TextRun:
|
|
29
|
+
stream: int
|
|
30
|
+
font: str
|
|
31
|
+
size_pt: float
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decoded_streams(data: bytes) -> tuple[list[bytes], list[str]]:
|
|
35
|
+
streams: list[bytes] = []
|
|
36
|
+
warnings: list[str] = []
|
|
37
|
+
cursor = 0
|
|
38
|
+
stream_number = 0
|
|
39
|
+
while True:
|
|
40
|
+
match = STREAM_START.search(data, cursor)
|
|
41
|
+
if not match:
|
|
42
|
+
break
|
|
43
|
+
stream_number += 1
|
|
44
|
+
end = data.find(b"endstream", match.end())
|
|
45
|
+
if end < 0:
|
|
46
|
+
warnings.append(f"stream {stream_number} has no endstream marker")
|
|
47
|
+
break
|
|
48
|
+
# Keep the raw stream bytes. zlib accepts PDF's trailing line break,
|
|
49
|
+
# while stripping could accidentally remove a legitimate compressed
|
|
50
|
+
# byte that happens to equal CR or LF.
|
|
51
|
+
payload = data[match.end() : end]
|
|
52
|
+
header = data[max(0, match.start() - 2048) : match.start()]
|
|
53
|
+
dictionary_start = header.rfind(b"<<")
|
|
54
|
+
dictionary = header[dictionary_start:] if dictionary_start >= 0 else header
|
|
55
|
+
if b"/FlateDecode" in dictionary:
|
|
56
|
+
try:
|
|
57
|
+
payload = zlib.decompress(payload)
|
|
58
|
+
except zlib.error as exc:
|
|
59
|
+
warnings.append(f"stream {stream_number} FlateDecode failed: {exc}")
|
|
60
|
+
cursor = end + len(b"endstream")
|
|
61
|
+
continue
|
|
62
|
+
elif b"/Filter" in dictionary:
|
|
63
|
+
warnings.append(f"stream {stream_number} uses an unsupported PDF filter")
|
|
64
|
+
cursor = end + len(b"endstream")
|
|
65
|
+
continue
|
|
66
|
+
streams.append(payload)
|
|
67
|
+
cursor = end + len(b"endstream")
|
|
68
|
+
return streams, warnings
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def audit_pdf(data: bytes, minimum_pt: float = 5.0) -> dict[str, object]:
|
|
72
|
+
streams, warnings = decoded_streams(data)
|
|
73
|
+
runs: list[TextRun] = []
|
|
74
|
+
for stream_index, stream in enumerate(streams, 1):
|
|
75
|
+
for match in TF_OPERATOR.finditer(stream):
|
|
76
|
+
try:
|
|
77
|
+
font = match.group(1).decode("ascii", errors="replace")
|
|
78
|
+
size = float(match.group(2))
|
|
79
|
+
except ValueError:
|
|
80
|
+
continue
|
|
81
|
+
if size > 0:
|
|
82
|
+
runs.append(TextRun(stream=stream_index, font=font, size_pt=size))
|
|
83
|
+
below = [run for run in runs if run.size_pt < minimum_pt]
|
|
84
|
+
return {
|
|
85
|
+
"auditable": bool(runs),
|
|
86
|
+
"minimum_required_pt": minimum_pt,
|
|
87
|
+
"minimum_found_pt": min((run.size_pt for run in runs), default=None),
|
|
88
|
+
"text_run_count": len(runs),
|
|
89
|
+
"below_minimum_count": len(below),
|
|
90
|
+
"below_minimum": [asdict(run) for run in below],
|
|
91
|
+
"warnings": warnings,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_text(path: Path, result: dict[str, object]) -> str:
|
|
96
|
+
lines = [
|
|
97
|
+
"Nature Figure PDF Text Audit",
|
|
98
|
+
f"pdf: {path}",
|
|
99
|
+
f"minimum required: {result['minimum_required_pt']:g} pt",
|
|
100
|
+
]
|
|
101
|
+
if not result["auditable"]:
|
|
102
|
+
lines.append("verdict: NOT AUDITABLE — no supported Tf text operators were found")
|
|
103
|
+
else:
|
|
104
|
+
lines.extend(
|
|
105
|
+
[
|
|
106
|
+
f"minimum found: {result['minimum_found_pt']:g} pt",
|
|
107
|
+
f"text runs: {result['text_run_count']}",
|
|
108
|
+
f"below minimum: {result['below_minimum_count']}",
|
|
109
|
+
f"verdict: {'FAIL' if result['below_minimum_count'] else 'PASS'}",
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
for run in result["below_minimum"]:
|
|
113
|
+
lines.append(f" - stream {run['stream']}: /{run['font']} {run['size_pt']:g} Tf")
|
|
114
|
+
for warning in result["warnings"]:
|
|
115
|
+
lines.append(f"warning: {warning}")
|
|
116
|
+
lines.append("note: Tf scanning does not replace final-size visual inspection or account for every PDF transform")
|
|
117
|
+
return "\n".join(lines)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
121
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
122
|
+
parser.add_argument("pdf", type=Path, help="exported PDF figure")
|
|
123
|
+
parser.add_argument("--min-pt", type=float, default=5.0, help="minimum allowed Tf font size in points")
|
|
124
|
+
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
125
|
+
return parser
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(argv: list[str] | None = None) -> int:
|
|
129
|
+
args = build_parser().parse_args(argv)
|
|
130
|
+
if args.min_pt <= 0:
|
|
131
|
+
print("error: --min-pt must be positive", file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
try:
|
|
134
|
+
data = args.pdf.read_bytes()
|
|
135
|
+
except OSError as exc:
|
|
136
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
137
|
+
return 2
|
|
138
|
+
if not data.startswith(b"%PDF-"):
|
|
139
|
+
print(f"error: not a PDF file: {args.pdf}", file=sys.stderr)
|
|
140
|
+
return 2
|
|
141
|
+
result = audit_pdf(data, minimum_pt=args.min_pt)
|
|
142
|
+
if args.json:
|
|
143
|
+
print(json.dumps({"pdf": str(args.pdf), **result}, indent=2, ensure_ascii=False))
|
|
144
|
+
else:
|
|
145
|
+
print(render_text(args.pdf, result))
|
|
146
|
+
if not result["auditable"]:
|
|
147
|
+
return 2
|
|
148
|
+
return 1 if result["below_minimum_count"] else 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Small numerical/layout safety helpers for Python publication figures."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def interp_monotone(target: Any, xp: Any, fp: Any) -> Any:
|
|
12
|
+
"""Interpolate on a strictly monotone grid without silent direction errors.
|
|
13
|
+
|
|
14
|
+
``numpy.interp`` requires increasing ``xp``. This helper accepts a strictly
|
|
15
|
+
increasing or decreasing grid, reverses decreasing ``xp`` and ``fp``
|
|
16
|
+
together, and rejects duplicate/non-monotone coordinates.
|
|
17
|
+
"""
|
|
18
|
+
xp_array = np.asarray(xp, dtype=float)
|
|
19
|
+
fp_array = np.asarray(fp, dtype=float)
|
|
20
|
+
if xp_array.ndim != 1 or fp_array.ndim != 1 or xp_array.size != fp_array.size:
|
|
21
|
+
raise ValueError("xp and fp must be one-dimensional arrays of equal length")
|
|
22
|
+
if xp_array.size < 2:
|
|
23
|
+
raise ValueError("at least two interpolation points are required")
|
|
24
|
+
if not np.all(np.isfinite(xp_array)) or not np.all(np.isfinite(fp_array)):
|
|
25
|
+
raise ValueError("xp and fp must contain only finite values")
|
|
26
|
+
differences = np.diff(xp_array)
|
|
27
|
+
if np.all(differences > 0):
|
|
28
|
+
ordered_xp, ordered_fp = xp_array, fp_array
|
|
29
|
+
elif np.all(differences < 0):
|
|
30
|
+
ordered_xp, ordered_fp = xp_array[::-1], fp_array[::-1]
|
|
31
|
+
else:
|
|
32
|
+
raise ValueError("xp must be strictly monotone; duplicates or direction changes are unsafe")
|
|
33
|
+
return np.interp(target, ordered_xp, ordered_fp)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def label_y_above(values: Any, spread: Any | None = None, pad_fraction: float = 0.04) -> float:
|
|
37
|
+
"""Return a data-driven label y-position above values and optional spread."""
|
|
38
|
+
centers = np.asarray(values, dtype=float)
|
|
39
|
+
if centers.size == 0 or not np.all(np.isfinite(centers)):
|
|
40
|
+
raise ValueError("values must contain at least one finite value")
|
|
41
|
+
upper = centers if spread is None else centers + np.asarray(spread, dtype=float)
|
|
42
|
+
if not np.all(np.isfinite(upper)):
|
|
43
|
+
raise ValueError("spread must contain only finite values")
|
|
44
|
+
data_min = float(np.min(centers))
|
|
45
|
+
data_max = float(np.max(upper))
|
|
46
|
+
scale = max(data_max - data_min, abs(data_max), 1.0)
|
|
47
|
+
return data_max + max(0.0, pad_fraction) * scale
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["interp_monotone", "label_y_above"]
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate manuscript schematic drafts with OpenRouter's Images API."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import mimetypes
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
API_URL = "https://openrouter.ai/api/v1/images"
|
|
20
|
+
DEFAULT_MODEL = "openai/gpt-image-2"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_optional_text(text: str | None, path: str | None) -> str:
|
|
24
|
+
parts: list[str] = []
|
|
25
|
+
if text:
|
|
26
|
+
parts.append(text.strip())
|
|
27
|
+
if path:
|
|
28
|
+
parts.append(Path(path).read_text(encoding="utf-8").strip())
|
|
29
|
+
return "\n\n".join(part for part in parts if part)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_prompt(args: argparse.Namespace) -> str:
|
|
33
|
+
custom_prompt = read_optional_text(args.prompt, args.prompt_file)
|
|
34
|
+
if custom_prompt and args.raw:
|
|
35
|
+
return custom_prompt
|
|
36
|
+
|
|
37
|
+
title = args.title.strip() if args.title else ""
|
|
38
|
+
abstract = read_optional_text(args.abstract, args.abstract_file)
|
|
39
|
+
panel_map = args.panel_map.strip() if args.panel_map else ""
|
|
40
|
+
|
|
41
|
+
content_blocks = []
|
|
42
|
+
if title:
|
|
43
|
+
content_blocks.append(f"Title: {title}")
|
|
44
|
+
if abstract:
|
|
45
|
+
content_blocks.append(f"Article summary:\n{abstract}")
|
|
46
|
+
if panel_map:
|
|
47
|
+
content_blocks.append(f"Desired panel flow:\n{panel_map}")
|
|
48
|
+
if custom_prompt:
|
|
49
|
+
content_blocks.append(f"Additional instructions:\n{custom_prompt}")
|
|
50
|
+
|
|
51
|
+
if not content_blocks:
|
|
52
|
+
raise SystemExit(
|
|
53
|
+
"Provide --prompt/--prompt-file or at least one of --title, "
|
|
54
|
+
"--abstract/--abstract-file, or --panel-map."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
style = args.style or (
|
|
58
|
+
"Create a clean Nature-style scientific graphical abstract / mechanism "
|
|
59
|
+
"schematic for a research paper. Use a flat vector-like visual language, "
|
|
60
|
+
"restrained journal palette, clear hierarchy, simple arrows, and minimal "
|
|
61
|
+
"short labels. Keep the background uncluttered."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
constraints = (
|
|
65
|
+
"Scientific constraints: show only the mechanisms and entities described "
|
|
66
|
+
"below; do not invent quantitative values, p-values, microscopy results, "
|
|
67
|
+
"institutional logos, journal marks, or unsupported experimental claims. "
|
|
68
|
+
"Use conceptual visual elements rather than fake data panels. Text labels "
|
|
69
|
+
"must be short and easy to redraw later."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return "\n\n".join([style, constraints, *content_blocks])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def reference_to_payload(value: str) -> dict[str, Any]:
|
|
76
|
+
if value.startswith(("http://", "https://", "data:")):
|
|
77
|
+
url = value
|
|
78
|
+
else:
|
|
79
|
+
path = Path(value)
|
|
80
|
+
if not path.exists():
|
|
81
|
+
raise SystemExit(f"Reference image not found: {value}")
|
|
82
|
+
media_type = mimetypes.guess_type(path.name)[0] or "image/png"
|
|
83
|
+
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
84
|
+
url = f"data:{media_type};base64,{encoded}"
|
|
85
|
+
return {"type": "image_url", "image_url": {"url": url}}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_payload(args: argparse.Namespace) -> dict[str, Any]:
|
|
89
|
+
payload: dict[str, Any] = {
|
|
90
|
+
"model": args.model,
|
|
91
|
+
"prompt": build_prompt(args),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
optional_fields = {
|
|
95
|
+
"aspect_ratio": args.aspect_ratio,
|
|
96
|
+
"resolution": args.resolution,
|
|
97
|
+
"quality": args.quality,
|
|
98
|
+
"output_format": args.output_format,
|
|
99
|
+
"background": args.background,
|
|
100
|
+
"size": args.size,
|
|
101
|
+
"n": args.n,
|
|
102
|
+
}
|
|
103
|
+
for key, value in optional_fields.items():
|
|
104
|
+
if value is not None:
|
|
105
|
+
payload[key] = value
|
|
106
|
+
|
|
107
|
+
if args.reference_image:
|
|
108
|
+
payload["input_references"] = [
|
|
109
|
+
reference_to_payload(value) for value in args.reference_image
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
return payload
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def media_extension(media_type: str | None, output_format: str | None) -> str:
|
|
116
|
+
if media_type == "image/svg+xml":
|
|
117
|
+
return ".svg"
|
|
118
|
+
if media_type == "image/jpeg":
|
|
119
|
+
return ".jpg"
|
|
120
|
+
if media_type == "image/webp":
|
|
121
|
+
return ".webp"
|
|
122
|
+
if media_type == "image/png":
|
|
123
|
+
return ".png"
|
|
124
|
+
if output_format == "jpeg":
|
|
125
|
+
return ".jpg"
|
|
126
|
+
if output_format in {"png", "webp"}:
|
|
127
|
+
return f".{output_format}"
|
|
128
|
+
return ".png"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def decode_b64_image(value: str) -> bytes:
|
|
132
|
+
if value.startswith("data:"):
|
|
133
|
+
value = value.split(",", 1)[1]
|
|
134
|
+
return base64.b64decode(value)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def request_images(payload: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
|
|
138
|
+
api_key = os.environ.get(args.api_key_env)
|
|
139
|
+
if not api_key:
|
|
140
|
+
raise SystemExit(f"Missing API key: set {args.api_key_env}.")
|
|
141
|
+
|
|
142
|
+
headers = {
|
|
143
|
+
"Authorization": f"Bearer {api_key}",
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
}
|
|
146
|
+
site_url = args.site_url or os.environ.get("OPENROUTER_SITE_URL")
|
|
147
|
+
app_name = args.app_name or os.environ.get("OPENROUTER_APP_NAME")
|
|
148
|
+
if site_url:
|
|
149
|
+
headers["HTTP-Referer"] = site_url
|
|
150
|
+
if app_name:
|
|
151
|
+
headers["X-Title"] = app_name
|
|
152
|
+
|
|
153
|
+
request = urllib.request.Request(
|
|
154
|
+
args.api_url,
|
|
155
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
156
|
+
headers=headers,
|
|
157
|
+
method="POST",
|
|
158
|
+
)
|
|
159
|
+
try:
|
|
160
|
+
with urllib.request.urlopen(request, timeout=args.timeout) as response:
|
|
161
|
+
return json.loads(response.read().decode("utf-8"))
|
|
162
|
+
except urllib.error.HTTPError as exc:
|
|
163
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
164
|
+
raise SystemExit(f"Image request failed ({exc.code}) at {args.api_url}: {body}") from exc
|
|
165
|
+
except urllib.error.URLError as exc:
|
|
166
|
+
raise SystemExit(f"Image request failed at {args.api_url}: {exc}") from exc
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def save_outputs(response: dict[str, Any], payload: dict[str, Any], args: argparse.Namespace) -> None:
|
|
170
|
+
outdir = Path(args.outdir)
|
|
171
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
basename = args.basename or time.strftime("openrouter_schematic_%Y%m%d_%H%M%S")
|
|
173
|
+
|
|
174
|
+
saved: list[str] = []
|
|
175
|
+
for index, item in enumerate(response.get("data", []), start=1):
|
|
176
|
+
media_type = item.get("media_type")
|
|
177
|
+
ext = media_extension(media_type, args.output_format)
|
|
178
|
+
suffix = "" if len(response.get("data", [])) == 1 else f"_{index:02d}"
|
|
179
|
+
outpath = outdir / f"{basename}{suffix}{ext}"
|
|
180
|
+
|
|
181
|
+
if "b64_json" in item:
|
|
182
|
+
outpath.write_bytes(decode_b64_image(item["b64_json"]))
|
|
183
|
+
saved.append(str(outpath))
|
|
184
|
+
elif "url" in item:
|
|
185
|
+
with urllib.request.urlopen(item["url"], timeout=args.timeout) as image_response:
|
|
186
|
+
outpath.write_bytes(image_response.read())
|
|
187
|
+
saved.append(str(outpath))
|
|
188
|
+
else:
|
|
189
|
+
raise SystemExit(f"No image bytes or URL in response item {index}: {item}")
|
|
190
|
+
|
|
191
|
+
metadata = {
|
|
192
|
+
"api_url": args.api_url,
|
|
193
|
+
"request": payload,
|
|
194
|
+
"response_usage": response.get("usage"),
|
|
195
|
+
"created": response.get("created"),
|
|
196
|
+
"saved_files": saved,
|
|
197
|
+
}
|
|
198
|
+
metadata_path = outdir / f"{basename}_request_metadata.json"
|
|
199
|
+
metadata_path.write_text(json.dumps(metadata, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
200
|
+
|
|
201
|
+
print("Saved:")
|
|
202
|
+
for path in saved:
|
|
203
|
+
print(f" {path}")
|
|
204
|
+
print(f" {metadata_path}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse_args() -> argparse.Namespace:
|
|
208
|
+
parser = argparse.ArgumentParser(
|
|
209
|
+
description="Generate manuscript schematic drafts with OpenRouter's Images API."
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument("--model", default=os.environ.get("OPENROUTER_IMAGE_MODEL", DEFAULT_MODEL))
|
|
212
|
+
parser.add_argument("--title")
|
|
213
|
+
parser.add_argument("--abstract")
|
|
214
|
+
parser.add_argument("--abstract-file")
|
|
215
|
+
parser.add_argument("--panel-map")
|
|
216
|
+
parser.add_argument("--prompt")
|
|
217
|
+
parser.add_argument("--prompt-file")
|
|
218
|
+
parser.add_argument("--style")
|
|
219
|
+
parser.add_argument("--raw", action="store_true", help="Use prompt text without the schematic scaffold.")
|
|
220
|
+
parser.add_argument("--reference-image", action="append", help="Path, URL, or data URL for image-to-image guidance.")
|
|
221
|
+
parser.add_argument("--outdir", default="openrouter_schematic")
|
|
222
|
+
parser.add_argument("--basename")
|
|
223
|
+
parser.add_argument("--aspect-ratio", default="16:9")
|
|
224
|
+
parser.add_argument("--resolution", default="2K")
|
|
225
|
+
parser.add_argument("--size", help="Optional OpenRouter size shorthand, such as 2048x2048.")
|
|
226
|
+
parser.add_argument("--quality", default="high", choices=["auto", "low", "medium", "high"])
|
|
227
|
+
parser.add_argument("--output-format", default="png", choices=["png", "jpeg", "webp"])
|
|
228
|
+
parser.add_argument("--background", choices=["auto", "transparent", "opaque"])
|
|
229
|
+
parser.add_argument("--n", type=int, default=1)
|
|
230
|
+
parser.add_argument("--api-key-env", default="OPENROUTER_API_KEY")
|
|
231
|
+
parser.add_argument(
|
|
232
|
+
"--api-url",
|
|
233
|
+
default=os.environ.get("SCHEMATIC_IMAGE_API_URL", API_URL),
|
|
234
|
+
help=(
|
|
235
|
+
"Images endpoint to call. Defaults to OpenRouter; point it at any "
|
|
236
|
+
"OpenAI-compatible images endpoint to use that service instead."
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
parser.add_argument("--site-url")
|
|
240
|
+
parser.add_argument("--app-name")
|
|
241
|
+
parser.add_argument("--timeout", type=int, default=180)
|
|
242
|
+
parser.add_argument("--dry-run", action="store_true", help="Print request payload without calling the API.")
|
|
243
|
+
return parser.parse_args()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def main() -> int:
|
|
247
|
+
args = parse_args()
|
|
248
|
+
payload = build_payload(args)
|
|
249
|
+
|
|
250
|
+
if args.dry_run:
|
|
251
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
response = request_images(payload, args)
|
|
255
|
+
save_outputs(response, payload, args)
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
if __name__ == "__main__":
|
|
260
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read or write the user's default nature-figure plotting backend."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
VALID_BACKENDS = {"python", "r"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def config_path() -> Path:
|
|
17
|
+
override = os.environ.get("NATURE_FIGURE_CONFIG")
|
|
18
|
+
if override:
|
|
19
|
+
return Path(override).expanduser()
|
|
20
|
+
return Path("~/.config/nature-skills/nature-figure.json").expanduser()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_config(path: Path) -> dict:
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return {}
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
28
|
+
except json.JSONDecodeError as exc:
|
|
29
|
+
raise SystemExit(f"Invalid nature-figure config JSON at {path}: {exc}") from exc
|
|
30
|
+
if not isinstance(data, dict):
|
|
31
|
+
raise SystemExit(f"Invalid nature-figure config at {path}: expected object")
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def write_config(path: Path, data: dict) -> None:
|
|
36
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_backend(path: Path) -> int:
|
|
41
|
+
backend = read_config(path).get("backend")
|
|
42
|
+
if backend in VALID_BACKENDS:
|
|
43
|
+
print(backend)
|
|
44
|
+
return 0
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def set_backend(path: Path, backend: str) -> int:
|
|
49
|
+
backend = backend.lower()
|
|
50
|
+
if backend not in VALID_BACKENDS:
|
|
51
|
+
raise SystemExit("backend must be one of: python, r")
|
|
52
|
+
data = read_config(path)
|
|
53
|
+
data["backend"] = backend
|
|
54
|
+
write_config(path, data)
|
|
55
|
+
print(backend)
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def clear_backend(path: Path) -> int:
|
|
60
|
+
data = read_config(path)
|
|
61
|
+
data.pop("backend", None)
|
|
62
|
+
write_config(path, data)
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_args() -> argparse.Namespace:
|
|
67
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
68
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
69
|
+
subparsers.add_parser("get", help="Print the saved backend; exit 1 if unset.")
|
|
70
|
+
set_parser = subparsers.add_parser("set", help="Save the default backend.")
|
|
71
|
+
set_parser.add_argument("backend", choices=sorted(VALID_BACKENDS))
|
|
72
|
+
subparsers.add_parser("clear", help="Remove the saved backend preference.")
|
|
73
|
+
subparsers.add_parser("path", help="Print the config path.")
|
|
74
|
+
return parser.parse_args()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main() -> int:
|
|
78
|
+
args = parse_args()
|
|
79
|
+
path = config_path()
|
|
80
|
+
if args.command == "get":
|
|
81
|
+
return get_backend(path)
|
|
82
|
+
if args.command == "set":
|
|
83
|
+
return set_backend(path, args.backend)
|
|
84
|
+
if args.command == "clear":
|
|
85
|
+
return clear_backend(path)
|
|
86
|
+
if args.command == "path":
|
|
87
|
+
print(path)
|
|
88
|
+
return 0
|
|
89
|
+
raise AssertionError(args.command)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
raise SystemExit(main())
|