"""投标知识库与资质能力:检索公司投标知识库、匹配所需资质、发布人类应提供的文件清单。 对应业务:从公司投标知识库中获取招标所需资质文档;发布人类应提供的文件(清单式人类任务,阻塞门禁)。 """ import json import logging from .bid_common import ( get_db, new_id, rec_to_dict, rows_to_dicts, to_int, to_float, record, create_human_task, find_human_task, get_project, resolve_project_id, HT_HUMAN_DOCS, ) logger = logging.getLogger("pipeline.bidding.kb") # ══════════════ 知识库 ══════════════ async def search_bid_kb(query="", doc_type="", org_id="", limit=10): """检索公司投标知识库(按名称/标签/编号/正文关键词)。返回命中文档摘要列表。""" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: sql = ("SELECT id, doc_type, doc_name, doc_no, tags, issuer, customer, " "contract_amount, valid_until, file_path, LEFT(content_text, 300) AS preview " "FROM bid_kb_docs WHERE status='active'") p = {} if org_id: sql += " AND org_id=${oid}$" p["oid"] = org_id if doc_type: sql += " AND doc_type=${dt}$" p["dt"] = doc_type q = (query or "").strip() if q: sql += (" AND (doc_name LIKE ${kw}$ OR tags LIKE ${kw}$ OR doc_no LIKE ${kw}$ " "OR issuer LIKE ${kw}$ OR customer LIKE ${kw}$ OR content_text LIKE ${kw}$)") p["kw"] = "%" + q + "%" sql += " ORDER BY doc_type, created_at DESC LIMIT " + str(to_int(limit, 10)) recs = await sor.sqlExe(sql, p) await sor.sqlExe("COMMIT", {}) return rows_to_dicts(recs) async def list_kb_docs(doc_type="", org_id="", limit=100): """列出知识库文档(不含正文)。""" return await search_bid_kb(query="", doc_type=doc_type, org_id=org_id, limit=limit) async def kb_doc_detail(kb_doc_id, length=6000): """取知识库文档正文(供撰写/资质引用)。""" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe("SELECT * FROM bid_kb_docs WHERE id=${kid}$", {"kid": kb_doc_id}) await sor.sqlExe("COMMIT", {}) if not recs: return "知识库文档不存在: %s" % kb_doc_id d = rec_to_dict(recs[0]) d["content_text"] = (d.get("content_text") or "")[:to_int(length, 6000)] return json.dumps(d, ensure_ascii=False, default=str) async def add_kb_doc(doc_name, doc_type="qualification", doc_no="", tags="", file_path="", content_text="", issuer="", customer="", contract_amount="", valid_from="", valid_until="", org_id="0", who=None, agent_id=None): """录入知识库文档(资质证书/业绩案例/模板/财务/人员)。""" if not doc_name: return False, "缺少 doc_name" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: kid = new_id() data = { "id": kid, "org_id": org_id or "0", "doc_type": (doc_type or "qualification")[:30], "doc_name": str(doc_name)[:250], "doc_no": str(doc_no or "")[:60], "tags": str(tags or "")[:480], "file_path": str(file_path or "")[:480], "content_text": content_text or "", "issuer": str(issuer or "")[:250], "customer": str(customer or "")[:250], "status": "active", "created_by": who or "", } amt = to_float(contract_amount, 0.0) if amt > 0: data["contract_amount"] = amt if valid_from: data["valid_from"] = str(valid_from)[:10] if valid_until: data["valid_until"] = str(valid_until)[:10] await sor.C("bid_kb_docs", data) await sor.sqlExe("COMMIT", {}) return True, "知识库文档已录入: %s (%s)" % (doc_name, kid) # ══════════════ 资质匹配 ══════════════ async def list_qualifications(project_id, match_status=""): try: project_id = await resolve_project_id(project_id) except ValueError as e: return "ERROR: 项目上下文缺失(%s)。请显式传 project_id 后重试。" % e db, dbname = get_db() async with db.sqlorContext(dbname) as sor: sql = ("SELECT id, qual_name, is_mandatory, owner, match_status, kb_doc_id, " "file_path, note, requirement FROM bid_qualifications WHERE project_id=${pid}$") p = {"pid": project_id} if match_status: sql += " AND match_status=${st}$" p["st"] = match_status sql += " ORDER BY is_mandatory DESC, created_at" recs = await sor.sqlExe(sql, p) await sor.sqlExe("COMMIT", {}) out = rows_to_dicts(recs) for r in out: r["requirement"] = (r.get("requirement") or "")[:400] return out async def match_qualification(qual_id, kb_doc_id, note="", who=None, agent_id=None): """把知识库文档挂到资质项上:match_status=matched。""" if not (qual_id and kb_doc_id): return False, "缺少 qual_id 或 kb_doc_id" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: kb = await sor.sqlExe( "SELECT id, doc_name, file_path, valid_until FROM bid_kb_docs WHERE id=${kid}$", {"kid": kb_doc_id}) await sor.sqlExe("COMMIT", {}) if not kb: return False, "知识库文档不存在: %s" % kb_doc_id d = rec_to_dict(kb[0]) await sor.sqlExe( "UPDATE bid_qualifications SET kb_doc_id=${kid}$, match_status='matched', " "owner='kb', file_path=${fp}$, note=${nt}$, updated_at=NOW() WHERE id=${qid}$", {"kid": kb_doc_id, "fp": (d.get("file_path") or "")[:480], "nt": (note or ("命中知识库:" + (d.get("doc_name") or "")))[:480], "qid": qual_id}) rec = await sor.sqlExe( "SELECT project_id, qual_name, match_status FROM bid_qualifications WHERE id=${qid}$", {"qid": qual_id}) await sor.sqlExe("COMMIT", {}) if not rec: return False, "资质项不存在: %s" % qual_id r = rec_to_dict(rec[0]) if r.get("match_status") != "matched": return False, "资质匹配写入失败" await record(sor, r.get("project_id"), "bid_qualifications", qual_id, "match", to_state="matched", who=who, agent_id=agent_id, detail="kb_doc=" + kb_doc_id) return True, "资质「%s」已匹配知识库文档「%s」" % (r.get("qual_name"), d.get("doc_name")) async def mark_qualification_missing(qual_id, note="", need_human="1", who=None, agent_id=None): """知识库无此资质:标记 missing 或 human_required(需人类提供)。""" st = "human_required" if str(need_human) in ("1", "true", "True", "是") else "missing" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: await sor.sqlExe( "UPDATE bid_qualifications SET match_status=${st}$, owner='human', " "note=${nt}$, updated_at=NOW() WHERE id=${qid}$", {"st": st, "nt": (note or "")[:480], "qid": qual_id}) rec = await sor.sqlExe( "SELECT project_id, qual_name FROM bid_qualifications WHERE id=${qid}$", {"qid": qual_id}) await sor.sqlExe("COMMIT", {}) if not rec: return False, "资质项不存在: %s" % qual_id r = rec_to_dict(rec[0]) await record(sor, r.get("project_id"), "bid_qualifications", qual_id, "mark_missing", to_state=st, who=who, agent_id=agent_id, detail=note) return True, "资质「%s」标记为 %s" % (r.get("qual_name"), st) async def request_human_docs(project_id, items="", note="", who=None, agent_id=None): """发布「人类应提供的文件」清单人类任务(阻塞门禁)。 items 为空时自动汇总 match_status in (missing, human_required) 的资质项。 """ try: project_id = await resolve_project_id(project_id) except ValueError as e: return False, str(e) 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 exist = await find_human_task(sor, project_id, HT_HUMAN_DOCS, status="pending") if exist: return True, "已有待办的人类文件清单任务: %s" % exist.get("id") lines = [] if items: if isinstance(items, str): try: parsed = json.loads(items) except (ValueError, TypeError): parsed = [x.strip() for x in items.split(",") if x.strip()] else: parsed = items for x in (parsed or []): if isinstance(x, dict): lines.append("- %s:%s" % (x.get("name", ""), x.get("note", ""))) else: lines.append("- %s" % x) else: recs = await sor.sqlExe( "SELECT qual_name, requirement, is_mandatory, note FROM bid_qualifications " "WHERE project_id=${pid}$ AND match_status IN ('missing','human_required') " "ORDER BY is_mandatory DESC", {"pid": project_id}) await sor.sqlExe("COMMIT", {}) for r in (recs or []): d = rec_to_dict(r) lines.append("- %s%s:%s" % (d.get("qual_name", ""), "(必需)" if str(d.get("is_mandatory")) == "1" else "", (d.get("note") or d.get("requirement") or "")[:200])) if not lines: return True, "无需人类提供的文件(资质均已从知识库匹配)" desc = ("以下资质/证明文件公司知识库中缺失,请人工提供原件扫描件并上传到项目工作空间:" "\n" + "\n".join(lines)) if note: desc += "\n\n补充说明:" + note desc += "\n\n提供完毕后请完成本任务,系统将自动继续投标文件编写。" hid = await create_human_task( sor, project_id, HT_HUMAN_DOCS, "需人工提供的投标资质文件(%d 项)" % len(lines), desc, assignee_role="owner.superuser", form_schema={"project_id": project_id, "count": len(lines)}) await sor.sqlExe( "UPDATE bid_qualifications SET human_task_id=${hid}$ WHERE project_id=${pid}$ " "AND match_status IN ('missing','human_required')", {"hid": hid, "pid": project_id}) await sor.sqlExe("COMMIT", {}) return True, "已发布人类文件清单任务 %s(%d 项)" % (hid, len(lines)) async def auto_match_qualifications(project_id, org_id="", who=None, agent_id=None): """批量自动匹配:对 pending 资质项按名称关键词检索知识库,命中即 matched,否则 human_required。""" try: project_id = await resolve_project_id(project_id) except ValueError as e: return False, str(e) quals = await list_qualifications(project_id, match_status="pending") if not quals: return True, "无待匹配资质项" matched, missing = 0, 0 for q in quals: name = (q.get("qual_name") or "").strip() if not name: continue hits = await search_bid_kb(query=name, org_id=org_id, limit=3) if not hits: key = name.replace("证书", "").replace("认证", "").strip()[:8] if key: hits = await search_bid_kb(query=key, org_id=org_id, limit=3) if hits: ok, _ = await match_qualification(q["id"], hits[0]["id"], note="自动匹配:" + (hits[0].get("doc_name") or ""), who=who, agent_id=agent_id) matched += 1 if ok else 0 else: await mark_qualification_missing(q["id"], note="知识库未命中,需人工提供", need_human="1", who=who, agent_id=agent_id) missing += 1 return True, ("资质自动匹配完成:命中知识库 %d 项,需人工提供 %d 项。" "请对需人工提供的项调用 request_human_docs 发布清单。" % (matched, missing)) async def extract_writing_spec(project_id, kb_doc_id="", note="", who=None, agent_id=None): """PM 从样板标书(bid_kb_docs doc_type=sample_bid)抽取编写规范并落库。 抽取结果写入项目级「编写规范」记录(bid_doc_requirements req_type=writing_spec), 供写者章节编写与评审参照:章节结构惯例/篇幅口径/表格图表示例/商务标要素清单。 本工具只负责落库框架,具体规范条目由 PM(LLM)读样板标书内容后逐条给出。 """ try: project_id = await resolve_project_id(project_id) except ValueError as e: return False, str(e) db, dbname = get_db() async with db.sqlorContext(dbname) as sor: if kb_doc_id: recs = await sor.sqlExe( "SELECT id, doc_name, doc_type, LEFT(content_text, 8000) AS content_text " "FROM bid_kb_docs WHERE id=${i}$", {"i": kb_doc_id}) await sor.sqlExe("COMMIT", {}) if not recs: return False, "样板标书不存在: %s" % kb_doc_id doc = rec_to_dict(recs[0]) if (doc.get("doc_type") or "") != "sample_bid": return False, ("该文档类型是 %s 不是 sample_bid;" "请先用 add_kb_doc(doc_type=sample_bid) 录入样板标书" % doc.get("doc_type")) else: recs = await sor.sqlExe( "SELECT id, doc_name, doc_type, LEFT(content_text, 8000) AS content_text " "FROM bid_kb_docs WHERE doc_type='sample_bid' ORDER BY created_at DESC LIMIT 1", {}) await sor.sqlExe("COMMIT", {}) if not recs: return False, ("知识库无样板标书(doc_type=sample_bid)。" "请人工提供一份中标样板标书,用 add_kb_doc(doc_type=sample_bid) 录入后重试。") doc = rec_to_dict(recs[0]) # 落一条 writing_spec 记录(幂等:同项目同样板只留最新) await sor.sqlExe( "DELETE FROM bid_doc_requirements WHERE project_id=${pid}$ AND req_type='writing_spec'", {"pid": project_id}) await sor.C("bid_doc_requirements", { "id": new_id(), "project_id": project_id, "req_type": "writing_spec", "chapter_no": "0", "chapter_title": "编写规范(样板抽取:%s)" % (doc.get("doc_name") or "")[:60], "requirement": (note or "")[:20000] or "(PM 抽取规范见任务交付说明)", "source_ref": doc.get("id") or "", }) await sor.sqlExe("COMMIT", {}) return True, ("已绑定样板标书《%s》并初始化编写规范记录。请通读其内容," "抽取:①章节结构惯例 ②篇幅口径(每类章节字数/页数)③表格图表示例 " "④商务标要素清单(投标函/报价/资质证明/承诺),写入本任务交付说明," "供写者与评审参照。样板正文预览:\n%s" % (doc.get("doc_name"), (doc.get("content_text") or "")[:3000]))