From db610031c13467d3815c38b30ffe6e98d1ea1258 Mon Sep 17 00:00:00 2001 From: potaser Date: Thu, 27 Aug 2026 00:21:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BD=BB=E9=87=8F=E6=8B=93?= =?UTF-8?q?=E5=B1=95=E5=88=86=E6=9E=90=20NL=20=E8=A7=A6=E5=8F=91=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E8=BD=AE=E6=B5=8B=E8=AF=95=E5=8F=8D=E9=A6=88=EF=BC=88?= =?UTF-8?q?PM=20=E8=A3=81=E6=96=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P0 目录截断:_rough_candidates 关键词源扩展 extension_id token 与参数 flag;命中数降序排序;MAX_CATALOG_CHARS 12000→24000;截断时 reason 注明 - P1a 过滤缺失 extension_id 的记录,不再产出 None 候选 - P1b hmmsearch/hmmer/blast/blastp 不再全量触发,改为按关键词源匹配子集;仅「拓展分析/扩展分析/extension」允许全量候选 - P2 extension_name 改为 extension_id 紧凑形式(去尾缀时间戳截 30 字符);message 中文模板(extension_id 为主、summary 括号补充) - P2 nl-import-paths:目录同名冲突 _1/_2 后缀;多路径先整体校验后复制 --- apps/web/vue3/main.py | 51 ++++++++-- .../extension_stage_lightweight/nl_intent.py | 95 ++++++++++++++----- 2 files changed, 114 insertions(+), 32 deletions(-) diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index c6d42da24..49a0a7333 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -4451,7 +4451,8 @@ async def nl_import_paths(req: NlImportPathsRequest): """把服务器上的绝对路径(文件或目录)复制进当前 job 的 input/_nl_imports/。 供轻量拓展分析自然语言触发向导使用;路径必须真实存在且解析后位于 - /home/xzq/ 或 /data/ 之下。 + /home/xzq/ 或 /data/ 之下。所有路径先整体校验,任一非法直接 400, + 全部合法后才逐个复制。 """ job_id = str(req.job_id or "").strip() if not job_id: @@ -4463,7 +4464,7 @@ async def nl_import_paths(req: NlImportPathsRequest): raise HTTPException(status_code=400, detail=f"无法创建 job 输入目录: {exc}") from exc allowed_roots = ("/home/xzq/", "/data/") - imported: dict[str, str] = {} + sources: list[Path] = [] for raw_path in req.paths or []: raw_text = str(raw_path or "").strip() if not raw_text: @@ -4483,8 +4484,12 @@ async def nl_import_paths(req: NlImportPathsRequest): status_code=400, detail=f"路径必须位于 {', '.join(allowed_roots)} 之下: {raw_text}", ) - target_dir = job_dir / "_nl_imports" - target_dir.mkdir(parents=True, exist_ok=True) + sources.append(source) + + target_dir = job_dir / "_nl_imports" + target_dir.mkdir(parents=True, exist_ok=True) + imported: dict[str, str] = {} + for source, raw_path in zip(sources, [str(p or "").strip() for p in req.paths or []]): if source.is_file(): destination = target_dir / source.name candidate = destination @@ -4495,16 +4500,21 @@ async def nl_import_paths(req: NlImportPathsRequest): try: shutil.copy2(source, candidate) except OSError as exc: - raise HTTPException(status_code=400, detail=f"复制文件失败: {raw_text}: {exc}") from exc + raise HTTPException(status_code=400, detail=f"复制文件失败: {raw_path}: {exc}") from exc name = candidate.name else: destination = target_dir / source.name + candidate = destination + counter = 1 + while candidate.exists(): + candidate = target_dir / f"{source.name}_{counter}" + counter += 1 try: - shutil.copytree(source, destination, dirs_exist_ok=True) + shutil.copytree(source, candidate, dirs_exist_ok=True) except OSError as exc: - raise HTTPException(status_code=400, detail=f"复制目录失败: {raw_text}: {exc}") from exc - name = destination.name - imported[raw_text] = f"input/{job_id}/_nl_imports/{name}" + raise HTTPException(status_code=400, detail=f"复制目录失败: {raw_path}: {exc}") from exc + name = candidate.name + imported[raw_path] = f"input/{job_id}/_nl_imports/{name}" return {"success": True, "imported": imported} @@ -16553,6 +16563,7 @@ def _match_lightweight_extension_intent(message: str, job_id: str = "") -> dict[ 返回 dict 含 lightweight=True;任何失败返回 {},绝不抛异常。 """ + records: list[dict] = [] try: from gfa_core.extension_stage_lightweight import nl_intent @@ -16571,10 +16582,20 @@ def _match_lightweight_extension_intent(message: str, job_id: str = "") -> dict[ return {} if resolution is None or not resolution.extension_id: return {} + summary_hint = "" + for record in records: + if str(record.get("extension_id") or "") != resolution.extension_id: + continue + candidate = record.get("semantic_candidate") + if isinstance(candidate, dict): + first_sentence = re.split(r"[。.!?;;]", str(candidate.get("function_summary") or ""))[0].strip() + summary_hint = first_sentence[:60] + break params = { "extension_id": resolution.extension_id, "extensionName": resolution.extension_name or resolution.extension_id, "extension_store": "lightweight", + "function_summary": summary_hint, "llm_slots": { "input_overrides": resolution.input_overrides, "parameter_overrides": resolution.parameter_overrides, @@ -16584,6 +16605,16 @@ def _match_lightweight_extension_intent(message: str, job_id: str = "") -> dict[ "confidence": resolution.confidence, "parse_reason": resolution.reason, } + if summary_hint: + message_text = ( + f"已识别为轻量拓展分析:{params['extension_id']}({summary_hint})。" + "请按引导上传或选择输入文件并核对参数后执行。" + ) + else: + message_text = ( + f"已识别为轻量拓展分析:{params['extension_id']}。" + "请按引导上传或选择输入文件并核对参数后执行。" + ) return { "stage": "extension_stage", "stages": ["extension_stage"], @@ -16596,7 +16627,7 @@ def _match_lightweight_extension_intent(message: str, job_id: str = "") -> dict[ "taskType": "extension_stage", "lightweight": True, "confidence": resolution.confidence, - "message": f"已识别为轻量拓展分析:{params['extensionName']}。请按引导上传或选择输入文件并核对参数后执行。", + "message": message_text, "job_id": job_id, "workspace": str(PROJECT_ROOT / "workspace" / job_id) if job_id else "", } diff --git a/gfa_core/extension_stage_lightweight/nl_intent.py b/gfa_core/extension_stage_lightweight/nl_intent.py index 0d61eb879..5026f1562 100644 --- a/gfa_core/extension_stage_lightweight/nl_intent.py +++ b/gfa_core/extension_stage_lightweight/nl_intent.py @@ -20,7 +20,7 @@ from pathlib import Path from typing import Any, Callable, Mapping PREFILL_THRESHOLD = 0.6 -MAX_CATALOG_CHARS = 12000 +MAX_CATALOG_CHARS = 24000 MAX_JOB_FILE_HINTS = 200 _ENGLISH_STOPWORDS = {"analysis", "data", "file", "run", "input", "output", "files", "using", "tool"} @@ -105,19 +105,65 @@ def _summary_keywords(record: Mapping[str, Any]) -> tuple[list[str], list[str]]: return english, chinese +def _extension_id_tokens(record: Mapping[str, Any]) -> list[str]: + """extension_id 里的英文 token(如 03___hmm_... → hmm、07_pangene__orthofinder_... → pangene/orthofinder)。""" + extension_id = str(record.get("extension_id") or "").lower() + return [ + token for token in re.split(r"[^A-Za-z0-9]+", extension_id) + if len(token) >= 3 and token not in _ENGLISH_STOPWORDS + ] + + +def _parameter_flag_tokens(record: Mapping[str, Any]) -> list[str]: + """parameters 的 flag 去前导 - 后作为关键词(如 --samples → samples)。""" + contract = record.get("runtime_binding_contract") + if not isinstance(contract, Mapping): + return [] + tokens: list[str] = [] + for item in contract.get("parameters") or []: + if not isinstance(item, Mapping): + continue + flag = str(item.get("flag") or "").strip().lower() + for token in re.split(r"[^A-Za-z0-9]+", flag): + if len(token) >= 3 and token not in _ENGLISH_STOPWORDS: + tokens.append(token) + return tokens + + +def _record_keyword_sources(record: Mapping[str, Any]) -> list[str]: + """一条记录的全部粗筛关键词:summary 英文/中文子串 + extension_id token + 参数 flag。""" + english, chinese = _summary_keywords(record) + return english + chinese + _extension_id_tokens(record) + _parameter_flag_tokens(record) + + def _rough_candidates(message: str, records: list[dict]) -> list[str]: - """按关键词粗筛可能命中的记录 id;不含 LLM 调用。""" + """按关键词粗筛可能命中的记录 id;不含 LLM 调用。 + + 返回按命中关键词数降序排列(同分按 id 排序);「拓展分析/扩展分析/extension」 + 这类不含功能线索的强触发词才允许全部候选进目录,其余消息只返回命中子集。 + """ text = str(message or "").lower() - if any(token in text for token in ("hmmsearch", "hmmer", "blast", "blastp")): - return [str(record.get("extension_id")) for record in records if record.get("extension_id")] if any(token in text for token in ("拓展分析", "扩展分析", "extension")): return [str(record.get("extension_id")) for record in records if record.get("extension_id")] - matched: list[str] = [] + scored: list[tuple[int, str]] = [] for record in records: - english, chinese = _summary_keywords(record) - if any(token in text for token in english) or any(sub in text for sub in chinese): - matched.append(str(record.get("extension_id"))) - return matched + extension_id = str(record.get("extension_id") or "").strip() + if not extension_id: + continue + hits = sum(1 for keyword in _record_keyword_sources(record) if keyword and keyword in text) + if hits: + scored.append((hits, extension_id)) + scored.sort(key=lambda item: (-item[0], item[1])) + return [extension_id for _, extension_id in scored] + + +def compact_extension_name(extension_id: str) -> str: + """extension_id 的紧凑展示形式:去掉尾缀时间戳(_后 10 位以上数字),再截断 30 字符。""" + value = str(extension_id or "").strip() + compact = re.sub(r"_\d{10,}$", "", value) + if compact: + return compact[:30] + return value[:30] def _rough_trigger(message: str, records: list[dict]) -> bool: @@ -126,14 +172,19 @@ def _rough_trigger(message: str, records: list[dict]) -> bool: def _build_catalog(records: list[dict]) -> dict: - """构造发给 LLM 的拓展目录;序列化后总长超过 MAX_CATALOG_CHARS 时截断。""" + """构造发给 LLM 的拓展目录;序列化后总长超过 MAX_CATALOG_CHARS 时截断。 + + records 顺序即目录顺序(_rough_candidates 已按命中关键词数降序排好)。 + """ items: list[dict] = [] + seen_ids: set[str] = set() total = 0 truncated = False for record in records: extension_id = str(record.get("extension_id") or "").strip() - if not extension_id: + if not extension_id or extension_id in seen_ids: continue + seen_ids.add(extension_id) contract = record.get("runtime_binding_contract") if not isinstance(contract, Mapping): continue @@ -279,9 +330,12 @@ def resolve_lightweight_extension_intent( return LightweightIntentResolution(None, None, {}, {}, None, 0.0, False, "no_published_records", []) if not candidates: return LightweightIntentResolution(None, None, {}, {}, None, 0.0, False, "no_trigger", []) - catalog = _build_catalog( - [record for record in published_records if str(record.get("extension_id") or "") in candidates] + candidate_ids = set(candidates) + ordered_records = sorted( + [record for record in published_records if str(record.get("extension_id") or "") in candidate_ids], + key=lambda record: candidates.index(str(record.get("extension_id") or "")), ) + catalog = _build_catalog(ordered_records) if not catalog["extensions"]: return LightweightIntentResolution(None, None, {}, {}, None, 0.0, False, "no_trigger", candidates) hints = list(job_file_hints or [])[:MAX_JOB_FILE_HINTS] @@ -322,14 +376,11 @@ def resolve_lightweight_extension_intent( ) extension_id = sanitized["extension_id"] - selected = next( - (item for item in catalog["extensions"] if item.get("extension_id") == extension_id), - None, - ) - extension_name = extension_id - if selected is not None: - first_sentence = re.split(r"[。.!?;;]", str(selected.get("function_summary") or ""))[0].strip() - extension_name = first_sentence[:60] or extension_id + # P2-4: extension_name 用 extension_id 的紧凑形式(去尾缀时间戳),不再用英文摘要句。 + extension_name = compact_extension_name(extension_id) + reason = "ok" + if catalog.get("truncated"): + reason = "ok; 候选过多已截断,请补充工具名" return LightweightIntentResolution( extension_id=extension_id, extension_name=extension_name, @@ -341,6 +392,6 @@ def resolve_lightweight_extension_intent( sanitized["confidence"] >= PREFILL_THRESHOLD and (sanitized["input_overrides"] or sanitized["parameter_overrides"]) ), - reason="ok", + reason=reason, candidates_considered=candidates, )