fix(投标): 招标书在工作空间还反复催上传根治+同一事项待办只发一次

- 对账器0b: 无bid_tender_files记录时先扫工作空间,发现招标书自动import_tender_file登记启动解析(与上传入口等价),不再发上传待办
- request_tender_file硬门禁: 已有文件记录拒绝发待办; pending存在返回已有id
- create_human_task机制层去重: 同项目+同类型pending存在→返回已有id不重复创建
- prompt/工具描述同步: 文件已在工作空间/已登记时禁止发上传待办
This commit is contained in:
yumoqing 2026-09-01 17:28:18 +08:00
parent ceeec055a3
commit b7bbee6563
4 changed files with 96 additions and 16 deletions

View File

@ -33,7 +33,7 @@ BID_TOOLS = [
parameters={"name": "项目名称", "description": "项目描述(可选)",
"tender_content": "招标文件正文(用户已提供时必填,直接导入跳过上传步骤)",
"tender_file_name": "招标文件名(可选)"}, category="project"),
ToolDefinition(name="request_tender_file", description="发布/重发「等待上传招标文件」人类任务",
ToolDefinition(name="request_tender_file", description="仅当项目确实没有任何招标文件且需要人工上传时才发「等待上传」人类待办(同一事项只发一次)。若文件已在工作空间/已登记,禁止调用——用 import_tender_file 登记",
parameters={"note": "说明(可选)"}, category="project"),
ToolDefinition(name="list_tender_files", description="查看已上传的招标文件及抽取状态", parameters={}, category="project"),
@ -122,7 +122,10 @@ BID_PROMPT = """你是「投标产线」的驾驶舱 agent负责从招标文
抽取不到就如实说"文件未载明"必要时 ask_user
- 标书内容的案例与业绩必须来自公司投标知识库的真实合同bid_kb_docs doc_type=case
禁止虚构客户名合同金额历史成果数字
- 章节没全部评审通过不允许合成标书评分没过门限不允许当作可交付"""
- 章节没全部评审通过不允许合成标书评分没过门限不允许当作可交付
- **招标文件已在工作空间/已登记list_tender_files 非空禁止发上传待办**
直接 import_tender_file(file_path=工作空间路径) 登记启动解析系统对账器也会
自动登记工作空间里的招标书同一事项的人类待办只发一次禁止重复催"""
# ══════════════════ 角色集 ══════════════════

View File

@ -284,7 +284,17 @@ async def has_pending_blocking_human_task(sor, project_id):
async def create_human_task(sor, project_id, task_type, title, description="",
assignee_role="owner.superuser", assignee_id="",
form_schema=None):
"""创建人类任务pipeline_human_tasks。iteration_id 留空 → 走项目级门禁。"""
"""创建人类任务pipeline_human_tasks。iteration_id 留空 → 走项目级门禁。
同一事项只发一次待办2026-09-01 机制层不变量同项目+同类型已有
pending 任务 直接返回已有 id不重复创建防止对账器/agent 每轮
重复发同类待办把用户淹没
"""
exist = await find_human_task(sor, project_id, task_type, status="pending")
if exist:
logger.info("bid create_human_task dedup: type=%s existing=%s project=%s",
task_type, exist.get("id"), project_id)
return exist.get("id")
hid = new_id()
await sor.C("pipeline_human_tasks", {
"id": hid,

View File

@ -15,6 +15,7 @@ RoleSpec.next_role 全部留空:投标产线不用「任务链跳下一角色
import asyncio
import json
import logging
import os
from .bid_common import (
get_db, rows_to_dicts, rec_to_dict, to_int, to_float, json_loads,
@ -204,6 +205,48 @@ async def _qc_collect_improvements(sor, project_id):
return items
async def _auto_import_workspace_tender(sor, project_id):
"""工作空间已有招标书文件但 bid_tender_files 无记录 → 自动注册并启动解析。
根治2026-09-01用户把招标书直接放进项目工作空间 agent 落盘
旧逻辑只认 bid_tender_files 表记录对账器每轮都发上传招标文件人类待办
用户怎么答文件就在目录里都没用死循环现在对账器主动扫描工作空间
发现招标书即 import_tender_file 注册落库+自动关闭上传待办+收敛器派解析
与上传入口完全等价不再催人
"""
try:
from pipeline_service.workspace import get_project_dir_by_id
pdir, _base = await get_project_dir_by_id(sor, project_id)
if not pdir or not os.path.isdir(pdir):
return ""
cands = []
for fn in sorted(os.listdir(pdir)):
ext = os.path.splitext(fn)[1].lower()
if ext not in (".docx", ".doc", ".pdf", ".txt", ".md"):
continue
fp = os.path.join(pdir, fn)
if os.path.isfile(fp) and os.path.getsize(fp) > 0:
cands.append((os.path.getsize(fp), fp, fn))
if not cands:
return ""
cands.sort(reverse=True) # 最大文件优先(招标书通常是项目里最大的文档)
_sz, fp, fn = cands[0]
from .bid_tender_capability import import_tender_file
ok, msg = await import_tender_file(
project_id, file_path=fp, file_name=fn,
who="system.reconciler", agent_id="bid_flow")
if ok:
logger.info("bid_flow: auto-imported workspace tender %s project=%s",
fn, project_id)
return "auto_imported: %s%s" % (fn, msg)
logger.warning("bid_flow: auto-import tender failed project=%s: %s",
project_id, str(msg)[:160])
except Exception as e:
logger.warning("bid_flow: auto-import workspace tender error project=%s: %s",
project_id, str(e)[:160])
return ""
async def reconcile_project(sor, project_id, project_name=""):
"""对单个投标项目做一次状态对账。返回动作说明列表(幂等,可反复调用)。"""
acts = []
@ -235,21 +278,27 @@ async def reconcile_project(sor, project_id, project_name=""):
# ── 0b. 招标文件到位则自动关闭上传任务,再判阻塞门禁 ──
await _auto_close_file_upload_task(sor, project_id)
# 产线起点兜底:项目无任何招标文件且无待办上传任务 → 自动发「上传招标文件」任务
# (无论项目经 create_bid_project 还是通用 create_project 创建,都从上传文件起步)
# 产线起点兜底:项目无招标文件记录时——
# ① 先扫工作空间:招标书已在项目目录里就直接自动注册并启动解析(根治
# 「文件在目录里还反复催上传」死循环2026-09-01不发待办
# ② 真没有且无 pending 上传待办才发一次(同一事项 pending 期间不重发)。
_n_files_pre = await _count(sor, "SELECT COUNT(*) AS c FROM bid_tender_files "
"WHERE project_id=${p}$", {"p": project_id})
if _n_files_pre <= 0:
_up_ht = await find_human_task(sor, project_id, HT_TENDER_FILE, status="pending")
if not _up_ht:
p = await get_project(sor, project_id)
hid = await create_human_task(
sor, project_id, HT_TENDER_FILE,
"上传招标文件:%s" % (p.get("name", "") or project_id),
("请上传招标文件(含答疑/补遗文件)。\n"
"上传后系统自动进入招标文件解析(抽取评分项、得分规则、所需资质、投标文件要求、章节骨架)。"))
await sor.sqlExe("COMMIT", {})
return acts + ["created: 上传招标文件任务 %s" % hid]
_imp = await _auto_import_workspace_tender(sor, project_id)
if _imp:
acts.append(_imp)
else:
_up_ht = await find_human_task(sor, project_id, HT_TENDER_FILE, status="pending")
if not _up_ht:
p = await get_project(sor, project_id)
hid = await create_human_task(
sor, project_id, HT_TENDER_FILE,
"上传招标文件:%s" % (p.get("name", "") or project_id),
("请上传招标文件(含答疑/补遗文件)。\n"
"上传后系统自动进入招标文件解析(抽取评分项、得分规则、所需资质、投标文件要求、章节骨架)。"))
await sor.sqlExe("COMMIT", {})
return acts + ["created: 上传招标文件任务 %s" % hid]
if await has_pending_blocking_human_task(sor, project_id):
return ["blocked: 有待办人类任务(等招标文件/等人类资料/等QC人工介入本轮不派发"]

View File

@ -238,12 +238,22 @@ async def list_members(project_id):
async def request_tender_file(project_id, note="", who=None, agent_id=None):
"""(重新)发布「等待上传招标文件」人类任务。"""
"""(重新)发布「等待上传招标文件」人类任务。
硬门禁2026-09-01同一事项只发一次待办
- 项目已有招标文件记录bid_tender_files 文件已到位**拒绝发待办**
提示用 list_tender_files 查看/解析会自动进行文件已在还催人上传
- 已有 pending 上传待办 返回已有任务 id不重复创建
"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
p = await get_project(sor, project_id)
if not p:
return False, "项目不存在: %s" % project_id
n = await _count_files(sor, project_id)
if n > 0:
return False, ("项目已有 %d 个招标文件记录,无需再发上传待办"
"list_tender_files 查看,解析自动进行)" % n)
exist = await find_human_task(sor, project_id, HT_TENDER_FILE, status="pending")
if exist:
return True, "已有待办上传任务: %s" % exist.get("id")
@ -255,6 +265,14 @@ async def request_tender_file(project_id, note="", who=None, agent_id=None):
return True, "已发布上传招标文件任务: %s" % hid
async def _count_files(sor, project_id):
recs = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_tender_files WHERE project_id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
return to_int(getattr(recs[0], "c", 0) if recs else 0)
async def list_tender_files(project_id):
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor: