修复轻量拓展分析 NL 触发第一轮测试反馈(PM 裁断)

- 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 后缀;多路径先整体校验后复制
This commit is contained in:
potaser
2026-08-27 00:21:31 +08:00
parent a60f457541
commit db610031c1
2 changed files with 114 additions and 32 deletions
+41 -10
View File
@@ -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 "",
}
@@ -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,
)