From 0656565bc98aec9f9e60920d8ce78fe6c52505d5 Mon Sep 17 00:00:00 2001 From: ocean-debug <59615229+ocean-debug@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:18:46 +0800 Subject: [PATCH] DAG --- .agents/skills/paper2skill-builder/SKILL.md | 29 - .../paper2skill-builder/agents/openai.yaml | 4 - .../environment_management_policy.md | 6 - .../references/generated_skill_schema.yaml | 12 - .../scripts/build_skill.py | 34 - .github/workflows/ci.yml | 18 + .gitignore | 14 +- CHANGELOG.md | 7 + CODEX_PAPER2SKILLS_THREE_STAGE_REFACTOR.md | 876 --------- CONTRIBUTING.md | 21 + LICENSE | 21 + README.md | 36 + paper2skill/cli.py | 11 +- paper2skill/collectors/repo_collector.py | 35 +- paper2skill/evidence/evidence_graph.py | 98 +- .../generators/codex_skill_generator.py | 213 ++- paper2skill/inference/classify_algorithm.py | 12 +- paper2skill/inference/infer_bio_contract.py | 60 +- paper2skill/inference/infer_environment.py | 5 + paper2skill/inference/infer_io_contract.py | 120 +- paper2skill/inference/infer_workflow.py | 116 +- paper2skill/miners/api_miner.py | 86 +- paper2skill/miners/dependency_miner.py | 101 +- paper2skill/miners/tutorial_miner.py | 23 +- paper2skill/templates/codex_skill/SKILL.md.j2 | 5 +- .../assets/input_manifest_template.yaml.j2 | 19 +- .../scripts/adapters/__init__.py.j2 | 5 + .../scripts/adapters/cli_adapter.py.j2 | 11 + .../scripts/adapters/notebook_adapter.py.j2 | 11 + .../scripts/adapters/python_api_adapter.py.j2 | 11 + .../scripts/adapters/r_script_adapter.R.j2 | 7 + .../templates/codex_skill/scripts/plan.py.j2 | 8 + .../templates/codex_skill/scripts/run.py.j2 | 33 +- paper2skill/validators/skill_validator.py | 6 + prd.md | 1628 ----------------- pyproject.toml | 7 +- tests/test_api_miner.py | 46 + tests/test_bio_contract_inference.py | 13 + tests/test_cli.py | 110 ++ tests/test_dependency_miner.py | 42 + tests/test_evidence_graph.py | 31 + tests/test_generator.py | 90 + tests/test_io_contract_inference.py | 25 + tests/test_workflow_dag.py | 46 + 44 files changed, 1445 insertions(+), 2667 deletions(-) delete mode 100644 .agents/skills/paper2skill-builder/SKILL.md delete mode 100644 .agents/skills/paper2skill-builder/agents/openai.yaml delete mode 100644 .agents/skills/paper2skill-builder/references/environment_management_policy.md delete mode 100644 .agents/skills/paper2skill-builder/references/generated_skill_schema.yaml delete mode 100644 .agents/skills/paper2skill-builder/scripts/build_skill.py create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md delete mode 100644 CODEX_PAPER2SKILLS_THREE_STAGE_REFACTOR.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 paper2skill/templates/codex_skill/scripts/adapters/__init__.py.j2 create mode 100644 paper2skill/templates/codex_skill/scripts/adapters/cli_adapter.py.j2 create mode 100644 paper2skill/templates/codex_skill/scripts/adapters/notebook_adapter.py.j2 create mode 100644 paper2skill/templates/codex_skill/scripts/adapters/python_api_adapter.py.j2 create mode 100644 paper2skill/templates/codex_skill/scripts/adapters/r_script_adapter.R.j2 delete mode 100644 prd.md create mode 100644 tests/test_api_miner.py create mode 100644 tests/test_evidence_graph.py create mode 100644 tests/test_io_contract_inference.py create mode 100644 tests/test_workflow_dag.py diff --git a/.agents/skills/paper2skill-builder/SKILL.md b/.agents/skills/paper2skill-builder/SKILL.md deleted file mode 100644 index bd903d3..0000000 --- a/.agents/skills/paper2skill-builder/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: paper2skill-builder -description: Use this skill when the user wants to generate a Codex skill from an algorithm paper, official source code repository, and official tutorial or example. The generated skill must include environment preflight, dependency checks, install planning, execution planning, output validation, and evidence reports. ---- - -# Paper2Skill Builder - -Use this repository-level skill to run Paper2Skill Builder from a paper, an -official repository, and official tutorial evidence. - -## Workflow - -1. Confirm the paper, repository, tutorial, skill name, and output directory. -2. Run `python -m paper2skill.cli plan` to inspect evidence without writing a child skill. -3. Run `python -m paper2skill.cli build` to generate the child Codex Skill. -4. Run `python -m paper2skill.cli validate --skill `. -5. Run `python -m paper2skill.cli test --skill --mode all`. - -## Environment Policy - -Generated child skills must run `scripts/preflight.py` before `scripts/run.py`. -Missing dependencies block execution and produce install plans. No installation -may run unless the user explicitly approves and passes `--confirm yes`. - -## Boundaries - -The MVP does not generate MCP servers or Codex plugin packages. It does not -download large datasets, configure GPU/CUDA automatically, or run arbitrary -repository install scripts. diff --git a/.agents/skills/paper2skill-builder/agents/openai.yaml b/.agents/skills/paper2skill-builder/agents/openai.yaml deleted file mode 100644 index d260a33..0000000 --- a/.agents/skills/paper2skill-builder/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: paper2skill-builder -description: Generate evidence-first Codex skills from paper, repository, and tutorial inputs. -entrypoints: - build: scripts/build_skill.py diff --git a/.agents/skills/paper2skill-builder/references/environment_management_policy.md b/.agents/skills/paper2skill-builder/references/environment_management_policy.md deleted file mode 100644 index 3d479bd..0000000 --- a/.agents/skills/paper2skill-builder/references/environment_management_policy.md +++ /dev/null @@ -1,6 +0,0 @@ -# Environment Management Policy - -Generated child skills must check Python, R, and CLI dependencies before any -run. Missing dependencies must block execution and produce install plans. In -non-interactive mode, `ask` behaves as `never`. Installation requires -`--confirm yes`. diff --git a/.agents/skills/paper2skill-builder/references/generated_skill_schema.yaml b/.agents/skills/paper2skill-builder/references/generated_skill_schema.yaml deleted file mode 100644 index 7d84719..0000000 --- a/.agents/skills/paper2skill-builder/references/generated_skill_schema.yaml +++ /dev/null @@ -1,12 +0,0 @@ -required_files: - - SKILL.md - - scripts/preflight.py - - scripts/env_manager.py - - scripts/plan.py - - scripts/run.py - - scripts/validate_outputs.py - - references/evidence_report.md - - references/tutorial_trace.json - - assets/environment_spec.yaml - - tests/test_preflight.py - - agents/openai.yaml diff --git a/.agents/skills/paper2skill-builder/scripts/build_skill.py b/.agents/skills/paper2skill-builder/scripts/build_skill.py deleted file mode 100644 index 0109a8f..0000000 --- a/.agents/skills/paper2skill-builder/scripts/build_skill.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -import argparse -import subprocess -import sys - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--example", choices=["toy_python", "toy_r"], default=None) - parser.add_argument("--paper", default=None) - parser.add_argument("--repo", default=None) - parser.add_argument("--tutorial", action="append", default=[]) - parser.add_argument("--skill-name", default=None) - parser.add_argument("--out", default=None) - args = parser.parse_args() - command = [sys.executable, "-m", "paper2skill.cli", "build"] - if args.example: - command.extend(["--example", args.example]) - if args.paper: - command.extend(["--paper", args.paper]) - if args.repo: - command.extend(["--repo", args.repo]) - for tutorial in args.tutorial: - command.extend(["--tutorial", tutorial]) - if args.skill_name: - command.extend(["--skill-name", args.skill_name]) - if args.out: - command.extend(["--out", args.out]) - return subprocess.run(command, check=False).returncode - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6e7f9a9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: CI + +on: + push: + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install + run: python -m pip install -e ".[dev]" + - name: Test + run: python -m pytest -q diff --git a/.gitignore b/.gitignore index 8a22d56..d20c472 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Local tooling state +.agents/ .understand-anything/ .vscode/ .idea/ @@ -7,6 +8,7 @@ __pycache__/ *.py[cod] .pytest_cache/ +.pytest-tmp/ .mypy_cache/ .ruff_cache/ .coverage @@ -31,8 +33,16 @@ paper2skill_plan/ .paper2skill-smoke-leaks.txt .paper2skill_desensitize_smoke.sh .paper2skills-sync.tar -.agents/skills/toy-*-skill/ -.agents/skills/smoke-*-skill/ +.paper2skill-resolve/ +.paper2skill_collection/ + +# Local planning / product notes, not release artifacts +prd.md +PRD.md +next.md +CODEX_*.md +*_PLAN.md +*_plan.md # Logs, temp files, and OS metadata *.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..371a517 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## Unreleased + +- Added resolved repository collection as the main build input path. +- Added workflow DAG, bio IO contract enrichment, evidence decisions, and adapter scaffolding. +- Added dependency/API miner coverage for environment files, CLI entrypoints, R exports, and workflow engines. diff --git a/CODEX_PAPER2SKILLS_THREE_STAGE_REFACTOR.md b/CODEX_PAPER2SKILLS_THREE_STAGE_REFACTOR.md deleted file mode 100644 index c1ec3d2..0000000 --- a/CODEX_PAPER2SKILLS_THREE_STAGE_REFACTOR.md +++ /dev/null @@ -1,876 +0,0 @@ -# Codex 工程任务:一次性完成 paper2skills 三阶段改造 - -> 目标:请在当前 `paper2skills` 仓库中完成一个端到端 MVP,使项目能够从 `paper + GitHub repo/tutorial` 中生成更可靠的 Codex skill。不要只做文档说明,要实现可运行代码、测试和示例。此次任务必须一次性覆盖以下三个阶段: -> -> 1. 文档解析层:支持 paper/supplementary/document 到 Markdown + section map + evidence snippets。 -> 2. Tutorial-first mining 层:克隆/索引 repo,扫描并筛选 tutorial,抽取 notebook/R/Python workflow 证据。 -> 3. Bio contract 推断层:从论文、tutorial、代码和依赖中推断生信输入输出、数据状态和外部资源约束。 - ---- - -## 0. 当前问题 - -当前项目已经有 `collectors/`、`miners/`、`inference/`、`generators/`、`runtime/`、`validators/` 等分层,但仍存在以下限制: - -1. `collect_paper()` 基本只支持 `.md/.txt`,不能处理 PDF、DOCX、HTML、supplementary 等常见论文材料。 -2. `collect_repo()` 对远程 GitHub URL 只记录 URL,不会 clone、pin commit、索引文件或抽取 evidence bundle。 -3. Tutorial mining 目前更接近浅层 step list,尚未形成可审计的 tutorial candidates、workflow trace、input/output evidence。 -4. `bio_contract` 字段设计较好,但大部分内容仍是 `not_confirmed`,缺少从 evidence 中自动填充的规则。 -5. 输出文件之间的证据链不够强:algorithm contract、bio contract、tutorial trace、environment report 之间没有统一的 `evidence_id` 连接。 - -本次改造目标不是一次性做成完整 Paper2Agent,而是完成一个稳定、可测试、可扩展的 engineering baseline。 - ---- - -## 1. 总体设计原则 - -### 1.1 Evidence-first - -所有推断字段必须绑定 evidence。 - -如果某个字段没有可靠证据,不允许猜测,必须输出: - -```yaml -value: not_confirmed -confidence: low -evidence: [] -``` - -### 1.2 Tutorial-first - -论文 Methods 是辅助证据,官方 tutorial / notebook / vignette / example 是执行层的优先证据。扫描优先级: - -```text -docs/** -vignettes/** -tutorials/** -examples/** -notebooks/** -README.md / README.rst -**/*.ipynb -**/*.md -**/*.Rmd -**/*.R -**/*.py # only if no notebook/markdown/vignette tutorial exists -``` - -### 1.3 Bio-specific - -不要只生成普通软件工程 contract。必须显式考虑生信数据约束: - -```text -modality -species -genome_build -gene_id_type -matrix_state -matrix_orientation -input_file_type -metadata_keys -condition_key -batch_key -celltype_key -sample_key -perturbation_key -external_resources -statistical_thresholds -expected_outputs -``` - -### 1.4 Safe-by-default - -不得在测试中访问真实网络;需要网络的功能必须可 mock 或用临时本地 git repo 测试。 - -不得自动安装依赖、下载大数据、执行远程脚本。执行层必须先 preflight,再给出 install plan,除非用户显式确认。 - -### 1.5 Backward compatible - -保留现有 CLI、现有测试和现有输出结构。新增字段和文件可以向后兼容,不要破坏已有 toy demo。 - ---- - -## 2. 本次必须新增或修改的模块 - -### 2.1 新增 `paper2skill/parsers/` - -新增目录: - -```text -paper2skill/parsers/ - __init__.py - base.py - markitdown_parser.py - plain_text_parser.py - html_parser.py - section_segmenter.py -``` - -#### `base.py` - -定义统一数据结构: - -```python -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -@dataclass -class ParsedSection: - section_id: str - title: str - level: int - text: str - source_path: str - start_line: int | None = None - end_line: int | None = None - -@dataclass -class ParsedDocument: - source_path: str - source_type: str - parser_name: str - markdown: str - sections: list[ParsedSection] = field(default_factory=list) - metadata: dict[str, Any] = field(default_factory=dict) - warnings: list[str] = field(default_factory=list) -``` - -定义 parser interface: - -```python -class DocumentParser: - name: str - supported_suffixes: set[str] - - def can_parse(self, path: Path) -> bool: - ... - - def parse(self, path: Path) -> ParsedDocument: - ... -``` - -#### `markitdown_parser.py` - -实现 MarkItDown parser: - -- 支持 `.pdf`, `.docx`, `.pptx`, `.xlsx`, `.html`, `.htm`, `.csv`, `.json`, `.xml`, `.zip`, `.epub`。 -- 如果 `markitdown` 未安装,不要报不可读堆栈;返回清晰错误:`MarkItDown is not installed. Install with pip install 'markitdown[pdf,docx,pptx,xlsx]'`。 -- 保存 parser warnings。 -- 不要在 parser 内调用网络。 - -#### `plain_text_parser.py` - -支持 `.md`, `.txt`, `.rst`。 - -#### `html_parser.py` - -轻量实现即可: - -- 优先用 `bs4`,若未安装则 fallback 到简单 tag removal。 -- 提取标题结构。 - -#### `section_segmenter.py` - -实现 Markdown section segmentation: - -- 根据 `#`, `##`, `###` 切分 section。 -- 为每个 section 生成稳定 `section_id`,例如:`paper:methods:preprocessing`。 -- 额外识别常见论文 section aliases: - -```python -METHOD_ALIASES = ["methods", "method", "materials and methods", "online methods", "experimental procedures"] -DATA_ALIASES = ["data availability", "datasets", "data", "availability"] -CODE_ALIASES = ["code availability", "software availability", "implementation"] -RESULT_ALIASES = ["results", "experiments", "benchmark", "evaluation"] -LIMITATION_ALIASES = ["limitations", "discussion"] -``` - ---- - -### 2.2 修改 `paper2skill/collectors/paper_collector.py` - -如果当前文件名不同,请在现有 collector 中等价修改。 - -新增功能: - -```python -def collect_paper(paper: str | Path, out_dir: Path | None = None) -> dict: - """Collect and parse a paper or supplementary document. - - Returns a dict containing: - - source_path - - source_type - - parser_name - - markdown_path - - section_map_path - - parsed_document - - warnings - """ -``` - -输出文件: - -```text -references/paper.md -references/paper_sections.json -references/paper_parser_report.json -``` - -`paper_sections.json` 结构: - -```json -{ - "source_path": "...", - "parser_name": "markitdown", - "sections": [ - { - "section_id": "paper:methods", - "title": "Methods", - "level": 1, - "start_line": 120, - "end_line": 260, - "char_count": 10342 - } - ], - "warnings": [] -} -``` - ---- - -### 2.3 修改 `paper2skill/collectors/repo_collector.py` - -当前远程 URL 不应只记录 URL,必须支持 clone 或 archive extraction。 - -实现: - -```python -def collect_repo(repo: str | Path, work_dir: Path, ref: str | None = None) -> dict: - """Resolve local or remote repository into a local evidence bundle.""" -``` - -要求: - -1. 如果是本地路径,直接索引。 -2. 如果是 GitHub URL,执行 shallow clone: - -```bash -git clone --depth 1 /repo/ -``` - -3. 如果传入 `ref`,checkout 到对应 ref。 -4. 记录 commit SHA: - -```bash -git -C rev-parse HEAD -``` - -5. 输出: - -```text -references/repo_manifest.json -references/repo_index.json -``` - -`repo_manifest.json`: - -```json -{ - "repo_url": "https://github.com/...", - "repo_name": "...", - "local_path": "...", - "commit_sha": "...", - "ref": "main", - "collected_at": "ISO-8601", - "is_remote": true -} -``` - -`repo_index.json`: - -```json -{ - "files": [ - { - "path": "docs/tutorial.ipynb", - "suffix": ".ipynb", - "size_bytes": 12345, - "category": "tutorial_candidate" - } - ] -} -``` - -测试不得访问 GitHub。用本地临时 git repo 模拟 remote/local repo 行为。 - ---- - -## 3. Tutorial-first mining - -### 3.1 新增或重构 `paper2skill/miners/tutorial_scanner.py` - -实现: - -```python -@dataclass -class TutorialCandidate: - path: str - title: str | None - file_type: str - priority: int - include_in_tools: bool - reason: str - signals: dict[str, bool] - evidence_id: str -``` - -扫描规则: - -1. 优先扫描 `docs/**`。 -2. notebook / markdown / Rmd / vignette 优先。 -3. `.py` 只有在无 `.ipynb/.md/.Rmd` tutorial 时才作为 tutorial 候选。 -4. 排除: - -```text -tests/** -test/** -benchmark/** -benchmarks/** -perf/** -profile/** -legacy/** -deprecated/** -old/** -templates/** -``` - -5. 文件名或标题包含以下词汇时,默认 exclude: - -```text -legacy -deprecated -outdated -old -archive -``` - -6. include 标准: - -```text -has_code -has_narrative -has_clear_task -has_input_signal -has_output_signal -not_test_or_benchmark -not_deprecated -``` - -输出: - -```text -references/tutorial_candidates.json -references/tutorial_scanner_report.json -``` - ---- - -### 3.2 修改 notebook / R / Python miner - -目标不是完整执行所有 tutorial,而是先生成可审计 workflow trace。 - -统一输出 `references/tutorial_trace.json`: - -```json -{ - "tutorials": [ - { - "path": "docs/tutorial.ipynb", - "title": "Preprocessing and clustering", - "steps": [ - { - "step_id": "tutorial_001:cell_003", - "language": "python", - "source": "docs/tutorial.ipynb:cell:3", - "code_preview": "sc.pp.normalize_total(adata)", - "imports": ["scanpy"], - "function_calls": ["sc.pp.normalize_total"], - "read_files": [], - "write_files": [], - "input_objects": ["adata"], - "output_objects": ["adata"], - "bio_signals": ["normalization", "single_cell"] - } - ] - } - ] -} -``` - -Python notebook: - -- 用 `nbformat` 读取。 -- 代码 cell 用 AST 提取 import、assignment、function call、read/write file。 -- Markdown cell 用于标题、任务描述和 evidence snippets。 - -R script/Rmd: - -- 用正则 + 简单 parser 提取:`library()`, `require()`, `<-`, `read.*`, `write.*`, `saveRDS`, `readRDS`, `ggsave`, `pdf`, `png`。 -- 识别 Seurat / SingleCellExperiment / DESeq2 / edgeR / clusterProfiler / CellChat / monocle3 / Signac 等常见调用。 - ---- - -## 4. Evidence graph - -新增: - -```text -paper2skill/evidence/ - __init__.py - evidence_graph.py -``` - -数据结构: - -```python -@dataclass -class EvidenceItem: - evidence_id: str - source_type: str # paper, tutorial, code, dependency, repo - source_path: str - locator: str # line range, cell id, function name, etc. - text: str - confidence_hint: str = "medium" - -@dataclass -class EvidenceClaim: - claim_id: str - field: str - value: Any - confidence: str - evidence_ids: list[str] - notes: str | None = None -``` - -输出: - -```text -references/evidence_graph.json -``` - -要求: - -- 每个 algorithm contract 和 bio contract 字段都能追溯到 evidence item。 -- 没有证据的字段使用 `not_confirmed`。 -- 有冲突时记录: - -```json -{ - "field": "matrix_state", - "conflict": true, - "values": ["raw_counts", "log_normalized"], - "evidence_ids": ["...", "..."] -} -``` - ---- - -## 5. Bio contract 推断 - -### 5.1 修改 `paper2skill/inference/infer_bio_contract.py` - -保留当前 schema,但新增 evidence-aware 推断。 - -建议输出结构: - -```yaml -modality: - value: scRNA-seq - confidence: high - evidence: [tutorial_001:cell_001, paper:methods:line_12] - -species: - value: human - confidence: medium - evidence: [paper:datasets] - -matrix_state: - value: raw_counts - confidence: high - evidence: [tutorial_001:cell_004] - -metadata_keys: - celltype_key: - value: cell_type - confidence: medium - evidence: [tutorial_001:cell_006] - batch_key: - value: batch - confidence: low - evidence: [] -``` - -### 5.2 规则库 - -新增: - -```text -paper2skill/inference/bio_rules.py -``` - -实现 rule-based recognizer: - -#### Modality keywords - -```python -MODALITY_RULES = { - "scRNA-seq": ["single-cell RNA", "scRNA-seq", "AnnData", "Seurat", "scanpy", "h5ad", "10x"], - "spatial_transcriptomics": ["spatial transcriptomics", "Visium", "Slide-seq", "MERFISH", "spatial", "Squidpy"], - "bulk_RNA-seq": ["bulk RNA", "DESeq2", "edgeR", "limma", "counts matrix"], - "scATAC-seq": ["scATAC", "Signac", "ArchR", "peak matrix", "fragments.tsv"], - "multiome": ["multiome", "RNA + ATAC", "paired RNA and ATAC"] -} -``` - -#### Matrix state rules - -```python -MATRIX_STATE_RULES = { - "raw_counts": ["raw counts", "count matrix", "read_10x_mtx", "Read10X", "counts slot", "layers['counts']"], - "normalized": ["NormalizeData", "normalize_total", "CPM", "TPM", "size factor"], - "log1p": ["log1p", "log-normalized", "LogNormalize"], - "scaled": ["ScaleData", "scale", "z-score", "standardized"] -} -``` - -#### Gene ID rules - -```python -GENE_ID_RULES = { - "gene_symbol": ["gene symbol", "HGNC", "GeneSymbol", "symbol"], - "ensembl_id": ["Ensembl", "ENSG", "ENSMUSG"], - "entrez_id": ["Entrez", "NCBI gene id"] -} -``` - -#### Species rules - -```python -SPECIES_RULES = { - "human": ["human", "Homo sapiens", "hg19", "hg38", "GRCh37", "GRCh38"], - "mouse": ["mouse", "Mus musculus", "mm10", "mm39", "GRCm38", "GRCm39"], - "macaque": ["macaque", "Macaca", "rheMac"] -} -``` - -### 5.3 Confidence rules - -```text -high: exact API/tutorial evidence, e.g. Read10X, sc.read_h5ad, NormalizeData, adata.obs['cell_type'] -medium: paper Methods text or README says it explicitly -low: only inferred from package names or weak keywords -not_confirmed: no evidence -``` - ---- - -## 6. Algorithm contract 与 IO contract 联动 - -修改: - -```text -paper2skill/inference/infer_algorithm_contract.py -paper2skill/inference/infer_io_contract.py -paper2skill/inference/infer_workflow.py -``` - -目标: - -1. `infer_workflow()` 不再只返回 step list,要返回 source-grounded workflow trace。 -2. `infer_io_contract()` 从 tutorial trace 和 bio contract 中补充 input requirements。 -3. `infer_algorithm_contract()` 每个核心字段都包含 evidence。 - -示例: - -```yaml -inputs: - primary_data: - value: AnnData h5ad file - required: true - evidence: [tutorial_001:cell_002] - metadata_keys: - celltype_key: - value: cell_type - required: false - evidence: [tutorial_001:cell_006] -``` - ---- - -## 7. CLI 改造 - -检查现有 CLI。如果已有 `build` 或 `generate` 命令,请向后兼容扩展。 - -目标命令: - -```bash -python -m paper2skill.cli build \ - --paper path/to/paper.pdf \ - --repo https://github.com/owner/repo \ - --out outputs/my_skill \ - --tutorial-filter "optional title or path" -``` - -必须支持: - -```bash -python -m paper2skill.cli build \ - --paper path/to/paper.md \ - --repo path/to/local/repo \ - --out outputs/my_skill -``` - -新增选项: - -```text ---repo-ref ---skip-repo-clone ---no-execute-tutorials ---strict-evidence -``` - -本次可以默认不执行 tutorial,只生成 trace;如果已有 executor,可只做 dry-run,不得强制真实运行大 notebook。 - ---- - -## 8. 输出文件要求 - -构建完成后,输出目录必须至少包含: - -```text -outputs/my_skill/ - SKILL.md - scripts/ - preflight.py - plan.py - run.py - validate_outputs.py - references/ - paper.md - paper_sections.json - paper_parser_report.json - repo_manifest.json - repo_index.json - tutorial_candidates.json - tutorial_trace.json - evidence_graph.json - algorithm_contract.yaml - bio_contract.yaml - io_contract.yaml - environment_report.json - install_plan.md - tests/ -``` - -如果某个输入缺失,例如未提供 paper,则对应文件可以不生成,但必须在 `build_report.json` 中记录原因。 - ---- - -## 9. 测试要求 - -新增测试目录或文件: - -```text -tests/test_document_parsers.py -tests/test_section_segmenter.py -tests/test_repo_collector.py -tests/test_tutorial_scanner.py -tests/test_bio_contract_inference.py -tests/test_end_to_end_three_stage_build.py -``` - -### 9.1 Parser tests - -- 用临时 `.md` 文件测试 plain text parser。 -- 如果 `markitdown` 未安装,测试应确认错误信息清晰,而不是失败。 -- 不要求在 CI 中真实解析大型 PDF。 - -### 9.2 Repo collector tests - -- 用 `tmp_path` 创建本地 git repo。 -- commit 一个 README、docs/tutorial.ipynb 或 docs/tutorial.md。 -- 调用 `collect_repo()`。 -- 验证 `repo_manifest.json` 中有 `commit_sha`。 -- 验证 `repo_index.json` 包含 tutorial candidate。 - -### 9.3 Tutorial scanner tests - -测试: - -1. `docs/tutorial.ipynb` 应 include。 -2. `tests/test_example.py` 应 exclude。 -3. `legacy/tutorial.ipynb` 应 exclude。 -4. 有 notebook/md 时,裸 `.py` tutorial 不应优先 include。 -5. 没有 notebook/md 时,`.py` tutorial 可以 include。 - -### 9.4 Bio contract tests - -输入 synthetic evidence: - -```text -sc.read_10x_mtx("data/") -sc.pp.normalize_total(adata) -sc.pp.log1p(adata) -adata.obs["cell_type"] -``` - -预期: - -```yaml -modality: scRNA-seq -matrix_state: raw_counts + normalized + log1p evidence trail -celltype_key: cell_type -``` - -不要只输出最终一个 matrix_state,要保留 transformation chain: - -```yaml -matrix_transformations: - - raw_counts_loaded - - normalized - - log1p_transformed -``` - -### 9.5 End-to-end test - -创建最小项目: - -```text -paper.md -repo/ - README.md - docs/tutorial.ipynb -``` - -运行 build,验证: - -```text -references/paper.md exists -references/paper_sections.json exists -references/repo_manifest.json exists -references/tutorial_candidates.json exists -references/tutorial_trace.json exists -references/evidence_graph.json exists -references/bio_contract.yaml exists -SKILL.md exists -``` - ---- - -## 10. README 更新 - -更新 README,新增章节: - -```text -Three-stage evidence pipeline - Stage 1: document parsing - Stage 2: tutorial-first mining - Stage 3: bio contract inference -``` - -加入一个最小例子: - -```bash -python -m paper2skill.cli build \ - --paper examples/minimal_paper.md \ - --repo examples/minimal_repo \ - --out outputs/minimal_skill -``` - -说明 MarkItDown 是 optional parser backend: - -```bash -pip install 'markitdown[pdf,docx,pptx,xlsx]' -``` - ---- - -## 11. 禁止项 - -不要做以下事情: - -1. 不要删除已有 tests。 -2. 不要让测试依赖真实 GitHub 网络访问。 -3. 不要生成无 evidence 的确定性结论。 -4. 不要把 README 当作唯一权威证据。 -5. 不要在 build 过程中自动安装依赖。 -6. 不要默认执行大型 notebook 或下载大型数据。 -7. 不要把 toy output 声称为真实算法结果。 -8. 不要为了通过测试而 hard-code 某个测试路径。 - ---- - -## 12. 推荐实现顺序 - -请按以下顺序完成,并在每步后运行相关测试: - -```text -1. 新增 parsers/base.py、plain_text_parser.py、section_segmenter.py -2. 改造 collect_paper() -3. 改造 collect_repo(),支持本地 repo 和 shallow clone -4. 新增 tutorial_scanner.py -5. 统一 tutorial_trace 输出 -6. 新增 evidence_graph.py -7. 改造 infer_bio_contract.py + bio_rules.py -8. 联动 infer_io_contract.py / infer_workflow.py / infer_algorithm_contract.py -9. 更新 generator,使 references 输出完整 -10. 更新 CLI -11. 新增 tests -12. 更新 README -13. 运行完整测试 -``` - ---- - -## 13. 验收命令 - -Codex 完成后必须运行: - -```bash -python -m pytest -q -``` - -如果项目已有 CLI 测试命令,也运行原命令。 - -额外运行一个最小 end-to-end demo: - -```bash -python -m paper2skill.cli build \ - --paper examples/minimal_paper.md \ - --repo examples/minimal_repo \ - --out /tmp/paper2skill_minimal_skill \ - --no-execute-tutorials -``` - -检查输出: - -```bash -ls /tmp/paper2skill_minimal_skill/references -cat /tmp/paper2skill_minimal_skill/references/bio_contract.yaml -cat /tmp/paper2skill_minimal_skill/references/evidence_graph.json -``` - ---- - -## 14. 最终交付说明 - -完成后,请在最终回复中总结: - -1. 修改了哪些文件。 -2. 新增了哪些模块。 -3. 三阶段 pipeline 如何运行。 -4. 哪些测试通过。 -5. 哪些功能仍是 MVP 限制。 -6. 下一步建议。 - -不要只给概要;必须提交实际代码变更。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3925fa5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +## Development Setup + +```bash +python -m pip install -e ".[dev]" +python -m pytest -q +``` + +## Safety Rules + +- Do not add online LLM calls. +- Do not auto-install dependencies in generated skills. +- Do not run unknown repository install scripts during build or tests. +- Keep generated public outputs free of absolute local paths. + +## Pull Request Expectations + +- Add or update tests for behavior changes. +- Keep toy Python and toy R examples passing. +- Mark demo-only execution clearly; do not present it as real algorithm execution. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..86da8a8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Paper2Skill contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 600bf8b..c3ad260 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,16 @@ and output validation. ## Install ```bash +python -m pip install -e . python -m pip install -e ".[dev]" ``` +Optional document parsing extras: + +```bash +python -m pip install -e ".[paper,html]" +``` + ## Quick Start ```bash @@ -63,6 +70,7 @@ call an online LLM or article API. plan.py run.py validate_outputs.py + adapters/ references/ assets/ tests/ @@ -82,6 +90,7 @@ references/paper_sections.json references/paper_parser_report.json references/repo_manifest.json references/repo_index.json +references/workflow_dag.json references/tutorial_candidates.json references/tutorial_scanner_report.json references/io_contract.yaml @@ -96,6 +105,32 @@ dependencies block execution, produce environment and install reports, and never trigger installation unless the user explicitly runs install with `--confirm yes`. In CI or non-interactive shells, `ask` behaves as `never`. +## Current Support Matrix + +Supported now: + +- Local repository builds and basic `file://` remote clone/index flows. +- Markdown, plain text, HTML, and optional MarkItDown document parsing. +- Python and R toy skill generation. +- Tutorial scanning, dependency mining, workflow DAG inference, and basic bio IO contracts. +- Environment preflight, install planning, and gated disposable conda environment creation. + +Experimental: + +- Full remote repository inference from cloned sources. +- Evidence graph conflict decisions. +- Source-aware bio contract inference. +- Adapter-based execution scaffolding. + +Not yet: + +- Robust large benchmark coverage. +- Complete Bioconductor metadata resolution. +- CUDA/system library automatic configuration. +- Large dataset download automation. +- Arbitrary install script execution. +- Fully automatic real algorithm execution for unknown repositories. + ## Maturity Levels The builder records maturity explicitly. The MVP targets stable L1 generation: @@ -105,6 +140,7 @@ toy Python and toy R examples are intended as L2 demo-executable examples. ## Roadmap - MVP: Codex Skill generation for Python and R algorithm repositories. +- Next: benchmark suite for Scanpy, Seurat, and DESeq2-style workflows. - Later: optional MCP export. - Later: optional Codex plugin packaging. - Out of scope for MVP: GPU/CUDA auto-configuration, large dataset download, diff --git a/paper2skill/cli.py b/paper2skill/cli.py index 66e8be5..8925326 100644 --- a/paper2skill/cli.py +++ b/paper2skill/cli.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any +from paper2skill.common import slugify from paper2skill.generators.codex_skill_generator import build_context, example_inputs, generate_skill, plan_outputs from paper2skill.runtime.env_manager import inspect_environment, load_environment_spec, public_environment_report from paper2skill.validators.skill_validator import validate_skill @@ -71,6 +72,8 @@ def command_plan(args: argparse.Namespace) -> int: skip_repo_clone=args.skip_repo_clone, no_execute_tutorials=args.no_execute_tutorials, strict_evidence=args.strict_evidence, + tutorial_filter=args.tutorial_filter, + collection_dir=Path(args.out) / ".paper2skill_collection", maturity_level=args.maturity_target or "L1", ) out = plan_outputs(context, args.out) @@ -96,11 +99,17 @@ def command_build(args: argparse.Namespace) -> int: "skip_repo_clone": args.skip_repo_clone, "no_execute_tutorials": args.no_execute_tutorials, "strict_evidence": args.strict_evidence, + "tutorial_filter": args.tutorial_filter, }.items(): if value is not None and value != "" and value != [] and value is not False: values[key] = value + if args.out: + out_dir = Path(args.out) + else: + inferred_name = values.get("skill_name") or values.get("algorithm_name") or "generated-skill" + out_dir = Path(".agents") / "skills" / slugify(str(inferred_name)) + values["collection_dir"] = out_dir.parent / ".paper2skill_collection" / out_dir.name context = build_context(**values) - out_dir = Path(args.out) if args.out else Path(".agents") / "skills" / context["skill_name"] generate_skill(context, out_dir) print(f"Generated skill at {out_dir}") return 0 diff --git a/paper2skill/collectors/repo_collector.py b/paper2skill/collectors/repo_collector.py index 69eccdc..2900d6f 100644 --- a/paper2skill/collectors/repo_collector.py +++ b/paper2skill/collectors/repo_collector.py @@ -23,15 +23,29 @@ def collect_repo( skip_clone: bool = False, ) -> dict[str, Any]: if not repo: - return {"url": None, "local_path": None, "ref": ref, "exists": False, "manifest": None, "index": {"files": []}} + return { + "url": None, + "local_path": None, + "resolved_path": None, + "ref": ref, + "exists": False, + "manifest": None, + "index": {"files": []}, + } base = Path(base_dir).resolve() if base_dir else Path.cwd().resolve() is_remote = is_remote_repo(repo) - if is_remote and not skip_clone and work_dir: + clone_status = "local" + if is_remote and skip_clone: + path: Path | None = None + clone_status = "skipped" + elif is_remote and work_dir: path = clone_repo(repo, Path(work_dir), ref) + clone_status = "cloned" else: path = local_repo_path(repo) - exists = path.exists() - manifest = build_repo_manifest(repo, path, ref, is_remote) + clone_status = "remote_unresolved" if is_remote else "local" + exists = bool(path and path.exists()) + manifest = build_repo_manifest(repo, path, ref, is_remote, clone_status) index = index_repo(path) if exists else {"files": []} if work_dir: references = Path(work_dir) / "references" @@ -39,7 +53,8 @@ def collect_repo( write_json(references / "repo_index.json", index) return { "url": repo if is_remote else None, - "local_path": public_local_path(path, base), + "local_path": public_local_path(path, base) if path else None, + "resolved_path": str(path) if path else None, "ref": ref, "exists": exists, "manifest": manifest, @@ -89,13 +104,15 @@ def repo_name_from_url(repo: str) -> str: return value or "repo" -def build_repo_manifest(repo: str, path: Path, ref: str | None, is_remote: bool) -> dict[str, Any]: +def build_repo_manifest(repo: str, path: Path | None, ref: str | None, is_remote: bool, clone_status: str = "local") -> dict[str, Any]: return { "repo_url": repo if is_remote else None, - "repo_name": path.name, - "local_path": str(path), - "commit_sha": git_rev_parse(path), + "repo_name": path.name if path else repo_name_from_url(repo), + "local_path": str(path) if path else None, + "commit_sha": git_rev_parse(path) if path else None, "ref": ref, + "requested_ref": ref, + "clone_status": clone_status, "collected_at": datetime.now(timezone.utc).isoformat(), "is_remote": is_remote, } diff --git a/paper2skill/evidence/evidence_graph.py b/paper2skill/evidence/evidence_graph.py index 720ca47..96bcc60 100644 --- a/paper2skill/evidence/evidence_graph.py +++ b/paper2skill/evidence/evidence_graph.py @@ -12,6 +12,8 @@ class EvidenceItem: locator: str text: str confidence_hint: str = "medium" + section_role: str = "unknown" + weight: float = 1.0 @dataclass @@ -35,16 +37,30 @@ def build_evidence_graph( items: list[EvidenceItem] = [] claims: list[EvidenceClaim] = [] for section in ((paper_evidence or {}).get("parsed_document") or {}).get("sections", []) or []: - items.append(EvidenceItem(section["section_id"], "paper", section.get("source_path", ""), f"lines:{section.get('start_line')}-{section.get('end_line')}", section.get("text", ""), "medium")) + role = section_role(section.get("section_id", ""), section.get("title", "")) + items.append( + EvidenceItem( + section["section_id"], + f"paper_{role}" if role != "unknown" else "paper", + section.get("source_path", ""), + f"lines:{section.get('start_line')}-{section.get('end_line')}", + section.get("text", ""), + "medium" if role in {"methods", "data", "code"} else "low", + role, + source_weight(f"paper_{role}"), + ) + ) for tutorial in (tutorial_trace or {}).get("tutorials", []) or []: for step in tutorial.get("steps", tutorial.get("workflow_steps", [])): - items.append(EvidenceItem(step.get("evidence_id") or step.get("step_id"), "tutorial", tutorial.get("path", ""), step.get("source", ""), step.get("command_or_code") or step.get("code_preview", ""), step.get("confidence", "high"))) + items.append(EvidenceItem(step.get("evidence_id") or step.get("step_id"), "tutorial", tutorial.get("path", ""), step.get("source", ""), step.get("command_or_code") or step.get("code_preview", ""), step.get("confidence", "high"), "code", source_weight("tutorial"))) for file_path in (dependency_evidence or {}).get("dependency_files", []): evidence_id = f"dependency:{file_path}" - items.append(EvidenceItem(evidence_id, "dependency", file_path, "file", file_path, "medium")) + items.append(EvidenceItem(evidence_id, "dependency", file_path, "file", file_path, "medium", "dependency", source_weight("dependency"))) collect_claims("bio_contract", bio_contract or {}, claims) collect_claims("algorithm_contract", algorithm_contract or {}, claims) - return {"items": [asdict(item) for item in items], "claims": [asdict(claim) for claim in claims], "conflicts": detect_conflicts(claims)} + conflicts = detect_conflicts(claims) + decisions = build_decisions(claims, items, conflicts) + return {"items": [asdict(item) for item in items], "claims": [asdict(claim) for claim in claims], "conflicts": conflicts, "decisions": decisions} def collect_claims(prefix: str, value: Any, claims: list[EvidenceClaim], path: str = "") -> None: @@ -69,3 +85,77 @@ def detect_conflicts(claims: list[EvidenceClaim]) -> list[dict[str, Any]]: for field, values in by_field.items() if len(values) > 1 ] + + +def build_decisions(claims: list[EvidenceClaim], items: list[EvidenceItem], conflicts: list[dict[str, Any]]) -> list[dict[str, Any]]: + item_map = {item.evidence_id: item for item in items} + fields = {conflict["field"] for conflict in conflicts} + decisions = [] + for field in sorted(fields): + candidates = [claim for claim in claims if claim.field == field and claim.value not in {None, "not_confirmed"}] + ranked = sorted(candidates, key=lambda claim: claim_score(claim, item_map), reverse=True) + if not ranked: + continue + best = ranked[0] + tied = [claim for claim in ranked if claim_score(claim, item_map) == claim_score(best, item_map)] + candidate_values = [ + { + "value": claim.value, + "evidence_ids": claim.evidence_ids, + "source_priority": best_source_type(claim, item_map), + "confidence": claim.confidence, + } + for claim in ranked + ] + if len({str(claim.value) for claim in tied}) == 1: + decision = {"value": best.value, "rule": "highest_weighted_evidence", "confidence": downgrade_confidence(best.confidence), "status": "decided"} + else: + decision = {"value": "not_confirmed", "rule": "conflicting_equal_weight_evidence", "confidence": "low", "status": "unresolved"} + decisions.append({"field": field, "candidate_values": candidate_values, "decision": decision}) + return decisions + + +def claim_score(claim: EvidenceClaim, item_map: dict[str, EvidenceItem]) -> tuple[float, int]: + confidence_rank = {"low": 0, "medium": 1, "high": 2}.get(claim.confidence, 0) + evidence_weight = max((item_map[eid].weight for eid in claim.evidence_ids if eid in item_map), default=0.0) + return (evidence_weight, confidence_rank) + + +def best_source_type(claim: EvidenceClaim, item_map: dict[str, EvidenceItem]) -> str: + ranked = sorted((item_map[eid] for eid in claim.evidence_ids if eid in item_map), key=lambda item: item.weight, reverse=True) + return ranked[0].source_type if ranked else "unknown" + + +def source_weight(source_type: str) -> float: + if source_type == "tutorial": + return 1.0 + if source_type in {"docs", "api"}: + return 0.9 + if source_type == "dependency": + return 0.7 + if source_type in {"paper_methods", "paper_data", "paper_code"}: + return 0.6 + if source_type.startswith("paper"): + return 0.2 + if source_type == "readme": + return 0.1 + return 0.0 + + +def section_role(section_id: str, title: str) -> str: + text = f"{section_id} {title}".lower() + if "data" in text: + return "data" + if "code" in text or "software" in text: + return "code" + if "method" in text: + return "methods" + if any(word in text for word in ["result", "benchmark", "evaluation"]): + return "results" + if any(word in text for word in ["abstract", "intro", "background", "discussion", "limitation"]): + return "background" + return "unknown" + + +def downgrade_confidence(confidence: str) -> str: + return "medium" if confidence == "high" else confidence diff --git a/paper2skill/generators/codex_skill_generator.py b/paper2skill/generators/codex_skill_generator.py index 80878f2..365ed08 100644 --- a/paper2skill/generators/codex_skill_generator.py +++ b/paper2skill/generators/codex_skill_generator.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import asdict, dataclass import json from pathlib import Path from typing import Any @@ -10,7 +11,7 @@ from paper2skill.collectors.path_sanitizer import REDACTED_LOCAL_PATH, public_data from paper2skill.collectors.paper_collector import collect_paper from paper2skill.collectors.repo_collector import collect_repo -from paper2skill.collectors.source_manifest import build_source_manifest +from paper2skill.collectors.tutorial_collector import collect_tutorials from paper2skill.common import PROJECT_ROOT, ensure_dir, slugify, write_json, write_text, write_yaml from paper2skill.evidence.evidence_graph import build_evidence_graph from paper2skill.inference.classify_algorithm import classify_algorithm @@ -29,6 +30,17 @@ TEMPLATE_ROOT = PROJECT_ROOT / "paper2skill" / "templates" / "codex_skill" +@dataclass +class ResolvedInputs: + paper_result: dict[str, Any] | None + repo_result: dict[str, Any] | None + repo_path: Path | None + explicit_tutorials: list[Path] + selected_tutorials: list[Path] + source_manifest: dict[str, Any] + warnings: list[str] + + def example_inputs(example: str) -> dict[str, Any]: fixtures = PROJECT_ROOT / "tests" / "fixtures" if example == "toy_python": @@ -58,6 +70,83 @@ def example_inputs(example: str) -> dict[str, Any]: raise ValueError(f"unknown example: {example}") +def resolve_inputs( + *, + paper: str | None, + repo: str | None, + tutorials: list[str], + paper_url: str | None, + paper_title: str | None, + repo_ref: str, + skip_repo_clone: bool, + tutorial_filter: str | None, + collection_dir: str | Path | None, + skill_name: str, +) -> ResolvedInputs: + base = Path.cwd().resolve() + work_dir = Path(collection_dir).resolve() if collection_dir else (base / ".paper2skill-resolve" / skill_name) + warnings: list[str] = [] + paper_result = collect_paper(paper, paper_url, paper_title, base) + repo_result = collect_repo(repo, repo_ref, base, work_dir=work_dir, skip_clone=skip_repo_clone) if repo else None + if repo_result and (repo_result.get("manifest") or {}).get("clone_status") == "skipped": + warnings.append("remote repo clone was skipped; repo-dependent mining was not executed") + repo_path = Path(repo_result["resolved_path"]) if repo_result and repo_result.get("resolved_path") and Path(repo_result["resolved_path"]).exists() else None + explicit_tutorials = resolve_tutorial_paths(tutorials, repo_path) + selected_tutorials = explicit_tutorials + tutorial_result = collect_tutorials([str(path) for path in explicit_tutorials], base_dir=repo_path or base) + source_manifest = { + "base_dir": REDACTED_LOCAL_PATH, + "paper": paper_result, + "repo": repo_result or {"url": None, "local_path": None, "resolved_path": None, "ref": repo_ref, "exists": False, "manifest": None, "index": {"files": []}}, + "tutorial": tutorial_result, + "options": { + "target": "codex_skill", + "allow_network": False, + "install_policy": "ask", + "maturity_target": "L1", + "repo_ref": repo_ref, + "skip_repo_clone": skip_repo_clone, + "tutorial_filter": tutorial_filter, + }, + } + return ResolvedInputs(paper_result, repo_result, repo_path, explicit_tutorials, selected_tutorials, source_manifest, warnings) + + +def resolve_tutorial_paths(tutorials: list[str], repo_path: Path | None) -> list[Path]: + resolved = [] + for value in tutorials: + path = Path(value) + if path.exists(): + resolved.append(path.resolve()) + continue + if repo_path and (repo_path / value).exists(): + resolved.append((repo_path / value).resolve()) + continue + resolved.append(path) + return resolved + + +def public_resolved_inputs(resolved: ResolvedInputs) -> dict[str, Any]: + data = asdict(resolved) + data["repo_path"] = str(resolved.repo_path) if resolved.repo_path else None + data["explicit_tutorials"] = [str(path) for path in resolved.explicit_tutorials] + data["selected_tutorials"] = [str(path) for path in resolved.selected_tutorials] + return public_data(data, PROJECT_ROOT) + + +def infer_adapter_type(repo_evidence: dict[str, Any], tutorial_trace: dict[str, Any], classification: dict[str, str]) -> str: + if repo_evidence.get("entrypoints") or repo_evidence.get("cli_commands"): + return "cli" + if repo_evidence.get("workflow_engines"): + return "workflow_engine" + if repo_evidence.get("package_type") == "r_package" or classification.get("language") == "r": + return "r_script" + tutorials = tutorial_trace.get("tutorials", []) + if tutorials and all(trace.get("language") == "python" for trace in tutorials) and not repo_evidence.get("api_functions"): + return "notebook" + return "demo_only" + + def build_context( *, skill_name: str | None = None, @@ -74,23 +163,47 @@ def build_context( skip_repo_clone: bool = False, no_execute_tutorials: bool = True, strict_evidence: bool = False, + tutorial_filter: str | None = None, + collection_dir: str | Path | None = None, ) -> dict[str, Any]: skill_name = slugify(skill_name or algorithm_name or "generated-skill") algorithm_name = algorithm_name or skill_name.replace("-", " ").title() tutorials = tutorials or [] - source_manifest = build_source_manifest(paper, repo, tutorials, paper_url, paper_title, repo_ref=repo_ref) - if tutorials: - tutorial_trace = mine_tutorials(tutorials) - elif repo and Path(repo).exists(): - tutorial_trace = mine_repo_tutorials(repo) + resolved = resolve_inputs( + paper=paper, + repo=repo, + tutorials=tutorials, + paper_url=paper_url, + paper_title=paper_title, + repo_ref=repo_ref, + skip_repo_clone=skip_repo_clone, + tutorial_filter=tutorial_filter, + collection_dir=collection_dir, + skill_name=skill_name, + ) + source_manifest = resolved.source_manifest + tutorial_paths = [str(path) for path in resolved.selected_tutorials] + if tutorial_paths: + tutorial_trace = mine_tutorials(tutorial_paths, base_dir=resolved.repo_path if resolved.repo_path else None) + if resolved.repo_path and not tutorials: + repo_tutorial_trace = mine_repo_tutorials(resolved.repo_path, tutorial_filter=tutorial_filter) + tutorial_trace["tutorial_candidates"] = repo_tutorial_trace.get("tutorial_candidates", []) + tutorial_trace["tutorial_scanner_report"] = repo_tutorial_trace.get("tutorial_scanner_report", {}) + else: + tutorial_trace["tutorial_candidates"] = [] + tutorial_trace["tutorial_scanner_report"] = {"total": len(tutorial_paths), "included": len(tutorial_paths), "filter": tutorial_filter} + elif resolved.repo_path: + tutorial_trace = mine_repo_tutorials(resolved.repo_path, tutorial_filter=tutorial_filter) else: - tutorial_trace = {"tutorials": [], "workflow_steps": [], "steps": [], "tutorial_candidates": [], "tutorial_scanner_report": {"total": 0, "included": 0}} - dependency_evidence = mine_dependencies(repo, tutorials) - repo_evidence = mine_api(repo) + tutorial_trace = {"tutorials": [], "workflow_steps": [], "steps": [], "tutorial_candidates": [], "tutorial_scanner_report": {"total": 0, "included": 0, "filter": tutorial_filter}} + tutorial_trace["tutorial_execution_status"] = "not_executed_by_policy" if no_execute_tutorials else "not_executed" + dependency_evidence = mine_dependencies(resolved.repo_path, tutorial_paths) + repo_evidence = mine_api(resolved.repo_path) classification = classify_algorithm(repo_evidence, tutorial_trace) if language: classification["language"] = language classification["execution_mode"] = "python_api" if language == "python" else "r_script" + adapter_type = infer_adapter_type(repo_evidence, tutorial_trace, classification) environment_spec = infer_environment_spec(dependency_evidence, classification["language"]) if classification["language"] == "python" and not environment_spec["python"]["packages"]: environment_spec["python"]["packages"] = [] @@ -98,9 +211,11 @@ def build_context( environment_spec["r"]["required"] = True environment_report = inspect_environment(environment_spec) install_plan = build_install_plan(environment_report, environment_spec) - io_contract = infer_io_contract(tutorial_trace) parameters = infer_parameters(tutorial_trace) workflow = infer_workflow(tutorial_trace) + paper_sections = (((source_manifest.get("paper") or {}).get("parsed_document") or {}).get("sections") or []) + bio_contract = infer_bio_contract(tutorial_trace, paper_sections, dependency_evidence, strict_evidence=strict_evidence) + io_contract = infer_io_contract(tutorial_trace, bio_contract) algorithm_contract = { "algorithm": { "name": algorithm_name, @@ -109,6 +224,7 @@ def build_context( "modality": "not_confirmed", "language": classification["language"], "execution_mode": classification["execution_mode"], + "adapter_type": adapter_type, "maturity_level": maturity_level, }, **io_contract, @@ -124,8 +240,6 @@ def build_context( "sources": source_manifest, "claims": _claims_from_context(algorithm_contract, workflow), } - paper_sections = (((source_manifest.get("paper") or {}).get("parsed_document") or {}).get("sections") or []) - bio_contract = infer_bio_contract(tutorial_trace, paper_sections, dependency_evidence) evidence_graph = build_evidence_graph( paper_evidence=source_manifest.get("paper"), tutorial_trace=tutorial_trace, @@ -140,6 +254,7 @@ def build_context( "language": classification["language"], "maturity_level": maturity_level, "source_manifest": source_manifest, + "resolved_inputs": public_resolved_inputs(resolved), "paper_evidence": _paper_evidence(source_manifest), "repo_evidence": repo_evidence, "dependency_evidence": dependency_evidence, @@ -161,7 +276,9 @@ def build_context( "skip_repo_clone": skip_repo_clone, "no_execute_tutorials": no_execute_tutorials, "strict_evidence": strict_evidence, + "tutorial_filter": tutorial_filter, }, + "warnings": resolved.warnings, "demo_data": _demo_data_for_language(classification["language"]), } @@ -177,6 +294,11 @@ def generate_skill(context: dict[str, Any], out_dir: str | Path) -> Path: "scripts/plan.py.j2": "scripts/plan.py", "scripts/run.py.j2": "scripts/run.py", "scripts/validate_outputs.py.j2": "scripts/validate_outputs.py", + "scripts/adapters/__init__.py.j2": "scripts/adapters/__init__.py", + "scripts/adapters/python_api_adapter.py.j2": "scripts/adapters/python_api_adapter.py", + "scripts/adapters/cli_adapter.py.j2": "scripts/adapters/cli_adapter.py", + "scripts/adapters/notebook_adapter.py.j2": "scripts/adapters/notebook_adapter.py", + "scripts/adapters/r_script_adapter.R.j2": "scripts/adapters/r_script_adapter.R", "references/evidence_report.md.j2": "references/evidence_report.md", "references/paper_summary.md.j2": "references/paper_summary.md", "references/repo_summary.md.j2": "references/repo_summary.md", @@ -194,6 +316,7 @@ def generate_skill(context: dict[str, Any], out_dir: str | Path) -> Path: content = env.get_template(template_name).render(**public_context) write_text(root / rel_target, content) write_json(root / "references" / "tutorial_trace.json", public_context["tutorial_trace"]) + write_json(root / "references" / "workflow_dag.json", public_context["workflow"].get("workflow_dag", {"nodes": [], "edges": []})) write_json(root / "references" / "tutorial_candidates.json", public_context["tutorial_trace"].get("tutorial_candidates", [])) write_json(root / "references" / "tutorial_scanner_report.json", public_context["tutorial_trace"].get("tutorial_scanner_report", {})) write_json(root / "references" / "environment_report.json", _public_environment_report(context["environment_report"])) @@ -204,7 +327,7 @@ def generate_skill(context: dict[str, Any], out_dir: str | Path) -> Path: write_yaml(root / "references" / "bio_contract.yaml", public_context["bio_contract"]) write_yaml(root / "references" / "io_contract.yaml", {"input_contract": public_context["algorithm_contract"].get("input_contract"), "output_contract": public_context["algorithm_contract"].get("output_contract")}) write_json(root / "references" / "evidence_graph.json", public_context["evidence_graph"]) - write_optional_collection_outputs(context, root) + write_optional_collection_outputs(public_context, root) write_json(root / "references" / "build_report.json", public_data(build_report(context), PROJECT_ROOT)) write_text(root / "references" / "install_plan.md", public_context["install_plan_markdown"]) write_text(root / "assets" / "environment_spec.yaml", json.dumps(public_context["environment_spec"], indent=2) + "\n") @@ -216,24 +339,69 @@ def generate_skill(context: dict[str, Any], out_dir: str | Path) -> Path: return root -def write_optional_collection_outputs(context: dict[str, Any], root: Path) -> None: - sources = context.get("input_sources", {}) - if sources.get("paper"): - collect_paper(sources["paper"], out_dir=root) - if sources.get("repo"): - repo_result = collect_repo(sources["repo"], ref=sources.get("repo_ref"), work_dir=root, skip_clone=sources.get("skip_repo_clone", False)) - write_json(root / "references" / "repo_manifest.json", public_data(repo_result.get("manifest"), root)) - write_json(root / "references" / "repo_index.json", public_data(repo_result.get("index"), root)) +def write_optional_collection_outputs(public_context: dict[str, Any], root: Path) -> None: + paper = (public_context.get("source_manifest") or {}).get("paper") or {} + parsed = paper.get("parsed_document") or {} + if parsed: + write_text(root / "references" / "paper.md", parsed.get("markdown", "")) + write_json( + root / "references" / "paper_sections.json", + { + "source_path": parsed.get("source_path"), + "parser_name": parsed.get("parser_name"), + "sections": [ + { + "section_id": section.get("section_id"), + "title": section.get("title"), + "level": section.get("level"), + "start_line": section.get("start_line"), + "end_line": section.get("end_line"), + "char_count": len(section.get("text", "")), + } + for section in parsed.get("sections", []) + ], + "warnings": parsed.get("warnings", []), + }, + ) + write_json( + root / "references" / "paper_parser_report.json", + { + "source_path": parsed.get("source_path"), + "source_type": parsed.get("source_type"), + "parser_name": parsed.get("parser_name"), + "warnings": parsed.get("warnings", []), + }, + ) + repo = (public_context.get("source_manifest") or {}).get("repo") or {} + write_json(root / "references" / "repo_manifest.json", repo.get("manifest")) + write_json(root / "references" / "repo_index.json", repo.get("index", {"files": []})) def build_report(context: dict[str, Any]) -> dict[str, Any]: sources = context.get("input_sources", {}) missing = {} + warnings = list(context.get("warnings", [])) if not sources.get("paper"): missing["paper"] = "not provided" if not sources.get("repo"): missing["repo"] = "not provided" - return {"status": "built", "missing_inputs": missing, "options": sources} + if sources.get("no_execute_tutorials", True): + warnings.append("tutorial_execution_status=not_executed_by_policy") + unresolved = [ + decision + for decision in (context.get("evidence_graph") or {}).get("decisions", []) + if (decision.get("decision") or {}).get("status") == "unresolved" + ] + if unresolved: + warnings.append("unresolved evidence conflicts present") + return { + "status": "built_with_warnings" if warnings else "built", + "missing_inputs": missing, + "options": sources, + "warnings": sorted(dict.fromkeys(warnings)), + "tutorial_execution_status": (context.get("tutorial_trace") or {}).get("tutorial_execution_status", "not_executed_by_policy"), + "unresolved_conflicts": unresolved, + } def plan_outputs(context: dict[str, Any], out_dir: str | Path) -> Path: @@ -243,6 +411,7 @@ def plan_outputs(context: dict[str, Any], out_dir: str | Path) -> Path: write_json(root / "paper_evidence.json", public_context["paper_evidence"]) write_json(root / "repo_evidence.json", public_context["repo_evidence"]) write_json(root / "tutorial_trace.json", public_context["tutorial_trace"]) + write_json(root / "workflow_dag.json", public_context["workflow"].get("workflow_dag", {"nodes": [], "edges": []})) write_yaml(root / "algorithm_contract.preview.yaml", public_context["algorithm_contract"]) write_json(root / "environment_report.json", _public_environment_report(context["environment_report"])) write_text(root / "build_plan.md", _build_plan_markdown(public_context)) diff --git a/paper2skill/inference/classify_algorithm.py b/paper2skill/inference/classify_algorithm.py index c310f1f..f295b49 100644 --- a/paper2skill/inference/classify_algorithm.py +++ b/paper2skill/inference/classify_algorithm.py @@ -11,8 +11,18 @@ def classify_algorithm(repo_evidence: dict[str, Any], tutorial_trace: dict[str, language = "python" elif "r" in languages: language = "r" + if repo_evidence.get("workflow_engines"): + execution_mode = "workflow_engine" + elif repo_evidence.get("entrypoints") or repo_evidence.get("cli_commands"): + execution_mode = "cli" + elif language == "python": + execution_mode = "python_api" + elif language == "r": + execution_mode = "r_script" + else: + execution_mode = "unknown" return { "domain": "bioinformatics", "language": language or "unknown", - "execution_mode": "python_api" if language == "python" else ("r_script" if language == "r" else "unknown"), + "execution_mode": execution_mode, } diff --git a/paper2skill/inference/infer_bio_contract.py b/paper2skill/inference/infer_bio_contract.py index 407ccbb..e4d9995 100644 --- a/paper2skill/inference/infer_bio_contract.py +++ b/paper2skill/inference/infer_bio_contract.py @@ -53,13 +53,18 @@ def default_bio_contract() -> dict[str, Any]: } -def infer_bio_contract(tutorial_trace: dict[str, Any], paper_sections: list[dict[str, Any]] | None = None, dependency_evidence: dict[str, Any] | None = None) -> dict[str, Any]: +def infer_bio_contract( + tutorial_trace: dict[str, Any], + paper_sections: list[dict[str, Any]] | None = None, + dependency_evidence: dict[str, Any] | None = None, + strict_evidence: bool = False, +) -> dict[str, Any]: text_items = evidence_texts(tutorial_trace, paper_sections or []) all_text = "\n".join(text for _eid, text, _source in text_items) - modality = first_with_evidence(text_items, MODALITY_RULES) - species = first_with_evidence(text_items, SPECIES_RULES) - gene_id = first_with_evidence(text_items, GENE_ID_RULES) - transformations = transformation_chain(text_items) + modality = first_with_evidence(text_items, MODALITY_RULES, strict_evidence) + species = first_with_evidence(text_items, SPECIES_RULES, strict_evidence) + gene_id = first_with_evidence(text_items, GENE_ID_RULES, strict_evidence) + transformations = transformation_chain(text_items, strict_evidence) celltype_key = metadata_key(all_text, "celltype_key", ["cell_type", "celltype", "celltypes"]) base = default_bio_contract()["bio_contract"] base["modality"] = { @@ -72,9 +77,9 @@ def infer_bio_contract(tutorial_trace: dict[str, Any], paper_sections: list[dict "gene_id_type": field_value(gene_id[0], gene_id[2], gene_id[1]) if gene_id else field_value(), } base["input_matrix_state"] = { - "raw_counts_required": field_value("raw_counts_loaded" in transformations, "high" if transformations else "low", evidence_for_value(text_items, MATRIX_STATE_RULES, "raw_counts_loaded")), - "normalized_allowed": field_value("normalized" in transformations, "high" if "normalized" in transformations else "low", evidence_for_value(text_items, MATRIX_STATE_RULES, "normalized")), - "log_transformed_allowed": field_value("log1p_transformed" in transformations, "high" if "log1p_transformed" in transformations else "low", evidence_for_value(text_items, MATRIX_STATE_RULES, "log1p_transformed")), + "raw_counts_required": field_value("raw_counts_loaded" in transformations, "high" if "raw_counts_loaded" in transformations else "low", evidence_for_value(text_items, MATRIX_STATE_RULES, "raw_counts_loaded", strict_evidence)), + "normalized_allowed": field_value("normalized" in transformations, "high" if "normalized" in transformations else "low", evidence_for_value(text_items, MATRIX_STATE_RULES, "normalized", strict_evidence)), + "log_transformed_allowed": field_value("log1p_transformed" in transformations, "high" if "log1p_transformed" in transformations else "low", evidence_for_value(text_items, MATRIX_STATE_RULES, "log1p_transformed", strict_evidence)), "matrix_orientation": field_value(), "matrix_transformations": transformations, } @@ -94,30 +99,36 @@ def evidence_texts(tutorial_trace: dict[str, Any], paper_sections: list[dict[str text = "\n".join([step.get("code_preview", ""), step.get("command_or_code", ""), " ".join(step.get("function_calls", [])), " ".join(step.get("imports", []))]) items.append((step.get("evidence_id") or step.get("step_id", "tutorial:unknown"), text, "tutorial")) for section in paper_sections: - items.append((section.get("section_id", "paper:unknown"), section.get("text", ""), "paper")) + role = section_role(section.get("section_id", ""), section.get("title", "")) + items.append((section.get("section_id", "paper:unknown"), section.get("text", ""), f"paper:{role}")) return items -def first_with_evidence(items: list[tuple[str, str, str]], rules: dict[str, list[str]]) -> tuple[str, list[str], str] | None: +def first_with_evidence(items: list[tuple[str, str, str]], rules: dict[str, list[str]], strict_evidence: bool = False) -> tuple[str, list[str], str] | None: for evidence_id, text, source in items: matches = match_rules(text, rules) if matches: - return matches[0], [evidence_id], "high" if source == "tutorial" else "medium" + confidence = confidence_for_source(source) + if strict_evidence and confidence == "low": + continue + return matches[0], [evidence_id], confidence return None -def transformation_chain(items: list[tuple[str, str, str]]) -> list[str]: +def transformation_chain(items: list[tuple[str, str, str]], strict_evidence: bool = False) -> list[str]: found = [] for value in MATRIX_STATE_RULES: - if evidence_for_value(items, MATRIX_STATE_RULES, value): + if evidence_for_value(items, MATRIX_STATE_RULES, value, strict_evidence): found.append(value) return found -def evidence_for_value(items: list[tuple[str, str, str]], rules: dict[str, list[str]], value: str) -> list[str]: +def evidence_for_value(items: list[tuple[str, str, str]], rules: dict[str, list[str]], value: str, strict_evidence: bool = False) -> list[str]: words = rules[value] evidence = [] - for evidence_id, text, _source in items: + for evidence_id, text, source in items: + if strict_evidence and confidence_for_source(source) == "low": + continue if any(word.lower() in text.lower() for word in words): evidence.append(evidence_id) return evidence @@ -128,3 +139,22 @@ def metadata_key(text: str, _field: str, candidates: list[str]) -> str | None: if re.search(rf"['\"]{re.escape(candidate)}['\"]", text) or candidate in text: return candidate return None + + +def section_role(section_id: str, title: str) -> str: + text = f"{section_id} {title}".lower() + if any(word in text for word in ["methods", "method", "data", "code", "software"]): + return "methods" + if any(word in text for word in ["results", "benchmark", "evaluation"]): + return "results" + if any(word in text for word in ["discussion", "limitation", "abstract", "introduction", "background"]): + return "background" + return "unknown" + + +def confidence_for_source(source: str) -> str: + if source == "tutorial" or source.startswith("docs") or source.startswith("api"): + return "high" + if source in {"paper:methods", "paper:data", "paper:code"}: + return "medium" + return "low" diff --git a/paper2skill/inference/infer_environment.py b/paper2skill/inference/infer_environment.py index e4c1742..19236c1 100644 --- a/paper2skill/inference/infer_environment.py +++ b/paper2skill/inference/infer_environment.py @@ -16,6 +16,11 @@ def infer_environment_spec(dependencies: dict[str, Any], language: str = "unknow "packages": r_packages, }, "executables": dependencies.get("executables", []), + "conda": { + "packages": dependencies.get("conda_records", []), + }, + "system_requirements": dependencies.get("system_requirements", []), + "external_resources": dependencies.get("external_resources", []), "optional_dependencies": dependencies.get("optional", {"python": {}, "r": {}}), "ignored_dependencies": dependencies.get("ignored", []), "optional": { diff --git a/paper2skill/inference/infer_io_contract.py b/paper2skill/inference/infer_io_contract.py index 5cf48aa..9cc8717 100644 --- a/paper2skill/inference/infer_io_contract.py +++ b/paper2skill/inference/infer_io_contract.py @@ -1,23 +1,133 @@ from __future__ import annotations +from pathlib import PurePosixPath from typing import Any -def infer_io_contract(tutorial_trace: dict[str, Any]) -> dict[str, Any]: +def infer_io_contract(tutorial_trace: dict[str, Any], bio_contract: dict[str, Any] | None = None) -> dict[str, Any]: inputs = [] outputs = [] + format_evidence = [] for step in tutorial_trace.get("workflow_steps", []): inputs.extend(step.get("inputs", [])) + inputs.extend(step.get("read_files", [])) outputs.extend(step.get("outputs", [])) + outputs.extend(step.get("write_files", [])) + format_evidence.extend(step_format_evidence(step)) + bio = (bio_contract or {}).get("bio_contract", {}) return { "input_contract": { - "required": [ - {"name": "input_manifest", "type": "yaml", "state": "required"}, - {"name": "primary_data", "type": "file", "state": "not_confirmed" if not inputs else "tutorial_confirmed"}, - ] + "required": { + "input_manifest": {"type": "yaml", "state": "required"}, + "primary_data": { + "type": "file", + "state": "not_confirmed" if not inputs else "tutorial_confirmed", + "format": file_format_field(format_evidence), + "matrix_state": matrix_state_field(bio), + "matrix_orientation": field_value("unknown", "low", []), + "metadata_keys": metadata_keys(bio), + "organism": organism_fields(bio), + "external_resources": external_resources(bio), + "evidence": sorted({str(value) for value in inputs if value}), + }, + } }, "output_contract": { "required": ["qc/environment_report.json", "qc/input_validation.json", "workflow/plan.json", "result.json", "results/"], "tutorial_outputs": sorted(dict.fromkeys(outputs)), }, } + + +def step_format_evidence(step: dict[str, Any]) -> list[str]: + values: list[str] = [] + values.extend(string_values(step.get("inputs", []))) + values.extend(string_values(step.get("read_files", []))) + for call in step.get("function_calls", []) or []: + if isinstance(call, dict): + name = call.get("name") + if name: + values.append(str(name)) + values.extend(string_values(call.get("args", []))) + elif call: + values.append(str(call)) + for key in ["code_preview", "command_or_code"]: + value = step.get(key) + if isinstance(value, str) and value.strip(): + values.append(value) + return values + + +def string_values(values: Any) -> list[str]: + if not isinstance(values, list): + values = [values] + return [str(value) for value in values if value] + + +def field_value(value: Any, confidence: str = "low", evidence: list[str] | None = None) -> dict[str, Any]: + return {"value": value, "confidence": confidence, "evidence": evidence or []} + + +def file_format_field(paths: list[Any]) -> dict[str, Any]: + for value in paths: + detected = detect_file_format(str(value)) + if detected != "unknown": + return field_value(detected, "high", [str(value)]) + return field_value("unknown", "low", []) + + +def detect_file_format(value: str) -> str: + lower = value.lower() + name = PurePosixPath(lower.replace("\\", "/")).name + if lower.endswith(".h5ad"): + return "h5ad" + if lower.endswith((".rds", ".rda")): + return "rds" + if name == "matrix.mtx" or "read_10x" in lower or "filtered_feature_bc_matrix" in lower: + return "10x_mtx" + if lower.endswith(".mtx"): + return "mtx" + if lower.endswith((".csv", ".tsv")): + return "csv" if lower.endswith(".csv") else "tsv" + if lower.endswith(".loom"): + return "loom" + if lower.endswith((".h5", ".hdf5")): + return "hdf5" + return "unknown" + + +def matrix_state_field(bio: dict[str, Any]) -> dict[str, Any]: + matrix = bio.get("input_matrix_state", {}) + transformations = matrix.get("matrix_transformations", []) or [] + if "log1p_transformed" in transformations: + return field_value("log1p", "high", matrix.get("log_transformed_allowed", {}).get("evidence", [])) + if "normalized" in transformations: + return field_value("normalized", "high", matrix.get("normalized_allowed", {}).get("evidence", [])) + if "raw_counts_loaded" in transformations or matrix.get("raw_counts_required", {}).get("value") is True: + return field_value("raw_counts", "high", matrix.get("raw_counts_required", {}).get("evidence", [])) + return field_value("unknown", "low", []) + + +def metadata_keys(bio: dict[str, Any]) -> dict[str, Any]: + metadata = bio.get("metadata_requirements", {}) + return {key: metadata.get(key, field_value("not_confirmed")) for key in ["sample_key", "batch_key", "condition_key", "celltype_key", "perturbation_key"]} + + +def organism_fields(bio: dict[str, Any]) -> dict[str, Any]: + organism = bio.get("organism", {}) + return { + "species": organism.get("species_supported", field_value("not_confirmed")), + "genome_build": organism.get("genome_build", field_value("not_confirmed")), + "gene_id_type": organism.get("gene_id_type", field_value("not_confirmed")), + } + + +def external_resources(bio: dict[str, Any]) -> dict[str, Any]: + resources = bio.get("reference_resources", {}) + return { + "genome": resources.get("genome", field_value("not_confirmed")), + "annotation": resources.get("annotation", field_value("not_confirmed")), + "grn": resources.get("grn", field_value("not_confirmed")), + "ligand_receptor_database": resources.get("ligand_receptor_database", field_value("not_confirmed")), + "pathway_database": resources.get("pathway_database", resources.get("database", field_value("not_confirmed"))), + } diff --git a/paper2skill/inference/infer_workflow.py b/paper2skill/inference/infer_workflow.py index c457fcf..535c7c7 100644 --- a/paper2skill/inference/infer_workflow.py +++ b/paper2skill/inference/infer_workflow.py @@ -3,6 +3,120 @@ from typing import Any +STEP_TYPE_RULES = { + "load_data": ["read", "read_csv", "read_h5ad", "read_10x", "read10x", "load", "import"], + "qc": ["calculate_qc", "filter_cells", "filter_genes", "mito", "qc"], + "filtering": ["filter", "subset"], + "normalization": ["normalize", "normalizedata", "normalize_total"], + "transformation": ["log1p", "lognormalize", "scale", "scaledata"], + "feature_selection": ["highly_variable", "variablefeatures"], + "dimensionality_reduction": ["pca", "umap", "tsne", "neighbors"], + "clustering": ["leiden", "louvain", "findclusters"], + "differential_expression": ["rank_genes", "findmarkers", "deseq", "edger", "limma"], + "model_training": ["fit", "train"], + "prediction": ["predict", "classify"], + "visualization": ["plot", "scatter", "umap", "tsne", "savefig", "ggsave"], + "save_output": ["write", "to_csv", "save", "saverds", "write_h5ad"], +} + + def infer_workflow(tutorial_trace: dict[str, Any]) -> dict[str, Any]: steps = tutorial_trace.get("workflow_steps", []) - return {"steps": steps, "evidence_priority": ["tutorial", "docs", "api", "dependency_files", "paper", "readme"]} + dag = build_workflow_dag(steps) + return { + "steps": steps, + "workflow_dag": dag, + "evidence_priority": ["tutorial", "docs", "api", "dependency_files", "paper", "readme"], + } + + +def build_workflow_dag(steps: list[dict[str, Any]]) -> dict[str, Any]: + nodes = [] + edges = [] + last_object_producer: dict[str, str] = {} + last_file_producer: dict[str, str] = {} + for index, step in enumerate(steps): + step_id = step.get("step_id") or step.get("id") or f"step_{index + 1:03d}" + node_type = classify_step_type(step) + input_objects = list(dict.fromkeys(step.get("input_objects", []) or [])) + output_objects = list(dict.fromkeys(step.get("output_objects", []) or [])) + input_files = list(dict.fromkeys(step.get("read_files", step.get("inputs", [])) or [])) + output_files = list(dict.fromkeys(step.get("write_files", step.get("outputs", [])) or [])) + for name in input_objects: + producer = last_object_producer.get(name) + if producer and producer != step_id: + edges.append({"from": producer, "to": step_id, "reason": f"shared_object:{name}"}) + for name in input_files: + producer = last_file_producer.get(name) + if producer and producer != step_id: + edges.append({"from": producer, "to": step_id, "reason": f"shared_file:{name}"}) + for name in output_objects: + last_object_producer[name] = step_id + for name in output_files: + last_file_producer[name] = step_id + nodes.append( + { + "step_id": step_id, + "type": node_type, + "language": step.get("language", "unknown"), + "source": step.get("source"), + "input_files": input_files, + "output_files": output_files, + "input_objects": input_objects, + "output_objects": output_objects, + "object_state_after": object_state_after(step, output_objects or input_objects, node_type), + "parameters": step.get("parameters", {}), + "evidence": [step.get("evidence_id") or step_id], + "confidence": step.get("confidence", "medium"), + } + ) + return {"nodes": nodes, "edges": dedupe_edges(edges)} + + +def classify_step_type(step: dict[str, Any]) -> str: + haystack = " ".join( + [ + str(step.get("description", "")), + str(step.get("command_or_code", "")), + " ".join(str(value) for value in step.get("function_calls", []) or []), + " ".join(str(value) for value in step.get("bio_signals", []) or []), + ] + ).lower() + for step_type, words in STEP_TYPE_RULES.items(): + if any(word.lower() in haystack for word in words): + return step_type + if step.get("write_files") or step.get("outputs"): + return "save_output" + if step.get("read_files") or step.get("inputs"): + return "load_data" + return "other" + + +def object_state_after(step: dict[str, Any], objects: list[str], node_type: str) -> dict[str, dict[str, Any]]: + state: dict[str, dict[str, Any]] = {} + signals = set(step.get("bio_signals", []) or []) + for name in objects: + value: dict[str, Any] = {} + if "single_cell" in signals: + value["modality"] = "scRNA-seq" + if node_type == "load_data" or "raw_counts" in signals: + value["matrix_state"] = "raw_counts" + if node_type == "normalization" or "normalization" in signals: + value["matrix_state"] = "normalized" + if node_type == "transformation" or "log_transform" in signals: + value["matrix_state"] = "log1p" + if value: + state[name] = value + return state + + +def dedupe_edges(edges: list[dict[str, str]]) -> list[dict[str, str]]: + seen = set() + result = [] + for edge in edges: + key = (edge["from"], edge["to"], edge["reason"]) + if key in seen: + continue + seen.add(key) + result.append(edge) + return result diff --git a/paper2skill/miners/api_miner.py b/paper2skill/miners/api_miner.py index e8f02ce..3756575 100644 --- a/paper2skill/miners/api_miner.py +++ b/paper2skill/miners/api_miner.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re +import tomllib from pathlib import Path from typing import Any, Callable @@ -15,10 +17,14 @@ def mine_api(repo_path: str | Path | None) -> dict[str, Any]: api_functions = [] classes = [] r_functions = [] + entrypoints = pyproject_entrypoints(root) + setup_entrypoints(root) + cli_commands = [] for path in root.rglob("*.py"): if any(part.startswith(".") for part in path.parts): continue - mined = mine_python_source(path.read_text(encoding="utf-8", errors="replace")) + source = path.read_text(encoding="utf-8", errors="replace") + mined = mine_python_source(source) + cli_commands.extend(python_cli_commands(source, path, root)) for item in mined.get("functions", []): item = dict(item) item["path"] = public_local_path(path, root) @@ -33,16 +39,19 @@ def mine_api(repo_path: str | Path | None) -> dict[str, Any]: item = dict(item) item["path"] = public_local_path(path, root) r_functions.append(item) + r_exports = r_namespace_exports(root) language = "python" if api_functions or classes else ("r" if r_functions else "unknown") return { "language": language, "package_type": _package_type(root), "install_files": public_local_paths([p for p in [root / "pyproject.toml", root / "setup.py", root / "DESCRIPTION"] if p.exists()], root), "dependency_files": public_local_paths([p for p in [root / "requirements.txt", root / "renv.lock"] if p.exists()], root), - "entrypoints": [], - "cli_commands": [], + "entrypoints": entrypoints, + "cli_commands": cli_commands, "api_functions": api_functions or r_functions, + "r_exports": r_exports, "classes": classes, + "workflow_engines": workflow_engines(root), "tutorials": public_local_paths([p for p in root.rglob("*") if p.suffix.lower() in {".ipynb", ".py", ".r", ".rmd"}], root), "notebooks": public_local_paths(root.rglob("*.ipynb"), root), "examples": _public_matching_paths(root, lambda value: "example" in value.lower() or "demo" in value.lower()), @@ -67,3 +76,74 @@ def _package_type(root: Path) -> str: if (root / "DESCRIPTION").exists(): return "r_package" return "unknown" + + +def pyproject_entrypoints(root: Path) -> list[dict[str, Any]]: + path = root / "pyproject.toml" + if not path.exists(): + return [] + try: + data = tomllib.loads(path.read_text(encoding="utf-8", errors="replace")) + except tomllib.TOMLDecodeError: + return [] + scripts = data.get("project", {}).get("scripts", {}) or {} + return [{"name": name, "target": target, "source": "pyproject.toml", "type": "console_script"} for name, target in scripts.items()] + + +def setup_entrypoints(root: Path) -> list[dict[str, Any]]: + path = root / "setup.py" + if not path.exists(): + return [] + text = path.read_text(encoding="utf-8", errors="replace") + matches = re.findall(r"['\"]([A-Za-z0-9_.-]+)\s*=\s*([A-Za-z0-9_.:]+)['\"]", text) + return [{"name": name, "target": target, "source": "setup.py", "type": "console_script"} for name, target in matches] + + +def python_cli_commands(source: str, path: Path, root: Path) -> list[dict[str, Any]]: + commands = [] + public_path = public_local_path(path, root) + if "argparse.ArgumentParser" in source or "ArgumentParser(" in source: + commands.append({"framework": "argparse", "path": public_path, "name": path.stem}) + if "@click.command" in source or "@click.group" in source: + commands.append({"framework": "click", "path": public_path, "name": path.stem}) + if "typer.Typer" in source or "Typer(" in source: + commands.append({"framework": "typer", "path": public_path, "name": path.stem}) + if "fire.Fire" in source: + commands.append({"framework": "fire", "path": public_path, "name": path.stem}) + return commands + + +def r_namespace_exports(root: Path) -> list[dict[str, Any]]: + namespace = root / "NAMESPACE" + if not namespace.exists(): + return [] + text = namespace.read_text(encoding="utf-8", errors="replace") + names = re.findall(r"export\(([^)]+)\)", text) + exports = [] + for group in names: + for name in group.split(","): + clean = name.strip().strip("'\"") + if clean: + exports.append({"name": clean, "source": "NAMESPACE"}) + return exports + + +def workflow_engines(root: Path) -> list[dict[str, Any]]: + engines = [] + checks = [ + ("snakemake", ["Snakefile", "*.smk"]), + ("nextflow", ["nextflow.config", "main.nf"]), + ("wdl", ["*.wdl"]), + ("cwl", ["*.cwl"]), + ] + for engine, patterns in checks: + paths = [] + for pattern in patterns: + paths.extend(root.glob(pattern)) + paths.extend(root.rglob(pattern) if "*" in pattern else []) + public = sorted({public_local_path(path, root) for path in paths if path.is_file()}) + if public: + engines.append({"engine": engine, "files": public}) + if (root / "workflow").exists(): + engines.append({"engine": "workflow_directory", "files": ["workflow/"]}) + return engines diff --git a/paper2skill/miners/dependency_miner.py b/paper2skill/miners/dependency_miner.py index 7012af7..8ca22f9 100644 --- a/paper2skill/miners/dependency_miner.py +++ b/paper2skill/miners/dependency_miner.py @@ -4,15 +4,18 @@ import re import tomllib import sys +import configparser from pathlib import Path from typing import Any +import yaml from packaging.requirements import InvalidRequirement, Requirement from paper2skill.collectors.path_sanitizer import public_local_path from paper2skill.miners.script_miner import R_LIBRARY_RE R_BASE_PACKAGES = {"base", "compiler", "datasets", "graphics", "grDevices", "grid", "methods", "parallel", "splines", "stats", "tools", "utils"} +BIOCONDUCTOR_HINTS = {"DESeq2", "edgeR", "limma", "clusterProfiler", "SingleCellExperiment", "SummarizedExperiment", "AnnotationDbi", "BiocGenerics", "scran", "scater", "ComplexHeatmap"} def parse_requirements(path: Path) -> list[str]: @@ -159,6 +162,64 @@ def parse_description_fields(path: Path) -> tuple[list[str], dict[str, list[str] return required, {key: value for key, value in optional.items() if value} +def parse_description_metadata(path: Path) -> dict[str, list[str]]: + text = path.read_text(encoding="utf-8", errors="replace") + fields: dict[str, list[str]] = {"SystemRequirements": [], "Remotes": []} + capture: str | None = None + for line in text.splitlines(): + match = re.match(r"^(SystemRequirements|Remotes):", line) + if match: + capture = match.group(1) + line = line.split(":", 1)[1] + elif capture and line and not line.startswith((" ", "\t")): + capture = None + if capture: + fields[capture].extend([item.strip() for item in line.split(",") if item.strip()]) + return {key: value for key, value in fields.items() if value} + + +def parse_environment_yml(path: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + try: + data = yaml.safe_load(path.read_text(encoding="utf-8", errors="replace")) or {} + except yaml.YAMLError: + return [], [] + python_records = [] + conda_records = [] + for dep in data.get("dependencies", []) or []: + if isinstance(dep, str): + name = dep.split("=", 1)[0].strip() + if name and name not in {"python", "pip", "r-base"}: + conda_records.append({"package": dep, "name": name, "source": path.name, "required": True, "category": "runtime"}) + elif isinstance(dep, dict): + for key, values in dep.items(): + if key == "pip": + for spec in values or []: + record = _python_record(str(spec), path.name, "dependencies.pip") + if record: + python_records.append(record) + else: + conda_records.append({"package": key, "source": path.name, "required": True, "category": "runtime"}) + return python_records, conda_records + + +def parse_setup_cfg(path: Path) -> list[dict[str, Any]]: + parser = configparser.ConfigParser() + parser.read(path, encoding="utf-8") + specs = [] + if parser.has_option("options", "install_requires"): + specs = [line.strip() for line in parser.get("options", "install_requires").splitlines() if line.strip()] + return [record for spec in specs if (record := _python_record(spec, "setup.cfg", "options.install_requires"))] + + +def parse_setup_py(path: Path) -> list[dict[str, Any]]: + text = path.read_text(encoding="utf-8", errors="replace") + match = re.search(r"install_requires\s*=\s*\[([^\]]*)\]", text, re.S) + if not match: + return [] + specs = re.findall(r"['\"]([^'\"]+)['\"]", match.group(1)) + return [record for spec in specs if (record := _python_record(spec, "setup.py", "install_requires"))] + + def parse_renv_lock(path: Path) -> list[str]: try: data = json.loads(path.read_text(encoding="utf-8", errors="replace")) @@ -173,18 +234,32 @@ def mine_dependencies(repo_path: str | Path | None, tutorial_paths: list[str | P r_packages: list[str] = [] r_records: list[dict[str, Any]] = [] dependency_files = [] + conda_records: list[dict[str, Any]] = [] + system_requirements: list[dict[str, Any]] = [] + external_resources: list[dict[str, Any]] = [] optional: dict[str, dict[str, list[str]]] = {"python": {}, "r": {}} ignored: list[dict[str, str]] = [] root = Path(repo_path).resolve() if repo_path else None if root and root.exists(): - for file_name in ["requirements.txt"]: - path = root / file_name - if path.exists(): + requirement_paths = [root / "requirements.txt"] + requirements_dir = root / "requirements" + if requirements_dir.exists(): + requirement_paths.extend(sorted(requirements_dir.glob("*.txt"))) + for path in requirement_paths: + if path.exists() and path.is_file(): dependency_files.append(public_local_path(path, root)) records, skipped = parse_requirements_records(path) python_records.extend(records) python_packages.extend(item["spec"] for item in records) ignored.extend(skipped) + for file_name in ["environment.yml", "environment.yaml", "conda.yml"]: + path = root / file_name + if path.exists(): + dependency_files.append(public_local_path(path, root)) + records, conda = parse_environment_yml(path) + python_records.extend(records) + python_packages.extend(item["spec"] for item in records) + conda_records.extend(conda) pyproject = root / "pyproject.toml" if pyproject.exists(): dependency_files.append(public_local_path(pyproject, root)) @@ -193,13 +268,28 @@ def mine_dependencies(repo_path: str | Path | None, tutorial_paths: list[str | P python_packages.extend(item["spec"] for item in records) optional["python"].update(optional_python) ignored.extend(skipped) + setup_cfg = root / "setup.cfg" + if setup_cfg.exists(): + dependency_files.append(public_local_path(setup_cfg, root)) + records = parse_setup_cfg(setup_cfg) + python_records.extend(records) + python_packages.extend(item["spec"] for item in records) + setup_py = root / "setup.py" + if setup_py.exists(): + dependency_files.append(public_local_path(setup_py, root)) + records = parse_setup_py(setup_py) + python_records.extend(records) + python_packages.extend(item["spec"] for item in records) description = root / "DESCRIPTION" if description.exists(): dependency_files.append(public_local_path(description, root)) required_r, optional_r = parse_description_fields(description) r_packages.extend(required_r) - r_records.extend({"name": name, "source": "DESCRIPTION", "evidence": "Imports/Depends", "required": True, "category": "runtime"} for name in required_r) + r_records.extend({"name": name, "source": "Bioconductor_or_unknown" if name in BIOCONDUCTOR_HINTS else "DESCRIPTION", "evidence": "Imports/Depends", "required": True, "category": "runtime"} for name in required_r) optional["r"].update(optional_r) + metadata = parse_description_metadata(description) + system_requirements.extend({"value": item, "source": "DESCRIPTION", "required": True, "install": "plan_only"} for item in metadata.get("SystemRequirements", [])) + external_resources.extend({"name": item, "type": "r_remote", "source": "DESCRIPTION:Remotes", "required": False, "downloadable": False} for item in metadata.get("Remotes", [])) renv = root / "renv.lock" if renv.exists(): dependency_files.append(public_local_path(renv, root)) @@ -224,6 +314,9 @@ def mine_dependencies(repo_path: str | Path | None, tutorial_paths: list[str | P "r_records": sorted(r_records, key=lambda item: item["name"]), "optional": optional, "ignored": ignored, + "conda_records": sorted(conda_records, key=lambda item: item.get("package", item.get("name", ""))), + "system_requirements": system_requirements, + "external_resources": external_resources, "executables": [], } diff --git a/paper2skill/miners/tutorial_miner.py b/paper2skill/miners/tutorial_miner.py index 0ff9e90..5880d8b 100644 --- a/paper2skill/miners/tutorial_miner.py +++ b/paper2skill/miners/tutorial_miner.py @@ -29,12 +29,27 @@ def mine_tutorials(paths: list[str | Path], base_dir: str | Path | None = None) return {"tutorials": traces, "workflow_steps": workflow_steps, "steps": workflow_steps} -def mine_repo_tutorials(repo_root: str | Path) -> dict[str, Any]: +def mine_repo_tutorials(repo_root: str | Path, tutorial_filter: str | None = None) -> dict[str, Any]: scan = scan_tutorial_candidates(repo_root) - included = [Path(repo_root) / item.path for item in scan["candidates"] if item.include_in_tools] + candidates = scan["candidates"] + if tutorial_filter: + needle = tutorial_filter.lower() + filtered = [] + for item in candidates: + title = item.title or "" + if needle in item.path.lower() or needle in title.lower(): + filtered.append(item) + else: + item.include_in_tools = False + item.reason = "excluded_by_filter" + candidates = filtered + included = [Path(repo_root) / item.path for item in candidates if item.include_in_tools] trace = mine_tutorials(included, base_dir=repo_root) - trace["tutorial_candidates"] = [item.__dict__ for item in scan["candidates"]] - trace["tutorial_scanner_report"] = scan["report"] + trace["tutorial_candidates"] = [item.__dict__ for item in candidates] + report = dict(scan["report"]) + report["filter"] = tutorial_filter + report["included_after_filter"] = sum(1 for item in candidates if item.include_in_tools) + trace["tutorial_scanner_report"] = report return trace diff --git a/paper2skill/templates/codex_skill/SKILL.md.j2 b/paper2skill/templates/codex_skill/SKILL.md.j2 index 841dd1a..6238001 100644 --- a/paper2skill/templates/codex_skill/SKILL.md.j2 +++ b/paper2skill/templates/codex_skill/SKILL.md.j2 @@ -57,7 +57,7 @@ Run: python scripts/plan.py --manifest input_manifest.yaml ``` -The planner reads `references/tutorial_trace.json` and writes `workflow/plan.json` and `workflow/plan.md`. +The planner reads `references/tutorial_trace.json` and `references/workflow_dag.json`, then writes `workflow/plan.json` and `workflow/plan.md`. ## Execution workflow @@ -67,7 +67,7 @@ Run: python scripts/run.py --manifest input_manifest.yaml ``` -`run.py` calls preflight first and stops if preflight fails. Demo execution only proves the tutorial path, not biological validity on user data. +`run.py` calls preflight first and stops if preflight fails. It records the selected adapter in `workflow/adapter_report.json`. Demo execution only proves the tutorial path, not biological validity on user data, and must not be treated as real algorithm execution unless the adapter report explicitly passes. ## Output contract @@ -96,6 +96,7 @@ Dry-run results are not biological results. Demo runs only prove that the tutori - `references/evidence_report.md` - `references/tutorial_trace.md` +- `references/workflow_dag.json` - `references/api_reference.md` - `references/paper_summary.md` - `references/repo_summary.md` diff --git a/paper2skill/templates/codex_skill/assets/input_manifest_template.yaml.j2 b/paper2skill/templates/codex_skill/assets/input_manifest_template.yaml.j2 index 96bb341..160b904 100644 --- a/paper2skill/templates/codex_skill/assets/input_manifest_template.yaml.j2 +++ b/paper2skill/templates/codex_skill/assets/input_manifest_template.yaml.j2 @@ -3,11 +3,26 @@ "primary_data": { "path": "path/to/input.csv", "format": "csv", - "exists": true + "exists": true, + "matrix_state": "unknown", + "matrix_orientation": "unknown", + "species": "not_confirmed", + "genome_build": "not_confirmed", + "gene_id_type": "not_confirmed" }, "metadata": { "sample_key": "sample", - "condition_key": "condition" + "batch_key": "not_confirmed", + "condition_key": "condition", + "celltype_key": "not_confirmed", + "perturbation_key": "not_confirmed" + }, + "external_resources": { + "genome": "not_confirmed", + "annotation": "not_confirmed", + "grn": "not_confirmed", + "ligand_receptor_database": "not_confirmed", + "pathway_database": "not_confirmed" }, "algorithm": { "mode": "demo_or_user_data", diff --git a/paper2skill/templates/codex_skill/scripts/adapters/__init__.py.j2 b/paper2skill/templates/codex_skill/scripts/adapters/__init__.py.j2 new file mode 100644 index 0000000..aca0fa6 --- /dev/null +++ b/paper2skill/templates/codex_skill/scripts/adapters/__init__.py.j2 @@ -0,0 +1,5 @@ +"""Generated execution adapters. + +Adapters are intentionally conservative: they do not run unreviewed repository +install scripts or arbitrary commands. +""" diff --git a/paper2skill/templates/codex_skill/scripts/adapters/cli_adapter.py.j2 b/paper2skill/templates/codex_skill/scripts/adapters/cli_adapter.py.j2 new file mode 100644 index 0000000..3c50bf0 --- /dev/null +++ b/paper2skill/templates/codex_skill/scripts/adapters/cli_adapter.py.j2 @@ -0,0 +1,11 @@ +from __future__ import annotations + +from pathlib import Path + + +def run(manifest: dict, out: Path) -> dict: + return { + "status": "not_implemented", + "adapter_type": "cli", + "message": "CLI execution requires a verified command mapping and is not auto-generated.", + } diff --git a/paper2skill/templates/codex_skill/scripts/adapters/notebook_adapter.py.j2 b/paper2skill/templates/codex_skill/scripts/adapters/notebook_adapter.py.j2 new file mode 100644 index 0000000..5c9ea7a --- /dev/null +++ b/paper2skill/templates/codex_skill/scripts/adapters/notebook_adapter.py.j2 @@ -0,0 +1,11 @@ +from __future__ import annotations + +from pathlib import Path + + +def run(manifest: dict, out: Path) -> dict: + return { + "status": "not_implemented", + "adapter_type": "notebook", + "message": "Notebook execution is intentionally gated and not run automatically.", + } diff --git a/paper2skill/templates/codex_skill/scripts/adapters/python_api_adapter.py.j2 b/paper2skill/templates/codex_skill/scripts/adapters/python_api_adapter.py.j2 new file mode 100644 index 0000000..786ad30 --- /dev/null +++ b/paper2skill/templates/codex_skill/scripts/adapters/python_api_adapter.py.j2 @@ -0,0 +1,11 @@ +from __future__ import annotations + +from pathlib import Path + + +def run(manifest: dict, out: Path) -> dict: + return { + "status": "not_implemented", + "adapter_type": "python_api", + "message": "Python API execution requires user-reviewed adapter wiring.", + } diff --git a/paper2skill/templates/codex_skill/scripts/adapters/r_script_adapter.R.j2 b/paper2skill/templates/codex_skill/scripts/adapters/r_script_adapter.R.j2 new file mode 100644 index 0000000..e8b5e3d --- /dev/null +++ b/paper2skill/templates/codex_skill/scripts/adapters/r_script_adapter.R.j2 @@ -0,0 +1,7 @@ +run_adapter <- function(manifest_path, out_dir) { + list( + status = "not_implemented", + adapter_type = "r_script", + message = "R script execution requires user-reviewed adapter wiring." + ) +} diff --git a/paper2skill/templates/codex_skill/scripts/plan.py.j2 b/paper2skill/templates/codex_skill/scripts/plan.py.j2 index f2f34f0..54c0ca7 100644 --- a/paper2skill/templates/codex_skill/scripts/plan.py.j2 +++ b/paper2skill/templates/codex_skill/scripts/plan.py.j2 @@ -33,17 +33,25 @@ def main(argv: list[str] | None = None) -> int: out = Path(args.out) manifest = load_json_yaml(Path(args.manifest)) trace = load_json(ROOT / "references" / "tutorial_trace.json") + dag_path = ROOT / "references" / "workflow_dag.json" + dag = load_json(dag_path) if dag_path.exists() else {"nodes": [], "edges": []} steps = trace.get("workflow_steps", []) plan = { "status": "planned", "manifest": env_manager.public_data(manifest), "steps": env_manager.public_data(steps), + "workflow_dag": env_manager.public_data(dag), "evidence_used": [step.get("evidence_id") for step in steps], } write_json(out / "workflow" / "plan.json", plan) lines = ["# Workflow Plan", ""] for step in steps: lines.append("- `%s`: %s" % (step.get("id"), step.get("description", step.get("name")))) + if dag.get("edges"): + lines.append("") + lines.append("## Dependencies") + for edge in dag.get("edges", []): + lines.append("- `%s` -> `%s` (%s)" % (edge.get("from"), edge.get("to"), edge.get("reason"))) if not steps: lines.append("- No tutorial-confirmed steps.") (out / "workflow").mkdir(parents=True, exist_ok=True) diff --git a/paper2skill/templates/codex_skill/scripts/run.py.j2 b/paper2skill/templates/codex_skill/scripts/run.py.j2 index e33af1a..aeae483 100644 --- a/paper2skill/templates/codex_skill/scripts/run.py.j2 +++ b/paper2skill/templates/codex_skill/scripts/run.py.j2 @@ -51,6 +51,27 @@ def summarize_csv(path: Path) -> dict: return {"rows": len(rows), "value_sum": sum(values), "value_mean": sum(values) / len(values) if values else None} +def adapter_type() -> str: + contract = load_json_yaml(ROOT / "references" / "algorithm_contract.yaml") + return contract.get("algorithm", {}).get("adapter_type", "demo_only") + + +def run_adapter(kind: str, manifest: dict, out: Path) -> dict: + if kind == "python_api": + from adapters import python_api_adapter + + return python_api_adapter.run(manifest, out) + if kind == "cli": + from adapters import cli_adapter + + return cli_adapter.run(manifest, out) + if kind == "notebook": + from adapters import notebook_adapter + + return notebook_adapter.run(manifest, out) + return {"status": "not_implemented", "adapter_type": kind, "message": "No executable adapter is configured."} + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--manifest", default=str(ROOT / "assets" / "demo_input_manifest.yaml")) @@ -63,6 +84,13 @@ def main(argv: list[str] | None = None) -> int: return preflight.returncode subprocess.run([sys.executable, str(ROOT / "scripts" / "plan.py"), "--manifest", args.manifest, "--out", str(out)], check=True) manifest = load_json_yaml(Path(args.manifest)) + selected_adapter = adapter_type() + adapter_report = {"status": "demo_only", "adapter_type": selected_adapter, "message": "Demo summary runner was used."} + if selected_adapter != "demo_only": + adapter_report = run_adapter(selected_adapter, manifest, out) + if adapter_report.get("status") != "pass": + adapter_report["fallback"] = "demo_summary_runner" + write_json(out / "workflow" / "adapter_report.json", env_manager.public_data(adapter_report)) rel_input = manifest["inputs"]["primary_data"]["path"] summary = summarize_csv(ROOT / rel_input) write_json(out / "results" / "summary.json", summary) @@ -78,7 +106,7 @@ def main(argv: list[str] | None = None) -> int: shutil.copyfile(ROOT / "assets" / "environment_spec.yaml", repro / "environment_spec.yaml") (repro / "command_history.sh").write_text("python scripts/run.py --manifest %s\n" % public_cli_path(args.manifest), encoding="utf-8") (out / "logs").mkdir(parents=True, exist_ok=True) - (out / "logs" / "run.log").write_text("demo run completed\n", encoding="utf-8") + (out / "logs" / "run.log").write_text("demo run completed; adapter_type=%s\n" % selected_adapter, encoding="utf-8") (out / "README.md").write_text("# Result\n\nDemo run completed.\n", encoding="utf-8") (out / "report.md").write_text("# Report\n\nDemo summary: `%s`.\n" % summary, encoding="utf-8") result = { @@ -90,8 +118,9 @@ def main(argv: list[str] | None = None) -> int: "environment": load_json(out / "qc" / "environment_report.json"), "parameter_resolution": load_json(out / "parameters" / "resolved_parameters.json"), "workflow_summary": load_json(out / "workflow" / "plan.json"), + "adapter": load_json(out / "workflow" / "adapter_report.json"), "outputs": ["results/summary.json"], - "caveats": ["demo run is not user-data biological validation"], + "caveats": ["demo run is not user-data biological validation", "demo summary output is not a real algorithm execution unless adapter.status is pass"], "evidence_used": load_json(out / "workflow" / "plan.json").get("evidence_used", []), } write_json(out / "result.json", result) diff --git a/paper2skill/validators/skill_validator.py b/paper2skill/validators/skill_validator.py index 836d512..91c027e 100644 --- a/paper2skill/validators/skill_validator.py +++ b/paper2skill/validators/skill_validator.py @@ -17,12 +17,18 @@ "scripts/plan.py", "scripts/run.py", "scripts/validate_outputs.py", + "scripts/adapters/__init__.py", + "scripts/adapters/python_api_adapter.py", + "scripts/adapters/cli_adapter.py", + "scripts/adapters/notebook_adapter.py", + "scripts/adapters/r_script_adapter.R", "references/evidence_report.md", "references/paper_summary.md", "references/repo_summary.md", "references/api_reference.md", "references/tutorial_trace.md", "references/tutorial_trace.json", + "references/workflow_dag.json", "references/tutorial_candidates.json", "references/tutorial_scanner_report.json", "references/environment_report.json", diff --git a/prd.md b/prd.md deleted file mode 100644 index a133d4c..0000000 --- a/prd.md +++ /dev/null @@ -1,1628 +0,0 @@ -# PRD: Paper2Skill Builder - -## 0. 一句话目标 - -从零开始构建一个 **Paper2Skill Builder**:输入一篇算法论文、官方源代码仓库和官方运行示例,自动生成一个可被 Codex 或其他 coding agent 调用的算法 Skill。生成的子 Skill 必须包含输入/输出规范、证据链、运行计划、环境管理、依赖检查、安装确认、执行脚本、结果验证和最小测试。 - -本项目不迁移、不兼容、不引用任何旧项目实现,采用 greenfield implementation。 - ---- - -## 1. 项目背景与动机 - -当前很多生物信息学算法论文都配有 GitHub 仓库、tutorial notebook、example script 或 demo data,但这些材料通常分散在论文、README、docs、notebooks、examples 和源码 API 中。用户要真正复用一个算法,往往需要解决以下问题: - -1. 论文中描述的是方法逻辑,仓库中暴露的是实际 API,两者经常不完全一致。 -2. README 通常只提供安装和简单示例,不足以指导 agent 稳定调用算法。 -3. 官方 tutorial/notebook 才最接近可运行流程,但其步骤、参数、输入输出和环境假设没有被结构化。 -4. 生物信息学算法对环境依赖极强,例如 Python/R 包、Bioconductor、系统库、conda 环境、CUDA、Jupyter、Rscript 等。 -5. Codex 或其他 agent 如果只读论文和 README,很容易误用输入状态、混淆 raw counts 与 normalized matrix、错误选择物种/基因 ID、跳过 QC,或者在依赖缺失时直接失败。 -6. 生成的 Skill 不能只是说明文档,还应该能执行 preflight、plan、run、validate,并在缺失依赖时给出明确安装方案。 - -因此,本项目的核心目标是把“算法论文 + 官方源码 + 官方运行示例”转化为一个结构化、可验证、可运行、可被 agent 调用的算法 Skill。 - ---- - -## 2. 产品定位 - -### 2.1 产品名称 - -建议包名和项目名: - -```text -paper2skill -``` - -建议核心 Skill 名称: - -```text -paper2skill-builder -``` - -### 2.2 产品定义 - -Paper2Skill Builder 是一个用于生成算法 Skill 的工程工具。 - -它不是: - -- 不是论文总结器; -- 不是通用组学分析 agent; -- 不是直接替代用户运行所有分析的黑盒平台; -- 不是一开始就做 MCP server 或 Codex plugin; -- 不是根据 abstract 生成伪 workflow 的工具。 - -它是: - -- 一个 evidence-first 的 Skill 生成器; -- 一个从官方运行示例中挖掘算法执行流程的工具; -- 一个为 Codex/agent 生成可调用算法 Skill 的 builder; -- 一个能够管理子 Skill 环境依赖、运行前检查和安装确认的框架。 - -### 2.3 第一阶段输出形态 - -优先输出 Codex Skill。 - -生成的子 Skill 应位于: - -```text -.agents/skills// -``` - -一个 Codex Skill 是一个目录,必须包含 `SKILL.md`,并可包含 `scripts/`、`references/`、`assets/`、`agents/openai.yaml` 等文件。Paper2Skill Builder 生成的子 Skill 必须充分利用这些目录,而不是只生成一个说明文档。 - -### 2.4 后续扩展 - -第一版不实现 MCP server 和 plugin,但架构要预留导出层: - -```text -paper2skill export-mcp -paper2skill package-plugin -``` - ---- - -## 3. 目标用户 - -### 3.1 主要用户 - -1. 生物信息学研究者 - 希望把新的算法论文和官方代码转成可复用分析 Skill。 - -2. 算法开发者 - 希望把自己的算法仓库包装成 Codex 可调用的标准 Skill。 - -3. Agent / Codex 使用者 - 希望 Codex 能稳定调用某个算法,而不是每次重新读 README 和 notebook。 - -4. 多组学 workflow 构建者 - 希望把多个算法 Skill 组合为更大的分析 workflow。 - -### 3.2 典型使用场景 - -#### 场景 A:根据论文和 GitHub 生成 Skill - -用户提供: - -```text -paper: path/to/paper.pdf -repo: https://github.com/xxx/algorithm -tutorial: examples/demo.ipynb -``` - -系统生成: - -```text -.agents/skills/algorithm-name-task/ -``` - -#### 场景 B:Codex 调用生成的子 Skill - -用户在 Codex 中说: - -```text -$algorithm-name-task -请用这个 Skill 检查我的输入数据是否符合算法要求,并生成运行计划。 -``` - -子 Skill 应先执行 `scripts/preflight.py`,检查输入文件、参数和环境依赖。 - -#### 场景 C:依赖缺失时询问用户是否安装 - -子 Skill 检查环境后发现缺少依赖: - -```text -Missing Python packages: -- scanpy -- anndata -- leidenalg - -Missing R packages: -- Seurat -``` - -系统必须停止执行,生成安装方案,并询问用户: - -```text -检测到依赖缺失。是否允许自动安装? -选项: -1. 在当前环境安装 -2. 创建独立 conda/mamba 环境 -3. 只输出安装命令,不安装 -4. 取消运行 -``` - -未经用户明确确认,不能自动安装任何包。 - ---- - -## 4. 核心原则 - -### 4.1 从零开始 - -本项目不依赖旧项目代码、目录结构或命名。可以借鉴旧讨论中的思想,但不能保留旧项目的历史负担。 - -### 4.2 Skill first, plugin later - -第一阶段只做 Codex Skill 生成。Plugin 和 MCP 作为后续可选导出目标,不进入 MVP。 - -### 4.3 Tutorial first - -证据优先级必须固定为: - -```text -1. 官方 tutorial notebook / example script / demo -2. 官方 docs / vignette -3. 源码 API / function signatures / docstrings -4. dependency files / environment files -5. paper Methods -6. paper abstract -7. README -``` - -README 只能作为导航证据,不应成为核心 workflow、参数或输入输出声明的唯一依据。 - -### 4.4 Environment first - -生成的子 Skill 在任何 run 之前都必须执行环境检查。缺失依赖时必须先报告并等待用户确认。 - -### 4.5 Evidence traceability - -每个重要结论都需要 evidence: - -- 算法分类; -- 输入要求; -- 输出要求; -- 参数默认值; -- workflow step; -- DAG edge; -- 环境依赖; -- 安装命令来源; -- tutorial 运行示例来源。 - -### 4.6 Honest maturity - -生成的子 Skill 必须标注成熟度。不能把只会 dry-run 的 Skill 标记为可执行。 - ---- - -## 5. MVP 范围 - -### 5.1 MVP 必须支持 - -1. Python 项目: - - `requirements.txt` - - `pyproject.toml` - - `setup.py` - - notebook / `.py` tutorial - -2. R 项目: - - `DESCRIPTION` - - `renv.lock` - - `.R` script - - `.Rmd` 或 notebook-style example - -3. 论文输入: - - PDF 文件; - - article URL; - - paper title + repo URL; - - 本地 markdown/text 论文内容。 - -4. 运行示例输入: - - `.ipynb` - - `.py` - - `.R` - - `.Rmd` - -5. 生成 Codex Skill: - - `SKILL.md` - - `scripts/preflight.py` - - `scripts/env_manager.py` - - `scripts/plan.py` - - `scripts/run.py` - - `scripts/validate_outputs.py` - - `references/evidence_report.md` - - `references/paper_summary.md` - - `references/api_reference.md` - - `references/tutorial_trace.md` - - `references/tutorial_trace.json` - - `assets/input_manifest_template.yaml` - - `assets/config_template.yaml` - - `assets/environment_spec.yaml` - - `tests/test_preflight.py` - - `tests/test_environment.py` - - `tests/test_plan.py` - - `agents/openai.yaml` - -6. 环境检查: - - 检查 Python 可执行文件; - - 检查 Python package 是否可 import; - - 检查 Rscript 是否存在; - - 检查 R package 是否 installed; - - 检查命令行工具是否存在; - - 输出缺失依赖; - - 生成安装建议; - - 未经用户确认不安装。 - -7. CLI 命令: - - `paper2skill plan` - - `paper2skill build` - - `paper2skill validate` - - `paper2skill test` - - `paper2skill inspect-env` - -### 5.2 MVP 不做 - -1. 不做 MCP server 生成; -2. 不做 Codex plugin 打包; -3. 不做大规模自动下载数据; -4. 不做自动修复所有 tutorial 运行错误; -5. 不做多论文合并; -6. 不做 full Snakemake/Nextflow pipeline 生成; -7. 不做 GPU/CUDA 自动配置; -8. 不做自动选择替代算法; -9. 不做没有官方源码或官方运行示例的完整可执行 Skill; -10. 不做未经用户确认的依赖安装。 - ---- - -## 6. 系统架构 - -### 6.1 顶层目录结构 - -```text -paper2skill/ - README.md - pyproject.toml - prd.md - - paper2skill/ - __init__.py - cli.py - - collectors/ - __init__.py - paper_collector.py - repo_collector.py - tutorial_collector.py - - miners/ - __init__.py - paper_miner.py - api_miner.py - notebook_miner.py - script_miner.py - r_miner.py - dependency_miner.py - environment_miner.py - - inference/ - __init__.py - classify_algorithm.py - infer_io_contract.py - infer_parameters.py - infer_workflow.py - infer_bio_contract.py - infer_environment.py - - generators/ - __init__.py - codex_skill_generator.py - skill_markdown_generator.py - script_generator.py - test_generator.py - report_generator.py - - validators/ - __init__.py - schema_validator.py - skill_validator.py - env_validator.py - tutorial_validator.py - output_validator.py - - runtime/ - __init__.py - env_manager.py - python_probe.py - r_probe.py - install_planner.py - command_runner.py - - schemas/ - algorithm_skill_schema.yaml - bio_contract_schema.yaml - evidence_schema.yaml - environment_schema.yaml - tutorial_trace_schema.yaml - - templates/ - codex_skill/ - SKILL.md.j2 - scripts/ - preflight.py.j2 - env_manager.py.j2 - plan.py.j2 - run.py.j2 - validate_outputs.py.j2 - references/ - evidence_report.md.j2 - paper_summary.md.j2 - api_reference.md.j2 - tutorial_trace.md.j2 - assets/ - input_manifest_template.yaml.j2 - config_template.yaml.j2 - environment_spec.yaml.j2 - tests/ - test_preflight.py.j2 - test_environment.py.j2 - test_plan.py.j2 - agents/ - openai.yaml.j2 - - .agents/ - skills/ - paper2skill-builder/ - SKILL.md - scripts/ - build_skill.py - references/ - generated_skill_schema.yaml - environment_management_policy.md - - tests/ - fixtures/ - toy_python_algorithm/ - toy_r_algorithm/ - toy_notebook.ipynb - toy_script.py - toy_script.R - test_collectors.py - test_miners.py - test_environment.py - test_generator.py - test_generated_skill.py -``` - ---- - -## 7. CLI 设计 - -### 7.1 `paper2skill plan` - -只分析输入,不生成 Skill 文件。 - -```bash -paper2skill plan \ - --paper path/to/paper.pdf \ - --repo https://github.com/org/repo \ - --tutorial examples/demo.ipynb \ - --out paper2skill_plan/ -``` - -输出: - -```text -paper2skill_plan/ - source_manifest.json - paper_evidence.json - repo_evidence.json - tutorial_trace.json - algorithm_contract.preview.yaml - environment_report.json - build_plan.md -``` - -### 7.2 `paper2skill build` - -生成 Codex Skill。 - -```bash -paper2skill build \ - --paper path/to/paper.pdf \ - --repo https://github.com/org/repo \ - --tutorial examples/demo.ipynb \ - --skill-name algorithm-task \ - --out .agents/skills/algorithm-task -``` - -### 7.3 `paper2skill validate` - -验证生成的 Skill 结构、schema、证据链、环境策略和测试文件是否完整。 - -```bash -paper2skill validate \ - --skill .agents/skills/algorithm-task -``` - -### 7.4 `paper2skill test` - -运行生成 Skill 的测试。 - -```bash -paper2skill test \ - --skill .agents/skills/algorithm-task \ - --mode preflight -``` - -支持模式: - -```text -preflight -environment -plan -demo -all -``` - -### 7.5 `paper2skill inspect-env` - -独立检查某个算法 Skill 的环境依赖。 - -```bash -paper2skill inspect-env \ - --skill .agents/skills/algorithm-task \ - --manifest input_manifest.yaml -``` - ---- - -## 8. 输入规范 - -### 8.1 Builder 输入 - -```yaml -paper: - path: path/to/paper.pdf - url: null - title: null - -repo: - url: https://github.com/org/repo - local_path: null - ref: main - -tutorial: - paths: - - examples/demo.ipynb - mode: official_example - -output: - skill_name: algorithm-task - out_dir: .agents/skills/algorithm-task - -options: - target: codex_skill - allow_network: false - install_policy: ask - maturity_target: L1 -``` - -### 8.2 子 Skill 输入 - -每个生成的子 Skill 必须要求用户提供 `input_manifest.yaml`。 - -示例: - -```yaml -inputs: - primary_data: - path: data/demo.h5ad - format: h5ad - exists: true - - metadata: - celltype_key: cell_type - sample_key: sample - condition_key: condition - - algorithm: - mode: demo_or_user_data - parameters: - target_gene: STAT3 - n_neighbors: 30 - -environment: - preferred_manager: conda - environment_name: algorithm-task-env - install_policy: ask -``` - ---- - -## 9. 生成的子 Skill 目录规范 - -```text -.agents/skills// - SKILL.md - - scripts/ - preflight.py - env_manager.py - plan.py - run.py - validate_outputs.py - - references/ - evidence_report.md - paper_summary.md - repo_summary.md - api_reference.md - tutorial_trace.md - tutorial_trace.json - environment_report.json - install_plan.md - - assets/ - input_manifest_template.yaml - config_template.yaml - environment_spec.yaml - requirements.txt - environment.yml - renv.lock.placeholder - demo_input_manifest.yaml - - tests/ - test_preflight.py - test_environment.py - test_plan.py - test_output_contract.py - - agents/ - openai.yaml -``` - ---- - -## 10. 生成的 `SKILL.md` 要求 - -`SKILL.md` 必须是 Codex 可读、可执行、边界清晰的操作说明。 - -### 10.1 Front matter - -```markdown ---- -name: -description: Use this skill to plan, validate, run, and interpret for . Trigger when the user asks to use , reproduce its official tutorial, or apply the algorithm to compatible input data. ---- -``` - -### 10.2 必须包含章节 - -```markdown -# Skill - -## What this skill does - -## When to use - -## When not to use - -## Required inputs - -## Input state requirements - -## Environment policy - -## Preflight workflow - -## Planning workflow - -## Execution workflow - -## Output contract - -## Validation workflow - -## Interpretation boundary - -## Failure modes - -## Evidence sources -``` - -### 10.3 Environment policy 必须写明 - -```markdown -Before running the algorithm, always execute `scripts/preflight.py`. - -If dependencies are missing: -1. Stop execution. -2. Report missing Python packages, R packages, executables, and system-level requirements. -3. Show install options generated by `scripts/env_manager.py plan`. -4. Ask the user whether to install automatically. -5. Do not install anything unless the user explicitly approves. -6. If the user declines, provide manual installation commands and keep the run blocked. -``` - ---- - -## 11. 环境管理需求 - -### 11.1 总体规则 - -生成的子 Skill 必须内置环境管理能力。 - -环境管理由以下文件负责: - -```text -scripts/env_manager.py -assets/environment_spec.yaml -references/environment_report.json -references/install_plan.md -tests/test_environment.py -``` - -### 11.2 依赖检测 - -必须检测以下类型: - -#### Python - -- Python executable; -- Python version; -- pip 是否可用; -- required Python packages; -- import name 与 package name 的映射; -- extras 和 version specifier 处理。 - -例如: - -```text -scanpy[leiden]>=1.10; python_version >= "3.10" -``` - -检测 import 时必须: - -```text -package spec: scanpy[leiden]>=1.10; python_version >= "3.10" -import probe: scanpy -preserve original spec in install command -``` - -#### R - -- Rscript 是否可用; -- R version; -- required CRAN packages; -- required Bioconductor packages; -- `DESCRIPTION`、`renv.lock`、tutorial `library()` 和 `require()` 调用。 - -#### CLI executables - -- command 是否在 PATH; -- 版本命令,例如 `tool --version`; -- 是否来自 conda environment。 - -#### Optional - -MVP 可以只记录但不自动处理: - -- CUDA; -- system libraries; -- Java; -- compilers; -- external databases; -- reference genomes; -- large model weights。 - -### 11.3 环境报告 - -`preflight.py` 必须输出: - -```text -qc/environment_report.json -qc/missing_dependencies.json -references/install_plan.md -``` - -`environment_report.json` 示例: - -```json -{ - "status": "blocked_dependencies_missing", - "python": { - "executable": "/usr/bin/python", - "version": "3.11.8", - "packages": [ - { - "name": "scanpy[leiden]>=1.10", - "import_name": "scanpy", - "installed": false, - "required": true - } - ] - }, - "r": { - "rscript_available": true, - "packages": [ - { - "name": "Seurat", - "installed": false, - "source": "CRAN_or_unknown" - } - ] - }, - "executables": [], - "install_policy": "ask" -} -``` - -### 11.4 安装策略 - -支持四种策略: - -```text -never -ask -current_env -isolated_env -``` - -默认必须是: - -```text -ask -``` - -但在非交互式测试或 CI 中,`ask` 必须退化为: - -```text -never -``` - -也就是不能在 CI 中自动安装。 - -### 11.5 安装选项 - -当依赖缺失时,`env_manager.py plan` 必须生成: - -```text -Option A: install into current environment -Option B: create isolated venv -Option C: create conda/mamba environment -Option D: manual install only -Option E: cancel -``` - -示例: - -```bash -python scripts/env_manager.py plan \ - --manifest input_manifest.yaml \ - --out qc/install_plan.json -``` - -如果用户明确同意: - -```bash -python scripts/env_manager.py install \ - --strategy isolated_env \ - --confirm yes -``` - -必须要求 `--confirm yes`,否则退出。 - -### 11.6 安装命令优先级 - -安装命令来源优先级: - -```text -1. 官方 docs/tutorial 中的安装命令 -2. environment.yml / conda.yml -3. requirements.txt / pyproject.toml / setup.py -4. DESCRIPTION / renv.lock -5. README -6. 自动推断 fallback -``` - -不能无脑使用 `pip install `。 - -### 11.7 安装后复检 - -安装完成后必须重新执行: - -```bash -python scripts/preflight.py --manifest input_manifest.yaml -``` - -只有复检通过,才能进入 run。 - ---- - -## 12. 论文解析需求 - -### 12.1 输出文件 - -```text -references/paper_summary.md -references/paper_evidence.json -``` - -### 12.2 必须抽取 - -```yaml -paper: - title: - doi: - year: - authors: - official_code: - official_data: - method_purpose: - algorithm_type: - input_data: - output_data: - core_steps: - key_parameters: - benchmark_datasets: - evaluation_metrics: - limitations: -``` - -### 12.3 论文证据边界 - -论文主要用于: - -- 方法目的; -- 生物学/算法背景; -- benchmark 指标; -- 解释边界; -- 方法适用范围。 - -论文不应作为唯一依据来确定: - -- 运行命令; -- 真实 API; -- 包安装方式; -- 输入文件路径; -- 参数默认值; -- workflow 执行顺序。 - -这些应优先来自 tutorial、docs 和源码。 - ---- - -## 13. 源码解析需求 - -### 13.1 输出文件 - -```text -references/repo_summary.md -references/api_reference.md -references/repo_evidence.json -``` - -### 13.2 必须解析 - -```yaml -repo: - language: - package_type: - install_files: - dependency_files: - entrypoints: - cli_commands: - api_functions: - classes: - tutorials: - notebooks: - examples: - docs: -``` - -### 13.3 API 解析 - -Python: - -- AST 解析 `def`、`class`、import; -- docstring; -- function signature; -- default parameters; -- file read/write; -- package imports。 - -R: - -- 函数定义; -- `library()` / `require()`; -- `read.csv` / `readRDS` / `read_h5ad`; -- `write.csv` / `saveRDS`; -- R package DESCRIPTION; -- exported functions from NAMESPACE。 - ---- - -## 14. Tutorial Trace Mining 需求 - -### 14.1 Tutorial 是最高优先级证据 - -必须优先使用官方 tutorial / example / notebook 推断: - -- workflow steps; -- step order; -- input/output; -- default parameters; -- execution mode; -- expected outputs; -- demo data; -- required environment。 - -### 14.2 输出文件 - -```text -references/tutorial_trace.json -references/tutorial_trace.md -``` - -### 14.3 Notebook trace - -对 `.ipynb` 必须解析: - -```yaml -cells: - - index: - cell_type: - source: - imports: - assignments: - function_calls: - file_reads: - file_writes: - parameters: - plots: -``` - -### 14.4 Script trace - -对 `.py`、`.R` 必须解析: - -```yaml -script: - path: - language: - imports: - functions: - top_level_steps: - parameters: - file_reads: - file_writes: -``` - -### 14.5 Workflow step inference - -每个 step 必须包含: - -```yaml -id: -name: -description: -source: -source_type: -evidence_id: -inputs: -outputs: -parameters: -command_or_code: -confidence: -``` - ---- - -## 15. Algorithm Contract 需求 - -生成的子 Skill 必须包含 machine-readable contract: - -```text -references/algorithm_contract.yaml -``` - -示例: - -```yaml -algorithm: - name: CellOracle - task: in_silico_tf_perturbation - domain: bioinformatics - modality: single_cell_transcriptomics - language: python - execution_mode: python_api - maturity_level: L1 - -input_contract: - required: - - name: input_data - type: h5ad - state: raw_or_normalized - - name: celltype_key - type: string - - name: perturbation_targets - type: list[string] - -output_contract: - required: - - qc/environment_report.json - - qc/input_validation.json - - results/ - - report.md - -environment_contract: - install_policy_default: ask - preflight_required: true - auto_install_requires_confirmation: true -``` - ---- - -## 16. Bioinformatics Contract 需求 - -生成的子 Skill 如属于生物信息学算法,必须包含: - -```text -references/bio_contract.yaml -``` - -字段: - -```yaml -bio_contract: - modality: - primary: - secondary: - - organism: - species_supported: - genome_build: - gene_id_type: - - input_matrix_state: - raw_counts_required: - normalized_allowed: - log_transformed_allowed: - matrix_orientation: - - metadata_requirements: - celltype_key: - sample_key: - batch_key: - condition_key: - - minimum_data_requirements: - min_cells: - min_genes: - min_cells_per_group: - - reference_resources: - genome: - annotation: - database: - grn: - ligand_receptor_database: - - statistical_contract: - multiple_testing: - fdr_threshold: - metric: - - interpretation_boundary: - dry_run_is_not_biological_result: true - demo_run_is_not_user_data_validation: true - cross_species_mapping_requires_confirmation: true -``` - -如果证据不足,字段必须写: - -```text -not_confirmed -``` - -不能自动猜测。 - ---- - -## 17. Skill 成熟度分级 - -每个生成的子 Skill 必须标注 maturity。 - -```yaml -maturity: - level: L1 - status: contract_and_preflight -``` - -### L0: summary only - -只有论文/方法说明,无可执行脚本。 - -### L1: contract + preflight - -有输入输出 contract、环境检查、运行计划,但不保证能跑官方 demo。 - -### L2: demo executable - -能跑通官方 tutorial/demo 的最小路径。 - -### L3: user-data executable - -能应用到用户提供的真实数据,并有明确输入状态检查。 - -### L4: benchmarked - -有 reference output、regression test、关键数值/表格/图形对齐。 - -### L5: agent-ready - -可被 Codex 或其他 agent 稳定调用,具备完整错误恢复和解释边界。 - -MVP 目标: - -```text -稳定生成 L1; -至少 1 个 toy Python 算法达到 L2; -至少 1 个 toy R 算法达到 L2。 -``` - ---- - -## 18. 子 Skill 脚本行为规范 - -### 18.1 `scripts/preflight.py` - -职责: - -1. 读取 input manifest; -2. 检查输入路径; -3. 检查输入状态字段; -4. 检查环境依赖; -5. 检查必需参数; -6. 输出 QC JSON; -7. 如果依赖缺失,状态为 `blocked_dependencies_missing`。 - -不能执行主算法。 - -### 18.2 `scripts/env_manager.py` - -职责: - -1. 解析 environment spec; -2. 检测 Python/R/CLI 依赖; -3. 生成安装计划; -4. 在用户确认后执行安装; -5. 安装后复检。 - -命令: - -```bash -python scripts/env_manager.py inspect -python scripts/env_manager.py plan -python scripts/env_manager.py install --strategy isolated_env --confirm yes -``` - -### 18.3 `scripts/plan.py` - -职责: - -1. 读取 manifest; -2. 读取 tutorial trace; -3. 生成 workflow execution plan; -4. 不运行主算法; -5. 输出 `workflow/plan.json` 和 `workflow/plan.md`。 - -### 18.4 `scripts/run.py` - -职责: - -1. 必须先调用 preflight; -2. 如果 preflight 不通过,停止; -3. 如果环境缺失,停止; -4. 如果用户未确认安装,停止; -5. 运行 demo 或用户数据路径; -6. 输出结果目录; -7. 输出 `result.json`。 - -### 18.5 `scripts/validate_outputs.py` - -职责: - -1. 检查 expected outputs; -2. 检查关键表格/图形/JSON 是否存在; -3. 对 demo run 可检查 reference output; -4. 输出 `qc/output_validation.json`。 - ---- - -## 19. 子 Skill 输出结果规范 - -运行后必须输出: - -```text -result/ - README.md - report.md - result.json - - qc/ - input_validation.json - environment_report.json - missing_dependencies.json - output_validation.json - qc_summary.json - - workflow/ - plan.json - plan.md - executed_steps.json - - parameters/ - resolved_parameters.json - parameter_sources.json - - results/ - - - logs/ - preflight.log - env_manager.log - run.log - - reproducibility/ - source_manifest.json - algorithm_contract.yaml - bio_contract.yaml - environment_spec.yaml - command_history.sh -``` - -`result.json` 必须包含: - -```json -{ - "status": "pass | warn | fail | blocked_dependencies_missing | blocked_input_invalid", - "skill_name": "", - "algorithm": "", - "maturity_level": "", - "input_validation": {}, - "environment": {}, - "parameter_resolution": {}, - "workflow_summary": {}, - "outputs": [], - "caveats": [], - "evidence_used": [] -} -``` - ---- - -## 20. 测试需求 - -### 20.1 Builder 测试 - -必须有: - -```text -tests/test_collectors.py -tests/test_notebook_miner.py -tests/test_script_miner.py -tests/test_dependency_miner.py -tests/test_environment.py -tests/test_generator.py -tests/test_generated_skill.py -``` - -### 20.2 生成子 Skill 的测试 - -每个子 Skill 必须生成: - -```text -tests/test_preflight.py -tests/test_environment.py -tests/test_plan.py -tests/test_output_contract.py -``` - -### 20.3 环境测试 - -必须测试: - -1. 所有依赖存在 → preflight pass; -2. 缺少 Python 包 → blocked_dependencies_missing; -3. 缺少 R 包 → blocked_dependencies_missing; -4. 缺少 Rscript → blocked_runtime_missing; -5. 非交互模式下不自动安装; -6. 未传 `--confirm yes` 不安装; -7. 安装计划能正确保留原始 package spec; -8. `scanpy[leiden]` 这类 extras 能正确 strip import name; -9. R package 能通过 `Rscript -e` 检测。 - -### 20.4 模拟缺失依赖 - -支持环境变量: - -```text -PAPER2SKILL_FORCE_MISSING_PACKAGES=scanpy,Seurat -PAPER2SKILL_FORCE_MISSING_EXECUTABLES=Rscript -``` - -用于测试 blocked 状态。 - ---- - -## 21. 安全与边界 - -### 21.1 安装安全 - -1. 不自动安装; -2. 不自动升级用户环境中的包; -3. 不自动删除或覆盖已有环境; -4. 不自动运行未知 shell script; -5. 不自动执行 repository 中的 arbitrary install script; -6. 安装命令必须写入 `install_plan.md`; -7. 用户确认后才执行。 - -### 21.2 数据安全 - -1. 不自动上传用户数据; -2. 不把用户数据路径写入公开文档; -3. 不自动下载大型数据集; -4. 不自动修改原始输入文件。 - -### 21.3 生物学解释边界 - -生成 Skill 必须明确: - -```text -dry-run 结果不是生物学结果; -demo run 只证明 tutorial 路径可运行; -用户数据分析必须通过输入状态检查; -跨物种、跨平台、跨 assay 迁移需要用户确认; -缺少证据时必须标记 not_confirmed。 -``` - ---- - -## 22. README 要求 - -项目 README 必须包含: - -1. 项目定位; -2. 为什么不是论文总结器; -3. 为什么优先生成 Codex Skill; -4. 安装方式; -5. 快速开始; -6. 输入示例; -7. 输出 Skill 结构; -8. 环境管理策略; -9. maturity level; -10. toy Python demo; -11. toy R demo; -12. 开发路线图。 - ---- - -## 23. Codex Skill: `paper2skill-builder` - -项目本身要提供一个 Codex Skill,用于让 Codex 调用 Paper2Skill Builder。 - -目录: - -```text -.agents/skills/paper2skill-builder/ - SKILL.md - scripts/ - build_skill.py - references/ - generated_skill_schema.yaml - environment_management_policy.md - agents/ - openai.yaml -``` - -`SKILL.md` front matter: - -```markdown ---- -name: paper2skill-builder -description: Use this skill when the user wants to generate a Codex skill from an algorithm paper, official source code repository, and official tutorial or example. The generated skill must include environment preflight, dependency checks, install planning, execution planning, output validation, and evidence reports. ---- -``` - ---- - -## 24. Codex `/plan` 提示词 - -把本 PRD 放进仓库后,先让 Codex 执行: - -```text -/plan - -请阅读 prd.md,并为从零实现 Paper2Skill Builder 制定工程计划。 - -要求: -1. 不引用任何旧项目。 -2. 第一阶段只做 Codex Skill 生成,不做 MCP 和 plugin。 -3. 必须实现环境管理:生成的子 Skill 在 run 前先检查依赖;缺少包时先报告并询问用户是否安装;未经确认不得自动安装。 -4. 先支持 Python 和 R 算法仓库。 -5. 先实现 toy Python 和 toy R demo。 -6. 请输出 PR 拆分、文件结构、实现顺序、测试命令和验收标准。 -``` - ---- - -## 25. Codex `/goal` 提示词:Milestone 1 - -```text -/goal - -Milestone 1: Build the greenfield Paper2Skill Builder skeleton. - -Read prd.md first and implement only the MVP skeleton. - -Scope: -- Do not import or reference any previous project. -- Do not implement MCP or plugin export. -- Implement the package structure, CLI skeleton, schemas, templates, and the repository-level Codex skill `paper2skill-builder`. -- Add toy Python and toy R fixtures. -- Add basic tests for CLI availability, schema loading, and generated skill scaffold. - -Required outputs: -1. paper2skill/ Python package. -2. CLI commands: plan, build, validate, test, inspect-env. -3. .agents/skills/paper2skill-builder/SKILL.md. -4. schemas for algorithm skill, bio contract, evidence, environment, tutorial trace. -5. templates for generated Codex skills. -6. tests with toy fixtures. - -Definition of done: -- `python -m paper2skill.cli --help` works. -- `paper2skill build` can scaffold a toy generated skill. -- Generated skill contains SKILL.md, scripts/preflight.py, scripts/env_manager.py, scripts/plan.py, scripts/run.py, scripts/validate_outputs.py, references, assets, tests, and agents/openai.yaml. -- Unit tests pass. -``` - ---- - -## 26. Codex `/goal` 提示词:Milestone 2 - -```text -/goal - -Milestone 2: Implement environment management for generated child skills. - -Read prd.md and implement the environment management requirements. - -Scope: -- Add dependency detection for Python, R, and CLI executables. -- Generated child skills must run preflight before run. -- Missing dependencies must block execution. -- The system must generate install plans and ask for user confirmation before installation. -- No automatic install without `--confirm yes`. - -Required behavior: -1. Detect Python packages using importlib.util.find_spec. -2. Strip extras/version markers for import probing but preserve original specs for installation. -3. Detect R packages using Rscript. -4. Detect missing Rscript. -5. Generate environment_report.json, missing_dependencies.json, install_plan.md. -6. Support install policies: never, ask, current_env, isolated_env. -7. In non-interactive mode, ask must behave as never. -8. Add tests using PAPER2SKILL_FORCE_MISSING_PACKAGES and PAPER2SKILL_FORCE_MISSING_EXECUTABLES. - -Definition of done: -- Missing scanpy triggers blocked_dependencies_missing. -- Missing Seurat triggers blocked_dependencies_missing. -- Missing Rscript triggers blocked_runtime_missing. -- No install command runs without explicit `--confirm yes`. -- All environment tests pass. -``` - ---- - -## 27. Codex `/goal` 提示词:Milestone 3 - -```text -/goal - -Milestone 3: Implement tutorial trace mining and workflow inference. - -Read prd.md and implement tutorial-first evidence extraction. - -Scope: -- Parse .ipynb, .py, .R, and .Rmd examples. -- Extract imports, assignments, function calls, file reads, file writes, parameters, and execution order. -- Generate tutorial_trace.json and tutorial_trace.md. -- Use tutorial trace as highest-priority evidence for workflow steps. - -Definition of done: -- Toy notebook trace includes ordered code cells. -- Toy Python script trace includes imports, calls, reads, writes. -- Toy R script trace includes library calls and function calls. -- Generated child skill workflow steps cite tutorial evidence. -- Tests pass. -``` - ---- - -## 28. 验收标准 - -MVP 完成时,必须满足: - -1. 可以从 toy Python 算法生成一个 Codex Skill; -2. 可以从 toy R 算法生成一个 Codex Skill; -3. 生成的 Skill 能执行 preflight; -4. 依赖缺失时能正确 block; -5. 依赖缺失时能生成安装计划; -6. 未确认时不自动安装; -7. 生成的 Skill 有完整 `SKILL.md`; -8. 生成的 Skill 有 environment manager; -9. 生成的 Skill 有 evidence report; -10. 生成的 Skill 有 tutorial trace; -11. 生成的 Skill 有 tests; -12. Builder 自身测试通过。 - ---- - -## 29. 非目标 - -以下内容不应在 MVP 中实现: - -```text -MCP server -Codex plugin packaging -自动运行任意外部 install.sh -大规模真实数据下载 -GPU/CUDA 自动配置 -复杂 workflow engine 生成 -多算法组合 workflow -无 tutorial 情况下声称 native executable -无用户确认自动安装 -``` - ---- - -## 30. 最终交付物 - -```text -paper2skill/ - prd.md - README.md - pyproject.toml - paper2skill/ - .agents/skills/paper2skill-builder/ - tests/ -``` - -运行命令: - -```bash -python -m paper2skill.cli --help -python -m paper2skill.cli build --example toy_python -python -m paper2skill.cli validate --skill .agents/skills/toy-python-skill -python -m paper2skill.cli test --skill .agents/skills/toy-python-skill --mode all -``` - -最终目标: - -```text -Paper2Skill Builder 能把算法论文、官方源码和官方运行示例转化为一个 Codex 可调用的算法 Skill。 -生成的子 Skill 在运行前必须检查环境,缺少依赖时必须询问用户是否安装,并且未经用户明确确认不得自动安装。 -``` diff --git a/pyproject.toml b/pyproject.toml index 3ddd84b..7e8ca9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,13 +12,18 @@ dependencies = [ "Jinja2>=3.1", "packaging>=23", "PyYAML>=6.0", - "pytest>=8.0", ] [project.optional-dependencies] dev = [ "pytest>=8.0", ] +paper = [ + "markitdown[pdf,docx,pptx,xlsx]>=0.1", +] +html = [ + "beautifulsoup4>=4.12", +] [project.scripts] paper2skill = "paper2skill.cli:main" diff --git a/tests/test_api_miner.py b/tests/test_api_miner.py new file mode 100644 index 0000000..73e13be --- /dev/null +++ b/tests/test_api_miner.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pathlib import Path + +from paper2skill.miners.api_miner import mine_api + + +def test_api_miner_detects_python_cli_entrypoints_and_workflows(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "cli-demo" +version = "0.1.0" + +[project.scripts] +demo-tool = "demo.cli:main" +""".strip() + + "\n", + encoding="utf-8", + ) + (tmp_path / "demo").mkdir() + (tmp_path / "demo" / "cli.py").write_text( + """ +import argparse + +def main(): + parser = argparse.ArgumentParser() + parser.parse_args() +""".strip() + + "\n", + encoding="utf-8", + ) + (tmp_path / "Snakefile").write_text("rule all:\n input: []\n", encoding="utf-8") + evidence = mine_api(tmp_path) + assert evidence["entrypoints"] == [{"name": "demo-tool", "target": "demo.cli:main", "source": "pyproject.toml", "type": "console_script"}] + assert evidence["cli_commands"][0]["framework"] == "argparse" + assert evidence["workflow_engines"][0]["engine"] == "snakemake" + + +def test_api_miner_detects_r_namespace_exports(tmp_path: Path): + (tmp_path / "DESCRIPTION").write_text("Package: demo\n", encoding="utf-8") + (tmp_path / "NAMESPACE").write_text("export(run_demo, summarize)\n", encoding="utf-8") + (tmp_path / "R").mkdir() + (tmp_path / "R" / "demo.R").write_text("run_demo <- function(x) x\n", encoding="utf-8") + evidence = mine_api(tmp_path) + assert [item["name"] for item in evidence["r_exports"]] == ["run_demo", "summarize"] diff --git a/tests/test_bio_contract_inference.py b/tests/test_bio_contract_inference.py index 2f3947a..8f07d9b 100644 --- a/tests/test_bio_contract_inference.py +++ b/tests/test_bio_contract_inference.py @@ -32,3 +32,16 @@ def test_bio_contract_infers_single_cell_transform_chain_and_metadata_key(): assert bio["modality"]["primary"]["value"] == "scRNA-seq" assert bio["input_matrix_state"]["matrix_transformations"] == ["raw_counts_loaded", "normalized", "log1p_transformed"] assert bio["metadata_requirements"]["celltype_key"]["value"] == "cell_type" + + +def test_strict_bio_contract_does_not_promote_background_species_keyword(): + sections = [ + { + "section_id": "paper:introduction", + "title": "Introduction", + "text": "Human disease studies often motivate computational methods.", + } + ] + contract = infer_bio_contract({"tutorials": [], "workflow_steps": []}, paper_sections=sections, strict_evidence=True) + species = contract["bio_contract"]["organism"]["species_supported"] + assert species["value"] == "not_confirmed" diff --git a/tests/test_cli.py b/tests/test_cli.py index e68dc28..449a3ad 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,13 @@ from __future__ import annotations +import json +import shutil import subprocess import sys from pathlib import Path +import pytest + def test_cli_help_available(): result = subprocess.run([sys.executable, "-m", "paper2skill.cli", "--help"], text=True, capture_output=True, check=False) @@ -23,3 +27,109 @@ def test_cli_build_toy_python(tmp_path: Path): assert result.returncode == 0, result.stdout + result.stderr assert (out / "SKILL.md").exists() assert (out / "scripts" / "preflight.py").exists() + + +def test_cli_build_skip_repo_clone_records_warning(tmp_path: Path): + out = tmp_path / "skip-clone-skill" + result = subprocess.run( + [ + sys.executable, + "-m", + "paper2skill.cli", + "build", + "--repo", + "https://example.invalid/demo.git", + "--skip-repo-clone", + "--out", + str(out), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + manifest = json.loads((out / "references" / "repo_manifest.json").read_text(encoding="utf-8")) + report = json.loads((out / "references" / "build_report.json").read_text(encoding="utf-8")) + assert manifest["clone_status"] == "skipped" + assert "remote repo clone was skipped" in " ".join(report["warnings"]) + + +def test_cli_tutorial_filter_changes_generated_trace(tmp_path: Path): + repo = tmp_path / "repo" + (repo / "docs").mkdir(parents=True) + for name in ["pbmc", "other"]: + notebook = { + "cells": [ + {"cell_type": "markdown", "source": [f"# {name} tutorial\n"], "metadata": {}}, + {"cell_type": "code", "source": ["print('x')\n"], "metadata": {}, "outputs": [], "execution_count": None}, + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5, + } + (repo / "docs" / f"{name}.ipynb").write_text(json.dumps(notebook), encoding="utf-8") + out = tmp_path / "filtered-skill" + result = subprocess.run( + [ + sys.executable, + "-m", + "paper2skill.cli", + "build", + "--repo", + str(repo), + "--tutorial-filter", + "pbmc", + "--no-execute-tutorials", + "--out", + str(out), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + candidates = json.loads((out / "references" / "tutorial_candidates.json").read_text(encoding="utf-8")) + trace = json.loads((out / "references" / "tutorial_trace.json").read_text(encoding="utf-8")) + report = json.loads((out / "references" / "build_report.json").read_text(encoding="utf-8")) + assert [item["path"] for item in candidates] == ["docs/pbmc.ipynb"] + assert trace["tutorial_execution_status"] == "not_executed_by_policy" + assert report["tutorial_execution_status"] == "not_executed_by_policy" + + +@pytest.mark.skipif(shutil.which("git") is None, reason="git is required for repo-ref CLI testing") +def test_cli_repo_ref_is_used_for_file_remote_repo(tmp_path: Path): + paper = tmp_path / "paper.md" + paper.write_text("# Methods\n\nDemo.\n", encoding="utf-8") + repo = tmp_path / "source" + repo.mkdir() + subprocess.run(["git", "init", str(repo)], check=True, text=True, capture_output=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "test@example.com"], check=True, text=True, capture_output=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "Test User"], check=True, text=True, capture_output=True) + (repo / "pyproject.toml").write_text("[project]\nname='demo'\nversion='0.1.0'\ndependencies=['scanpy']\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "."], check=True, text=True, capture_output=True) + subprocess.run(["git", "-C", str(repo), "commit", "-m", "initial"], check=True, text=True, capture_output=True) + sha = subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"], check=True, text=True, capture_output=True).stdout.strip() + out = tmp_path / "ref-skill" + result = subprocess.run( + [ + sys.executable, + "-m", + "paper2skill.cli", + "build", + "--paper", + str(paper), + "--repo", + repo.as_uri(), + "--repo-ref", + sha, + "--out", + str(out), + ], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + manifest = json.loads((out / "references" / "repo_manifest.json").read_text(encoding="utf-8")) + assert manifest["requested_ref"] == sha + assert manifest["commit_sha"] == sha diff --git a/tests/test_dependency_miner.py b/tests/test_dependency_miner.py index 588b5f1..ae83bb3 100644 --- a/tests/test_dependency_miner.py +++ b/tests/test_dependency_miner.py @@ -129,3 +129,45 @@ def test_r_suggests_and_renv_lock_are_recorded_not_required(tmp_path): assert deps["r"] == ["Matrix", "Seurat"] assert deps["optional"]["r"]["DESCRIPTION:Suggests"] == ["knitr", "testthat"] assert deps["optional"]["r"]["renv.lock"] == ["dplyr", "ggplot2"] + + +def test_environment_yml_setup_cfg_and_description_system_requirements(tmp_path): + (tmp_path / "environment.yml").write_text( + """ +name: demo +dependencies: + - python=3.11 + - numpy + - pip: + - scanpy>=1.10 +""".strip() + + "\n", + encoding="utf-8", + ) + (tmp_path / "setup.cfg").write_text( + """ +[options] +install_requires = + anndata>=0.10 +""".strip() + + "\n", + encoding="utf-8", + ) + (tmp_path / "DESCRIPTION").write_text( + """ +Package: demo +Imports: + DESeq2 +SystemRequirements: libxml2, hdf5 +Remotes: user/custompkg +""".strip() + + "\n", + encoding="utf-8", + ) + deps = mine_dependencies(tmp_path) + assert "scanpy>=1.10" in deps["python"] + assert "anndata>=0.10" in deps["python"] + assert deps["conda_records"][0]["name"] == "numpy" + assert deps["r_records"][0]["source"] == "Bioconductor_or_unknown" + assert {"value": "libxml2", "source": "DESCRIPTION", "required": True, "install": "plan_only"} in deps["system_requirements"] + assert deps["external_resources"][0]["name"] == "user/custompkg" diff --git a/tests/test_evidence_graph.py b/tests/test_evidence_graph.py new file mode 100644 index 0000000..23fa466 --- /dev/null +++ b/tests/test_evidence_graph.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from paper2skill.evidence.evidence_graph import build_evidence_graph + + +def test_evidence_graph_adds_decision_for_conflicting_claims(): + graph = build_evidence_graph( + tutorial_trace={ + "tutorials": [ + { + "path": "docs/tutorial.ipynb", + "steps": [ + { + "step_id": "tutorial_001:cell_001", + "evidence_id": "tutorial.ipynb:cell:1", + "source": "docs/tutorial.ipynb:cell:1", + "command_or_code": "adata = sc.read_10x_mtx('data/')", + "confidence": "high", + } + ], + } + ] + }, + bio_contract={"field": {"value": "raw_counts", "confidence": "high", "evidence": ["tutorial.ipynb:cell:1"]}}, + algorithm_contract={"field": {"value": "normalized", "confidence": "medium", "evidence": ["readme:input"]}}, + ) + assert graph["conflicts"] + decision = graph["decisions"][0] + assert decision["field"] == "field" + assert decision["decision"]["value"] == "raw_counts" + assert decision["decision"]["status"] == "decided" diff --git a/tests/test_generator.py b/tests/test_generator.py index 016c12b..eb2ac66 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,7 +1,12 @@ from __future__ import annotations +import json +import shutil +import subprocess from pathlib import Path +import pytest + from paper2skill.common import PROJECT_ROOT from paper2skill.generators.codex_skill_generator import build_context, example_inputs, generate_skill, plan_outputs from paper2skill.validators.skill_validator import validate_skill @@ -14,6 +19,8 @@ def test_generator_creates_complete_toy_python_skill(tmp_path: Path): assert result["status"] == "pass", result assert (out / "references" / "algorithm_contract.yaml").exists() assert (out / "references" / "bio_contract.yaml").exists() + assert (out / "references" / "workflow_dag.json").exists() + assert (out / "scripts" / "adapters" / "python_api_adapter.py").exists() def test_generator_creates_toy_r_skill(tmp_path: Path): @@ -68,6 +75,89 @@ def test_plan_outputs_do_not_include_absolute_paths(tmp_path: Path): assert_no_absolute_path_markers(out) +@pytest.mark.skipif(shutil.which("git") is None, reason="git is required for file:// remote build") +def test_remote_file_repo_build_uses_cloned_path_for_mining(tmp_path: Path): + repo = tmp_path / "remote-source" + repo.mkdir() + subprocess.run(["git", "init", str(repo)], check=True, text=True, capture_output=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "test@example.com"], check=True, text=True, capture_output=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "Test User"], check=True, text=True, capture_output=True) + paper = tmp_path / "paper.md" + paper.write_text("# Methods\n\nWe used scRNA-seq data.\n", encoding="utf-8") + (repo / "docs").mkdir() + notebook = { + "cells": [ + {"cell_type": "markdown", "source": ["# PBMC tutorial\n"], "metadata": {}}, + {"cell_type": "code", "source": ["import scanpy as sc\nadata = sc.read_10x_mtx('data/')\n"], "metadata": {}, "outputs": [], "execution_count": None}, + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5, + } + (repo / "docs" / "pbmc.ipynb").write_text(json.dumps(notebook), encoding="utf-8") + (repo / "pyproject.toml").write_text("[project]\nname='remote-demo'\nversion='0.1.0'\ndependencies=['scanpy']\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "."], check=True, text=True, capture_output=True) + subprocess.run(["git", "-C", str(repo), "commit", "-m", "initial"], check=True, text=True, capture_output=True) + sha = subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"], check=True, text=True, capture_output=True).stdout.strip() + + context = build_context( + paper=str(paper), + repo=repo.as_uri(), + repo_ref=sha, + collection_dir=tmp_path / "collection", + no_execute_tutorials=True, + ) + manifest = context["source_manifest"]["repo"]["manifest"] + assert manifest["clone_status"] == "cloned" + assert manifest["requested_ref"] == sha + assert manifest["commit_sha"] == sha + assert "scanpy" in context["dependency_evidence"]["python"] + assert context["tutorial_trace"]["workflow_steps"] + out = generate_skill(context, tmp_path / "skill") + written_manifest = json.loads((out / "references" / "repo_manifest.json").read_text(encoding="utf-8")) + assert written_manifest["commit_sha"] == sha + assert "pyproject.toml" in (out / "references" / "repo_index.json").read_text(encoding="utf-8") + + +def test_generate_skill_does_not_collect_repo_again(tmp_path: Path, monkeypatch): + context = build_context(**example_inputs("toy_python")) + + def fail_collect(*_args, **_kwargs): + raise AssertionError("generate_skill must not collect repos") + + monkeypatch.setattr("paper2skill.generators.codex_skill_generator.collect_repo", fail_collect) + out = generate_skill(context, tmp_path / "skill") + assert (out / "references" / "repo_manifest.json").exists() + + +def test_skip_repo_clone_disables_remote_repo_mining(tmp_path: Path): + context = build_context(repo="https://example.invalid/demo.git", skip_repo_clone=True, collection_dir=tmp_path / "collection") + repo = context["source_manifest"]["repo"] + assert repo["manifest"]["clone_status"] == "skipped" + assert context["dependency_evidence"]["python"] == [] + assert "remote repo clone was skipped" in " ".join(context["warnings"]) + + +def test_tutorial_filter_changes_candidates_and_trace(tmp_path: Path): + repo = tmp_path / "repo" + (repo / "docs").mkdir(parents=True) + for name in ["pbmc", "other"]: + notebook = { + "cells": [ + {"cell_type": "markdown", "source": [f"# {name} tutorial\n"], "metadata": {}}, + {"cell_type": "code", "source": ["print('x')\n"], "metadata": {}, "outputs": [], "execution_count": None}, + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5, + } + (repo / "docs" / f"{name}.ipynb").write_text(json.dumps(notebook), encoding="utf-8") + context = build_context(repo=str(repo), tutorial_filter="pbmc") + candidates = context["tutorial_trace"]["tutorial_candidates"] + assert [item["path"] for item in candidates] == ["docs/pbmc.ipynb"] + assert context["tutorial_trace"]["tutorials"][0]["path"] == "docs/pbmc.ipynb" + + def assert_no_absolute_path_markers(root: Path) -> None: markers = { str(PROJECT_ROOT), diff --git a/tests/test_io_contract_inference.py b/tests/test_io_contract_inference.py new file mode 100644 index 0000000..863ba8a --- /dev/null +++ b/tests/test_io_contract_inference.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from paper2skill.inference.infer_io_contract import infer_io_contract + + +def test_io_contract_infers_10x_format_from_scanpy_reader_call(): + trace = { + "workflow_steps": [ + { + "step_id": "tutorial_001:cell_001", + "code_preview": "adata = sc.read_10x_mtx('data/')", + "command_or_code": "import scanpy as sc\nadata = sc.read_10x_mtx('data/')", + "function_calls": ["sc.read_10x_mtx"], + "read_files": [], + "inputs": [], + } + ] + } + + contract = infer_io_contract(trace) + + primary_data = contract["input_contract"]["required"]["primary_data"] + assert primary_data["format"]["value"] == "10x_mtx" + assert primary_data["format"]["confidence"] == "high" + assert "sc.read_10x_mtx" in primary_data["format"]["evidence"] diff --git a/tests/test_workflow_dag.py b/tests/test_workflow_dag.py new file mode 100644 index 0000000..2612c2a --- /dev/null +++ b/tests/test_workflow_dag.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from paper2skill.inference.infer_workflow import infer_workflow + + +def test_workflow_dag_infers_shared_object_edges_and_step_types(): + trace = { + "workflow_steps": [ + { + "step_id": "tutorial_001:cell_001", + "language": "python", + "command_or_code": "adata = sc.read_h5ad('input.h5ad')", + "function_calls": ["sc.read_h5ad"], + "output_objects": ["adata"], + "read_files": ["input.h5ad"], + "bio_signals": ["single_cell"], + "evidence_id": "tutorial.ipynb:cell:1", + "confidence": "high", + }, + { + "step_id": "tutorial_001:cell_002", + "language": "python", + "command_or_code": "sc.pp.normalize_total(adata)", + "function_calls": ["sc.pp.normalize_total"], + "input_objects": ["adata"], + "bio_signals": ["normalization"], + "evidence_id": "tutorial.ipynb:cell:2", + "confidence": "high", + }, + { + "step_id": "tutorial_001:cell_003", + "language": "python", + "command_or_code": "sc.pp.log1p(adata)", + "function_calls": ["sc.pp.log1p"], + "input_objects": ["adata"], + "bio_signals": ["log_transform"], + "evidence_id": "tutorial.ipynb:cell:3", + "confidence": "high", + }, + ] + } + workflow = infer_workflow(trace) + dag = workflow["workflow_dag"] + assert [node["type"] for node in dag["nodes"]] == ["load_data", "normalization", "transformation"] + assert {"from": "tutorial_001:cell_001", "to": "tutorial_001:cell_002", "reason": "shared_object:adata"} in dag["edges"] + assert dag["nodes"][1]["object_state_after"]["adata"]["matrix_state"] == "normalized"