pipeline-bidding/pipeline_bidding/bid_analysis_capability.py

941 lines
45 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.

"""招标文件解析能力:抽取评分项+得分规则、所需资质、投标文件要求,生成章节骨架。
对应业务:招标文件获得后,从招标文件中抽取评分项以及得分规则、所需资质、投标文件要求。
章节骨架规则(写进 bid-doc-spec 技能):章节子项直接来自评分标准原文枚举的子维度,不是通用目录。
"""
import json
import logging
import os
from .bid_common import (
get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int, json_loads,
llm_json, record, resolve_project_id, CH_PENDING, TD_SUMMARIZED,
)
logger = logging.getLogger("pipeline.bidding.analysis")
EXTRACT_PROMPT = """你是招标文件分析师。下面是招标文件正文(可能被截断),请抽取结构化信息。
【招标文件正文】
__CONTENT__
严格只输出一个 JSON 对象:
{
"tender_facts": {
"title": "项目名称",
"purchaser": "招标人/采购人单位名称",
"tender_no": "招标编号/项目编号",
"industry": "所属行业(可选)",
"summary": "项目概况摘要,200字以内,说明采购标的、范围、主要要求",
"bid_start_at": "获取招标文件/投标开始时间 YYYY-MM-DD(可选)",
"bid_deadline": "投标截止时间 YYYY-MM-DD",
"project_period": "项目周期/工期,如 12个月 / 180日历天",
"budget_amount": "招标控制价/预算金额(仅数字,人民币元)",
"deposit_amount": "投标保证金(仅数字,人民币元)",
"qualification_req": "投标人资格/资质要求原文摘要",
"key_dates": [{"name": "节点名称", "date": "YYYY-MM-DD"}],
"risk_note": "风险提示:时间紧、资质缺口、否决项等;无则空"
},
"scoring_items": [
{"section": "business|technical|price", "item_no": "评分项编号", "item_name": "评分项名称",
"max_score": 数字满分, "scoring_rule": "得分规则原文(含档位/扣分/加分细则)",
"is_veto": "0或1(是否否决项/废标项)", "source_ref": "出处,如 第三章 评标办法 表1"}
],
"qualifications": [
{"qual_name": "资质名称", "requirement": "要求原文", "is_mandatory": "0或1"}
],
"doc_requirements": [
{"req_type": "structure|format|seal|copies|deadline|other",
"chapter_no": "章节号(structure 类必填,如 1 / 1.1)",
"chapter_title": "章节标题(structure 类必填)",
"requirement": "要求内容", "page_limit": 数字或null, "source_ref": "出处"}
]
}
要求:
1. tender_facts 是招标文件的核心商务要素(项目名称/招标人/招标编号/投标截止时间/项目周期/预算金额/保证金/资质门槛),必须优先准确抽取。
2. scoring_items 必须逐条覆盖评标办法中的每个评分项,满分数字准确;得分规则要保留原文档位/扣分/加分细则;否决项/废标条款 is_veto=1。
3. doc_requirements 中 req_type=structure 的条目就是投标文件应有的章节结构,按招标文件要求的顺序给出。
4. 只抽取正文中真实存在的内容,缺失就不要输出该字段/该条,禁止编造。
金额只填数字(如 1200000),不带单位和千分位;日期只填 YYYY-MM-DD。"""
# ── 四维度拆分抽取提示词(2026-09-02 起:解析任务拆成四个并行维度)──
PROMPT_SCORING = """你是招标文件分析师。下面是一份招标文件正文(可能被截断),本任务只负责【评分项与得分规则】这一个维度。
【招标文件正文】
__CONTENT__
严格只输出一个 JSON 对象:
{
"tender_facts": {"title": "项目名称", "budget_amount": "预算金额数字", "risk_note": "评标相关风险提示,无则空"},
"scoring_items": [
{"section": "business|technical|price", "item_no": "评分项编号", "item_name": "评分项名称",
"max_score": 数字满分, "scoring_rule": "得分规则原文(含档位/扣分/加分细则)",
"is_veto": "0或1(是否否决项/废标项)", "source_ref": "出处,如 第三章 评标办法 表1"}
]
}
要求:
1. 必须逐条覆盖评标办法中的每个评分项,不得漏项、不得编造;满分数字与原文一致。
2. 得分规则保留原文档位/扣分/加分细则,不要概括失真。
3. 否决项/废标条款 is_veto=1,漏标视为致命缺陷。
4. 只输出评分维度相关内容,不要输出资质/文件要求/章节等其他维度。
5. 招标文件里没有评标办法就输出 {"tender_facts": {}, "scoring_items": []},禁止编造。"""
PROMPT_QUALS = """你是招标文件分析师。下面是一份招标文件正文(可能被截断),本任务只负责【资质清单与要求】这一个维度。
【招标文件正文】
__CONTENT__
严格只输出一个 JSON 对象:
{
"tender_facts": {"title": "项目名称", "qualification_req": "投标人资格/资质要求原文摘要"},
"qualifications": [
{"qual_name": "资质名称", "requirement": "要求原文", "is_mandatory": "0或1(是否强制)"}
]
}
要求:
1. 资格要求中的每项资质/证书/业绩门槛/体系认证全部录入,无漏项、无编造。
2. 要求描述与原文一致(等级/有效期/联合体口径),强制性条款 is_mandatory=1,加分项不得误标必需。
3. 只输出资质维度相关内容,不要输出评分/文件要求/章节等其他维度。
4. 招标文件里没有资质要求就输出 {"tender_facts": {}, "qualifications": []},禁止编造。"""
PROMPT_REQS = """你是招标文件分析师。下面是一份招标文件正文(可能被截断),本任务只负责【投标文件要求与章节结构】这一个维度。
【招标文件正文】
__CONTENT__
严格只输出一个 JSON 对象:
{
"doc_requirements": [
{"req_type": "structure|format|seal|copies|deadline|other",
"chapter_no": "章节号(structure 类必填,如 1 / 1.1)",
"chapter_title": "章节标题(structure 类必填)",
"requirement": "要求内容", "page_limit": 数字或null, "source_ref": "出处"}
]
}
要求:
1. req_type=structure 的条目就是投标文件应有的章节结构,按招标文件要求的顺序给出,章节号/标题准确。
2. 格式/密封/份数/截止时间等要求(format/seal/copies/deadline)无遗漏。
3. 只输出投标文件要求维度相关内容,不要输出评分/资质等其他维度。
4. 只抽取正文中真实存在的内容,禁止编造;没有就输出 {"doc_requirements": []}。"""
PROMPT_COST_BENEFIT = """你是投标商务分析专家。下面是一份招标文件正文(可能被截断),请做【投标成本与收益分析】。
【招标文件正文】
__CONTENT__
严格只输出一个 JSON 对象:
{
"items": [
{"category": "cost|benefit", "item_name": "项目名(如 投标保证金/履约保证金/人员投入成本/设备采购成本/预期收益)",
"amount": 金额数字或null, "amount_note": "金额口径说明(估算/上限/区间),估算须写清依据",
"basis": "推理依据:招标文件原文引用 + 推理过程", "confidence": "high|medium|low",
"source_ref": "出处(如 第二章 投标须知)"}
],
"summary": {"total_cost_est": 成本合计估算数字或null, "total_revenue_est": 收益合计估算数字或null,
"margin_est": "毛利空间文字结论(区间/口径说明)", "note": "关键风险与口径声明"}
}
要求:
1. 成本项:投标保证金、履约保证金、实施/运维人力成本、设备/软件采购成本、差旅/投标直接费用等,按招标要求和行业常识估算,估算口径必须写在 amount_note 和 basis 里。
2. 收益项:预算金额/招标控制价(招标文件明确给出才算,不得编造数字)。
3. 每项必须给 basis(原文引用+推理),招标文件没提的成本用行业常识估算并标 confidence=low,严禁凭空编造确定性数字。
4. summary.margin_est 是成本合计与收益合计的差额分析,口径与单项一致;算不出就写"无法估算"并说明原因。"""
# ══════════════ 招标文件读取 ══════════════
async def list_tender_files(project_id, extract_status=""):
"""列出项目的招标文件。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sql = ("SELECT id, file_name, file_type, extract_status, file_size, file_path "
"FROM bid_tender_files WHERE project_id=${pid}$")
p = {"pid": project_id}
if extract_status:
sql += " AND extract_status=${st}$"
p["st"] = extract_status
sql += " ORDER BY created_at"
recs = await sor.sqlExe(sql, p)
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)
async def read_tender_file(file_id, offset=0, length=20000):
"""读招标文件正文(分段读,供 LLM 分析)。正文为空时尝试从磁盘提取。
磁盘提取支持相对路径:裸文件名/相对路径按项目根目录解析
(上传时可能只存了文件名,2026-09-01 修复)。
"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id, project_id, file_name, file_path, content_text "
"FROM bid_tender_files WHERE id=${fid}$", {"fid": file_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return "招标文件不存在: %s" % file_id
d = rec_to_dict(recs[0])
txt = d.get("content_text") or ""
if not txt.strip() and d.get("file_path"):
pdir = await _project_dir_of(sor, d.get("project_id", ""))
real = _resolve_file_path(pdir, d["file_path"])
txt = _extract_file_text(real) if real else ""
if txt:
await sor.sqlExe(
"UPDATE bid_tender_files SET content_text=${c}$, file_path=${p}$, "
"updated_at=NOW() WHERE id=${fid}$",
{"c": txt[:2000000], "p": real, "fid": file_id})
await sor.sqlExe("COMMIT", {})
if not txt.strip():
return ("招标文件正文为空(file_path=%s)。请确认文件已上传且格式为 docx/pdf/txt。"
% d.get("file_path", ""))
o = to_int(offset, 0)
n = to_int(length, 20000)
seg = txt[o:o + n]
return ("【%s】字符 %d-%d / 共 %d\n%s"
% (d.get("file_name", ""), o, o + len(seg), len(txt), seg))
def _extract_file_text(path):
"""从 docx/pdf/txt 提取纯文本(无依赖时返回空串,不抛异常)。"""
import os
import re
import zipfile
if not path or not os.path.isfile(path):
return ""
ext = os.path.splitext(path)[1].lower()
try:
if ext in (".txt", ".md", ".csv", ".json"):
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
if ext == ".docx":
with zipfile.ZipFile(path) as z:
out = []
for name in z.namelist():
if not (name.startswith("word/") and name.endswith(".xml")):
continue
if "document" not in name and "header" not in name and "footer" not in name:
continue
xml = z.read(name).decode("utf-8", errors="ignore")
xml = xml.replace("</w:p>", "\n")
out.append("".join(re.findall(r"<w:t[^>]*>(.*?)</w:t>", xml, flags=re.S)))
return "\n".join(out)
if ext == ".pdf":
try:
import fitz
doc = fitz.open(path)
return "\n".join(pg.get_text() for pg in doc)
except ImportError:
return ""
except Exception as e:
logger.warning("extract file text failed %s: %s", path, str(e)[:120])
return ""
async def _project_dir_of(sor, project_id):
"""项目根目录(供相对路径解析)。统一走引擎 get_project_dir_by_id:
读库(创建时已写死),不再自行推导——口径漂移是上传错位的根因(2026-09-01 根治)。
"""
if not project_id:
return ""
try:
from pipeline_service.workspace import get_project_dir_by_id
pdir, _ = await get_project_dir_by_id(sor, project_id)
return pdir or ""
except Exception as e:
logger.warning("project dir resolve failed pid=%s: %s", project_id, str(e)[:120])
return ""
def _resolve_file_path(project_dir, file_path):
"""把存储的 file_path 解析成磁盘绝对路径。
- 绝对路径且存在:原样返回;
- 相对/裸文件名:依次尝试 项目根目录、项目根目录下的 docs/、进程 cwd,存在即返回;
- 都找不到返回 ''。
"""
fp = str(file_path or "").strip()
if not fp:
return ""
if os.path.isabs(fp):
return fp if os.path.isfile(fp) else ""
cands = []
if project_dir:
cands.append(os.path.join(project_dir, fp))
cands.append(os.path.join(project_dir, "docs", fp))
cands.append(fp)
for c in cands:
if os.path.isfile(c):
return os.path.abspath(c)
return ""
async def _best_tender_record(sor, project_id):
"""选最优招标文件记录(修复 2026-09-01「解析读旧截断稿」根因)。
一个项目可能有多条招标文件记录(重复上传/导入)。选择规则:
**可得正文最多者胜**——正文取 len(content_text),磁盘文件取文件大小,
两者取大;并列时取最新。返回 dict 附带 _resolved_path(磁盘绝对路径提示)。
旧实现 ORDER BY created_at LIMIT 1 固定取最老记录——最老的往往是早期
截断摘要,完整文件后来才传,导致解析永远读不到完整文件。
只按「有正文优先」也不够:截断稿有正文、完整稿只在磁盘上,会误选截断稿。
"""
recs = await sor.sqlExe(
"SELECT id, project_id, file_name, file_path, content_text, created_at "
"FROM bid_tender_files WHERE project_id=${pid}$ AND file_type='tender_doc' "
"ORDER BY created_at DESC", {"pid": project_id})
if not recs:
return {}
rows = rows_to_dicts(recs, limit=50)
pdir = await _project_dir_of(sor, project_id)
best, best_score, best_real = {}, -1, ""
for r in rows:
score = len((r.get("content_text") or "").strip())
real = ""
if (r.get("file_path") or "").strip():
real = _resolve_file_path(pdir, r["file_path"])
if real:
try:
score = max(score, os.path.getsize(real))
except OSError:
pass
if score > best_score:
best, best_score, best_real = r, score, real
if best and best_real:
best["_resolved_path"] = best_real
return best
async def mark_file_extracted(file_id, note="", who=None, agent_id=None):
"""标记招标文件已抽取完成。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
await sor.sqlExe(
"UPDATE bid_tender_files SET extract_status='extracted', extract_note=${n}$, "
"updated_at=NOW() WHERE id=${fid}$", {"n": (note or "")[:480], "fid": file_id})
await sor.sqlExe("COMMIT", {})
return True, "已标记抽取完成: %s" % file_id
# ══════════════ 抽取落库(逐条) ══════════════
async def add_scoring_item(project_id, item_name, max_score, scoring_rule="",
section="technical", item_no="", is_veto="0",
source_ref="", target_chapter_no="", weight="",
who=None, agent_id=None):
"""新增一个评分项及得分规则。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
if not item_name:
return False, "缺少 item_name"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
exist = await sor.sqlExe(
"SELECT id FROM bid_scoring_items WHERE project_id=${pid}$ AND item_name=${nm}$ LIMIT 1",
{"pid": project_id, "nm": str(item_name)[:250]})
await sor.sqlExe("COMMIT", {})
if exist:
return True, "评分项已存在: %s" % item_name
n = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_scoring_items WHERE project_id=${pid}$",
{"pid": project_id})
order_no = to_int(getattr(n[0], "c", 0) if n else 0) + 1
sid = new_id()
await sor.C("bid_scoring_items", {
"id": sid, "project_id": project_id,
"section": (section or "technical")[:30],
"item_no": str(item_no or "")[:30],
"item_name": str(item_name)[:250],
"max_score": to_float(max_score, 0.0),
"scoring_rule": scoring_rule or "",
"is_veto": "1" if str(is_veto) in ("1", "true", "True", "是") else "0",
"weight": to_float(weight, 0.0) or None,
"source_ref": str(source_ref or "")[:480],
"target_chapter_no": str(target_chapter_no or "")[:30],
"order_no": order_no,
})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_scoring_items", sid, "add",
who=who, agent_id=agent_id, detail=str(item_name)[:200])
return True, "评分项已录入: %s (%s分)" % (item_name, max_score)
async def add_qualification(project_id, qual_name, requirement="", is_mandatory="1",
owner="kb", who=None, agent_id=None):
"""新增一项招标所需资质。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
if not qual_name:
return False, "缺少 qual_name"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
exist = await sor.sqlExe(
"SELECT id FROM bid_qualifications WHERE project_id=${pid}$ AND qual_name=${nm}$ LIMIT 1",
{"pid": project_id, "nm": str(qual_name)[:250]})
await sor.sqlExe("COMMIT", {})
if exist:
return True, "资质项已存在: %s" % qual_name
qid = new_id()
await sor.C("bid_qualifications", {
"id": qid, "project_id": project_id,
"qual_name": str(qual_name)[:250],
"requirement": requirement or "",
"is_mandatory": "1" if str(is_mandatory) in ("1", "true", "True", "是") else "0",
"owner": (owner or "kb")[:15],
"match_status": "pending",
})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_qualifications", qid, "add",
who=who, agent_id=agent_id, detail=str(qual_name)[:200])
return True, "资质要求已录入: %s" % qual_name
async def add_doc_requirement(project_id, requirement, req_type="structure",
chapter_no="", chapter_title="", page_limit="",
source_ref="", who=None, agent_id=None):
"""新增一条投标文件要求(structure 类同时是章节骨架来源)。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
if not requirement:
return False, "缺少 requirement"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
exist = await sor.sqlExe(
"SELECT id FROM bid_doc_requirements WHERE project_id=${pid}$ "
"AND req_type=${rt}$ AND requirement=${rq}$ LIMIT 1",
{"pid": project_id, "rt": (req_type or "structure")[:30],
"rq": str(requirement or "")[:500]})
await sor.sqlExe("COMMIT", {})
if exist:
return True, "投标文件要求已存在: %s" % str(requirement)[:50]
n = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_doc_requirements WHERE project_id=${pid}$",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
order_no = to_int(getattr(n[0], "c", 0) if n else 0) + 1
rid = new_id()
await sor.C("bid_doc_requirements", {
"id": rid, "project_id": project_id,
"req_type": (req_type or "structure")[:30],
"chapter_no": str(chapter_no or "")[:30],
"chapter_title": str(chapter_title or "")[:250],
"requirement": requirement or "",
"page_limit": to_int(page_limit, 0) or None,
"source_ref": str(source_ref or "")[:480],
"order_no": order_no,
})
await sor.sqlExe("COMMIT", {})
return True, "投标文件要求已录入: %s %s" % (req_type, chapter_title or "")
async def _upsert_tender_facts(project_id, facts, who=None, agent_id=None):
"""把投标要素写入 bid_tenders(source=tender_doc,复用原招标信息表承载商务要素)。
无项目关联记录则新建,有则更新要素字段(幂等,可随文件重复解析覆盖)。
"""
if not facts or not isinstance(facts, dict):
return
str_fields = ("title", "purchaser", "tender_no", "industry", "summary",
"bid_start_at", "bid_deadline", "project_period",
"qualification_req", "risk_note")
num_fields = ("budget_amount", "deposit_amount")
data = {}
for k in str_fields:
v = (facts.get(k) or "").strip()
if v:
data[k] = v[:250] if k in ("title", "purchaser", "tender_no", "industry",
"bid_start_at", "bid_deadline", "project_period") else v[:4000]
for k in num_fields:
v = facts.get(k)
if v not in (None, "", "无"):
amt = to_float(str(v).replace(",", "").replace("元", "").strip(), 0.0)
if amt > 0:
data[k] = amt
kd = facts.get("key_dates")
if kd:
data["key_dates"] = json.dumps(kd, ensure_ascii=False)[:4000]
if not data:
return
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id FROM bid_tenders WHERE project_id=${pid}$ "
"ORDER BY created_at DESC LIMIT 1", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
if recs:
tid = getattr(recs[0], "id", "")
sets = ", ".join("%s=${%s}$" % (k, k) for k in data)
p = dict(data)
p["tid"] = tid
await sor.sqlExe(
"UPDATE bid_tenders SET " + sets + ", updated_at=NOW() WHERE id=${tid}$", p)
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_tenders", tid, "extract_facts",
who=who, agent_id=agent_id,
detail="更新要素: %s" % ", ".join(sorted(data)))
else:
tid = new_id()
data["id"] = tid
data["project_id"] = project_id
data["org_id"] = "0"
data["source"] = "tender_doc"
data["status"] = TD_SUMMARIZED
data["created_by"] = who or ""
await sor.C("bid_tenders", data)
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_tenders", tid, "extract_facts",
who=who, agent_id=agent_id,
detail="从招标文件抽取要素: %s" % ", ".join(sorted(data)))
async def _load_tender_text(sor, project_id, file_id=""):
"""读招标文件正文(各维度抽取共用)。返回 (txt, file_name, err)。
file_id 指定 → 取该记录;否则选最优记录(有正文优先、其次磁盘有文件,同类取最新)。
磁盘文件首次读到时回写 content_text(幂等)。
"""
if file_id:
recs = await sor.sqlExe(
"SELECT id, content_text, file_path, file_name FROM bid_tender_files "
"WHERE id=${fid}$", {"fid": file_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return "", "", "项目下没有该招标文件(bid_tender_files),无法抽取"
d = rec_to_dict(recs[0])
else:
# 选最优记录:有正文优先、其次磁盘有文件,同类取最新
# (2026-09-01 修复:旧实现固定取最老记录,完整文件后来上传时永远读不到)
d = await _best_tender_record(sor, project_id)
if not d:
return "", "", "项目下没有招标文件(bid_tender_files),无法抽取"
txt = d.get("content_text") or ""
if not txt.strip() and d.get("file_path"):
pdir = await _project_dir_of(sor, project_id)
real = d.get("_resolved_path") or _resolve_file_path(pdir, d["file_path"])
txt = _extract_file_text(real) if real else ""
if txt:
await sor.sqlExe(
"UPDATE bid_tender_files SET content_text=${c}$, file_path=${p}$ "
"WHERE id=${fid}$",
{"c": txt[:2000000], "p": real, "fid": d["id"]})
await sor.sqlExe("COMMIT", {})
if not txt.strip():
return "", d.get("file_name", ""), (
"招标文件正文为空,无法抽取(file=%s,file_path=%s);"
"请确认文件已上传到项目目录" % (d.get("file_name", ""), d.get("file_path", "")))
return txt, d.get("file_name", ""), ""
async def extract_from_file(project_id, file_id="", model_name="", offset=0,
length=30000, who=None, agent_id=None):
"""LLM 一次性抽取三类信息并批量落库(评分项/资质/投标文件要求)。
大文件分段调用(offset 递增),重复项按名称去重,可安全多次调用。
【2026-09-02 起已由四维度拆分取代】对账器按维度各派一个任务并行抽取
(extract_scoring / extract_quals / extract_reqs_outline / extract_cost_benefit),
本函数保留仅为向后兼容(旧角色技能/旧项目重做仍可调),新流程不再用它。
"""
try:
project_id = await resolve_project_id(project_id, file_id=file_id)
except ValueError as e:
return False, str(e)
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
if err:
return False, err
o = to_int(offset, 0)
seg = txt[o:o + to_int(length, 30000)]
data, err = await llm_json(EXTRACT_PROMPT.replace("__CONTENT__", seg),
model_name=model_name, retries=1)
if data is None:
return False, "LLM 抽取失败: %s" % err
cnt = {"scoring_items": 0, "qualifications": 0, "doc_requirements": 0}
await _upsert_tender_facts(project_id, data.get("tender_facts"),
who=who, agent_id=agent_id)
for it in (data.get("scoring_items") or []):
if not isinstance(it, dict) or not it.get("item_name"):
continue
ok, _ = await add_scoring_item(
project_id, it.get("item_name"), it.get("max_score", 0),
scoring_rule=it.get("scoring_rule", ""), section=it.get("section", "technical"),
item_no=it.get("item_no", ""), is_veto=it.get("is_veto", "0"),
source_ref=it.get("source_ref", ""), who=who, agent_id=agent_id)
cnt["scoring_items"] += 1 if ok else 0
for it in (data.get("qualifications") or []):
if not isinstance(it, dict) or not it.get("qual_name"):
continue
ok, _ = await add_qualification(
project_id, it.get("qual_name"), requirement=it.get("requirement", ""),
is_mandatory=it.get("is_mandatory", "1"), who=who, agent_id=agent_id)
cnt["qualifications"] += 1 if ok else 0
for it in (data.get("doc_requirements") or []):
if not isinstance(it, dict) or not it.get("requirement"):
continue
ok, _ = await add_doc_requirement(
project_id, it.get("requirement"), req_type=it.get("req_type", "structure"),
chapter_no=it.get("chapter_no", ""), chapter_title=it.get("chapter_title", ""),
page_limit=it.get("page_limit", ""), source_ref=it.get("source_ref", ""),
who=who, agent_id=agent_id)
cnt["doc_requirements"] += 1 if ok else 0
return True, ("抽取完成(文件 %s,字符 %d-%d):评分项 %d,资质 %d,投标文件要求 %d"
% (fname, o, o + len(seg),
cnt["scoring_items"], cnt["qualifications"], cnt["doc_requirements"]))
# ── 四维度拆分抽取(2026-09-02:解析任务拆成四个并行维度,各派一个任务)──
async def _extract_seg(txt, prompt, model_name, offset, length):
"""取分段正文 → LLM 抽取。返回 (data, err)。"""
o = to_int(offset, 0)
seg = txt[o:o + to_int(length, 30000)]
data, err = await llm_json(prompt.replace("__CONTENT__", seg),
model_name=model_name, retries=1)
if data is None:
return None, "LLM 抽取失败: %s" % err
return data, ""
async def extract_scoring(project_id, file_id="", model_name="", offset=0,
length=30000, who=None, agent_id=None):
"""维度1:抽取评分项 + 得分规则(落 bid_scoring_items;顺带更新商务要素)。"""
try:
project_id = await resolve_project_id(project_id, file_id=file_id)
except ValueError as e:
return False, str(e)
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
if err:
return False, err
data, err = await _extract_seg(txt, PROMPT_SCORING, model_name, offset, length)
if data is None:
return False, err
cnt = 0
await _upsert_tender_facts(project_id, data.get("tender_facts"),
who=who, agent_id=agent_id)
for it in (data.get("scoring_items") or []):
if not isinstance(it, dict) or not it.get("item_name"):
continue
ok, _ = await add_scoring_item(
project_id, it.get("item_name"), it.get("max_score", 0),
scoring_rule=it.get("scoring_rule", ""), section=it.get("section", "technical"),
item_no=it.get("item_no", ""), is_veto=it.get("is_veto", "0"),
source_ref=it.get("source_ref", ""), who=who, agent_id=agent_id)
cnt += 1 if ok else 0
return True, ("评分项维度抽取完成(文件 %s,字符 %d-%d):录入 %d 项。"
% (fname, to_int(offset, 0), to_int(offset, 0) + to_int(length, 30000), cnt))
async def extract_quals(project_id, file_id="", model_name="", offset=0,
length=30000, who=None, agent_id=None):
"""维度2:抽取资质清单与要求(落 bid_qualifications)。"""
try:
project_id = await resolve_project_id(project_id, file_id=file_id)
except ValueError as e:
return False, str(e)
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
if err:
return False, err
data, err = await _extract_seg(txt, PROMPT_QUALS, model_name, offset, length)
if data is None:
return False, err
cnt = 0
await _upsert_tender_facts(project_id, data.get("tender_facts"),
who=who, agent_id=agent_id)
for it in (data.get("qualifications") or []):
if not isinstance(it, dict) or not it.get("qual_name"):
continue
ok, _ = await add_qualification(
project_id, it.get("qual_name"), requirement=it.get("requirement", ""),
is_mandatory=it.get("is_mandatory", "1"), who=who, agent_id=agent_id)
cnt += 1 if ok else 0
return True, ("资质维度抽取完成(文件 %s,字符 %d-%d):录入 %d 项。"
% (fname, to_int(offset, 0), to_int(offset, 0) + to_int(length, 30000), cnt))
async def extract_reqs_outline(project_id, file_id="", model_name="", offset=0,
length=30000, who=None, agent_id=None):
"""维度3:抽取投标文件要求(落 bid_doc_requirements),并自动生成章节骨架。"""
try:
project_id = await resolve_project_id(project_id, file_id=file_id)
except ValueError as e:
return False, str(e)
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
if err:
return False, err
data, err = await _extract_seg(txt, PROMPT_REQS, model_name, offset, length)
if data is None:
return False, err
cnt = 0
for it in (data.get("doc_requirements") or []):
if not isinstance(it, dict) or not it.get("requirement"):
continue
ok, _ = await add_doc_requirement(
project_id, it.get("requirement"), req_type=it.get("req_type", "structure"),
chapter_no=it.get("chapter_no", ""), chapter_title=it.get("chapter_title", ""),
page_limit=it.get("page_limit", ""), source_ref=it.get("source_ref", ""),
who=who, agent_id=agent_id)
cnt += 1 if ok else 0
# 自动生成章节骨架(structure 类 → 章节,评分项挂载能挂多少挂多少)
ok_o, msg_o = await create_chapter_outline(project_id, who=who, agent_id=agent_id)
return True, ("投标文件要求维度抽取完成(文件 %s,字符 %d-%d):录入 %d 条要求。%s"
% (fname, to_int(offset, 0), to_int(offset, 0) + to_int(length, 30000),
cnt, msg_o if not ok_o else "章节骨架已生成。"))
async def add_cost_benefit_item(project_id, category, item_name, amount="", amount_note="",
basis="", confidence="medium", source_ref="",
who=None, agent_id=None):
"""补录一条成本/收益项(分析漏项或人工修正时用)。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
if not item_name:
return False, "缺少 item_name"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
exist = await sor.sqlExe(
"SELECT id FROM bid_cost_benefit WHERE project_id=${pid}$ AND item_name=${nm}$ "
"AND category=${c}$ LIMIT 1",
{"pid": project_id, "nm": str(item_name)[:250], "c": (category or "cost")[:16]})
await sor.sqlExe("COMMIT", {})
if exist:
return True, "该成本/收益项已存在: %s" % item_name
n = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_cost_benefit WHERE project_id=${pid}$",
{"pid": project_id})
order_no = to_int(getattr(n[0], "c", 0) if n else 0) + 1
iid = new_id()
amt = to_float(amount, 0.0)
await sor.C("bid_cost_benefit", {
"id": iid, "project_id": project_id,
"kind": "item", "category": (category or "cost")[:16],
"item_name": str(item_name)[:250],
"amount": amt or None,
"amount_note": str(amount_note or "")[:200],
"basis": basis or "",
"confidence": (confidence or "medium")[:16],
"source_ref": str(source_ref or "")[:480],
"order_no": order_no,
})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_cost_benefit", iid, "add",
who=who, agent_id=agent_id, detail=str(item_name)[:200])
return True, "成本/收益项已录入: %s (%s)" % (item_name, category)
async def extract_cost_benefit(project_id, file_id="", model_name="", offset=0,
length=30000, who=None, agent_id=None):
"""维度4:投标成本与收益分析(落 bid_cost_benefit:单项 + summary 结论行)。"""
try:
project_id = await resolve_project_id(project_id, file_id=file_id)
except ValueError as e:
return False, str(e)
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
if err:
return False, err
data, err = await _extract_seg(txt, PROMPT_COST_BENEFIT, model_name, offset, length)
if data is None:
return False, err
cnt = 0
for it in (data.get("items") or []):
if not isinstance(it, dict) or not it.get("item_name"):
continue
ok, _ = await add_cost_benefit_item(
project_id, it.get("category", "cost"), it.get("item_name"),
amount=it.get("amount", ""), amount_note=it.get("amount_note", ""),
basis=it.get("basis", ""), confidence=it.get("confidence", "medium"),
source_ref=it.get("source_ref", ""), who=who, agent_id=agent_id)
cnt += 1 if ok else 0
# 关键结论行(summary):kind=summary,供总览展示
sm = data.get("summary") or {}
if isinstance(sm, dict) and sm:
note = (sm.get("margin_est") or "")[:2000]
extra = (sm.get("note") or "")[:2000]
db2, dbname2 = get_db()
async with db2.sqlorContext(dbname2) as sor:
exist = await sor.sqlExe(
"SELECT id FROM bid_cost_benefit WHERE project_id=${pid}$ AND kind='summary' LIMIT 1",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
sdata = {
"category": "summary",
"item_name": "成本收益关键结论",
"amount": to_float(sm.get("total_cost_est"), 0.0) or None,
"amount_note": ("成本合计估算;收益合计估算=%s"
% str(sm.get("total_revenue_est") or "未估算"))[:200],
"basis": (note + ("\n\n风险与口径:" + extra if extra else ""))[:20000],
"confidence": "medium",
}
if exist:
sid = getattr(exist[0], "id", "")
sets = ", ".join("%s=${%s}$" % (k, k) for k in sdata if k != "id")
p = dict(sdata); p["sid"] = sid
await sor.sqlExe(
"UPDATE bid_cost_benefit SET " + sets + ", updated_at=NOW() WHERE id=${sid}$", p)
await sor.sqlExe("COMMIT", {})
else:
sdata.update({"id": new_id(), "project_id": project_id, "kind": "summary",
"source_ref": "", "order_no": 0})
await sor.C("bid_cost_benefit", sdata)
await sor.sqlExe("COMMIT", {})
return True, ("成本收益维度分析完成(文件 %s):录入 %d 项 + 关键结论。"
% (fname, cnt))
# ══════════════ 查询 ══════════════
async def list_scoring_items(project_id, section=""):
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, section, item_no, item_name, max_score, is_veto, "
"target_chapter_no, scoring_rule FROM bid_scoring_items WHERE project_id=${pid}$")
p = {"pid": project_id}
if section:
sql += " AND section=${sec}$"
p["sec"] = section
sql += " ORDER BY section, order_no"
recs = await sor.sqlExe(sql, p)
await sor.sqlExe("COMMIT", {})
out = rows_to_dicts(recs)
for r in out:
r["scoring_rule"] = (r.get("scoring_rule") or "")[:600]
return out
async def list_doc_requirements(project_id, req_type=""):
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, req_type, chapter_no, chapter_title, requirement, page_limit "
"FROM bid_doc_requirements WHERE project_id=${pid}$")
p = {"pid": project_id}
if req_type:
sql += " AND req_type=${rt}$"
p["rt"] = req_type
sql += " ORDER BY req_type, order_no"
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 "")[:600]
return out
# ══════════════ 章节骨架 ══════════════
def _infer_section(title):
"""按章节标题关键词推断商务/技术分类(商务标:报价/商务/资质证明/承诺函等;其余技术标)。"""
t = title or ""
biz_kw = ("报价", "投标函", "商务", "资格证明", "资质证明", "承诺书", "声明", "保证金",
"法定代表人", "授权委托", "财务状况", "业绩一览", "合同格式")
for kw in biz_kw:
if kw in t:
return "business"
return "technical"
async def create_chapter_outline(project_id, chapters="", who=None, agent_id=None):
"""生成章节骨架 → bid_chapters(status=pending)。
chapters 为空时:自动用 bid_doc_requirements(req_type=structure) 生成,
并按 target_chapter_no / 章节标题关键词把评分项挂到章节上(scoring_item_ids + max_score)。
"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
items = json_loads(chapters, []) if chapters else []
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
if not items:
recs = await sor.sqlExe(
"SELECT chapter_no, chapter_title, requirement, page_limit FROM bid_doc_requirements "
"WHERE project_id=${pid}$ AND req_type='structure' ORDER BY order_no",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
for r in (recs or []):
d = rec_to_dict(r)
if not (d.get("chapter_title") or d.get("chapter_no")):
continue
items.append({
"chapter_no": d.get("chapter_no") or "",
"title": d.get("chapter_title") or "",
"outline": (d.get("requirement") or "")[:4000],
"section": _infer_section(d.get("chapter_title") or ""),
})
if not items:
return False, ("无章节来源:请先抽取 req_type=structure 的投标文件要求,"
"或显式传 chapters=[{\"chapter_no\":\"1\",\"title\":\"...\"}]")
sitems = await sor.sqlExe(
"SELECT id, item_name, max_score, section, target_chapter_no "
"FROM bid_scoring_items WHERE project_id=${pid}$", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
sc = rows_to_dicts(sitems)
created, skipped = 0, 0
for idx, it in enumerate(items):
if not isinstance(it, dict):
continue
cno = str(it.get("chapter_no") or (idx + 1))[:30]
title = str(it.get("title") or it.get("chapter_title") or "").strip()[:250]
if not title:
continue
exist = await sor.sqlExe(
"SELECT id FROM bid_chapters WHERE project_id=${pid}$ AND chapter_no=${cno}$ LIMIT 1",
{"pid": project_id, "cno": cno})
await sor.sqlExe("COMMIT", {})
if exist:
skipped += 1
continue
matched, msum = [], 0.0
for s in sc:
tgt = (s.get("target_chapter_no") or "").strip()
nm = s.get("item_name") or ""
if (tgt and tgt == cno) or (nm and (nm in title or title in nm)):
matched.append(s["id"])
msum += to_float(s.get("max_score"), 0.0)
sec = str(it.get("section") or _infer_section(title) or "technical")[:30]
await sor.C("bid_chapters", {
"id": new_id(), "project_id": project_id,
"chapter_no": cno, "parent_no": str(it.get("parent_no") or "")[:30],
"title": title, "section": sec,
"scoring_item_ids": json.dumps(matched, ensure_ascii=False),
"outline": str(it.get("outline") or "")[:20000],
"status": CH_PENDING, "version": 1, "revise_count": 0,
"max_score": msum or None,
"assignee_role": "agent.bid_biz_writer" if sec == "business" else "agent.bid_writer",
"order_no": idx + 1,
})
created += 1
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_chapters", project_id, "create_outline",
who=who, agent_id=agent_id, detail="新增 %d 章" % created)
return True, ("章节骨架已生成:新增 %d 章,已存在跳过 %d 章。"
"系统将自动为每个章节派发编写任务。" % (created, skipped))