pipeline-bidding/pipeline_bidding/bid_kb_capability.py

315 lines
16 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 "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]))