From 7b011da2645b1665e730ccf56c929d870a1e3cc4 Mon Sep 17 00:00:00 2001 From: potaser Date: Wed, 5 Aug 2026 20:15:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?1.=20main.py=20=E6=8C=87=E5=90=91=E4=BA=86?= =?UTF-8?q?=E6=96=B0extension=5Fstage=5Flightweight.api=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=202.=20=E4=BC=98=E5=8C=96=E4=BA=86start=5F*=5F9000.sh=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=97=B6=E4=BC=98=E5=85=88=E6=A0=B9=E6=8D=AEenv?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=90=AF=E5=8A=A8=E7=9A=84=E9=80=BB=E8=BE=91?= =?UTF-8?q?=203.=20=E5=A2=9E=E5=8A=A0=E4=BA=86=E4=B8=80=E9=94=AE=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E8=84=9A=E6=9C=ACmanage=5Fplantai.sh=EF=BC=8C?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E7=94=B1Agent=E9=80=9A=E8=BF=87promot?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=AF=B4=E5=90=AF=E5=8A=A8=E3=80=81=E9=87=8D?= =?UTF-8?q?=E5=90=AF=E7=AD=89=E8=BF=9B=E8=A1=8C=E6=9C=8D=E5=8A=A1=E7=9A=84?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=92=8C=E5=85=B3=E9=97=AD=E5=8F=8A=E9=87=8D?= =?UTF-8?q?=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/vue3/main.py | 3 + manage_plantai.sh | 161 ++++++++++++++++++++++++++++++++++++++++++ start_celery_9000.sh | 6 +- start_plantai_9000.sh | 9 ++- 4 files changed, 174 insertions(+), 5 deletions(-) create mode 100755 manage_plantai.sh diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index d8499c7c5..22962c7f0 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -4191,6 +4191,9 @@ def _attach_agent_manifest_status(task: Dict) -> None: print(f"[API DEBUG] Failed to attach agent manifest status: {e}") app = FastAPI(title="BotanicalBaseAgent API") +from gfa_core.extension_stage_lightweight.api import create_lightweight_extension_router + +app.include_router(create_lightweight_extension_router()) # MainAgent runtime state. Analysis planning and execution remain owned by # the original IntentAgent/PlanningAgent/frontend flow below. llm_manager = None diff --git a/manage_plantai.sh b/manage_plantai.sh new file mode 100755 index 000000000..cbb9b7e19 --- /dev/null +++ b/manage_plantai.sh @@ -0,0 +1,161 @@ +#!/bin/bash +# PlantAI-dev 9000 服务管理脚本 +# 用法: bash manage_plantai.sh {start|stop|restart|status} +# +# 项目: /home/xzq/5090/teamWorkspace/zh/PlantAI-dev +# 端口: 9000 (web), Redis DB 14 (queue prefix: plantai_dev_9002) + +set -euo pipefail + +# Conda 环境:启动脚本内部也依赖这个环境 +CONDA_ENV="${CONDA_ENV:-/home/xzq/miniconda3/envs/test4py311}" +export PATH="$CONDA_ENV/bin:$PATH" + +# 项目配置(与 .env 和启动脚本保持一致) +PROJECT_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-dev" +WEB_PORT="9000" +QUEUE_PREFIX="plantai_dev_9002" +WEB_LOG="/tmp/plantai_9000.log" +CELERY_LOG="/tmp/celery_9000.log" + +red() { echo -e "\033[31m$*\033[0m"; } +green(){ echo -e "\033[32m$*\033[0m"; } +cyan() { echo -e "\033[36m$*\033[0m"; } + +status() { + echo "" + cyan "=== PlantAI-dev 9000 服务状态 ===" + echo "" + + # Web 服务 + WEB_PID=$(ss -tlnp 2>/dev/null | grep ":$WEB_PORT " | grep -oP 'pid=\K\d+' || true) + if [ -n "${WEB_PID:-}" ]; then + green "[✓] Web 服务运行中 (端口 $WEB_PORT, PID $WEB_PID)" + else + red "[✗] Web 服务未启动 (端口 $WEB_PORT)" + fi + + # Redis + if redis-cli ping > /dev/null 2>&1; then + green "[✓] Redis 运行中 (localhost:6379)" + else + red "[✗] Redis 未启动" + fi + + # Celery workers + CELERY_COUNT=$(pgrep -fc "$QUEUE_PREFIX" || echo 0) + if [ "$CELERY_COUNT" -gt 0 ]; then + green "[✓] Celery workers 运行中 (${CELERY_COUNT} 进程, 队列前缀: $QUEUE_PREFIX)" + echo " Worker 列表:" + ps aux | grep "$QUEUE_PREFIX" | grep -v grep | grep -oP "queues=\K\S+" | sort | while read q; do + echo " - $q" + done + else + red "[✗] Celery workers 未启动 (队列前缀: $QUEUE_PREFIX)" + fi + echo "" +} + +stop() { + echo "" + cyan "=== 停止 PlantAI-dev 9000 服务 ===" + echo "" + + # 停止 Web 服务 + WEB_PID=$(ss -tlnp 2>/dev/null | grep ":$WEB_PORT " | grep -oP 'pid=\K\d+' || true) + if [ -n "${WEB_PID:-}" ]; then + echo "正在停止 Web 服务 (PID $WEB_PID)..." + kill "$WEB_PID" 2>/dev/null || true + sleep 1 + # 如果还没死,强制 kill + if ss -tlnp 2>/dev/null | grep -q ":$WEB_PORT"; then + kill -9 "$WEB_PID" 2>/dev/null || true + fi + green "[✓] Web 服务已停止" + else + echo "[ ] Web 服务未运行" + fi + + # 停止 Celery workers + CELERY_PIDS=$(pgrep -f "$QUEUE_PREFIX" 2>/dev/null) || true + if [ -n "${CELERY_PIDS:-}" ]; then + echo "正在停止 Celery workers..." + echo "$CELERY_PIDS" | xargs -r kill 2>/dev/null || true + sleep 2 + # 清理残留 + REMAIN=$(pgrep -f "$QUEUE_PREFIX" 2>/dev/null) || true + if [ -n "${REMAIN:-}" ]; then + echo " 强制清理残留进程..." + echo "$REMAIN" | xargs -r kill -9 2>/dev/null || true + fi + green "[✓] Celery workers 已停止" + else + echo "[ ] Celery workers 未运行" + fi + echo "" +} + +start() { + echo "" + cyan "=== 启动 PlantAI-dev 9000 服务 ===" + echo "" + + # 检查是否已在运行 + if ss -tlnp 2>/dev/null | grep -q ":$WEB_PORT"; then + red "[!] 端口 $WEB_PORT 已被占用,请先停止服务" + exit 1 + fi + + if ! redis-cli ping > /dev/null 2>&1; then + red "[!] Redis 未运行,请先启动 Redis" + exit 1 + fi + + cd "$PROJECT_DIR" + + # 启动 Celery workers + echo "正在启动 Celery workers..." + nohup bash start_celery_9000.sh > "$CELERY_LOG" 2>&1 & + sleep 3 + CELERY_COUNT=$(pgrep -fc "$QUEUE_PREFIX" || echo 0) + if [ "$CELERY_COUNT" -gt 0 ]; then + green " [✓] Celery workers 已启动 (${CELERY_COUNT} 进程)" + else + red " [✗] Celery workers 启动失败,查看 $CELERY_LOG" + fi + + # 启动 Web 服务 + echo "正在启动 Web 服务..." + nohup bash start_plantai_9000.sh > "$WEB_LOG" 2>&1 & + sleep 3 + if ss -tlnp 2>/dev/null | grep -q ":$WEB_PORT"; then + green " [✓] Web 服务已启动 (http://0.0.0.0:$WEB_PORT)" + else + red " [✗] Web 服务启动失败,查看 $WEB_LOG" + fi + + # 最终状态 + echo "" + echo "日志文件:" + echo " Web: $WEB_LOG" + echo " Celery: $CELERY_LOG" + echo " Worker: $PROJECT_DIR/logs/celery_plantai_dev_9000/" + echo "" +} + +restart() { + stop + sleep 1 + start +} + +case "${1:-status}" in + start) start ;; + stop) stop ;; + restart) restart ;; + status) status ;; + *) + echo "用法: bash manage_plantai.sh {start|stop|restart|status}" + exit 1 + ;; +esac diff --git a/start_celery_9000.sh b/start_celery_9000.sh index 50a257db2..2496254f8 100755 --- a/start_celery_9000.sh +++ b/start_celery_9000.sh @@ -25,13 +25,13 @@ fi if [ -n "$EXTERNAL_BBAV5_WORKER_NAME_PREFIX" ]; then BBAV5_WORKER_NAME_PREFIX="$EXTERNAL_BBAV5_WORKER_NAME_PREFIX" fi -if [ -z "$EXTERNAL_BBAV5_QUEUE_PREFIX" ]; then +if [ -z "${BBAV5_QUEUE_PREFIX-}" ]; then BBAV5_QUEUE_PREFIX="plantai_dev_9000" fi -if [ -z "$EXTERNAL_BBAV5_REDIS_URL" ]; then +if [ -z "${BBAV5_REDIS_URL-}" ]; then BBAV5_REDIS_URL="redis://localhost:6379/10" fi -if [ -z "$EXTERNAL_BBAV5_CELERY_LOG_DIR" ]; then +if [ -z "${BBAV5_CELERY_LOG_DIR-}" ]; then BBAV5_CELERY_LOG_DIR="logs/celery_plantai_dev_9000" fi if [ -z "$EXTERNAL_BBAV5_WORKER_NAME_PREFIX" ]; then diff --git a/start_plantai_9000.sh b/start_plantai_9000.sh index e907169fa..ab815fdb5 100755 --- a/start_plantai_9000.sh +++ b/start_plantai_9000.sh @@ -15,12 +15,17 @@ export PATH="$CONDA_ENV/bin:$PATH" # the caller's shell; GWAS/other sandboxes have dedicated restart scripts. export BBAV5_PROJECT_ROOT="$SCRIPT_DIR" export PLANTAI_PROJECT_ROOT="$SCRIPT_DIR" +if [ -f "$SCRIPT_DIR/.env" ]; then + set -a + . "$SCRIPT_DIR/.env" + set +a +fi export BBAV5_PROJECT_ENV_FILE="${BBAV5_PROJECT_ENV_FILE:-$SCRIPT_DIR/.env}" export BBAV5_LIGHTWEIGHT_EXTENSION_STORE="${BBAV5_LIGHTWEIGHT_EXTENSION_STORE:-$SCRIPT_DIR/.runtime/lightweight-extension/store}" export BBAV5_LIGHTWEIGHT_INPUT="${BBAV5_LIGHTWEIGHT_INPUT:-$SCRIPT_DIR/input}" export BBAV5_LIGHTWEIGHT_WORKSPACE="${BBAV5_LIGHTWEIGHT_WORKSPACE:-$SCRIPT_DIR/workspace}" -export BBAV5_QUEUE_PREFIX="plantai_dev_9000" -export BBAV5_REDIS_URL="redis://localhost:6379/10" +export BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_dev_9000}" +export BBAV5_REDIS_URL="${BBAV5_REDIS_URL:-redis://localhost:6379/10}" export PLANTAI_WEB_PORT="9000" export GENE_FAMILY_MAS_API="http://127.0.0.1:7926" export DUAL_KB_QA_BASE_URL="http://127.0.0.1:7926" From 4750bcc76f48ccf5b6cd8ed91f0482b752524593 Mon Sep 17 00:00:00 2001 From: potaser Date: Thu, 6 Aug 2026 19:34:11 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=97=B6=E5=8F=AF=E4=BB=A5=E6=8C=87=E5=AE=9Aweb?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=9B=E5=B0=86?= =?UTF-8?q?=E4=B8=BB=E9=A1=B5=E8=BF=90=E8=A1=8C=E7=BB=93=E6=9E=9C=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=8C=AA=E5=88=B0=E4=BA=86=E6=8B=93=E5=B1=95=E5=88=86?= =?UTF-8?q?=E6=9E=90title=E7=9A=84=E5=90=8E=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/vue3/index.html | 2 +- manage_plantai.sh | 46 ++++++++++++++++++++++++++++++---------- restart_plantai_9000.sh | 24 +++++++++++++++++++++ start_plantai_9000.sh | 23 ++++++++++++++++++-- 4 files changed, 81 insertions(+), 14 deletions(-) create mode 100755 restart_plantai_9000.sh diff --git a/apps/web/vue3/index.html b/apps/web/vue3/index.html index c1acbfe65..5281b307c 100644 --- a/apps/web/vue3/index.html +++ b/apps/web/vue3/index.html @@ -1365,6 +1365,7 @@

拓展分析阶段

+ 运行结果
已写阶段 @@ -1425,7 +1426,6 @@ 运行已发布 - 运行结果
diff --git a/manage_plantai.sh b/manage_plantai.sh index cbb9b7e19..52b893ae5 100755 --- a/manage_plantai.sh +++ b/manage_plantai.sh @@ -1,22 +1,46 @@ #!/bin/bash # PlantAI-dev 9000 服务管理脚本 -# 用法: bash manage_plantai.sh {start|stop|restart|status} +# 用法: bash manage_plantai.sh {start|stop|restart|status} [--port PORT] +# 默认端口: 9000 # # 项目: /home/xzq/5090/teamWorkspace/zh/PlantAI-dev # 端口: 9000 (web), Redis DB 14 (queue prefix: plantai_dev_9002) set -euo pipefail +# 解析命令行参数:第一个参数是命令,后面可选 --port / -p +CMD="" +WEB_PORT="9000" +for arg in "$@"; do + case "$arg" in + start|stop|restart|status) + CMD="$arg" + ;; + --port|-p) + WEB_PORT="" + ;; + *) + if [ -z "$WEB_PORT" ] && [[ "$arg" =~ ^[0-9]+$ ]]; then + WEB_PORT="$arg" + elif [ -z "$CMD" ]; then + echo "错误: 未知命令 '$arg'" + echo "用法: bash manage_plantai.sh {start|stop|restart|status} [--port PORT]" + exit 1 + fi + ;; + esac +done +CMD="${CMD:-status}" + # Conda 环境:启动脚本内部也依赖这个环境 CONDA_ENV="${CONDA_ENV:-/home/xzq/miniconda3/envs/test4py311}" export PATH="$CONDA_ENV/bin:$PATH" # 项目配置(与 .env 和启动脚本保持一致) PROJECT_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-dev" -WEB_PORT="9000" QUEUE_PREFIX="plantai_dev_9002" -WEB_LOG="/tmp/plantai_9000.log" -CELERY_LOG="/tmp/celery_9000.log" +WEB_LOG="/tmp/plantai_${WEB_PORT}.log" +CELERY_LOG="/tmp/celery_${WEB_PORT}.log" red() { echo -e "\033[31m$*\033[0m"; } green(){ echo -e "\033[32m$*\033[0m"; } @@ -24,7 +48,7 @@ cyan() { echo -e "\033[36m$*\033[0m"; } status() { echo "" - cyan "=== PlantAI-dev 9000 服务状态 ===" + cyan "=== PlantAI-dev ${WEB_PORT} 服务状态 ===" echo "" # Web 服务 @@ -58,7 +82,7 @@ status() { stop() { echo "" - cyan "=== 停止 PlantAI-dev 9000 服务 ===" + cyan "=== 停止 PlantAI-dev ${WEB_PORT} 服务 ===" echo "" # 停止 Web 服务 @@ -97,7 +121,7 @@ stop() { start() { echo "" - cyan "=== 启动 PlantAI-dev 9000 服务 ===" + cyan "=== 启动 PlantAI-dev ${WEB_PORT} 服务 ===" echo "" # 检查是否已在运行 @@ -126,7 +150,7 @@ start() { # 启动 Web 服务 echo "正在启动 Web 服务..." - nohup bash start_plantai_9000.sh > "$WEB_LOG" 2>&1 & + nohup bash start_plantai_9000.sh --port "$WEB_PORT" > "$WEB_LOG" 2>&1 & sleep 3 if ss -tlnp 2>/dev/null | grep -q ":$WEB_PORT"; then green " [✓] Web 服务已启动 (http://0.0.0.0:$WEB_PORT)" @@ -139,7 +163,7 @@ start() { echo "日志文件:" echo " Web: $WEB_LOG" echo " Celery: $CELERY_LOG" - echo " Worker: $PROJECT_DIR/logs/celery_plantai_dev_9000/" + echo " Worker: $PROJECT_DIR/logs/celery_${QUEUE_PREFIX}/" echo "" } @@ -149,13 +173,13 @@ restart() { start } -case "${1:-status}" in +case "$CMD" in start) start ;; stop) stop ;; restart) restart ;; status) status ;; *) - echo "用法: bash manage_plantai.sh {start|stop|restart|status}" + echo "用法: bash manage_plantai.sh {start|stop|restart|status} [--port PORT]" exit 1 ;; esac diff --git a/restart_plantai_9000.sh b/restart_plantai_9000.sh new file mode 100755 index 000000000..06e7bab13 --- /dev/null +++ b/restart_plantai_9000.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Project restart script — called by /api/admin/restart-project +# 用法: bash restart_plantai_9000.sh [--port PORT] +# 从环境变量 PLANTAI_WEB_PORT 或参数获取端口,默认 9000 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PORT="${PLANTAI_WEB_PORT:-9000}" +while [[ $# -gt 0 ]]; do + case "$1" in + --port|-p) + PORT="${2:?错误: --port 需要指定端口号}" + shift 2 + ;; + *) + shift + ;; + esac +done + +cd "$SCRIPT_DIR" +exec bash manage_plantai.sh restart --port "$PORT" diff --git a/start_plantai_9000.sh b/start_plantai_9000.sh index ab815fdb5..93050a096 100755 --- a/start_plantai_9000.sh +++ b/start_plantai_9000.sh @@ -1,8 +1,26 @@ #!/bin/bash -# Start the PlantAI-dev web service on port 9000. +# Start the PlantAI-dev web service. +# 用法: bash start_plantai_9000.sh [--port PORT] +# 默认端口: 9000 set -euo pipefail +# 解析命令行参数 +PORT="9000" +while [[ $# -gt 0 ]]; do + case "$1" in + --port|-p) + PORT="${2:?错误: --port 需要指定端口号}" + shift 2 + ;; + *) + echo "未知参数: $1" + echo "用法: bash start_plantai_9000.sh [--port PORT]" + exit 1 + ;; + esac +done + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" @@ -26,7 +44,8 @@ export BBAV5_LIGHTWEIGHT_INPUT="${BBAV5_LIGHTWEIGHT_INPUT:-$SCRIPT_DIR/input}" export BBAV5_LIGHTWEIGHT_WORKSPACE="${BBAV5_LIGHTWEIGHT_WORKSPACE:-$SCRIPT_DIR/workspace}" export BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_dev_9000}" export BBAV5_REDIS_URL="${BBAV5_REDIS_URL:-redis://localhost:6379/10}" -export PLANTAI_WEB_PORT="9000" +export PLANTAI_WEB_PORT="$PORT" +export PLANTAI_RESTART_SCRIPT="$SCRIPT_DIR/restart_plantai_9000.sh" export GENE_FAMILY_MAS_API="http://127.0.0.1:7926" export DUAL_KB_QA_BASE_URL="http://127.0.0.1:7926" export RAG_API_URL="http://127.0.0.1:7926/api/v1/qa-v1-1-73/chat" From 8c3d6010724794a3791aacade801e0006745c4c1 Mon Sep 17 00:00:00 2001 From: wtx Date: Thu, 6 Aug 2026 21:32:49 +0800 Subject: [PATCH 3/4] fix: allow reordered subfamily member records --- apps/cli/run_subfamily_confirm.py | 6 +-- gfa_core/mas/effective_members.py | 2 +- tests/unit/test_effective_member_resolver.py | 43 +++++++++++++++++++ ...test_subfamily_confirm_member_promotion.py | 36 ++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/apps/cli/run_subfamily_confirm.py b/apps/cli/run_subfamily_confirm.py index 273c5497b..b2df7a7ef 100755 --- a/apps/cli/run_subfamily_confirm.py +++ b/apps/cli/run_subfamily_confirm.py @@ -118,7 +118,7 @@ def _atomic_promote_pair(curated_id: Path, curated_pep: Path, final_id: Path, fi tmp_pep.write_text(curated_pep.read_text(encoding="utf-8", errors="ignore"), encoding="utf-8") tmp_ids = _read_id_list(tmp_id) tmp_fasta_ids = _read_fasta_ids(tmp_pep) - if tmp_ids != tmp_fasta_ids: + if sorted(tmp_ids) != sorted(tmp_fasta_ids): tmp_id.unlink(missing_ok=True) tmp_pep.unlink(missing_ok=True) raise RuntimeError(f"Temporary final member validation failed for {final_id.stem}") @@ -160,7 +160,7 @@ def _promote_curated_members_to_final(final_dir: Path, prefixes: Iterable[str], if not curated_ids: raise ValueError(f"Curated member ID file is empty: {curated_id}") fasta_ids = _read_fasta_ids(curated_pep) - if curated_ids != fasta_ids: + if sorted(curated_ids) != sorted(fasta_ids): raise ValueError( f"Curated member ID/FASTA mismatch for {member_prefix}: " f"{len(curated_ids)} IDs vs {len(fasta_ids)} FASTA records" @@ -179,7 +179,7 @@ def _promote_curated_members_to_final(final_dir: Path, prefixes: Iterable[str], promoted_ids = _read_id_list(final_id) promoted_fasta_ids = _read_fasta_ids(final_pep) - if promoted_ids != curated_ids or promoted_fasta_ids != curated_ids: + if sorted(promoted_ids) != sorted(curated_ids) or sorted(promoted_fasta_ids) != sorted(curated_ids): raise RuntimeError(f"Post-promotion final member validation failed for {member_prefix}") promotions.append({ "prefix": member_prefix, diff --git a/gfa_core/mas/effective_members.py b/gfa_core/mas/effective_members.py index 2c7efac23..d44b9d2b1 100755 --- a/gfa_core/mas/effective_members.py +++ b/gfa_core/mas/effective_members.py @@ -259,7 +259,7 @@ def _build_result( id_list = _read_ids(path_id) if path_id else [] pep_ids = _read_fasta_ids(path_pep) if path_pep else [] - if path_id and path_pep and id_list != pep_ids: + if path_id and path_pep and sorted(id_list) != sorted(pep_ids): raise MemberResolutionError(f"Member ID/FASTA mismatch: {path_id} vs {path_pep}") member_count = len(id_list or pep_ids) warnings = [] diff --git a/tests/unit/test_effective_member_resolver.py b/tests/unit/test_effective_member_resolver.py index 433f30b83..7f3fc16be 100755 --- a/tests/unit/test_effective_member_resolver.py +++ b/tests/unit/test_effective_member_resolver.py @@ -116,6 +116,49 @@ def test_pre_confirm_prefers_curated_before_identified(tmp_path: Path): assert result.member_count == 2 +def test_pre_confirm_accepts_same_curated_members_in_different_order(tmp_path: Path): + ws = tmp_path / "workspace" / "job1" + final_dir = ws / "final" + input_dir = tmp_path / "input" / "job1" + prefix = "Target.Sl.GRAS" + + _write(final_dir / f"{prefix}.curated_members.id", "gene1\ngene2\n") + _write(final_dir / f"{prefix}.curated_members.pep.fa", _pep(["gene2", "gene1"])) + + result = resolve_effective_members( + workspace=ws, + final_dir=final_dir, + input_dir=input_dir, + prefix=prefix, + phase="pre_confirm", + require="pair", + ) + + assert result.member_count == 2 + assert result.path_id == final_dir / f"{prefix}.curated_members.id" + assert result.path_pep == final_dir / f"{prefix}.curated_members.pep.fa" + + +def test_pre_confirm_rejects_different_curated_members_with_equal_counts(tmp_path: Path): + ws = tmp_path / "workspace" / "job1" + final_dir = ws / "final" + input_dir = tmp_path / "input" / "job1" + prefix = "Target.Sl.GRAS" + + _write(final_dir / f"{prefix}.curated_members.id", "gene1\ngene2\n") + _write(final_dir / f"{prefix}.curated_members.pep.fa", _pep(["gene1", "gene3"])) + + with pytest.raises(MemberResolutionError, match="ID/FASTA mismatch"): + resolve_effective_members( + workspace=ws, + final_dir=final_dir, + input_dir=input_dir, + prefix=prefix, + phase="pre_confirm", + require="pair", + ) + + def test_subfamily_evidence_required_rejects_unreviewed_identified_members(tmp_path: Path): ws = tmp_path / "workspace" / "job1" final_dir = ws / "final" diff --git a/tests/unit/test_subfamily_confirm_member_promotion.py b/tests/unit/test_subfamily_confirm_member_promotion.py index 17912eca0..82547a8e6 100755 --- a/tests/unit/test_subfamily_confirm_member_promotion.py +++ b/tests/unit/test_subfamily_confirm_member_promotion.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from apps.cli.run_subfamily_confirm import _promote_curated_members_to_final @@ -25,3 +27,37 @@ def test_promote_curated_members_to_final_records_checksums(tmp_path: Path): assert promotion["source_curated_id_checksum"] assert (final_dir / f"{prefix}.final_members.id").read_text(encoding="utf-8").splitlines() == curated_ids assert (final_dir / f"{prefix}.final_members.pep.fa").exists() + + +def test_promote_curated_members_accepts_same_members_in_different_order(tmp_path: Path): + final_dir = tmp_path / "final" + final_dir.mkdir() + prefix = "Target.Sl.GRAS" + (final_dir / f"{prefix}.curated_members.id").write_text("gene1\ngene2\n", encoding="utf-8") + curated_pep = ">gene2\nMT\n>gene1\nMA\n" + (final_dir / f"{prefix}.curated_members.pep.fa").write_text(curated_pep, encoding="utf-8") + labels = { + "gene1": {"confirmed_subfamily": "GRAS-a"}, + "gene2": {"confirmed_subfamily": "GRAS-b"}, + } + + promotions = _promote_curated_members_to_final(final_dir, [prefix], labels) + + assert promotions[0]["count"] == 2 + assert (final_dir / f"{prefix}.final_members.id").read_text(encoding="utf-8") == "gene1\ngene2\n" + assert (final_dir / f"{prefix}.final_members.pep.fa").read_text(encoding="utf-8") == curated_pep + + +def test_promote_curated_members_rejects_different_members_with_equal_counts(tmp_path: Path): + final_dir = tmp_path / "final" + final_dir.mkdir() + prefix = "Target.Sl.GRAS" + (final_dir / f"{prefix}.curated_members.id").write_text("gene1\ngene2\n", encoding="utf-8") + (final_dir / f"{prefix}.curated_members.pep.fa").write_text(">gene1\nMA\n>gene3\nMT\n", encoding="utf-8") + labels = { + "gene1": {"confirmed_subfamily": "GRAS-a"}, + "gene2": {"confirmed_subfamily": "GRAS-b"}, + } + + with pytest.raises(ValueError, match="ID/FASTA mismatch"): + _promote_curated_members_to_final(final_dir, [prefix], labels) From 822baab367d3a22f54d9d794f68a181d8b5aa6d4 Mon Sep 17 00:00:00 2001 From: potaser Date: Fri, 7 Aug 2026 08:57:22 +0800 Subject: [PATCH 4/4] fix: preserve species roles through confirmation --- apps/web/vue3/main.py | 30 +++++--- gfa_core/mas/agents/intent_v2.py | 76 +++++++++++++++++--- tests/unit/test_intent_species_roles.py | 58 +++++++++++++++ tests/unit/test_subfamily_prediction_plan.py | 58 +++++++++++++++ 4 files changed, 201 insertions(+), 21 deletions(-) diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index 22962c7f0..b727ff36f 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -777,18 +777,12 @@ def _reconcile_confirmed_gene_family_species( job_id, _param_list(routed_params.get("comparativeSpecies") or routed_intent.get("comparative_species")), ) - explicit_reference_set = set(explicit["referenceSpecies"]) - explicit_assigned_set = set( - explicit["targetSpecies"] - + explicit["referenceSpecies"] - + explicit["comparativeSpecies"] - ) + explicit_unassigned_set = set(explicit["unassigned"]) for species in structured_targets: config_role = config_role_by_species.get(species) if ( config_role in {None, "reference"} - and species not in explicit_reference_set - and species not in explicit_assigned_set + and species in explicit_unassigned_set ): conflicts.append({ "code": "analysis_target_config_role_conflict", @@ -865,10 +859,24 @@ def _species_role_conflict_message(conflicts: list[Dict[str, str]]) -> str: config_role = role_labels.get(conflict.get("config_role") or "", conflict.get("config_role") or "未知") requested_role = role_labels.get(conflict.get("requested_role") or "", conflict.get("requested_role") or "配置内部重复角色") details.append(f"{species}:config.xlsx 为{config_role},本轮为{requested_role}") + conflict_codes = {str(conflict.get("code") or "") for conflict in conflicts} + detail_text = ";".join(details) + if "config_species_role_conflict" in conflict_codes: + return ( + "检测到 config.xlsx 内部物种角色冲突:" + + detail_text + + "。请发送“打开 config.xlsx”,修正并重新确认物种角色后再继续。" + ) + if "analysis_target_config_role_conflict" in conflict_codes: + return ( + "检测到系统意图解析发生角色漂移:" + + detail_text + + "。config.xlsx 未被修改,系统已阻止按错误范围执行,请重新提交当前分析请求。" + ) return ( - "检测到物种角色不一致:" - + ";".join(details) - + "。为避免按错误范围执行,请发送“打开 config.xlsx”,核对并重新确认物种角色后再继续。" + "检测到本轮请求与项目物种角色不一致:" + + detail_text + + "。系统已阻止执行,请按 config.xlsx 中已确认的角色重新描述分析范围。" ) diff --git a/gfa_core/mas/agents/intent_v2.py b/gfa_core/mas/agents/intent_v2.py index 8ba8e8da4..334c8a33a 100755 --- a/gfa_core/mas/agents/intent_v2.py +++ b/gfa_core/mas/agents/intent_v2.py @@ -12,7 +12,13 @@ from gfa_core.mas.stage_catalog import ( STAGE_INFO as CATALOG_STAGE_INFO, complete_dependencies as catalog_complete_dependencies, ) -from gfa_core.mas.utils.intent_parser import is_gwas_fmt_intent, parse_gene_family, resolve_gwas_intent, resolve_population_intent +from gfa_core.mas.utils.intent_parser import ( + is_gwas_fmt_intent, + parse_gene_family, + parse_gene_family_scope_selection, + resolve_gwas_intent, + resolve_population_intent, +) class IntentAgentV2(BaseAgent): @@ -696,18 +702,68 @@ class IntentAgentV2(BaseAgent): print(f"[IntentAgentV2] Stages expanded: {original_stages} -> {intent_data['stages']}") query_lower = query.lower() - species_codes = re.findall(r"(? List[str]: + if isinstance(value, str): + values = [item.strip() for item in value.split(",")] + elif isinstance(value, (list, tuple, set)): + values = [str(item).strip() for item in value] + else: + values = [] + return list(dict.fromkeys(item for item in values if item)) + + target_species = as_species_list(selection.get("targetSpecies")) + reference_species = as_species_list(selection.get("referenceSpecies")) + comparative_species = as_species_list(selection.get("comparativeSpecies")) + has_query_scope = bool(target_species or reference_species or comparative_species) + + prefix_species = provided_prefix.split(".", 1)[0].strip() if provided_prefix and "." in provided_prefix else "" + if not target_species and prefix_species: + target_species = [prefix_species] + if not target_species: + target_species = as_species_list(params.get("targetSpecies") or intent_data.get("target_species")) + if not reference_species: + reference_species = as_species_list(params.get("referenceSpecies") or intent_data.get("reference_species")) + if not comparative_species: + comparative_species = as_species_list(params.get("comparativeSpecies") or intent_data.get("comparative_species")) + if not target_species and not has_query_scope: + llm_species = str(intent_data.get("species") or "").strip() + if llm_species and llm_species != "Multi": + target_species = [llm_species] + + reference_species = [sp for sp in reference_species if sp not in target_species] + comparative_species = [ + sp for sp in comparative_species + if sp not in target_species and sp not in reference_species + ] + scope_mode = ( + "target_plus_reference_plus_comparative" + if reference_species and comparative_species + else "target_plus_reference" + if reference_species + else "target_plus_comparative" + if comparative_species + else "target_only" + ) + + intent_data["target_species"] = target_species + intent_data["reference_species"] = reference_species + intent_data["comparative_species"] = comparative_species + intent_data["scopeMode"] = scope_mode + params.update({ + "targetSpecies": target_species, + "referenceSpecies": reference_species, + "comparativeSpecies": comparative_species, + "scopeMode": scope_mode, + "target_species": target_species, + }) explicit_species = bool(target_species) if target_species: - intent_data["target_species"] = target_species - intent_data.setdefault("params", {})["target_species"] = target_species intent_data["species"] = target_species[0] if len(target_species) == 1 else "Multi" + elif has_query_scope: + intent_data["species"] = "" generic_identify = ( "identify" in intent_data.get("stages", []) and not provided_prefix diff --git a/tests/unit/test_intent_species_roles.py b/tests/unit/test_intent_species_roles.py index bd3c2046d..c51c22541 100644 --- a/tests/unit/test_intent_species_roles.py +++ b/tests/unit/test_intent_species_roles.py @@ -1,3 +1,6 @@ +import pytest + +from gfa_core.mas.agents.intent_v2 import IntentAgentV2 from gfa_core.mas.utils.intent_parser import ( parse_explicit_species_role_mentions, parse_gene_family_scope_selection, @@ -30,3 +33,58 @@ def test_explicit_role_parser_keeps_unassigned_mentions_separate() -> None: "comparativeSpecies": [], "unassigned": ["Ppy"], } + + +@pytest.mark.parametrize("species", ["Gm", "Br", "Pt", "Sb"]) +def test_scope_parser_preserves_species_supported_by_legacy_intent(species: str) -> None: + scope = parse_gene_family_scope_selection(f"分析 {species} 的 SPL 基因家族") + + assert scope["targetSpecies"] == [species] + + +def test_scope_parser_accepts_explicit_role_bound_custom_species_code() -> None: + scope = parse_gene_family_scope_selection("目标物种 Sv;参考物种 At") + + assert scope["targetSpecies"] == ["Sv"] + assert scope["referenceSpecies"] == ["At"] + + +def test_scope_parser_does_not_treat_gene_family_as_species() -> None: + scope = parse_gene_family_scope_selection("目标物种 Ft;分析 SPL 基因家族") + + assert scope["targetSpecies"] == ["Ft"] + + +def test_scope_parser_does_not_bind_custom_code_to_generic_goal_word() -> None: + scope = parse_gene_family_scope_selection("分析目标 SPL 基因家族") + + assert scope["targetSpecies"] == [] + + +def test_intent_validation_keeps_confirmed_target_and_reference_separate() -> None: + agent = object.__new__(IntentAgentV2) + + intent = agent._validate_intent( + {"gene_family": "SPL", "stages": ["identify"], "params": {}}, + ( + "请按照你规划的步骤来帮我执行\n" + "已确认上下文:基因家族 SPL;目标物种 Ft;参考物种 At。" + ), + ) + + assert intent["target_species"] == ["Ft"] + assert intent["reference_species"] == ["At"] + assert intent["species"] == "Ft" + + +def test_intent_validation_preserves_unlabelled_multi_target_request() -> None: + agent = object.__new__(IntentAgentV2) + + intent = agent._validate_intent( + {"gene_family": "SPL", "stages": ["identify"], "params": {}}, + "分析 Ft 和 Os 的 SPL 基因家族", + ) + + assert intent["target_species"] == ["Ft", "Os"] + assert intent["reference_species"] == [] + assert intent["species"] == "Multi" diff --git a/tests/unit/test_subfamily_prediction_plan.py b/tests/unit/test_subfamily_prediction_plan.py index d2e6f6167..ff9f8f508 100755 --- a/tests/unit/test_subfamily_prediction_plan.py +++ b/tests/unit/test_subfamily_prediction_plan.py @@ -426,6 +426,64 @@ def test_confirmed_multi_species_roles_remain_separate(monkeypatch): assert resolved["classification_species"] == ["Ft", "Ppy"] +def test_confirmation_ack_normalizes_structured_role_drift(monkeypatch): + main = _load_vue3_main() + monkeypatch.setattr( + main, + "_confirmed_config_species_scope", + lambda _job_id: { + "targetSpecies": ["Ft"], + "referenceSpecies": ["At"], + "comparativeSpecies": [], + }, + ) + + resolved = main._reconcile_confirmed_gene_family_species( + "job-confirmed-ack", + "是", + { + "gene_family": "SPL", + "stages": ["identify"], + "target_species": ["Ft", "At"], + "params": {"targetSpecies": ["Ft", "At"]}, + }, + ) + + assert resolved["conflicts"] == [] + assert resolved["target_species"] == ["Ft"] + assert resolved["reference_species"] == ["At"] + + +def test_unassigned_reference_species_analysis_request_remains_blocked(monkeypatch): + main = _load_vue3_main() + monkeypatch.setattr( + main, + "_confirmed_config_species_scope", + lambda _job_id: { + "targetSpecies": ["Ft"], + "referenceSpecies": ["At"], + "comparativeSpecies": [], + }, + ) + + resolved = main._reconcile_confirmed_gene_family_species( + "job-unassigned-reference", + "分析 At 的 SPL 基因家族", + { + "gene_family": "SPL", + "stages": ["identify"], + "target_species": ["At"], + "params": {"targetSpecies": ["At"]}, + }, + ) + + assert any( + conflict["code"] == "analysis_target_config_role_conflict" + and conflict["species"] == "At" + for conflict in resolved["conflicts"] + ) + + def test_confirmed_config_duplicate_roles_are_blocked(monkeypatch): main = _load_vue3_main() monkeypatch.setattr(