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