fix(bidding): 解析读不到完整招标书根治——最优文件选择+路径解析+升级自动恢复

- extract/read 选最优记录: 磁盘文件大小与正文长度取大, 替代固定取最老
- 相对/裸文件名按项目根目录解析, 解析后回写绝对路径
- bid_flow: 新招标文件到来自动重置解析计数+清旧产出残留+关旧人工任务(防永久卡死)
- 主 agent 补 reset_task_retry 工具(待办文案承诺但一直未实现)
This commit is contained in:
yumoqing 2026-09-01 09:26:45 +08:00
parent d377d76f61
commit e387c7c5ce
3 changed files with 223 additions and 31 deletions

View File

@ -76,6 +76,8 @@ BID_TOOLS = [
parameters={}, category="agent"),
ToolDefinition(name="advance_bid", description="立即触发投标流程状态对账(补派应有的角色任务,通常无需手动调用)",
parameters={}, category="agent"),
ToolDefinition(name="reset_task_retry", description="人工处理完失败任务后恢复重试:把失败/挂起的任务清零重试计数并重新执行",
parameters={"task_id": "任务ID从diagnose_bid看到的失败任务"}, category="agent"),
]
# ══════════════════ 产线 prompt 片段 ══════════════════
@ -380,6 +382,19 @@ async def _h_advance_bid(sor, p, ctx):
return "对账结果:\n" + "\n".join("- " + a for a in (acts or ["无动作"]))
async def _h_reset_task_retry(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
tid = str(p.get("task_id") or "").strip()
if not tid:
return "ERROR: 缺少 task_id"
from pipeline_service.task_capability import reset_task_retry
ok, msg = await reset_task_retry(tid, pid, who="agent.main_agent",
agent_id=ctx.get("agent_id", "") or "")
return _fmt(ok, "任务已恢复,对账器将重新派发" if ok else msg)
BID_HANDLERS = {
"create_bid_project": _h_create_bid_project,
"import_tender_file": _h_import_tender_file,
@ -400,6 +415,7 @@ BID_HANDLERS = {
"bid_score_report": _h_bid_score_report,
"diagnose_bid": _h_diagnose_bid,
"advance_bid": _h_advance_bid,
"reset_task_retry": _h_reset_task_retry,
}

View File

@ -6,6 +6,7 @@
import json
import logging
import os
from .bid_common import (
get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int, json_loads,
@ -78,7 +79,11 @@ async def list_tender_files(project_id, extract_status=""):
async def read_tender_file(file_id, offset=0, length=20000):
"""读招标文件正文(分段读,供 LLM 分析)。正文为空时尝试从磁盘提取。"""
"""读招标文件正文(分段读,供 LLM 分析)。正文为空时尝试从磁盘提取。
磁盘提取支持相对路径裸文件名/相对路径按项目根目录解析
上传时可能只存了文件名2026-09-01 修复
"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
@ -90,11 +95,14 @@ async def read_tender_file(file_id, offset=0, length=20000):
d = rec_to_dict(recs[0])
txt = d.get("content_text") or ""
if not txt.strip() and d.get("file_path"):
txt = _extract_file_text(d["file_path"])
pdir = await _project_dir_of(sor, d.get("project_id", ""))
real = _resolve_file_path(pdir, d["file_path"])
txt = _extract_file_text(real) if real else ""
if txt:
await sor.sqlExe(
"UPDATE bid_tender_files SET content_text=${c}$, updated_at=NOW() "
"WHERE id=${fid}$", {"c": txt[:2000000], "fid": file_id})
"UPDATE bid_tender_files SET content_text=${c}$, file_path=${p}$, "
"updated_at=NOW() WHERE id=${fid}$",
{"c": txt[:2000000], "p": real, "fid": file_id})
await sor.sqlExe("COMMIT", {})
if not txt.strip():
return ("招标文件正文为空file_path=%s)。请确认文件已上传且格式为 docx/pdf/txt。"
@ -142,6 +150,93 @@ def _extract_file_text(path):
return ""
async def _project_dir_of(sor, project_id):
"""项目根目录(供相对路径解析)。解析口径与引擎上传落盘一致:
sd_projects.workspace_dir 优先为空时按 {base}/{org}/{pipeline}/{项目名} 兜底
"""
if not project_id:
return ""
try:
recs = await sor.sqlExe(
"SELECT name, org_id, pipeline_id, workspace_dir FROM sd_projects "
"WHERE id=${p}$ LIMIT 1", {"p": project_id})
if not recs:
return ""
r = recs[0]
ws = (getattr(r, 'workspace_dir', '') or '').strip()
if ws.startswith('/'):
return ws
from pipeline_service.workspace import get_workspace_base, build_workspace_path
base = await get_workspace_base(sor)
name = (getattr(r, 'name', '') or '').strip()
if not base or not name:
return ""
return build_workspace_path(base, getattr(r, 'org_id', '0') or '0',
getattr(r, 'pipeline_id', '') or 'general', name)
except Exception as e:
logger.warning("project dir resolve failed pid=%s: %s", project_id, str(e)[:120])
return ""
def _resolve_file_path(project_dir, file_path):
"""把存储的 file_path 解析成磁盘绝对路径。
- 绝对路径且存在原样返回
- 相对/裸文件名依次尝试 项目根目录项目根目录下的 docs/进程 cwd存在即返回
- 都找不到返回 ''
"""
fp = str(file_path or "").strip()
if not fp:
return ""
if os.path.isabs(fp):
return fp if os.path.isfile(fp) else ""
cands = []
if project_dir:
cands.append(os.path.join(project_dir, fp))
cands.append(os.path.join(project_dir, "docs", fp))
cands.append(fp)
for c in cands:
if os.path.isfile(c):
return os.path.abspath(c)
return ""
async def _best_tender_record(sor, project_id):
"""选最优招标文件记录(修复 2026-09-01「解析读旧截断稿」根因
一个项目可能有多条招标文件记录重复上传/导入选择规则
**可得正文最多者胜**正文取 len(content_text)磁盘文件取文件大小
两者取大并列时取最新返回 dict 附带 _resolved_path磁盘绝对路径提示
旧实现 ORDER BY created_at LIMIT 1 固定取最老记录最老的往往是早期
截断摘要完整文件后来才传导致解析永远读不到完整文件
只按有正文优先也不够截断稿有正文完整稿只在磁盘上会误选截断稿
"""
recs = await sor.sqlExe(
"SELECT id, project_id, file_name, file_path, content_text, created_at "
"FROM bid_tender_files WHERE project_id=${pid}$ AND file_type='tender_doc' "
"ORDER BY created_at DESC", {"pid": project_id})
if not recs:
return {}
rows = rows_to_dicts(recs, limit=50)
pdir = await _project_dir_of(sor, project_id)
best, best_score, best_real = {}, -1, ""
for r in rows:
score = len((r.get("content_text") or "").strip())
real = ""
if (r.get("file_path") or "").strip():
real = _resolve_file_path(pdir, r["file_path"])
if real:
try:
score = max(score, os.path.getsize(real))
except OSError:
pass
if score > best_score:
best, best_score, best_real = r, score, real
if best and best_real:
best["_resolved_path"] = best_real
return best
async def mark_file_extracted(file_id, note="", who=None, agent_id=None):
"""标记招标文件已抽取完成。"""
db, dbname = get_db()
@ -338,25 +433,31 @@ async def extract_from_file(project_id, file_id="", model_name="", offset=0,
recs = await sor.sqlExe(
"SELECT id, content_text, file_path, file_name FROM bid_tender_files "
"WHERE id=${fid}$", {"fid": file_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "项目下没有招标文件bid_tender_files无法抽取"
d = rec_to_dict(recs[0])
else:
recs = await sor.sqlExe(
"SELECT id, content_text, file_path, file_name FROM bid_tender_files "
"WHERE project_id=${pid}$ AND file_type='tender_doc' ORDER BY created_at LIMIT 1",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "项目下没有招标文件bid_tender_files无法抽取"
d = rec_to_dict(recs[0])
# 选最优记录:有正文优先、其次磁盘有文件,同类取最新
# 2026-09-01 修复:旧实现固定取最老记录,完整文件后来上传时永远读不到)
d = await _best_tender_record(sor, project_id)
if not d:
return False, "项目下没有招标文件bid_tender_files无法抽取"
txt = d.get("content_text") or ""
if not txt.strip() and d.get("file_path"):
txt = _extract_file_text(d["file_path"])
pdir = await _project_dir_of(sor, project_id)
real = d.get("_resolved_path") or _resolve_file_path(pdir, d["file_path"])
txt = _extract_file_text(real) if real else ""
if txt:
await sor.sqlExe(
"UPDATE bid_tender_files SET content_text=${c}$ WHERE id=${fid}$",
{"c": txt[:2000000], "fid": d["id"]})
"UPDATE bid_tender_files SET content_text=${c}$, file_path=${p}$ "
"WHERE id=${fid}$",
{"c": txt[:2000000], "p": real, "fid": d["id"]})
await sor.sqlExe("COMMIT", {})
if not txt.strip():
return False, "招标文件正文为空无法抽取file=%s" % d.get("file_name", "")
return False, ("招标文件正文为空无法抽取file=%sfile_path=%s"
"请确认文件已上传到项目目录" %
(d.get("file_name", ""), d.get("file_path", "")))
o = to_int(offset, 0)
seg = txt[o:o + to_int(length, 30000)]
data, err = await llm_json(EXTRACT_PROMPT.replace("__CONTENT__", seg),

View File

@ -53,12 +53,85 @@ async def _count(sor, sql, p):
return to_int(getattr(recs[0], "c", 0) if recs else 0)
async def _done_task_count(sor, project_id, role):
return await _count(sor,
"SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
"AND pipeline_id='role_task' AND role=${r}$ "
"AND state IN ('approved','completed')",
{"p": project_id, "r": role})
async def _done_task_count(sor, project_id, role, since=''):
sql = ("SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
"AND pipeline_id='role_task' AND role=${r}$ "
"AND state IN ('approved','completed')")
p = {"p": project_id, "r": role}
if since:
sql += " AND updated_at >= ${since}$"
p["since"] = since
return await _count(sor, sql, p)
async def _redo_done_count(sor, project_id, since=''):
sql = ("SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
"AND pipeline_id='role_task' AND role=${r}$ AND state IN "
"('approved','completed') AND params LIKE ${kw}$")
p = {"p": project_id, "r": R_ANALYST, "kw": "%qc_redo%"}
if since:
sql += " AND updated_at >= ${since}$"
p["since"] = since
return await _count(sor, sql, p)
async def _latest_tender_file_ts(sor, project_id):
"""项目最新招标文件的 created_at无文件返回 '')。"""
recs = await sor.sqlExe(
"SELECT created_at FROM bid_tender_files WHERE project_id=${p}$ "
"ORDER BY created_at DESC LIMIT 1", {"p": project_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return ''
return str(getattr(recs[0], "created_at", "") or "")
async def _latest_done_analysis_ts(sor, project_id):
"""项目最后一次已完成解析任务的 updated_at无则返回 '')。"""
recs = await sor.sqlExe(
"SELECT updated_at FROM pipeline_tasks WHERE tenant_id=${p}$ "
"AND pipeline_id='role_task' AND role=${r}$ AND state IN "
"('approved','completed') ORDER BY updated_at DESC LIMIT 1",
{"p": project_id, "r": R_ANALYST})
await sor.sqlExe("COMMIT", {})
if not recs:
return ''
return str(getattr(recs[0], "updated_at", "") or "")
async def _reset_analysis_for_new_file(sor, project_id, since_ts):
"""新招标文件到来 → 清空旧文件时代的解析残留,让新文件干净重跑(幂等)。
清空范围四类产出物评分项/资质/投标文件要求/章节骨架旧文件抽的对新文件
无意义+ 早于新文件的 QC 审核记录旧改进意见不能带入新文件解析
顺手关闭旧解析未产出完整结果人工介入任务自动恢复即处理完毕
"""
# 仅当存在早于新文件的 QC 记录时执行(说明此前有过旧解析,属恢复场景)
recs = await sor.sqlExe(
"SELECT id FROM bid_qc_reviews WHERE project_id=${p}$ "
"AND created_at < ${ts}$ LIMIT 1", {"p": project_id, "ts": since_ts})
await sor.sqlExe("COMMIT", {})
if not recs:
return False
for tbl in ("bid_scoring_items", "bid_qualifications", "bid_doc_requirements",
"bid_chapters"):
await sor.sqlExe(
"DELETE FROM %s WHERE project_id=${p}$" % tbl, {"p": project_id})
await sor.sqlExe("COMMIT", {})
await sor.sqlExe(
"DELETE FROM bid_qc_reviews WHERE project_id=${p}$ AND created_at < ${ts}$",
{"p": project_id, "ts": since_ts})
await sor.sqlExe("COMMIT", {})
ht = await find_human_task(sor, project_id, "general", status="pending")
if ht and "解析" in (ht.get("title") or ""):
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='done', qc_status='passed', "
"qc_comment='已上传新招标文件,系统自动重新解析(旧文件残留产出已清空)', "
"submitted_at=NOW() WHERE id=${h}$", {"h": ht.get("id")})
await sor.sqlExe("COMMIT", {})
logger.info("bid_flow: reset analysis outputs for new tender file project=%s",
project_id)
return True
async def _auto_close_file_upload_task(sor, project_id):
@ -131,15 +204,6 @@ async def _qc_collect_improvements(sor, project_id):
return items
async def _redo_done_count(sor, project_id):
"""已完成的 QC 退回重做任务数(解析重做轮次计数,防无限重派)。"""
return await _count(
sor, "SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
"AND pipeline_id='role_task' AND role=${r}$ AND state IN "
"('approved','completed') AND params LIKE ${kw}$",
{"p": project_id, "r": R_ANALYST, "kw": "%qc_redo%"})
async def reconcile_project(sor, project_id, project_name=""):
"""对单个投标项目做一次状态对账。返回动作说明列表(幂等,可反复调用)。"""
acts = []
@ -229,6 +293,17 @@ async def reconcile_project(sor, project_id, project_name=""):
{"p": project_id, "r": R_ANALYST, "kw": "%qc_redo%"})
attempts = await _done_task_count(sor, project_id, R_ANALYST)
n_redo_done = await _redo_done_count(sor, project_id)
# 自动恢复2026-09-01解析升级后来了**新的**招标文件(上传时间晚于
# 最后一次解析完成)→ 清掉旧文件残留产出与旧 QC 意见,重试计数从新文件
# 时刻重起。否则项目永久卡死:旧计数 ≥ 上限,永远不再派解析
# (中传项目「走不下去」的另一半根因)。
_ft = await _latest_tender_file_ts(sor, project_id)
_at = await _latest_done_analysis_ts(sor, project_id)
if _ft and _at and _ft > _at:
if await _reset_analysis_for_new_file(sor, project_id, _ft):
acts.append("recovered: 检测到新招标文件,已清空旧解析残留,重新解析")
attempts = await _done_task_count(sor, project_id, R_ANALYST, since=_ft)
n_redo_done = await _redo_done_count(sor, project_id, since=_ft)
first_attempts = max(0, attempts - n_redo_done)
qc_imp = await _qc_collect_improvements(sor, project_id)
if qc_imp or n_redo_open > 0: