diff --git a/pipeline_bidding/bid_ability.py b/pipeline_bidding/bid_ability.py index 920d842..0a5837e 100644 --- a/pipeline_bidding/bid_ability.py +++ b/pipeline_bidding/bid_ability.py @@ -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=工作空间路径) 登记启动解析;系统对账器也会 + 自动登记工作空间里的招标书。同一事项的人类待办只发一次,禁止重复催。""" # ══════════════════ 角色集 ══════════════════ diff --git a/pipeline_bidding/bid_common.py b/pipeline_bidding/bid_common.py index 2e50c37..56200f2 100644 --- a/pipeline_bidding/bid_common.py +++ b/pipeline_bidding/bid_common.py @@ -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, diff --git a/pipeline_bidding/bid_flow.py b/pipeline_bidding/bid_flow.py index bd40262..1c18a88 100644 --- a/pipeline_bidding/bid_flow.py +++ b/pipeline_bidding/bid_flow.py @@ -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人工介入),本轮不派发"] diff --git a/pipeline_bidding/bid_tender_capability.py b/pipeline_bidding/bid_tender_capability.py index 57a12ec..b93df34 100644 --- a/pipeline_bidding/bid_tender_capability.py +++ b/pipeline_bidding/bid_tender_capability.py @@ -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: