Files
PlantAI-rag-new/agent/methodology/api/methodology_routes.py

1117 lines
44 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Methodology extraction batch result routes."""
from __future__ import annotations
import csv
import json
import os
import re
import shlex
import subprocess
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from src.rag.unified.bbav5_methodology_config import build_bbav5_methodology_config, build_methodology_execution_plan, ensure_methodology_execution_contract, resolve_batch_dir
from src.rag.unified.figure_table_planner import build_figure_table_outputs
from src.rag.unified.methodology_qa_sandbox import DEFAULT_SANDBOX_ROOT, answer_from_sandbox, build_methodology_qa_sandbox, sanitize_token
from src.rag.project_config import PROJECT_ROOT, conda_env_path
# Use LLM extraction results by default (can be overridden via env var)
BATCH_DIR_NAME = os.environ.get("METHODOLOGY_BATCH_DIR", "methodology_batch_llm")
BATCH_DIR = PROJECT_ROOT / "data" / "evaluation" / BATCH_DIR_NAME
PAPERS_CSV = PROJECT_ROOT / "data" / "evaluation" / "methodology_papers.csv"
GOLD_CSV = PROJECT_ROOT / "data" / "evaluation" / "methodology_gold.csv"
REVIEW_RESULTS_JSON = BATCH_DIR / "review_results.json"
REVIEW_RESULTS_CSV = BATCH_DIR / "review_results.csv"
# 2026-08-31 新工程适配:上传目录/产出根支持 env 覆盖(数据根迁移时无需改代码)
UPLOAD_JOBS_DIR = Path(os.environ.get("METHODOLOGY_UPLOAD_JOBS_DIR", PROJECT_ROOT / "data" / "evaluation" / "methodology_upload_jobs"))
UPLOAD_OUTPUT_ROOT = Path(os.environ.get("METHODOLOGY_UPLOAD_OUTPUT_ROOT", PROJECT_ROOT / "data" / "evaluation" / "0505"))
DEFAULT_MINERU_CMD = str(conda_env_path("minerupy312") / "bin" / "mineru")
DEFAULT_MINERU_API_URL = "http://127.0.0.1:50215"
DEFAULT_MINERU_BACKEND = "hybrid-auto-engine"
DEFAULT_MINERU_METHOD = "auto"
DEFAULT_MINERU_LANG = "en"
DEFAULT_MINERU_MIN_FREE_GPU_MB = 6144
DEFAULT_MINERU_LOW_MEM_MIN_FREE_GPU_MB = 4096
DEFAULT_MINERU_RUNTIME_MODE = "single"
router = APIRouter(prefix="/methodology", tags=["methodology"])
class ReviewResult(BaseModel):
observation_id: str = Field(..., min_length=1)
paper_id: str = ""
review_status: str = ""
stage_review: str = ""
tools_review: str = ""
versions_review: str = ""
parameters_review: str = ""
inputs_outputs_review: str = ""
evidence_review: str = ""
bbav5_review: str = ""
bbav5_stage_review: str = ""
bbav5_tools_review: str = ""
support_status_review: str = ""
corrected_stage_value: str = ""
corrected_tools_value: str = ""
corrected_versions_value: str = ""
corrected_parameters_value: str = ""
corrected_evidence_value: str = ""
corrected_inputs_value: str = ""
corrected_outputs_value: str = ""
corrected_bbav5_stage_value: str = ""
corrected_bbav5_tools_value: str = ""
corrected_support_status_value: str = ""
manual_note: str = ""
class ReviewResultsPayload(BaseModel):
results: list[ReviewResult]
class MethodologySandboxBuildPayload(BaseModel):
methodology_dir: str = Field(..., min_length=1)
sandbox_root: str = str(DEFAULT_SANDBOX_ROOT)
run_id: str = ""
include_figures: bool = True
content_list_path: str = ""
class MethodologySandboxAnswerPayload(BaseModel):
question: str = Field(..., min_length=1)
registry_path: str = ""
sandbox_dir: str = ""
REVIEW_RESULT_FIELDS = [
"observation_id",
"paper_id",
"review_status",
"stage_review",
"tools_review",
"versions_review",
"parameters_review",
"inputs_outputs_review",
"evidence_review",
"bbav5_review",
"bbav5_stage_review",
"bbav5_tools_review",
"support_status_review",
"corrected_stage_value",
"corrected_tools_value",
"corrected_versions_value",
"corrected_parameters_value",
"corrected_evidence_value",
"corrected_inputs_value",
"corrected_outputs_value",
"corrected_bbav5_stage_value",
"corrected_bbav5_tools_value",
"corrected_support_status_value",
"manual_note",
]
def read_csv(path: Path) -> list[dict[str, str]]:
if not path.exists():
return []
with path.open("r", encoding="utf-8-sig", newline="") as handle:
return list(csv.DictReader(handle))
def read_json(path: Path) -> Any:
if not path.exists():
raise HTTPException(status_code=404, detail=f"Missing file: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=REVIEW_RESULT_FIELDS)
writer.writeheader()
for row in rows:
writer.writerow({field: row.get(field, "") for field in REVIEW_RESULT_FIELDS})
def read_review_results() -> list[dict[str, Any]]:
if not REVIEW_RESULTS_JSON.exists():
return []
data = json.loads(REVIEW_RESULTS_JSON.read_text(encoding="utf-8"))
if isinstance(data, dict):
return list(data.get("results", []))
return list(data)
def write_job_status(job_dir: Path, payload: dict[str, Any]) -> None:
payload = {"updated_at": datetime.now().isoformat(timespec="seconds"), **payload}
job_dir.mkdir(parents=True, exist_ok=True)
(job_dir / "status.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def parse_int_env(name: str, default: int) -> int:
value = os.environ.get(name, "").strip()
if not value:
return default
try:
parsed = int(value)
except ValueError:
return default
return max(0, parsed)
def parse_optional_int_env(name: str) -> int | None:
value = os.environ.get(name, "").strip()
if not value:
return None
try:
return max(0, int(value))
except ValueError:
return None
def env_bool(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def env_bool_text(name: str) -> str:
value = os.environ.get(name, "").strip().lower()
if value in {"1", "true", "yes", "on"}:
return "true"
if value in {"0", "false", "no", "off"}:
return "false"
return ""
def normalize_mineru_runtime_mode(value: str) -> str:
mode = str(value or "").strip().lower()
return mode if mode in {"single", "batch"} else DEFAULT_MINERU_RUNTIME_MODE
def mineru_default_min_free_gpu_mb(runtime: dict[str, str]) -> int:
mode = normalize_mineru_runtime_mode(runtime.get("mode", ""))
backend = str(runtime.get("backend") or "").strip().lower()
method = str(runtime.get("method") or "").strip().lower()
formula = str(runtime.get("formula") or "").strip().lower()
table = str(runtime.get("table") or "").strip().lower()
low_mem_pipeline = (
mode == "single"
and backend == "pipeline"
and method == "txt"
and formula == "false"
and table == "false"
)
if low_mem_pipeline:
return DEFAULT_MINERU_LOW_MEM_MIN_FREE_GPU_MB
return DEFAULT_MINERU_MIN_FREE_GPU_MB
def parse_gpu_memory_csv(text: str) -> list[dict[str, int]]:
rows: list[dict[str, int]] = []
for line in text.splitlines():
parts = [part.strip() for part in line.split(",")]
if len(parts) < 4:
continue
try:
rows.append(
{
"index": int(parts[0]),
"total_mb": int(parts[1]),
"used_mb": int(parts[2]),
"free_mb": int(parts[3]),
}
)
except ValueError:
continue
return rows
def parse_gpu_process_csv(text: str) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for line in text.splitlines():
parts = [part.strip() for part in line.split(",", 2)]
if len(parts) < 3:
continue
try:
rows.append({"pid": int(parts[0]), "name": parts[1], "used_mb": int(parts[2])})
except ValueError:
continue
return rows
def cuda_visible_gpu_indices(cuda_visible_devices: str) -> list[int]:
indices: list[int] = []
for token in cuda_visible_devices.split(","):
token = token.strip()
if token.isdigit():
indices.append(int(token))
return indices
def format_gpu_processes(processes: list[dict[str, Any]], limit: int = 8) -> str:
if not processes:
return "未检测到 GPU 计算进程"
ordered = sorted(processes, key=lambda row: int(row.get("used_mb") or 0), reverse=True)
formatted = []
for row in ordered[:limit]:
name = Path(str(row.get("name") or "")).name or str(row.get("name") or "")
formatted.append(f"PID {row.get('pid')} {name} {row.get('used_mb')}MiB")
if len(ordered) > limit:
formatted.append(f"... 另有 {len(ordered) - limit} 个进程")
return "; ".join(formatted)
def build_mineru_gpu_preflight_error(
gpu_rows: list[dict[str, int]],
process_rows: list[dict[str, Any]],
cuda_visible_devices: str,
min_free_mb: int,
runtime_mode: str = DEFAULT_MINERU_RUNTIME_MODE,
) -> str:
if min_free_mb <= 0:
return ""
visible_indices = cuda_visible_gpu_indices(cuda_visible_devices)
candidate_rows = [row for row in gpu_rows if row["index"] in visible_indices] if visible_indices else gpu_rows
if not candidate_rows:
return (
"MinerU GPU 预检失败:无法从 nvidia-smi 读取可用 GPU。"
f"CUDA_VISIBLE_DEVICES={cuda_visible_devices or '<empty>'}"
)
best = max(candidate_rows, key=lambda row: row["free_mb"])
if best["free_mb"] >= min_free_mb:
return ""
processes = format_gpu_processes(process_rows)
mode = normalize_mineru_runtime_mode(runtime_mode)
if mode == "single":
return (
"MinerU 按需启动需要 GPU 显存,但当前 GPU 显存不足,不能启动新的 PDF 解析任务。"
f"GPU{best['index']} free={best['free_mb']}MiB / total={best['total_mb']}MiB,"
f"需要至少 {min_free_mb}MiB 空闲显存。"
f"当前 GPU 进程:{processes}。"
"请先释放占用显存的 QA/VLLM 进程,或改用低显存 MinerU 配置后重试。"
)
return (
"MinerU 服务已热加载,但当前 GPU 显存余量不足,不能安全提交新的 PDF 解析任务。"
f"GPU{best['index']} free={best['free_mb']}MiB / total={best['total_mb']}MiB,"
f"需要至少 {min_free_mb}MiB 空闲显存。"
f"当前 GPU 进程:{processes}。"
"请先释放占用显存的 QA/VLLM/MinerU 进程,或调低 METHODOLOGY_MINERU_MIN_FREE_GPU_MB 后重试。"
)
def mineru_gpu_preflight_error(
cuda_visible_devices: str,
runtime_mode: str = DEFAULT_MINERU_RUNTIME_MODE,
runtime: dict[str, str] | None = None,
) -> str:
if os.environ.get("METHODOLOGY_MINERU_GPU_PREFLIGHT", "1").strip().lower() in {"0", "false", "no", "off"}:
return ""
runtime_payload = runtime or {"mode": runtime_mode}
min_free_mb = parse_optional_int_env("METHODOLOGY_MINERU_MIN_FREE_GPU_MB")
if min_free_mb is None:
min_free_mb = mineru_default_min_free_gpu_mb(runtime_payload)
if min_free_mb <= 0:
return ""
try:
memory = subprocess.run(
["nvidia-smi", "--query-gpu=index,memory.total,memory.used,memory.free", "--format=csv,noheader,nounits"],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
timeout=5,
)
except Exception as exc:
return f"MinerU GPU 预检失败:无法执行 nvidia-smi:{exc}"
if memory.returncode != 0:
return f"MinerU GPU 预检失败:nvidia-smi 返回 {memory.returncode}:{memory.stderr.strip() or memory.stdout.strip()}"
processes_text = ""
try:
processes = subprocess.run(
["nvidia-smi", "--query-compute-apps=pid,process_name,used_memory", "--format=csv,noheader,nounits"],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
timeout=5,
)
if processes.returncode == 0:
processes_text = processes.stdout
except Exception:
processes_text = ""
return build_mineru_gpu_preflight_error(
parse_gpu_memory_csv(memory.stdout),
parse_gpu_process_csv(processes_text),
cuda_visible_devices,
min_free_mb,
runtime_payload.get("mode", runtime_mode),
)
# 缺陷 #38(2026-09-11):成功任务的 job.log 必然有 doc_analyze_streaming 行(176 实测)。
# returncode≠0 且日志中无此行 = 启动期失败(pipeline 起来前就死了,真因被块缓冲吞掉)。
# 判定条件(methodology 与 legacy-rag 两处 build_mineru_runtime/run_uploaded_methodology_job 同改)
MINERU_PIPELINE_LOG_MARKER = "doc_analyze_streaming"
MINERU_STARTUP_RETRIES = 1
def job_log_has_pipeline_line(log_path: Path) -> bool:
"""job.log 是否已出现 pipeline 日志行(def _job_log_has_pipeline_line)。"""
try:
text = log_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
return MINERU_PIPELINE_LOG_MARKER in text
def summarize_mineru_failure(log_path: Path, returncode: int) -> str:
message = f"MinerU failed with exit code {returncode}; see {log_path}"
if not log_path.exists():
return message
try:
text = log_path.read_text(encoding="utf-8", errors="replace")
except Exception:
return message
if re.search(r"CUDA out of memory|OutOfMemoryError|cuda memory", text, re.IGNORECASE):
return (
"MinerU 解析失败:CUDA 显存不足。"
"这通常表示 GPU 已被 QA/VLLM/MinerU 进程占用,运行 PDF 检测/解析时没有足够额外显存。"
f"日志:{log_path}"
)
return message
def summarize_mineru_startup_failure(log_path: Path, returncode: int, retried: bool) -> str:
"""启动期失败(无 pipeline 行)的中文可读错误(缺陷 #38)。
附上 job.log 中可见的原始错误尾部,让用户能看到真因(修复前真因
被 stdout 块缓冲 + CLI 立即杀 API 进程永久丢失)。
"""
tail = ""
try:
text = log_path.read_text(encoding="utf-8", errors="replace")
lines = [ln for ln in text.splitlines() if ln.strip()]
if lines:
tail = " | ".join(ln.strip() for ln in lines[-3:])[:500]
except OSError:
tail = ""
detail = f"原始日志尾部:{tail}" if tail else f"原始错误见日志:{log_path}"
action = "重试后" if retried else ""
return (
f"提取启动阶段失败{action},可能受主机 GPU/显示重配置干扰,请重试"
f"(MinerU 退出码 {returncode};{detail})。"
)
def build_mineru_runtime() -> tuple[list[str], dict[str, str], dict[str, str]]:
env = os.environ.copy()
env.setdefault("MINERU_MODEL_SOURCE", "modelscope")
# 设备模式(缺陷 #37/#38,2026-09-10):METHODOLOGY_MINERU_DEVICE_MODE 默认空
# =不注入(mineru 自动探测,保持 175 GPU 行为);显式 "cpu" 时清空
# CUDA_VISIBLE_DEVICES(176 无 GPU 裸部署,禁止 mineru 卡在 CUDA 探测上)
device_mode = os.environ.get("METHODOLOGY_MINERU_DEVICE_MODE", "").strip().lower()
if device_mode == "cpu":
env["MINERU_DEVICE_MODE"] = "cpu"
env["CUDA_VISIBLE_DEVICES"] = ""
elif device_mode:
env["MINERU_DEVICE_MODE"] = device_mode
env.setdefault("CUDA_VISIBLE_DEVICES", "0")
else:
env.setdefault("CUDA_VISIBLE_DEVICES", "0")
env.setdefault("TORCH_CUDNN_V8_API_DISABLED", "1")
# 缺陷 #38(2026-09-11):子进程 API 的异常日志即写即落(不再被 stdout
# 块缓冲吞掉)——CLI 失败后立即杀 API 进程前真因已落到 job.log。
env["PYTHONUNBUFFERED"] = "1"
# mineru 3.1.13 CLI 陷阱(部署 agent 实测):带 MINERU_API_SHUTDOWN_ON_STDIN_EOF=1
# 时,stdin EOF 会让 API 进程静默 shutdown——subprocess 调用必须 stdin=DEVNULL
env.setdefault("MINERU_API_SHUTDOWN_ON_STDIN_EOF", "0")
mineru_cmd = shlex.split(os.environ.get("MINERU_CMD", DEFAULT_MINERU_CMD))
runtime_mode = normalize_mineru_runtime_mode(
os.environ.get("METHODOLOGY_MINERU_RUNTIME_MODE", DEFAULT_MINERU_RUNTIME_MODE)
)
runtime = {
"mode": runtime_mode,
"model_source": env.get("MINERU_MODEL_SOURCE", ""),
"cuda_visible_devices": env.get("CUDA_VISIBLE_DEVICES", ""),
"api_url": os.environ.get("V15_MINERU_API_URL", DEFAULT_MINERU_API_URL).strip(),
"use_api": "1" if runtime_mode == "batch" and env_bool("V15_MINERU_USE_API") else "0",
"backend": os.environ.get("V15_MINERU_BACKEND", DEFAULT_MINERU_BACKEND).strip() or DEFAULT_MINERU_BACKEND,
"method": os.environ.get("V15_MINERU_METHOD", DEFAULT_MINERU_METHOD).strip() or DEFAULT_MINERU_METHOD,
"lang": os.environ.get("V15_MINERU_LANG", DEFAULT_MINERU_LANG).strip() or DEFAULT_MINERU_LANG,
"formula": env_bool_text("V15_MINERU_FORMULA"),
"table": env_bool_text("V15_MINERU_TABLE"),
}
return mineru_cmd, env, runtime
def _preflight_executable(cmd: list[str], env_hint: str, env_name: str) -> None:
"""可执行文件 X_OK 预检(缺陷 #37/#38):失败抛中文可读错误。
修复前错误是裸英文 FileNotFoundError「No such file or directory:
'/home/xzq/miniconda3/envs/minerupy312/bin/mineru'」,用户无法定位。
"""
if not cmd:
raise RuntimeError(f"{env_hint} 命令为空,无法启动(请在 prod.env 配 {env_name})")
exe = Path(cmd[0])
if not exe.exists():
raise RuntimeError(
f"找不到 {env_hint} 可执行文件:{exe}(文件不存在)。"
f"请在 prod.env 配 {env_name} 指向正确路径后重启服务。"
)
if not os.access(exe, os.X_OK):
raise RuntimeError(
f"{env_hint} 不可执行(无执行权限):{exe}。"
f"请确认路径正确并授予执行权限(chmod +x),或在 prod.env 配 {env_name} 指向正确路径后重启服务。"
)
def gf_python_path() -> Path:
"""GF python 路径(缺陷 #37/#38):env PLANTAI_GF_PYTHON_ENV 覆盖 GF_PYTHON_ENV 默认值。"""
gf_env = os.environ.get("PLANTAI_GF_PYTHON_ENV") or os.environ.get("GF_PYTHON_ENV")
default_gf = "/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv"
return Path(gf_env or default_gf) / "bin" / "python"
def build_mineru_command(mineru_cmd: list[str], source_path: Path, mineru_out: Path, runtime: dict[str, str]) -> list[str]:
cmd = [
*mineru_cmd,
"-p",
str(source_path),
"-o",
str(mineru_out),
"-m",
runtime["method"],
"-b",
runtime["backend"],
"-l",
runtime["lang"],
]
if runtime.get("formula"):
cmd.extend(["-f", runtime["formula"]])
if runtime.get("table"):
cmd.extend(["-t", runtime["table"]])
if runtime.get("mode") == "batch" and runtime.get("use_api") == "1" and runtime.get("api_url"):
cmd.extend(["--api-url", runtime["api_url"]])
return cmd
def read_job_status(job_id: str) -> dict[str, Any]:
status_path = UPLOAD_JOBS_DIR / job_id / "status.json"
if not status_path.exists():
raise HTTPException(status_code=404, detail=f"Unknown methodology extraction job: {job_id}")
return json.loads(status_path.read_text(encoding="utf-8"))
def list_upload_job_statuses(limit: int = 50) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
if not UPLOAD_JOBS_DIR.exists():
return rows
for status_path in UPLOAD_JOBS_DIR.glob("*/status.json"):
try:
payload = json.loads(status_path.read_text(encoding="utf-8"))
except Exception:
continue
output_dir = str(payload.get("output_dir") or "")
source_path = str(payload.get("source_path") or "")
row = {
"job_id": payload.get("job_id") or status_path.parent.name,
"status": payload.get("status", ""),
"stage": payload.get("stage", ""),
"substage": payload.get("substage", ""),
"message": payload.get("message", ""),
"updated_at": payload.get("updated_at", ""),
"paper_id": payload.get("paper_id", ""),
"family": payload.get("family", ""),
"source_filename": Path(source_path).name if source_path else "",
"source_path": source_path,
"output_dir": output_dir,
"batch_dir": output_dir,
"stage_count": payload.get("stage_count", 0),
"species_count": payload.get("species_count", 0),
"figure_table_caption_count": payload.get("figure_table_caption_count", 0),
"figure_table_stage_count": payload.get("figure_table_stage_count", 0),
"config_url": payload.get("config_url", f"/api/v1/methodology/extract-jobs/{status_path.parent.name}/bbav5-config"),
"methodology_plan_url": payload.get("methodology_plan_url", f"/api/v1/methodology/extract-jobs/{status_path.parent.name}/plan"),
"methodology_sandbox_requested": payload.get("methodology_sandbox_requested", False),
"methodology_sandbox_status": payload.get("methodology_sandbox_status", ""),
"methodology_sandbox_dir": payload.get("methodology_sandbox_dir", ""),
"methodology_sandbox_registry": payload.get("methodology_sandbox_registry", ""),
"methodology_sandbox_collection": payload.get("methodology_sandbox_collection", ""),
}
rows.append(row)
rows.sort(key=lambda row: str(row.get("updated_at") or ""), reverse=True)
return rows[: max(1, min(int(limit or 50), 200))]
def run_uploaded_methodology_job(
job_id: str,
source_path: Path,
paper_id: str,
family: str,
output_dir: Path,
llm_model: str,
llm_request_timeout: int,
sandbox: bool = False,
sandbox_run_id: str = "",
) -> None:
job_dir = UPLOAD_JOBS_DIR / job_id
log_path = job_dir / "job.log"
try:
write_job_status(
job_dir,
{
"job_id": job_id,
"status": "running",
"stage": "mineru",
"message": "MinerU 正在解析上传文献",
"source_path": str(source_path),
"output_dir": str(output_dir),
},
)
content_list = source_path
if source_path.suffix.lower() != ".json":
mineru_out = job_dir / "mineru"
mineru_cmd, mineru_env, mineru_runtime = build_mineru_runtime()
_preflight_executable(mineru_cmd, "MinerU", "MINERU_CMD")
preflight_error = mineru_gpu_preflight_error(
mineru_runtime["cuda_visible_devices"],
mineru_runtime["mode"],
mineru_runtime,
)
if preflight_error:
with log_path.open("a", encoding="utf-8") as handle:
handle.write(preflight_error + "\n")
raise RuntimeError(preflight_error)
cmd = build_mineru_command(mineru_cmd, source_path, mineru_out, mineru_runtime)
with log_path.open("a", encoding="utf-8") as handle:
handle.write(
"$ env "
f"METHODOLOGY_MINERU_RUNTIME_MODE={mineru_runtime['mode']} "
f"MINERU_MODEL_SOURCE={mineru_runtime['model_source']} "
f"CUDA_VISIBLE_DEVICES={mineru_runtime['cuda_visible_devices']} "
+ " ".join(cmd)
+ "\n"
)
handle.write("$ " + " ".join(cmd) + "\n")
handle.flush()
result = subprocess.run(
cmd,
cwd=PROJECT_ROOT,
env=mineru_env,
text=True,
stdin=subprocess.DEVNULL,
stdout=handle,
stderr=subprocess.STDOUT,
)
# 缺陷 #38:启动期失败自动重试 1 次——returncode≠0 且 job.log 无
# doc_analyze_streaming 行 = pipeline 起来前就失败(真因曾因块缓冲
# 丢失)。重试的 stdout 继续 append 到同一 job.log,日志/状态留
# retry 标记(G6-2:已有 pipeline 行的中途失败不得触发重试)。
startup_retried = False
if result.returncode != 0 and not job_log_has_pipeline_line(log_path):
startup_retried = True
with log_path.open("a", encoding="utf-8") as handle:
handle.write(
f"[retry] MinerU 启动期失败(exit {result.returncode},"
"无 pipeline 阶段日志),自动重试 1/1\n"
)
handle.write("$ " + " ".join(cmd) + "\n")
handle.flush()
result = subprocess.run(
cmd,
cwd=PROJECT_ROOT,
env=mineru_env,
text=True,
stdin=subprocess.DEVNULL,
stdout=handle,
stderr=subprocess.STDOUT,
)
if result.returncode != 0:
if not job_log_has_pipeline_line(log_path):
raise RuntimeError(
summarize_mineru_startup_failure(log_path, result.returncode, retried=startup_retried)
)
raise RuntimeError(summarize_mineru_failure(log_path, result.returncode))
matches = sorted(mineru_out.rglob("*_content_list.json"))
if not matches:
raise RuntimeError(f"MinerU finished but no *_content_list.json was found under {mineru_out}")
content_list = matches[0]
write_job_status(
job_dir,
{
"job_id": job_id,
"status": "running",
"stage": "llm",
"message": "LLM 正在提取方法学并与 bbav5 对齐",
"source_path": str(source_path),
"content_list": str(content_list),
"output_dir": str(output_dir),
},
)
papers_csv = job_dir / "methodology_papers.csv"
with papers_csv.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=["paper_id", "family", "source_paper_role", "parsed_document_path"])
writer.writeheader()
writer.writerow(
{
"paper_id": paper_id,
"family": family,
"source_paper_role": "primary_gene_family_paper",
"parsed_document_path": str(content_list),
}
)
gf_python = gf_python_path()
_preflight_executable([str(gf_python)], "GF Python", "PLANTAI_GF_PYTHON_ENV")
batch_cmd = [
str(gf_python),
"scripts/run_methodology_batch.py",
"--papers",
str(papers_csv),
"--paper-id",
paper_id,
"--output-dir",
str(output_dir),
"--use-llm",
"--llm-model",
llm_model,
"--llm-request-timeout",
str(llm_request_timeout),
"--external-job-status-json",
str(job_dir / "status.json"),
]
with log_path.open("a", encoding="utf-8") as handle:
handle.write("$ " + " ".join(batch_cmd) + "\n")
handle.flush()
result = subprocess.run(
batch_cmd, cwd=PROJECT_ROOT, text=True, stdin=subprocess.DEVNULL,
stdout=handle, stderr=subprocess.STDOUT
)
if result.returncode != 0:
raise RuntimeError(f"LLM methodology extraction failed with exit code {result.returncode}; see {log_path}")
config = build_bbav5_methodology_config(output_dir)
figure_table_error = ""
figure_table_payload: dict[str, Any] = {}
try:
figure_table_payload = build_figure_table_outputs(
content_list_path=content_list,
output_dir=output_dir,
methodology_config=config,
)
config = build_bbav5_methodology_config(output_dir)
except Exception as exc: # keep the core methodology extraction result usable.
figure_table_error = str(exc)
preview = config.get("reproduction_plan_preview", {}) if isinstance(config, dict) else {}
sandbox_payload: dict[str, Any] = {}
sandbox_warning = ""
if sandbox:
try:
result = build_methodology_qa_sandbox(
output_dir,
sandbox_root=DEFAULT_SANDBOX_ROOT,
run_id=sandbox_run_id or f"upload_{sanitize_token(job_id)}",
include_figures=False,
)
report = result.get("report", {})
registry = result.get("registry", {})
sandbox_payload = {
"methodology_sandbox_dir": report.get("sandbox_dir", ""),
"methodology_sandbox_registry": report.get("registry_path", ""),
"methodology_sandbox_collection": registry.get("qdrant_collection", ""),
"methodology_sandbox_status": "built",
"methodology_sandbox_card_count": report.get("stage_card_count", 0) + report.get("plan_card_count", 0),
"methodology_sandbox_relation_count": report.get("relation_count", 0),
}
except Exception as exc:
sandbox_warning = str(exc)
sandbox_payload = {
"methodology_sandbox_status": "failed",
"methodology_sandbox_warning": sandbox_warning,
}
write_job_status(
job_dir,
{
"job_id": job_id,
"status": "completed",
"stage": "completed",
"substage": "done",
"message": "MinerU+LLM 方法学提取完成",
"source_path": str(source_path),
"content_list": str(content_list),
"output_dir": str(output_dir),
"paper_id": paper_id,
"family": family,
"stage_count": len(config.get("stages", [])),
"species_count": len(config.get("species", [])),
"figure_table_caption_count": len(config.get("figure_table_inventory", [])),
"figure_table_stage_count": len(preview.get("suggested_stages", [])) if isinstance(preview, dict) else 0,
"figure_table_warning": figure_table_error,
"figure_table_files": figure_table_payload.get("files", {}),
"config_url": f"/api/v1/methodology/extract-jobs/{job_id}/bbav5-config",
"methodology_plan_url": f"/api/v1/methodology/extract-jobs/{job_id}/plan",
"figure_table_inventory_url": f"/api/v1/methodology/extract-jobs/{job_id}/figure-table-inventory",
"reproduction_plan_preview_url": f"/api/v1/methodology/extract-jobs/{job_id}/reproduction-plan-preview",
**sandbox_payload,
},
)
except Exception as exc:
write_job_status(
job_dir,
{
"job_id": job_id,
"status": "failed",
"stage": "failed",
"message": str(exc),
"source_path": str(source_path),
"output_dir": str(output_dir),
"log_path": str(log_path),
},
)
@router.get("/status")
async def methodology_status():
summary = read_csv(BATCH_DIR / "summary.csv")
review = read_csv(BATCH_DIR / "review_template.csv")
return {
"papers_table": str(PAPERS_CSV),
"gold_table": str(GOLD_CSV),
"batch_dir": str(BATCH_DIR),
"batch_exists": BATCH_DIR.exists(),
"paper_count": len(read_csv(PAPERS_CSV)),
"summary_count": len(summary),
"review_row_count": len(review),
"files": {
"summary": str(BATCH_DIR / "summary.csv"),
"processing_status": str(BATCH_DIR / "processing_status.csv"),
"review_template": str(BATCH_DIR / "review_template.csv"),
"review_results": str(REVIEW_RESULTS_JSON),
"discovered_tools": str(BATCH_DIR / "discovered_tools.csv"),
"methodology_tool_audit": str(BATCH_DIR / "methodology_tool_audit.csv"),
"methodology_parameter_audit": str(BATCH_DIR / "methodology_parameter_audit.csv"),
"methodology_database_url_audit": str(BATCH_DIR / "methodology_database_url_audit.csv"),
"methodology_review_issues": str(BATCH_DIR / "methodology_review_issues.csv"),
"methodology_review_report": str(BATCH_DIR / "methodology_review_report.json"),
"progress": str(BATCH_DIR / "progress.jsonl"),
"run_log": str(BATCH_DIR / "methodology_batch_run.log"),
"species_summary": str(BATCH_DIR / "species_summary.csv"),
"observations": str(BATCH_DIR / "observations.json"),
"alignment": str(BATCH_DIR / "alignment.json"),
},
}
@router.get("/summary")
async def methodology_summary():
return {"rows": read_csv(BATCH_DIR / "summary.csv")}
@router.get("/review-template")
async def methodology_review_template():
return {"rows": read_csv(BATCH_DIR / "review_template.csv")}
@router.get("/processing-status")
async def methodology_processing_status():
return {"rows": read_csv(BATCH_DIR / "processing_status.csv")}
@router.get("/discovered-tools")
async def methodology_discovered_tools():
return {"rows": read_csv(BATCH_DIR / "discovered_tools.csv")}
@router.get("/tool-audit")
async def methodology_tool_audit():
return {"rows": read_csv(BATCH_DIR / "methodology_tool_audit.csv")}
@router.get("/parameter-audit")
async def methodology_parameter_audit():
return {"rows": read_csv(BATCH_DIR / "methodology_parameter_audit.csv")}
@router.get("/url-audit")
async def methodology_url_audit():
return {"rows": read_csv(BATCH_DIR / "methodology_database_url_audit.csv")}
@router.get("/auto-review-issues")
async def methodology_auto_review_issues():
return {"rows": read_csv(BATCH_DIR / "methodology_review_issues.csv")}
@router.get("/auto-review-report")
async def methodology_auto_review_report():
return read_json(BATCH_DIR / "methodology_review_report.json")
@router.get("/progress")
async def methodology_progress():
path = BATCH_DIR / "progress.jsonl"
if not path.exists():
return {"rows": []}
rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
return {"rows": rows}
@router.get("/run-log")
async def methodology_run_log():
path = BATCH_DIR / "methodology_batch_run.log"
if not path.exists():
return {"text": ""}
return {"text": path.read_text(encoding="utf-8")}
@router.get("/species-summary")
async def methodology_species_summary():
return {"rows": read_csv(BATCH_DIR / "species_summary.csv")}
@router.get("/review-items")
async def methodology_review_items():
rows = read_csv(BATCH_DIR / "review_template.csv")
saved = {row.get("observation_id"): row for row in read_review_results()}
for row in rows:
if row.get("observation_id") in saved:
row["saved_review"] = saved[row["observation_id"]]
return {"rows": rows, "saved_count": len(saved)}
@router.get("/review-results")
async def methodology_review_results():
return {"results": read_review_results()}
@router.post("/review-results")
async def save_methodology_review_results(payload: ReviewResultsPayload):
existing = {row.get("observation_id"): row for row in read_review_results()}
for item in payload.results:
existing[item.observation_id] = item.dict()
rows = list(existing.values())
REVIEW_RESULTS_JSON.parent.mkdir(parents=True, exist_ok=True)
REVIEW_RESULTS_JSON.write_text(json.dumps({"results": rows}, ensure_ascii=False, indent=2), encoding="utf-8")
write_csv(REVIEW_RESULTS_CSV, rows)
return {"saved_count": len(rows), "json_path": str(REVIEW_RESULTS_JSON), "csv_path": str(REVIEW_RESULTS_CSV)}
@router.get("/review-summary")
async def methodology_review_summary():
rows = read_review_results()
summary: dict[str, dict[str, int]] = {}
for field in [
"review_status",
"stage_review",
"tools_review",
"versions_review",
"parameters_review",
"evidence_review",
"bbav5_stage_review",
"bbav5_tools_review",
"support_status_review",
"bbav5_review",
]:
counts: dict[str, int] = {}
for row in rows:
value = str(row.get(field) or "<blank>")
counts[value] = counts.get(value, 0) + 1
summary[field] = counts
return {"reviewed_count": len(rows), "summary": summary}
@router.get("/alignment")
async def methodology_alignment():
return read_json(BATCH_DIR / "alignment.json")
@router.get("/bbav5-config")
async def methodology_bbav5_config(batch_dir: str = BATCH_DIR_NAME):
try:
return build_bbav5_methodology_config(resolve_batch_dir(batch_dir))
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/extract-upload")
async def methodology_extract_upload(
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
paper_id: str = Form("uploaded_paper"),
family: str = Form(""),
output_name: str = Form(""),
llm_model: str = Form("deepseek-v4-flash"),
llm_request_timeout: int = Form(75),
sandbox: bool = Form(False),
sandbox_run_id: str = Form(""),
):
job_id = uuid.uuid4().hex[:12]
clean_paper_id = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in (paper_id or f"paper_{job_id}"))
output_base = output_name or f"methodology_batch_{clean_paper_id}"
clean_output_base = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in output_base).strip("._-") or "methodology_batch"
clean_output = clean_output_base if clean_output_base.endswith(f"_{job_id}") else f"{clean_output_base}_{job_id}"
job_dir = UPLOAD_JOBS_DIR / job_id
upload_dir = job_dir / "upload"
upload_dir.mkdir(parents=True, exist_ok=True)
source_path = upload_dir / (file.filename or f"{clean_paper_id}.pdf")
source_path.write_bytes(await file.read())
output_dir = UPLOAD_OUTPUT_ROOT / clean_output
write_job_status(
job_dir,
{
"job_id": job_id,
"status": "queued",
"stage": "queued",
"message": "文献已上传,等待 MinerU+LLM 方法学提取",
"source_path": str(source_path),
"output_dir": str(output_dir),
"paper_id": clean_paper_id,
"family": family,
"methodology_sandbox_requested": sandbox,
},
)
background_tasks.add_task(
run_uploaded_methodology_job,
job_id,
source_path,
clean_paper_id,
family,
output_dir,
llm_model,
llm_request_timeout,
sandbox,
sandbox_run_id,
)
return {"success": True, "job_id": job_id, "status_url": f"/api/v1/methodology/extract-jobs/{job_id}"}
@router.get("/extract-jobs")
async def methodology_extract_jobs(limit: int = 50, status: str = ""):
rows = list_upload_job_statuses(limit)
if status:
rows = [row for row in rows if row.get("status") == status]
return {"jobs": rows, "count": len(rows)}
@router.get("/extract-jobs/{job_id}")
async def methodology_extract_job_status(job_id: str):
return read_job_status(job_id)
@router.get("/extract-jobs/{job_id}/bbav5-config")
async def methodology_extract_job_config(job_id: str):
status = read_job_status(job_id)
if status.get("status") != "completed":
raise HTTPException(status_code=409, detail=f"Extraction job is not completed: {status.get('status')}")
try:
return build_bbav5_methodology_config(status["output_dir"])
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/extract-jobs/{job_id}/plan")
async def methodology_extract_job_plan(job_id: str):
status = read_job_status(job_id)
if status.get("status") != "completed":
raise HTTPException(status_code=409, detail=f"Extraction job is not completed: {status.get('status')}")
output_dir = Path(status.get("output_dir", ""))
plan_path = output_dir / "methodology_plan.json"
if plan_path.exists():
plan = ensure_methodology_execution_contract(read_json(plan_path))
plan_path.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
return plan
try:
plan = build_methodology_execution_plan(output_dir)
plan_path.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
return plan
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/extract-jobs/{job_id}/figure-table-inventory")
async def methodology_extract_job_figure_table_inventory(job_id: str):
status = read_job_status(job_id)
output_dir = Path(status.get("output_dir", ""))
return {"rows": read_json(output_dir / "figure_table_inventory.json")}
@router.post("/sandbox/build")
async def methodology_sandbox_build(payload: MethodologySandboxBuildPayload):
try:
result = build_methodology_qa_sandbox(
payload.methodology_dir,
sandbox_root=payload.sandbox_root,
run_id=payload.run_id or None,
include_figures=payload.include_figures,
content_list_path=payload.content_list_path or None,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"success": True,
"report": result["report"],
"registry": result["registry"],
}
@router.post("/sandbox/answer")
async def methodology_sandbox_answer(payload: MethodologySandboxAnswerPayload):
registry_path = payload.registry_path.strip()
if not registry_path and payload.sandbox_dir.strip():
registry_path = str(Path(payload.sandbox_dir.strip()) / "methodology_plan_registry.sandbox.json")
if not registry_path:
raise HTTPException(status_code=400, detail="registry_path or sandbox_dir is required")
result = answer_from_sandbox(payload.question, registry_path)
if not result:
return {
"success": False,
"answer": "",
"sources": [],
"message": "No sandbox methodology plan matched the question.",
"match_status": "no_match",
}
return {
"success": result.get("success", True),
"answer": result["answer"],
"sources": result["sources"],
"notes": result.get("notes", []),
"plan_entry": result.get("plan_entry", {}),
"matched_plan": result.get("matched_plan", {}),
"match_status": result.get("match_status", ""),
"query_constraints": result.get("query_constraints", {}),
"candidate_plans": result.get("candidate_plans", []),
"mismatch_reasons": result.get("mismatch_reasons", []),
}
@router.get("/sandbox/example-batches")
async def methodology_sandbox_example_batches():
candidates = []
for path in PROJECT_ROOT.glob("methodology_batch_*"):
if path.is_dir() and (path / "review_template.csv").exists():
candidates.append(
{
"name": path.name,
"path": str(path),
"has_plan": (path / "methodology_plan.json").exists(),
"has_preview": (path / "reproduction_plan_preview.json").exists(),
}
)
candidates.sort(key=lambda item: item["name"])
return {
"success": True,
"sandbox_root": str(DEFAULT_SANDBOX_ROOT),
"batches": candidates,
}
@router.get("/extract-jobs/{job_id}/reproduction-plan-preview")
async def methodology_extract_job_reproduction_plan_preview(job_id: str):
status = read_job_status(job_id)
output_dir = Path(status.get("output_dir", ""))
return read_json(output_dir / "reproduction_plan_preview.json")