pipeline-bidding/pipeline_bidding/bid_kb_capability.py
yumoqing 19a3d67c68 feat: 投标产线模块(bidding_general)
- 12张 bid_* 表(models+DDL+CRUD):招标信息/邮箱配置/人员/招标文件/评分项/资质/
  投标文件要求/章节/评审/评分/知识库/标书
- 7个角色能力模块(bid_*_capability):采集摘要/解析/知识库资质/编写/评审/合成/评分
- bid_flow 状态对账器:角色任务唯一创建者+阻塞门禁(源头守门,引擎零特判)
- bid_mail:阿里云企业邮 IMAP 采集(凭据存DB AES,客户端专用密码兼容)
- 能力包注册(bid_ability)+ 角色工具自注册(role_tool_schemas)
- 技能:6 common 状态机/规范 + 9 角色技能(pipelines/bidding_general,随 pipeline-core 部署)
- i18n(zh/en)、init/data.json(appcodes+pipelines记录)、build.sh、RBAC load_path
2026-08-26 23:45:03 +08:00

264 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""投标知识库与资质能力:检索公司投标知识库、匹配所需资质、发布人类应提供的文件清单。
对应业务:从公司投标知识库中获取招标所需资质文档;发布人类应提供的文件(清单式人类任务,阻塞门禁)。
"""
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 []
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))