feat(bidding): 产线起点改为上传招标文件+解析产出QC契合度审核门禁

清理(前三步归商机产线):
- 删邮箱采集: bid_mail.py/bid_mail_accounts表与页面/首页按钮
- 删招标审批状态机: TD_*/HT_TENDER_APPROVAL/摘要审批工具族
- bid_tenders瘦身为解析抽取的商务要素(去邮件字段)

QC契合度审核:
- 新表bid_qc_reviews(qc_type/round/fit_score/pass_score/passed/improvement)
- bid_qc_capability: start_qc/finish_qc/list_qc/qc_report, 四类产出逐类审核
- 判定: 10分制打分, >9.5放行(参数bid_qc_pass_score); 不通过给改进意见
  +清空产出+对账器重派解析任务带意见重做; 超轮次抛qc_escalation人工
- bid_flow对账器A2门禁阶段: 幂等/在办去重/轮次守卫/重做不计首跑上限
- 驾驶舱: qc_report工具+解析QC审核菜单; 参数读appbase表+默认兜底
This commit is contained in:
yumoqing 2026-08-31 13:37:49 +08:00
parent 22fca127cb
commit 44f8c18ac3
32 changed files with 1599 additions and 1787 deletions

View File

@ -2,52 +2,62 @@
> 对照开发产线(`sdlc_general`)设计,复用平台通用内核(项目/会话/任务链/人类任务/交付件/问答冒泡/技能分层),
> 只增量定义投标产线自己的 **阶段、角色、工具、技能、任务流转规则、交付件**
版本v1.0.0  创建2026-08-26
> 版本v1.1.0  创建2026-08-26  修订2026-08-31产线起点改为上传招标文件解析产出加 QC 契合度审核门禁)
---
## 一、业务流程(需求原文映射)
## 零、产线边界v1.1 修订)
- 招标信息的**采集、摘要、审批归商机产线**。投标产线从「上传招标文件」开始:
用户/驾驶舱直接创建投标项目 → 上传招标文件 → 进入解析。
- `bid_tenders` 表保留,但只承载**招标文件解析抽取的商务要素**(项目名称/招标人/编号/
截止时间/周期/预算/保证金/资质要求source=tender_doc不再有邮件采集与审批状态机。
- 已删除邮箱采集bid_mail.py、bid_mail_accounts 表/页面、招标审批tender_approval 人类任务)、
摘要/审批/存档工具fetch_tenders/summarize_tender/submit_tender_approval/approve_tender 等)、
招标信息状态机new→summarized→pending_approval→…
---
## 一、业务流程
| # | 业务动作 | 承担者 | 落库 |
|---|---|---|---|
| 1 | 从 yumoqing@opencomputing.cn阿里云企业邮获取招标信息 | agent.tender_scout | bid_tenders |
| 2 | 对每条招标信息内容摘要:投标开始时间、项目周期、招标金额等 | agent.tender_scout | bid_tenders.summary/bid_start_at/project_period/budget_amount |
| 3 | 走审批流程 | 人human task + 可选钉钉审批) | pipeline_human_tasks(task_type=tender_approval) |
| 4 | 审批不通过 → 招标信息存档作废 | 机制 | bid_tenders.status=archived |
| 5 | 审批通过 → 创建项目 | 机制/主 agent | sd_projects(pipeline_id=bidding_general) |
| 6 | 设定参与人员 | 主 agent / 人 | bid_members |
| 7 | 等待上传招标文件 | 人(阻塞门禁) | pipeline_human_tasks(tender_file_upload) + bid_tender_files |
| 8 | 从招标文件抽取评分项及得分规则 | agent.tender_analyst | bid_scoring_items |
| 9 | 抽取所需资质 | agent.tender_analyst | bid_qualifications |
| 10 | 抽取投标文件要求(章节结构/格式/份数/密封) | agent.tender_analyst | bid_doc_requirements |
| 11 | 从公司投标知识库获取所需资质文档 | agent.bid_prep | bid_qualifications.kb_doc_id ← bid_kb_docs |
| 12 | 发布人类应提供的文件清单 | agent.bid_prep阻塞门禁 | pipeline_human_tasks(human_docs_request) |
| 13 | 按投标文件要求分章节编写投标文件 | agent.bid_writer | bid_chapters |
| 14 | 每章节完成后对照招标/投标文件要求评审,分数不够给改进意见重做 | agent.bid_reviewer | bid_reviews(scope=chapter) → 章节循环 |
| 15 | 合成标书 | agent.bid_compositor | bid_documentsdocx |
| 16 | 对整个标书评审 + 按评分项和得分规则打分 | agent.bid_scorer | bid_reviews(scope=whole) + bid_scores |
| 17 | 不合适 → 改进意见 → 修改问题章节 → 评审 → …… 直到得分通过 | 机制(整书循环) | bid_scores.target_chapter_id → 章节回 rejected |
| 1 | 创建投标项目 + 设定参与人员 | 驾驶舱 / 人 | sd_projects(pipeline_id=bidding_general) + bid_members |
| 2 | 等待上传招标文件 | 人(阻塞门禁,上传后自动完成) | pipeline_human_tasks(tender_file_upload) + bid_tender_files |
| 3 | 抽取评分项及得分规则 | agent.tender_analyst | bid_scoring_items |
| 4 | 抽取所需资质 | agent.tender_analyst | bid_qualifications |
| 5 | 抽取投标文件要求(章节结构/格式/份数/密封)+ 商务要素 | agent.tender_analyst | bid_doc_requirements + bid_tenders(source=tender_doc) |
| 6 | 生成章节骨架(评分项挂载) | agent.tender_analyst | bid_chapters |
| 7 | **解析产出 QC 契合度审核四类逐类10 分制 >9.5 放行,否则改进意见退回重做)** | agent.qc | bid_qc_reviews |
| 8 | 从公司投标知识库获取所需资质文档 | agent.bid_prep | bid_qualifications.kb_doc_id ← bid_kb_docs |
| 9 | 发布人类应提供的文件清单 | agent.bid_prep阻塞门禁 | pipeline_human_tasks(human_docs_request) |
| 10 | 按投标文件要求分章节编写投标文件 | agent.bid_writer | bid_chapters |
| 11 | 每章节完成后对照招标/投标文件要求评审,分数不够给改进意见重做 | agent.bid_reviewer | bid_reviews(scope=chapter) → 章节循环 |
| 12 | 合成标书 | agent.bid_compositor | bid_documentsdocx |
| 13 | 对整个标书评审 + 按评分项和得分规则打分 | agent.bid_scorer | bid_reviews(scope=whole) + bid_scores |
| 14 | 不合适 → 改进意见 → 修改问题章节 → 评审 → …… 直到得分通过 | 机制(整书循环) | bid_scores.target_chapter_id → 章节回 rejected |
| 15 | 交付确认 | 人 | pipeline_human_tasks(bid_delivery_confirm) → 项目 completed |
---
## 二、阶段与角色(对照开发产线)
## 二、阶段与角色
| 开发产线 | 投标产线 | 角色规范名 | 职责 | next_role |
|---|---|---|---|---|
| requirement 需求分析 | 招标情报 | `agent.tender_scout` | 收邮件、去重入库、摘要与要素抽取、提交审批 | `agent.tender_analyst` |
| —(新增门禁) | 招标审批 | 人类任务 | 审批通过立项 / 不通过存档作废 | — |
| design 设计 | 招标文件解析 | `agent.tender_analyst` | 抽取评分项+得分规则、所需资质、投标文件要求,产出章节骨架 | `agent.bid_prep` |
| —(新增) | 资料准备 | `agent.bid_prep` | 知识库资质匹配、缺失项发布人类文件清单 | `agent.bid_writer` |
| develop 开发 | 章节编写 | `agent.bid_writer` | 按章节骨架逐章写作(一章一任务) | `agent.bid_reviewer` |
| test 测试 | 章节评审 | `agent.bid_reviewer` | 对照招标要求+得分规则逐章打分,不达标退回重写 | `agent.bid_compositor` |
| —(新增) | 标书合成 | `agent.bid_compositor` | 合成 docx封面/目录/页码/偏离表),产出交付件 | `agent.bid_scorer` |
| deploy 部署 | 全书评分 | `agent.bid_scorer` | 按评分项+得分规则给整书打分、生成改进意见并映射章节 | ""(终结→人类交付确认) |
| agent.pm | 同名复用 | `agent.pm` | 任务审核/派发/验收 | — |
| agent.qc | 同名复用 | `agent.qc` | 交付件合规检查 | — |
| 阶段 | 角色规范名 | 职责 |
|---|---|---|
| 立项与上传 | 驾驶舱 + 人 | 建项目、配人、上传招标文件 |
| 招标文件解析 | `agent.tender_analyst` | 抽取评分项+得分规则、所需资质、投标文件要求、商务要素,产出章节骨架 |
| 解析产出 QC | `agent.qc` | 四类产出对照招标文件原文按 10 分制审核契合度,>9.5 放行,否则改进意见退回 |
| 资料准备 | `agent.bid_prep` | 知识库资质匹配、缺失项发布人类文件清单 |
| 章节编写 | `agent.bid_writer` | 按章节骨架逐章写作(一章一任务) |
| 章节评审 | `agent.bid_reviewer` | 对照招标要求+得分规则逐章打分,不达标退回重写 |
| 标书合成 | `agent.bid_compositor` | 合成 docx封面/目录/页码),产出交付件 |
| 全书评分 | `agent.bid_scorer` | 按评分项+得分规则给整书打分、生成改进意见并映射章节 |
| 任务验收 | `agent.pm` | 任务审核/验收 |
| 合规检查 | `agent.qc`(复用) | 解析产出契合度审核(本产线 QC 的主职责) |
> 角色命名遵守平台规范agent 角色 = `agent.{role}`,人类角色 = RBAC `{orgtype}.{role}`
> 同名角色跨产线隔离(按 pipeline_id 查 RoleSpec`agent.pm`/`agent.qc` 在两产线各有自己的技能与提示词。
> **RoleSpec.next_role 全部留空**:投标是「一章一任务 + 多级回退循环」扇出结构,
> 流转一律由 `bid_flow` 状态对账器(每 15s决定它是角色任务唯一创建者。
---
@ -56,68 +66,88 @@
### 3.1 主链(机制驱动,非 LLM 驱动)
```
[邮箱] --fetch_tenders--> bid_tenders(new)
--summarize_tender--> summarized
--submit_tender_approval--> pending_approval ┌── reject ──> archived(作废) ✗终止
└── approve ──> approved
--create_bid_project--> sd_projects + assign_members
--request_tender_file--> [人类任务:上传招标文件] (阻塞)
↓ 上传完成
agent.tender_analyst → 评分项 / 资质 / 投标文件要求 / 章节骨架
↓ approved (PM)
agent.bid_prep → 知识库资质匹配 + [人类任务:应提供文件清单](阻塞)
approved
agent.bid_writer → 逐章写作(每章一个任务,可并行受并发上限约束
↓ 章节 written
agent.bid_reviewer → 章节评审打分
↓ 全部章节 approved
agent.bid_compositor → 合成标书 docx
agent.bid_scorer → 整书评分
↓ 通过
[人类任务:交付确认] → 项目 completed
[立项] create_bid_project → sd_projects + 「上传招标文件」人类任务(阻塞)
↓ 上传完成(对账器自动关闭上传任务)
agent.tender_analyst → 评分项 / 资质 / 投标文件要求 / 商务要素 / 章节骨架
agent.qcA2 QC 门禁)→ 四类产出逐类契合度审核10 分制)
│ 得分 > 9.5 → 该类放行
│ 不通过 → 改进意见 → 清空该类产出 → 解析重做(携带意见)→ 重审
│ 轮次超限(默认 3→ 抛人工qc_escalation 阻塞,逃逸阀:人工修/强制放行)
↓ 全部放行
agent.bid_prep → 知识库资质匹配 + [人类任务:应提供文件清单](阻塞)
agent.bid_writer → 逐章写作(每章一个任务,可并行)
↓ 章节 written
agent.bid_reviewer → 章节评审打分
↓ 全部章节 approved
agent.bid_compositor → 合成标书 docx
agent.bid_scorer → 整书评分
↓ 通过
[人类任务:交付确认] → 项目 completed
```
### 3.2 循环一:章节级评审回退
### 3.2 QC 门禁解析产出契合度审核v1.1 新增)
- **审核对象(四类,逐类审核)**
| qc_type | 落库表 |
|---|---|
| `scoring_items` 评分项+得分规则 | bid_scoring_items |
| `qualifications` 所需资质 | bid_qualifications |
| `doc_requirements` 投标文件要求 | bid_doc_requirements |
| `chapter_outline` 章节骨架 | bid_chapters |
- **判定**agent.qc 对照招标文件原文逐项核对,按 10 分制打契合度分;
**得分高于通过分(`bid_qc_pass_score`,默认 9.5)才放行**(严格大于)。
- **不通过**:改进意见必填(缺什么/错在哪/对照原文哪一处)→ 系统清空该类产出物
→ 对账器重派解析任务params.qc_redo=1 + qc_improvements 携带意见)→ 重抽 → 重审。
- **轮次上限**:单类审核超 `bid_qc_max_round`(默认 3→ 抛 `qc_escalation` 阻塞人工任务。
逃逸阀:人工在产出物管理页修正后重新提交,或在审核记录页把该记录 passed 改 1 强制放行。
- **重做轮次上限**:解析重做任务完成数超 `bid_qc_max_round` 且产出仍缺 → 同样抛人工,防无限循环。
- 首跑解析重试上限MAX_ANALYST_ATTEMPT=2只计首跑任务QC 退回的重做不计入。
- 记录落 `bid_qc_reviews`qc_type/round/fit_score/pass_score/passed/improvement
### 3.3 循环一:章节级评审回退
- 触发:`review_chapter` 判定 `score / max_score < chapter_pass_ratio`(默认 0.8
- 动作:章节 `status=rejected` + `review_comment` 落库 + `revise_count += 1`
- 机制:`bid_reviewer` 任务 approved 后,若存在 rejected 章节 → 自动派生 `agent.bid_writer` 重写任务任务标题带章节号params 带 `chapter_id` + `improvement`
- 上限:`revise_count > bid_max_revise`(默认 3→ 抛人类介入fault_report + 人类任务),不无限重做
- 机制:存在 rejected 章节且无在办编写任务对账器自动派生 `agent.bid_writer` 重写任务params 带 `chapter_id`
- 上限:`revise_count > bid_max_revise`(默认 3→ 抛人类介入,不无限重做
### 3.3 循环二:整书评分回退
### 3.4 循环二:整书评分回退
- 触发:`score_bid` 总分率 `< bid_pass_ratio`(默认 0.85),或存在否决项(`is_veto`)未响应
- 动作:`bid_scores` 逐评分项落分 + `improvement` + `target_chapter_id`
- 机制:把 `target_chapter_id` 命中的章节置 `rejected` → 派生 `bid_writer` 任务 → 重写 → 章节评审 → 全部通过 → 重新合成 → 重新评分
- 轮次记录 `bid_reviews.round`,上限 `bid_max_round`(默认 3→ 抛人类介入
- 改进意见未映射到章节 → 抛人工指定(不静默丢失)
### 3.4 阻塞门禁(人类任务)
### 3.5 阻塞门禁(人类任务)
| task_type | 语义 | 阻塞范围 |
|---|---|---|
| `tender_approval` | 招标信息审批 | 该 tender 不立项、不推进 |
| `tender_file_upload` | 等待上传招标文件 | 项目全部角色任务 |
| `tender_file_upload` | 等待上传招标文件(上传后自动完成) | 项目全部角色任务 |
| `human_docs_request` | 人类应提供的文件 | 项目全部角色任务 |
| `bid_delivery_confirm` | 最终标书交付确认 | 项目收尾 |
| `qc_escalation` | 解析产出 QC 连续不达标 | 项目全部角色任务 |
| `bid_delivery_confirm` | 最终标书交付确认 | 项目收尾(不阻塞派发) |
**门禁的守门位置:投标产线的角色任务唯一创建者是 `bid_flow` 对账器**(每 15 秒状态对账)。
对账器在创建任务前就检查 `pipeline_human_tasks.project_id = <项目id> AND status='pending'`
有阻塞任务直接不派 —— 门禁在**创建源头**,不在引擎派发层。
> ⚠️ 概念区分(重要):`tenant_id` 是引擎的通用租户键(宿主可提供为 org_id / user_id 等)。
> **只有角色任务pipeline_id='role_task')这个具体场景约定 `tenant_id = project_id`**
> (与开发产线的既有任务创建约定一致,`_create_next_task` 同款)。
> **只有角色任务pipeline_id='role_task')这个具体场景约定 `tenant_id = project_id`**
> 这是角色任务派发机制的约定,不是引擎通用规则 —— 引擎层 SQL 不得引用该等价关系,
> 引擎对投标产线零特判投标的阻塞语义全部收敛在投标产线自己的代码bid_flow里。
### 3.5 硬规则
### 3.6 硬规则
- 审批不通过 = 终态archived不得再进入立项需重新评估须人工 `reopen_tender`
- 章节写作任务与章节一对一(`params.chapter_id`),禁止一个任务写多章(对照开发产线「单任务不要过大」)。
- 评审/评分只由 `agent.bid_reviewer` / `agent.bid_scorer` 执行写作角色不得自评源头杜绝越权capability 按角色缩小)。
- 得分/改进意见必须落库(`bid_reviews`/`bid_scores`),不接受"文档里声称评审通过"。
- 案例与业绩一致性:技术方案引用的案例必须能对应到 `bid_kb_docs(doc_type=case)` 真实合同,禁止虚构客户/金额(写进 bid-doc-spec 技能,评审项硬检查)。
- 章节写作任务与章节一对一(`params.chapter_id`),禁止一个任务写多章。
- 评审/评分只由 `agent.bid_reviewer` / `agent.bid_scorer` 执行,写作角色不得自评;
解析产出契合度审核只由 `agent.qc` 执行capability 按角色缩小,源头杜绝越权)。
- 得分/改进意见必须落库(`bid_reviews`/`bid_scores`/`bid_qc_reviews`),不接受"文档里声称评审通过"。
- 案例与业绩一致性:技术方案引用的案例必须能对应到 `bid_kb_docs(doc_type=case)` 真实合同,
禁止虚构客户/金额(写进 bid-doc-spec 技能,评审项硬检查)。
---
@ -125,21 +155,21 @@
| 表 | 说明 | 关键字段 |
|---|---|---|
| `bid_mail_accounts` | 招标邮箱配置(凭据不写死代码) | protocol/host/port/username/password(AES)/folder/filter_keywords/last_uid/enabled |
| `bid_tenders` | 招标信息 | mail_uid/subject/received_at/raw_content/title/purchaser/tender_no/summary/bid_start_at/bid_deadline/project_period/budget_amount/deposit/qualification_req/status/project_id |
| `bid_tenders` | 招标商务要素(解析抽取) | project_id/source/title/purchaser/tender_no/summary/bid_start_at/bid_deadline/project_period/budget_amount/deposit_amount/qualification_req |
| `bid_members` | 项目参与人员 | project_id/user_id/member_role/duty |
| `bid_tender_files` | 招标文件及附件 | project_id/tender_id/file_name/file_path/file_type/extract_status |
| `bid_scoring_items` | 评分项 + 得分规则 | project_id/section/item_name/max_score/scoring_rule/is_veto/weight/source_ref |
| `bid_tender_files` | 招标文件及附件 | project_id/tender_id/file_name/file_path/file_type/extract_status/content_text |
| `bid_scoring_items` | 评分项 + 得分规则 | project_id/section/item_name/max_score/scoring_rule/is_veto/target_chapter_no/source_ref |
| `bid_qualifications` | 所需资质 | project_id/qual_name/requirement/is_mandatory/kb_doc_id/match_status/owner |
| `bid_doc_requirements` | 投标文件要求 | project_id/req_type/chapter_no/chapter_title/requirement/page_limit |
| `bid_chapters` | 标书章节 | project_id/chapter_no/title/scoring_item_ids/status/version/content/file_path/review_score/review_comment/revise_count/assignee_role |
| `bid_qc_reviews` | **解析产出 QC 审核记录** | project_id/qc_type/round/fit_score/pass_score/passed/improvement/task_id |
| `bid_chapters` | 标书章节 | project_id/chapter_no/title/scoring_item_ids/status/version/content/review_score/revise_count/assignee_role |
| `bid_reviews` | 评审记录(章节级/整书级) | project_id/scope/chapter_id/round/reviewer/score/max_score/passed/comments |
| `bid_scores` | 评分明细(按评分项) | project_id/review_id/scoring_item_id/score/max_score/reason/improvement/target_chapter_id |
| `bid_kb_docs` | 公司投标知识库 | org_id/doc_type/doc_name/tags/file_path/content_text/valid_until/status |
| `bid_documents` | 合成标书 | project_id/version/file_path/page_count/total_score/status |
复用平台表:`sd_projects`项目pipeline_id=bidding_general`pipeline_tasks`(角色任务链)、
`pipeline_human_tasks`审批/人类任务)、`pipeline_deliverables`(交付件)、`pipeline_agent_questions`(问答冒泡)。
`pipeline_human_tasks`(人类任务)、`pipeline_deliverables`(交付件)、`pipeline_agent_questions`(问答冒泡)。
---
@ -147,18 +177,15 @@
### 5.1 主 agent驾驶舱工具 —— `PipelineAbility.tools`
**招标情报**`fetch_tenders` `list_tenders` `tender_detail` `summarize_tender`
`submit_tender_approval` `approve_tender` `reject_tender` `archive_tender` `reopen_tender`
**立项与人员**`create_bid_project` `assign_members` `list_members` `request_tender_file` `list_tender_files`
**解析结果查看**`list_scoring_items` `list_qualifications` `list_doc_requirements`
**解析结果与 QC**`list_scoring_items` `list_doc_requirements` `list_qualifications` `qc_report`
**知识库**`search_bid_kb` `list_kb_docs` `add_kb_doc` `request_human_docs`
**章节与标书**`create_chapter_outline` `list_chapters` `chapter_detail` `compose_bid` `list_bid_documents`
**章节与标书**`list_chapters` `chapter_detail` `list_bid_documents` `list_reviews` `bid_score_report`
**评审评分**`list_reviews` `bid_score_report`
**诊断推进**`diagnose_bid` `advance_bid`
**通用复用**(引擎级,来自 shared_ability`create_task` `list_tasks` `task_detail` `approve_task`
`complete_task` `cancel_task` `reset_task_retry` `start_agents` `diagnose_project`
@ -170,16 +197,16 @@
| capability | 工具 | 授予角色 |
|---|---|---|
| `bid_tender_capability` | `fetch_tenders` `summarize_tender` `set_tender_fields` `submit_tender_approval` `archive_tender` `list_tenders` | agent.tender_scout |
| `bid_analysis_capability` | `add_scoring_item` `add_qualification` `add_doc_requirement` `create_chapter_outline` `list_scoring_items` `list_doc_requirements` `mark_file_extracted` | agent.tender_analyst |
| `bid_kb_capability` | `search_bid_kb` `match_qualification` `mark_qualification_missing` `request_human_docs` `list_qualifications` | agent.bid_prep |
| `bid_write_capability` | `list_chapters` `chapter_detail` `write_chapter` `submit_chapter` `list_scoring_items` `search_bid_kb` | agent.bid_writer |
| `bid_review_capability` | `review_chapter` `reject_chapter` `approve_chapter` `list_chapters` `list_scoring_items` `list_doc_requirements` | agent.bid_reviewer |
| `bid_compose_capability` | `compose_bid` `list_chapters` `list_doc_requirements` | agent.bid_compositor |
| `bid_score_capability` | `score_bid` `add_score_item_result` `request_chapter_revise` `list_scoring_items` `list_bid_documents` | agent.bid_scorer |
| `bid_analysis_capability` | `read_tender_file` `list_tender_files` `extract_from_file` `add_scoring_item` `add_qualification` `add_doc_requirement` `create_chapter_outline` `list_scoring_items` `list_doc_requirements` `mark_file_extracted` | agent.tender_analyst |
| `bid_qc_capability` | `start_qc` `finish_qc` `list_qc` `qc_report`+ 只读工具) | agent.qc |
| `bid_kb_capability` | `search_bid_kb` `kb_doc_detail` `list_qualifications` `match_qualification` `mark_qualification_missing` `auto_match_qualifications` `request_human_docs` | agent.bid_prep |
| `bid_write_capability` | `list_chapters` `chapter_detail` `write_chapter` `submit_chapter` `search_bid_kb` `kb_doc_detail` `list_scoring_items` | agent.bid_writer |
| `bid_review_capability` | `review_chapter` `approve_chapter` `reject_chapter` `list_reviews` `list_chapters` `list_scoring_items` `list_doc_requirements` | agent.bid_reviewer |
| `bid_compose_capability` | `compose_bid` `list_chapters` `list_bid_documents` `read_bid_document` `list_doc_requirements` | agent.bid_compositor |
| `bid_score_capability` | `start_bid_score` `add_score_item_result` `finalize_bid_score` `request_chapter_revise` `read_bid_document` `list_scoring_items` `list_bid_documents` `bid_score_report` | agent.bid_scorer |
> capability 按角色职责缩小 —— 写作角色拿不到 `review_chapter`/`score_bid`,评审角色拿不到 `write_chapter`
> 越权在源头杜绝,不靠事后 RBAC 校验。
> capability 按角色职责缩小 —— 写作角色拿不到 `review_chapter`/`score_bid`,评审角色拿不到 `write_chapter`
> 解析角色拿不到 `finish_qc`QC 拿不到 `extract_from_file`越权在源头杜绝,不靠事后 RBAC 校验。
---
@ -188,8 +215,8 @@
### common/(产线通用规范,状态机 + 门禁)
| 技能 | 内容 |
|---|---|
| `bid-workflow` | 产线总纲:阶段链、门禁、两级循环、回退语义、机制 vs LLM 分界 |
| `tender` | 招标信息状态机new→summarized→pending_approval→approved/rejected→archived |
| `bid-workflow` | 产线总纲:阶段链、QC 门禁、两级循环、阻塞门禁、机制 vs LLM 分界 |
| `bid-qc` | 解析产出 QC 契合度审核规范四类审核清单、10 分制打分口径、退回重做流程capability 声明) |
| `bid-chapter` | 章节状态机 + 评审循环 + 重做上限 + 一章一任务 |
| `bid-review` | 评审与评分规范:对照评分项/得分规则打分、改进意见→章节映射、否决项处理 |
| `bid-qualification` | 资质匹配规范:知识库检索 → matched/missing/human_required → 人类文件清单 |
@ -197,8 +224,8 @@
| `team-communication` | 问答冒泡链(复用开发产线同名规范) |
### roles/角色定义frontmatter 声明 capability
`agent.tender_scout` `agent.tender_analyst` `agent.bid_prep` `agent.bid_writer`
`agent.bid_reviewer` `agent.bid_compositor` `agent.bid_scorer` `agent.pm` `agent.qc`
`agent.tender_analyst` `agent.bid_prep` `agent.bid_writer` `agent.bid_reviewer`
`agent.bid_compositor` `agent.bid_scorer` `agent.qc` `agent.pm`
---
@ -206,22 +233,34 @@
| 内容 | 位置 | 理由 |
|---|---|---|
| 主 agent 能力包 `bid_ability.py` | pipeline-service | 与 sdlc_ability 同构ability 注册表在 core任何模块可注册 |
| 角色能力工具 `bid_*_capability.py` | pipeline-service | `exec_capability_tool` 只 import `pipeline_service.*` |
| 邮箱采集 `bid_mail_fetcher.py` | pipeline-service | 被能力工具调用 |
| 通用任务 handler 汇总 `shared_ability.py` | pipeline-service | 复用而非重写 SDLC 的通用任务/交付件/问答 handler |
| 表 / CRUD / UI / dspy / RBAC | pipeline-bidding新模块仓库 | 产线业务资产独立仓库,宿主 load_pipeline_bidding() 装载 |
| 能力模块bid_*_capability / bid_flow / bid_common | pipeline-bidding 仓库 | 产线能力包住自己仓库,宿主不装零接触 |
| 主 agent 能力包 `bid_ability.py` | pipeline-bidding 仓库 | 与能力模块同仓ability 注册表在 core任何模块可注册 |
| 表 / CRUD / UI / dspy / RBAC | pipeline-bidding 仓库 | 产线业务资产独立仓库,宿主 load_pipeline_bidding() 装载 |
| 技能 | pipeline-core/skills_library/pipelines/bidding_general | 技能库统一目录,部署期同步到机构工作空间 |
| 引擎改动 | 仅 `exec_capability_tool` 支持全路径 module8 行) | 引擎零投标特判 |
---
## 八、验收口径
## 八、门限参数appbase params 表 + DEFAULT_PARAMS 兜底)
1. `bid_*` 12 张表建表成功CRUD 页面可增删改查。
2. `fetch_tenders` 真连阿里云企业邮 IMAP 取到邮件并入库(凭据来自 `bid_mail_accounts`)。
3. `summarize_tender` 真调 LLM 产出摘要与 5 要素并落库。
4. 审批不通过 → tender 状态 archived通过 → sd_projects 出现 pipeline_id=bidding_general 的项目。
5. 上传招标文件人类任务 pending 时poller 不派发该项目 agent 任务(项目级门禁生效)。
| 参数 | 默认 | 含义 |
|---|---|---|
| `bid_chapter_pass_ratio` | 0.8 | 章节评审通过门限(得分率) |
| `bid_pass_ratio` | 0.85 | 整书评分通过门限(得分率) |
| `bid_max_revise` | 3 | 单章节最大重做次数 |
| `bid_max_round` | 3 | 整书最大评分轮次 |
| `bid_qc_pass_score` | 9.5 | 解析产出 QC 契合度通过分10 分制,严格大于才放行) |
| `bid_qc_max_round` | 3 | 单类产出 QC 最大审核轮次 / 解析重做轮次上限 |
---
## 九、验收口径
1. `bid_*` 12 张表建表成功CRUD 页面可增删改查(含 bid_qc_reviews
2. 上传招标文件人类任务 pending 时,对账器不派发该项目任何角色任务(门禁生效);上传后任务自动关闭。
3. 解析任务产出评分项/资质/投标文件要求/章节骨架后,对账器自动派 `agent.qc` 审核任务。
4. QC 审核得分 >9.5 → 该类放行进入下一阶段≤9.5 → 该类产出被清空 + 解析重做任务携带改进意见DB 可查)。
5. QC 轮次超限 → `qc_escalation` 阻塞人工任务创建,项目暂停派发。
6. 章节评审不达标 → 章节 rejected + 自动派生重写任务DB 可查)。
7. 整书评分不达标 → bid_scores 有 improvement + target_chapter_id命中章节回 rejected。
8. 全流程任务链在 `pipeline_tasks` 中可追溯role 从 tender_scout 到 bid_scorer
8. 全流程任务链在 `pipeline_tasks` 中可追溯role 从 tender_analyst 到 bid_scorer,含 agent.qc)。

View File

@ -1,23 +1,22 @@
招标信息: Tender Notices
招标信息台: Tender Inbox
邮箱配置: Mail Account Config
投标产线: Bidding Pipeline
投标驾驶舱: Bidding Cockpit
招标商务要素: Tender Business Facts
投标知识库: Bid Knowledge Base
评分项: Scoring Items
标书章节: Bid Chapters
合成标书: Assembled Bid Document
采集招标信息: Fetch Tender Notices
提交审批: Submit for Approval
审批通过: Approved
审批不通过: Rejected
存档作废: Archived / Voided
立项: Create Project
创建投标项目: Create Bid Project
参与人员: Team Members
上传招标文件: Upload Tender Document
招标文件解析: Tender Document Parsing
解析QC审核: Parsing QC Review
契合度得分: Fit Score
通过分: Pass Score
改进意见: Improvement Suggestions
资质要求: Qualification Requirements
投标文件要求: Bid Document Requirements
章节评审: Chapter Review
整书评分: Whole-document Scoring
改进意见: Improvement Suggestions
得分规则: Scoring Rules
评审通过: Review Passed
退回重写: Returned for Rewrite

View File

@ -1,23 +1,22 @@
招标信息: 招标信息
招标信息台: 招标信息台
邮箱配置: 邮箱配置
投标产线: 投标产线
投标驾驶舱: 投标驾驶舱
招标商务要素: 招标商务要素
投标知识库: 投标知识库
评分项: 评分项
标书章节: 标书章节
合成标书: 合成标书
采集招标信息: 采集招标信息
提交审批: 提交审批
审批通过: 审批通过
审批不通过: 审批不通过
存档作废: 存档作废
立项: 立项
创建投标项目: 创建投标项目
参与人员: 参与人员
上传招标文件: 上传招标文件
招标文件解析: 招标文件解析
解析QC审核: 解析QC审核
契合度得分: 契合度得分
通过分: 通过分
改进意见: 改进意见
资质要求: 资质要求
投标文件要求: 投标文件要求
章节评审: 章节评审
整书评分: 整书评分
改进意见: 改进意见
得分规则: 得分规则
评审通过: 评审通过
退回重写: 退回重写

View File

@ -4,29 +4,27 @@
"id": "bidding_general",
"name": "通用投标产线",
"pipeline_type": "bidding",
"description": "招标信息采集→内容摘要→审批→立项与人员→招标文件解析(评分项/得分规则/资质/投标文件要求)→资质准备→分章节编写→章节评审→合成标书→整书评分→交付",
"description": "上传招标文件→解析(评分项/得分规则/资质/投标文件要求/章节骨架)→QC契合度审核→资质准备→分章节编写→章节评审→合成标书→整书评分→交付。招标信息的采集/摘要/审批归商机产线。",
"status": "published",
"org_id": "0"
}
],
"appcodes": [
{
"parentid": "bid_tender_status",
"parentname": "招标信息状态",
"parentid": "bid_qc_type",
"parentname": "QC审核对象",
"items": [
{"k": "new", "v": "新采集"},
{"k": "summarized", "v": "已摘要"},
{"k": "pending_approval", "v": "待审批"},
{"k": "approved", "v": "审批通过"},
{"k": "rejected", "v": "审批不通过"},
{"k": "archived", "v": "存档作废"}
{"k": "scoring_items", "v": "评分项+得分规则"},
{"k": "qualifications", "v": "所需资质"},
{"k": "doc_requirements", "v": "投标文件要求"},
{"k": "chapter_outline", "v": "章节骨架"}
]
},
{
"parentid": "bid_tender_source",
"parentname": "招标信息来源",
"items": [
{"k": "mail", "v": "邮件采集"},
{"k": "tender_doc", "v": "招标文件解析"},
{"k": "manual", "v": "手工录入"}
]
},

View File

@ -1,42 +0,0 @@
{
"tblname": "bid_mail_accounts",
"title": "招标邮箱配置",
"params": {
"sortby": [
"created_at desc"
],
"confidential_fields": [],
"browserfields": {
"exclouded": [
"password",
"last_uid"
]
},
"editexclouded": [
"id",
"created_at",
"updated_at",
"last_uid",
"last_fetch_at"
],
"editable": {
"new_data_url": "{{entire_url('add_bid_mail_accounts.dspy')}}",
"update_data_url": "{{entire_url('update_bid_mail_accounts.dspy')}}",
"delete_data_url": "{{entire_url('delete_bid_mail_accounts.dspy')}}"
},
"data_filter": {
"AND": [
{
"field": "account_name",
"op": "LIKE",
"var": "name_input"
},
{
"field": "enabled",
"op": "=",
"var": "enabled_input"
}
]
}
}
}

44
json/bid_qc_reviews.json Normal file
View File

@ -0,0 +1,44 @@
{
"tblname": "bid_qc_reviews",
"title": "解析产出QC审核",
"params": {
"sortby": [
"qc_type",
"round desc"
],
"confidential_fields": [],
"browserfields": {
"exclouded": [
"comments"
]
},
"editexclouded": [
"id",
"created_at",
"updated_at",
"task_id",
"reviewer",
"review_type"
],
"editable": {
"new_data_url": "{{entire_url('add_bid_qc_reviews.dspy')}}",
"update_data_url": "{{entire_url('update_bid_qc_reviews.dspy')}}",
"delete_data_url": "{{entire_url('delete_bid_qc_reviews.dspy')}}"
},
"data_filter": {
"AND": [
{
"field": "project_id",
"op": "=",
"var": "project_id"
},
{
"field": "qc_type",
"op": "=",
"var": "qc_type_input"
}
]
},
"subtables": []
}
}

View File

@ -1,19 +1,15 @@
{
"tblname": "bid_tenders",
"title": "招标信息",
"title": "招标商务要素",
"params": {
"sortby": [
"received_at desc"
"created_at desc"
],
"confidential_fields": [],
"browserfields": {
"exclouded": [
"raw_content",
"attach_json",
"key_dates",
"org_id",
"account_id",
"mail_uid",
"risk_note"
]
},
@ -21,7 +17,6 @@
"id",
"created_at",
"updated_at",
"approval_task_id",
"project_id"
],
"editable": {
@ -37,9 +32,9 @@
"var": "title_input"
},
{
"field": "status",
"field": "project_id",
"op": "=",
"var": "status_input"
"var": "project_id"
},
{
"field": "purchaser",

View File

@ -1,159 +0,0 @@
{
"summary": [
{
"name": "bid_mail_accounts",
"title": "招标邮箱配置",
"primary": [
"id"
],
"catelog": "entity"
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "机构ID",
"type": "str",
"length": 32,
"nullable": "no",
"default": "0"
},
{
"name": "account_name",
"title": "配置名称",
"type": "str",
"length": 128,
"nullable": "no"
},
{
"name": "protocol",
"title": "协议",
"type": "str",
"length": 16,
"nullable": "no",
"default": "imap"
},
{
"name": "host",
"title": "服务器地址",
"type": "str",
"length": 128,
"nullable": "no"
},
{
"name": "port",
"title": "端口",
"type": "int",
"nullable": "no",
"default": "993"
},
{
"name": "use_ssl",
"title": "使用SSL",
"type": "str",
"length": 1,
"nullable": "no",
"default": "1"
},
{
"name": "username",
"title": "登录账号",
"type": "str",
"length": 128,
"nullable": "no"
},
{
"name": "password",
"title": "登录密码(AES加密)",
"type": "str",
"length": 500,
"nullable": "no"
},
{
"name": "folder",
"title": "邮件目录",
"type": "str",
"length": 64,
"default": "INBOX"
},
{
"name": "filter_keywords",
"title": "招标关键词(逗号分隔)",
"type": "str",
"length": 500
},
{
"name": "fetch_days",
"title": "回溯天数",
"type": "int",
"default": "7"
},
{
"name": "last_uid",
"title": "上次同步UID",
"type": "str",
"length": 64
},
{
"name": "last_fetch_at",
"title": "上次同步时间",
"type": "timestamp"
},
{
"name": "enabled",
"title": "启用",
"type": "str",
"length": 1,
"nullable": "no",
"default": "1"
},
{
"name": "status",
"title": "状态",
"type": "str",
"length": 16,
"default": "active"
},
{
"name": "created_by",
"title": "创建人",
"type": "str",
"length": 32
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
],
"indexes": [
{
"name": "idx_bma_org",
"idxtype": "index",
"idxfields": [
"org_id"
]
},
{
"name": "idx_bma_enabled",
"idxtype": "index",
"idxfields": [
"enabled"
]
}
],
"codes": []
}

129
models/bid_qc_reviews.json Normal file
View File

@ -0,0 +1,129 @@
{
"summary": [
{
"name": "bid_qc_reviews",
"title": "解析产出QC审核记录",
"primary": [
"id"
],
"catelog": "entity"
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "project_id",
"title": "项目ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "qc_type",
"title": "审核对象",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "round",
"title": "审核轮次",
"type": "int",
"nullable": "no",
"default": "1"
},
{
"name": "reviewer",
"title": "审核方",
"type": "str",
"length": 64
},
{
"name": "review_type",
"title": "审核类型",
"type": "str",
"length": 16,
"default": "agent"
},
{
"name": "fit_score",
"title": "契合度得分(10分制)",
"type": "float",
"length": 4,
"dec": 2
},
{
"name": "pass_score",
"title": "通过分",
"type": "float",
"length": 4,
"dec": 2
},
{
"name": "passed",
"title": "是否通过",
"type": "str",
"length": 1,
"nullable": "no",
"default": "0"
},
{
"name": "improvement",
"title": "改进意见",
"type": "text"
},
{
"name": "comments",
"title": "审核说明",
"type": "text"
},
{
"name": "task_id",
"title": "关联任务ID",
"type": "str",
"length": 32
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
],
"indexes": [
{
"name": "idx_bqr_project",
"idxtype": "index",
"idxfields": [
"project_id"
]
},
{
"name": "idx_bqr_type",
"idxtype": "index",
"idxfields": [
"qc_type"
]
}
],
"codes": [
{
"field": "qc_type",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='bid_qc_type'"
}
]
}

View File

@ -2,7 +2,7 @@
"summary": [
{
"name": "bid_tenders",
"title": "招标信息",
"title": "招标商务要素",
"primary": [
"id"
],
@ -26,51 +26,18 @@
"default": "0"
},
{
"name": "source",
"title": "信息来源",
"type": "str",
"length": 16,
"nullable": "no",
"default": "mail"
},
{
"name": "account_id",
"title": "邮箱配置ID",
"name": "project_id",
"title": "项目ID",
"type": "str",
"length": 32
},
{
"name": "mail_uid",
"title": "邮件UID",
"name": "source",
"title": "来源",
"type": "str",
"length": 64
},
{
"name": "mail_from",
"title": "发件人",
"type": "str",
"length": 255
},
{
"name": "mail_subject",
"title": "邮件主题",
"type": "str",
"length": 500
},
{
"name": "received_at",
"title": "收信时间",
"type": "timestamp"
},
{
"name": "raw_content",
"title": "邮件原文",
"type": "text"
},
{
"name": "attach_json",
"title": "附件清单(JSON)",
"type": "text"
"length": 16,
"nullable": "no",
"default": "tender_doc"
},
{
"name": "title",
@ -98,18 +65,20 @@
},
{
"name": "summary",
"title": "内容摘要",
"title": "项目概况摘要",
"type": "text"
},
{
"name": "bid_start_at",
"title": "投标开始时间",
"type": "date"
"type": "str",
"length": 16
},
{
"name": "bid_deadline",
"title": "投标截止时间",
"type": "date"
"type": "str",
"length": 16
},
{
"name": "project_period",
@ -121,14 +90,14 @@
"name": "budget_amount",
"title": "招标金额",
"type": "float",
"length": 18,
"length": 14,
"dec": 2
},
{
"name": "deposit_amount",
"title": "投标保证金",
"type": "float",
"length": 18,
"length": 14,
"dec": 2
},
{
@ -138,7 +107,7 @@
},
{
"name": "key_dates",
"title": "关键时间节点(JSON)",
"title": "关键节点(JSON)",
"type": "text"
},
{
@ -151,32 +120,13 @@
"title": "状态",
"type": "str",
"length": 16,
"nullable": "no",
"default": "new"
},
{
"name": "approval_task_id",
"title": "审批任务ID",
"type": "str",
"length": 32
},
{
"name": "project_id",
"title": "立项项目ID",
"type": "str",
"length": 32
},
{
"name": "archive_reason",
"title": "作废/存档原因",
"type": "str",
"length": 500
"default": "summarized"
},
{
"name": "created_by",
"title": "创建人",
"type": "str",
"length": 32
"length": 64
},
{
"name": "created_at",
@ -198,20 +148,6 @@
"org_id"
]
},
{
"name": "idx_bt_status",
"idxtype": "index",
"idxfields": [
"status"
]
},
{
"name": "idx_bt_mailuid",
"idxtype": "index",
"idxfields": [
"mail_uid"
]
},
{
"name": "idx_bt_project",
"idxtype": "index",
@ -228,13 +164,6 @@
}
],
"codes": [
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='bid_tender_status'"
},
{
"field": "source",
"table": "appcodes_kv",

View File

@ -1,50 +1,4 @@
-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_mail_accounts.json
-- 建库时请用以下语句支持emoji字符
-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
drop table if exists bid_mail_accounts;
CREATE TABLE bid_mail_accounts
(
`id` VARCHAR(32) NOT NULL comment '主键ID',
`org_id` VARCHAR(32) NOT NULL DEFAULT '0' comment '机构ID',
`account_name` VARCHAR(128) NOT NULL comment '配置名称',
`protocol` VARCHAR(16) NOT NULL DEFAULT 'imap' comment '协议',
`host` VARCHAR(128) NOT NULL comment '服务器地址',
`port` int NOT NULL DEFAULT '993' comment '端口',
`use_ssl` VARCHAR(1) NOT NULL DEFAULT '1' comment '使用SSL',
`username` VARCHAR(128) NOT NULL comment '登录账号',
`password` VARCHAR(500) NOT NULL comment '登录密码(AES加密)',
`folder` VARCHAR(64) DEFAULT 'INBOX' comment '邮件目录',
`filter_keywords` VARCHAR(500) comment '招标关键词(逗号分隔)',
`fetch_days` int DEFAULT '7' comment '回溯天数',
`last_uid` VARCHAR(64) comment '上次同步UID',
`last_fetch_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '上次同步时间',
`enabled` VARCHAR(1) NOT NULL DEFAULT '1' comment '启用',
`status` VARCHAR(16) DEFAULT 'active' comment '状态',
`created_by` VARCHAR(32) comment '创建人',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间'
,primary key(id)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '招标邮箱配置'
;
CREATE INDEX bid_mail_accounts_idx_bma_org ON bid_mail_accounts(org_id);
CREATE INDEX bid_mail_accounts_idx_bma_enabled ON bid_mail_accounts(enabled);
-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_scoring_items.json
@ -302,7 +256,6 @@ CREATE INDEX bid_reviews_idx_br_chapter ON bid_reviews(chapter_id);
-- 建库时请用以下语句支持emoji字符
-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
drop table if exists bid_tenders;
@ -311,32 +264,23 @@ CREATE TABLE bid_tenders
`id` VARCHAR(32) NOT NULL comment '主键ID',
`org_id` VARCHAR(32) NOT NULL DEFAULT '0' comment '机构ID',
`source` VARCHAR(16) NOT NULL DEFAULT 'mail' comment '信息来源',
`account_id` VARCHAR(32) comment '邮箱配置ID',
`mail_uid` VARCHAR(64) comment '邮件UID',
`mail_from` VARCHAR(255) comment '发件人',
`mail_subject` VARCHAR(500) comment '邮件主题',
`received_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '收信时间',
`raw_content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '邮件原文',
`attach_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '附件清单(JSON)',
`project_id` VARCHAR(32) comment '项目ID',
`source` VARCHAR(16) NOT NULL DEFAULT 'tender_doc' comment '来源',
`title` VARCHAR(255) comment '项目名称',
`purchaser` VARCHAR(255) comment '招标人/采购人',
`tender_no` VARCHAR(64) comment '招标编号',
`industry` VARCHAR(64) comment '行业',
`summary` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '内容摘要',
`bid_start_at` date comment '投标开始时间',
`bid_deadline` date comment '投标截止时间',
`summary` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '项目概况摘要',
`bid_start_at` VARCHAR(16) comment '投标开始时间',
`bid_deadline` VARCHAR(16) comment '投标截止时间',
`project_period` VARCHAR(64) comment '项目周期',
`budget_amount` double(18,2) comment '招标金额',
`deposit_amount` double(18,2) comment '投标保证金',
`budget_amount` double(14,2) comment '招标金额',
`deposit_amount` double(14,2) comment '投标保证金',
`qualification_req` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '资质要求摘要',
`key_dates` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '关键时间节点(JSON)',
`key_dates` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '关键节点(JSON)',
`risk_note` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '风险提示',
`status` VARCHAR(16) NOT NULL DEFAULT 'new' comment '状态',
`approval_task_id` VARCHAR(32) comment '审批任务ID',
`project_id` VARCHAR(32) comment '立项项目ID',
`archive_reason` VARCHAR(500) comment '作废/存档原因',
`created_by` VARCHAR(32) comment '创建人',
`status` VARCHAR(16) DEFAULT 'summarized' comment '状态',
`created_by` VARCHAR(64) comment '创建人',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间'
@ -348,12 +292,10 @@ CREATE TABLE bid_tenders
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '招标信息'
comment '招标商务要素'
;
CREATE INDEX bid_tenders_idx_bt_org ON bid_tenders(org_id);
CREATE INDEX bid_tenders_idx_bt_status ON bid_tenders(status);
CREATE INDEX bid_tenders_idx_bt_mailuid ON bid_tenders(mail_uid);
CREATE INDEX bid_tenders_idx_bt_project ON bid_tenders(project_id);
CREATE INDEX bid_tenders_idx_bt_deadline ON bid_tenders(bid_deadline);
@ -524,3 +466,43 @@ comment '招标所需资质'
CREATE INDEX bid_qualifications_idx_bq_project ON bid_qualifications(project_id);
CREATE INDEX bid_qualifications_idx_bq_match ON bid_qualifications(match_status);
-- /home/ymq/work/pipeline/pipeline-bidding/models/bid_qc_reviews.json
-- 建库时请用以下语句支持emoji字符
-- CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
drop table if exists bid_qc_reviews;
CREATE TABLE bid_qc_reviews
(
`id` VARCHAR(32) NOT NULL comment '主键ID',
`project_id` VARCHAR(32) NOT NULL comment '项目ID',
`qc_type` VARCHAR(32) NOT NULL comment '审核对象',
`round` int NOT NULL DEFAULT '1' comment '审核轮次',
`reviewer` VARCHAR(64) comment '审核方',
`review_type` VARCHAR(16) DEFAULT 'agent' comment '审核类型',
`fit_score` double(4,2) comment '契合度得分(10分制)',
`pass_score` double(4,2) comment '通过分',
`passed` VARCHAR(1) NOT NULL DEFAULT '0' comment '是否通过',
`improvement` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '改进意见',
`comments` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '审核说明',
`task_id` VARCHAR(32) comment '关联任务ID',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间'
,primary key(id)
)
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
engine=innodb
comment '解析产出QC审核记录'
;
CREATE INDEX bid_qc_reviews_idx_bqr_project ON bid_qc_reviews(project_id);
CREATE INDEX bid_qc_reviews_idx_bqr_type ON bid_qc_reviews(qc_type);

View File

@ -39,6 +39,8 @@ BID_TOOLS = [
parameters={"req_type": "类型(可选 structure/format/seal/copies/deadline)"}, category="analysis"),
ToolDefinition(name="list_qualifications", description="查看招标所需资质及匹配状态matched/missing/human_required",
parameters={"match_status": "匹配状态(可选)"}, category="analysis"),
ToolDefinition(name="qc_report", description="查看解析产出 QC 契合度审核总览(评分项/资质/投标文件要求/章节骨架各类的最新得分与放行状态)",
parameters={}, category="analysis"),
# ── 知识库 ──
ToolDefinition(name="search_bid_kb", description="检索公司投标知识库(资质证书/业绩案例/模板/财务/人员)",
@ -75,10 +77,14 @@ BID_TOOLS = [
BID_PROMPT = """你是「投标产线」的驾驶舱 agent负责从上传招标文件到标书交付的全流程管理
创建投标项目 上传招标文件 解析评分项/得分规则/资质/投标文件要求/章节骨架
资质文档准备 分章节编写 章节评审 合成标书 整书评分 交付确认
解析产出 QC 契合度审核 资质文档准备 分章节编写 章节评审 合成标书 整书评分 交付确认
招标信息的采集/摘要/审批归商机产线本产线从上传招标文件起步用户要采集招标信息时说明去商机产线
## 流转由机制驱动(重要)
角色任务由系统状态对账器自动派发 15 秒一轮**不需要**手动为每个章节创建任务
- 解析产出须经 QC 契合度审核评分标准/得分规则/资质/投标文件要求/章节骨架逐类对照招标文件原文
10 分制打分得分高于通过分默认 9.5才放行不通过给改进意见 清空产出 解析重做 重审
- 章节评审不达标 章节自动退回重写
- 整书评分不达标 改进意见自动落到问题章节并退回重写 重新合成 重新评分
你的职责是立项与配人推动上传招标文件处理卡点与提问回答评审/评分中的人类决策
@ -88,11 +94,13 @@ BID_PROMPT = """你是「投标产线」的驾驶舱 agent负责从上传招
以下人类任务 pending 系统不会给该项目派发任何 agent 任务
- 等待上传招标文件tender_file_upload上传后自动完成
- 等待人类提供资质文件human_docs_request
遇到项目"不动了" diagnose_bid 看是不是卡在这两个门禁上缺什么就催人补什么不要硬推
- 解析产出 QC 连续不达标qc_escalation人工修正产出或在审核记录页强制放行
遇到项目"不动了" diagnose_bid 看是不是卡在门禁上缺什么就催人补什么不要硬推
## 典型场景
- 用户说创建一个投标项目 XXX create_bid_project(name=XXX)随后提示上传招标文件
- 用户问招标文件上传了吗 / 解析出来了吗 list_tender_files 看上传与抽取状态
- 用户问解析质量怎么样 / QC 过了吗 qc_report讲清哪类产出通过哪类还在改
- 用户问进展 diagnose_bid含章节分布在办任务最近评分不要只报"正在进行"
- 用户问能得多少分 / 差在哪 bid_score_report + list_reviews讲清失分项与改进意见落到了哪个章节
- 用户要补资质/案例 add_kb_doc 录入公司投标知识库知识库缺的用 request_human_docs 让人工提供
@ -298,6 +306,14 @@ async def _h_request_human_docs(sor, p, ctx):
return _fmt(ok, msg)
async def _h_qc_report(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
return err
from .bid_qc_capability import qc_report
return await qc_report(pid)
async def _h_list_chapters(sor, p, ctx):
pid, err = _need_project(ctx)
if err:
@ -364,6 +380,7 @@ BID_HANDLERS = {
"list_scoring_items": _h_list_scoring_items,
"list_doc_requirements": _h_list_doc_requirements,
"list_qualifications": _h_list_qualifications,
"qc_report": _h_qc_report,
"search_bid_kb": _h_search_bid_kb,
"list_kb_docs": _h_list_kb_docs,
"add_kb_doc": _h_add_kb_doc,
@ -407,6 +424,8 @@ def register_bid_ability():
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
{"label": "📑 评分项", "icon": "", "url": "/pipeline-bidding/bid_scoring_items/index.ui",
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
{"label": "🔍 解析QC审核", "icon": "", "url": "/pipeline-bidding/bid_qc_reviews/index.ui",
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
{"label": "📝 标书章节", "icon": "", "url": "/pipeline-bidding/bid_chapters/index.ui",
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
{"label": "🎯 资质", "icon": "", "url": "/pipeline-bidding/bid_qualifications/index.ui",

View File

@ -22,13 +22,8 @@ CH_REVIEWING = "reviewing"
CH_APPROVED = "approved"
CH_REJECTED = "rejected"
# ── 招标信息状态 ──
TD_NEW = "new"
# ── 招标信息状态bid_tenders 现仅承载解析抽取的商务要素,无审批流)──
TD_SUMMARIZED = "summarized"
TD_PENDING_APPROVAL = "pending_approval"
TD_APPROVED = "approved"
TD_REJECTED = "rejected"
TD_ARCHIVED = "archived"
# ── 标书状态 ──
DOC_DRAFT = "draft"
@ -38,12 +33,27 @@ DOC_REJECTED = "rejected"
DOC_DELIVERED = "delivered"
# ── 人类任务类型(阻塞门禁)──
HT_TENDER_APPROVAL = "tender_approval"
HT_TENDER_FILE = "tender_file_upload"
HT_HUMAN_DOCS = "human_docs_request"
HT_QC_ESCALATION = "qc_escalation"
HT_DELIVERY_CONFIRM = "bid_delivery_confirm"
BLOCKING_HT_TYPES = (HT_TENDER_FILE, HT_HUMAN_DOCS)
BLOCKING_HT_TYPES = (HT_TENDER_FILE, HT_HUMAN_DOCS, HT_QC_ESCALATION)
# ── QC 契合度审核类型(解析产出逐类审核)──
QC_TYPE_SCORING = "scoring_items" # 评分项 + 得分规则
QC_TYPE_QUALS = "qualifications" # 所需资质
QC_TYPE_REQS = "doc_requirements" # 投标文件要求
QC_TYPE_OUTLINE = "chapter_outline" # 章节骨架
QC_TYPES = (QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE)
# QC 审核对象 → 产出物落库表QC 不通过时清空重做的对象;对账器判存在性也用)
QC_OUTPUT_TABLE = {
QC_TYPE_SCORING: "bid_scoring_items",
QC_TYPE_QUALS: "bid_qualifications",
QC_TYPE_REQS: "bid_doc_requirements",
QC_TYPE_OUTLINE: "bid_chapters",
}
# 任务未终结状态(判断「是否已有在办任务」用)
OPEN_TASK_STATES = ("submitted", "running", "review", "qc_review")
@ -55,6 +65,8 @@ DEFAULT_PARAMS = {
"bid_pass_ratio": "0.85", # 整书评分通过门限
"bid_max_revise": "3", # 单章节最大重做次数
"bid_max_round": "3", # 整书最大评分轮次
"bid_qc_pass_score": "9.5", # 解析产出 QC 契合度通过分10 分制,高于此分才放行)
"bid_qc_max_round": "3", # 单类产出 QC 最大审核轮次(超限抛人工)
}
@ -167,12 +179,14 @@ async def resolve_project_id(project_id="", **entity_ids):
async def get_thresholds(sor):
"""四个门限参数(章节门限/整书门限/章节重做上限/整书轮次上限)。"""
"""门限参数(章节/整书/重做上限/轮次上限 + QC 契合度门限)。"""
return {
"chapter_pass_ratio": to_float(await get_param(sor, "bid_chapter_pass_ratio"), 0.8),
"bid_pass_ratio": to_float(await get_param(sor, "bid_pass_ratio"), 0.85),
"max_revise": to_int(await get_param(sor, "bid_max_revise"), 3),
"max_round": to_int(await get_param(sor, "bid_max_round"), 3),
"qc_pass_score": to_float(await get_param(sor, "bid_qc_pass_score"), 9.5),
"qc_max_round": to_int(await get_param(sor, "bid_qc_max_round"), 3),
}

View File

@ -23,7 +23,9 @@ from .bid_common import (
create_human_task, get_project, PIPELINE_ID,
CH_PENDING, CH_WRITING, CH_WRITTEN, CH_REJECTED, CH_APPROVED,
DOC_DRAFT, DOC_REVIEWING, DOC_PASSED, DOC_REJECTED, DOC_DELIVERED,
HT_TENDER_FILE, HT_HUMAN_DOCS, HT_DELIVERY_CONFIRM,
HT_TENDER_FILE, HT_HUMAN_DOCS, HT_QC_ESCALATION, HT_DELIVERY_CONFIRM,
QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE, QC_TYPES,
QC_OUTPUT_TABLE,
)
logger = logging.getLogger("pipeline.bidding")
@ -34,8 +36,9 @@ R_WRITER = "agent.bid_writer"
R_REVIEWER = "agent.bid_reviewer"
R_COMPOSITOR = "agent.bid_compositor"
R_SCORER = "agent.bid_scorer"
R_QC = "agent.qc"
MAX_ANALYST_ATTEMPT = 2 # 解析阶段最多重派次数(防死循环)
MAX_ANALYST_ATTEMPT = 2 # 解析阶段首跑最多重派次数(防死循环QC 退回的重做不计入
MAX_PREP_ATTEMPT = 2
POLL_INTERVAL = 15
@ -75,6 +78,67 @@ async def _auto_close_file_upload_task(sor, project_id):
return True
# ══════════════ QC 门禁辅助 ══════════════
async def _qc_pending_types(sor, project_id):
"""返回未放行的产出类型清单(按依赖序),每项 (qc_type, state, improvement, round)
state=unaudited产出已存在但从未审核state=rework最近一轮审核未通过
已通过或人工在记录页强制放行的类型不再出现
"""
pending = []
for t in QC_TYPES:
tbl = QC_OUTPUT_TABLE[t]
n_out = await _count(sor, "SELECT COUNT(*) AS c FROM " + tbl +
" WHERE project_id=${p}$", {"p": project_id})
recs = await sor.sqlExe(
"SELECT round, passed, improvement FROM bid_qc_reviews "
"WHERE project_id=${p}$ AND qc_type=${t}$ ORDER BY round DESC LIMIT 1",
{"p": project_id, "t": t})
await sor.sqlExe("COMMIT", {})
if not recs:
if n_out > 0:
pending.append((t, "unaudited", "", 0))
continue
r = rec_to_dict(recs[0])
if str(r.get("passed")) == "1":
continue
rnd = to_int(r.get("round"), 1)
if n_out > 0:
pending.append((t, "rework", r.get("improvement") or "", rnd))
else:
# 产出已被清空待重做round 信息带回,供对账器判断是否已超轮次上限
pending.append((t, "awaiting_redo", r.get("improvement") or "", rnd))
return pending
async def _qc_collect_improvements(sor, project_id):
"""汇总各类产出最新一轮的改进意见(解析重做任务的 params.qc_improvements"""
items = []
for t in QC_TYPES:
recs = await sor.sqlExe(
"SELECT round, passed, improvement FROM bid_qc_reviews "
"WHERE project_id=${p}$ AND qc_type=${t}$ ORDER BY round DESC LIMIT 1",
{"p": project_id, "t": t})
await sor.sqlExe("COMMIT", {})
if not recs:
continue
r = rec_to_dict(recs[0])
if str(r.get("passed")) == "1" or not (r.get("improvement") or "").strip():
continue
items.append("[%s] 第%s轮:%s" % (t, r.get("round"), r["improvement"]))
return items
async def _redo_done_count(sor, project_id):
"""已完成的 QC 退回重做任务数(解析重做轮次计数,防无限重派)。"""
return await _count(
sor, "SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
"AND pipeline_id='role_task' AND role=${r}$ AND state IN "
"('approved','completed') AND params LIKE ${kw}$",
{"p": project_id, "r": R_ANALYST, "kw": "%qc_redo%"})
async def reconcile_project(sor, project_id, project_name=""):
"""对单个投标项目做一次状态对账。返回动作说明列表(幂等,可反复调用)。"""
acts = []
@ -122,7 +186,7 @@ async def reconcile_project(sor, project_id, project_name=""):
await sor.sqlExe("COMMIT", {})
return acts + ["created: 上传招标文件任务 %s" % hid]
if await has_pending_blocking_human_task(sor, project_id):
return ["blocked: 有待办人类任务(等招标文件/等人类资料),本轮不派发"]
return ["blocked: 有待办人类任务(等招标文件/等人类资料/等QC人工介入),本轮不派发"]
th = await get_thresholds(sor)
@ -156,8 +220,39 @@ async def reconcile_project(sor, project_id, project_name=""):
if need_analysis:
if await count_open_tasks(sor, project_id, role=R_ANALYST) > 0:
return acts + ["waiting: 招标文件解析任务在办"]
# 携带 QC 改进意见的重做任务qc_redo=1不计入首跑重试上限
n_redo_open = await _count(
sor, "SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
"AND pipeline_id='role_task' AND role=${r}$ AND state IN "
"('submitted','running','review','qc_review') AND params LIKE ${kw}$",
{"p": project_id, "r": R_ANALYST, "kw": "%qc_redo%"})
attempts = await _done_task_count(sor, project_id, R_ANALYST)
if attempts >= MAX_ANALYST_ATTEMPT:
n_redo_done = await _redo_done_count(sor, project_id)
first_attempts = max(0, attempts - n_redo_done)
qc_imp = await _qc_collect_improvements(sor, project_id)
if qc_imp or n_redo_open > 0:
# QC 退回重做:携带改进意见,不受首跑重试上限约束;但重做轮次有上限(防无限循环)
if n_redo_done >= th["qc_max_round"]:
ht = await find_human_task(sor, project_id, "general", status="pending")
if not ht:
await create_human_task(
sor, project_id, "general",
"解析重做轮次已用尽,需人工介入",
("QC 退回后解析已重做 %d 轮,产出仍未通过契合度审核:\n%s\n\n"
"请人工核对招标文件并修正产出物(评分项/资质/投标文件要求管理页),"
"或在 QC 审核记录页把对应记录 passed 改为 1 强制放行。"
% (n_redo_done, "\n".join(qc_imp)[:3000])),
assignee_role="owner.superuser")
await sor.sqlExe("COMMIT", {})
return acts + ["escalated: QC 重做轮次用尽 → 人工介入"]
tid = await create_role_task(
sor, project_id, R_ANALYST,
"%s 招标文件解析重做QC 退回,按改进意见修正)" % pname,
{"stage": "analysis_redo", "task_kind": "bid_analysis", "qc_redo": 1,
"qc_improvements": qc_imp})
await sor.sqlExe("COMMIT", {})
return acts + ["created: 解析重做任务 %sQC 改进意见 %d 条)" % (tid, len(qc_imp))]
if first_attempts >= MAX_ANALYST_ATTEMPT:
ht = await find_human_task(sor, project_id, "general", status="pending")
if not ht:
await create_human_task(
@ -165,7 +260,7 @@ async def reconcile_project(sor, project_id, project_name=""):
("解析任务已完成 %d 次,但仍缺少:%s\n"
"请人工检查招标文件是否可读bid_tender_files.content_text"
"或人工补录评分项/投标文件要求后完成本任务。"
% (attempts,
% (first_attempts,
"".join(x for x in [
"评分项" if n_items == 0 else "",
"投标文件章节结构" if n_struct == 0 else "",
@ -181,6 +276,69 @@ async def reconcile_project(sor, project_id, project_name=""):
await sor.sqlExe("COMMIT", {})
return acts + ["created: 解析任务 %s" % tid]
# ── A2. 解析产出 QC 门禁(评分标准/得分规则/资质/投标文件要求/章节骨架的契合度审核)──
# 每类产出须经 agent.qc 对照招标文件原文按 10 分制打分,得分高于通过分(默认 9.5)才放行。
qc_pending = await _qc_pending_types(sor, project_id)
if qc_pending:
# 轮次用尽守卫:最新审核已达上限仍未通过 → 不再空转派审核任务,
# 确保有阻塞人工任务(逃逸阀:人工修产出物或在记录页强制放行后流程自恢复)。
over = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending
if s == "rework" and rnd >= th["qc_max_round"]]
if over:
ht = await find_human_task(sor, project_id, HT_QC_ESCALATION, status="pending")
if not ht:
await create_human_task(
sor, project_id, HT_QC_ESCALATION,
"解析产出 QC 连续不达标需人工介入",
("以下产出物审核已达 %d 轮上限仍未达标:\n%s\n\n"
"请人工核对招标文件并修正产出物,或在 QC 审核记录页bid_qc_reviews"
"把对应记录 passed 改为 1 强制放行。\n处理完成本任务后流程自动恢复。"
% (th["qc_max_round"],
"\n".join("- %s(第%s轮):%s" % (t, rnd, (imp or "")[:300])
for t, s, imp, rnd in over))),
assignee_role="owner.superuser")
await sor.sqlExe("COMMIT", {})
return acts + ["blocked: QC 审核轮次用尽(%s),等人工介入"
% "/".join(t for t, _, _, _ in over)]
# awaiting_redo产出已被 QC 清空 → 先重派解析重做,不能对空产出审核
needs_redo = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending if s == "awaiting_redo"]
if needs_redo:
if await count_open_tasks(sor, project_id, role=R_ANALYST) > 0:
return acts + ["waiting: 解析重做任务在办QC 退回产出已清空)"]
n_redo_done = await _redo_done_count(sor, project_id)
if n_redo_done >= th["qc_max_round"]:
ht = await find_human_task(sor, project_id, "general", status="pending")
if not ht:
await create_human_task(
sor, project_id, "general",
"解析重做轮次已用尽,需人工介入",
("QC 退回后解析已重做 %d 轮,产出仍未通过契合度审核。\n"
"请人工核对招标文件并修正产出物,或在 QC 审核记录页强制放行。"
% n_redo_done),
assignee_role="owner.superuser")
await sor.sqlExe("COMMIT", {})
return acts + ["escalated: QC 重做轮次用尽 → 人工介入"]
qc_imp = await _qc_collect_improvements(sor, project_id)
tid = await create_role_task(
sor, project_id, R_ANALYST,
"%s 招标文件解析重做QC 退回产出已清空,按改进意见重抽)" % pname,
{"stage": "analysis_redo", "task_kind": "bid_analysis", "qc_redo": 1,
"qc_improvements": qc_imp})
await sor.sqlExe("COMMIT", {})
return acts + ["created: 解析重做任务 %s(产出被 QC 清空:%s"
% (tid, "/".join(t for t, _, _, _ in needs_redo))]
needs_qc = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending]
if await count_open_tasks(sor, project_id, role=R_QC) > 0:
return acts + ["waiting: 解析产出 QC 审核任务在办"]
types_str = "/".join(t for t, _, _, _ in needs_qc)
tid = await create_role_task(
sor, project_id, R_QC,
"%s 解析产出契合度审核(%s" % (pname, types_str),
{"stage": "qc_analysis", "task_kind": "bid_qc",
"qc_types": [t for t, _, _, _ in needs_qc]})
await sor.sqlExe("COMMIT", {})
return acts + ["created: QC 审核任务 %s%s" % (tid, types_str)]
# ── B. 资料准备(知识库资质匹配 + 人类文件清单)──
prep_done = await has_done_task(sor, project_id, R_PREP)
if n_quals > 0 and not prep_done:
@ -343,7 +501,7 @@ async def diagnose_bid_project(project_id):
await sor.sqlExe("COMMIT", {})
counts = {}
for tbl in ("bid_tender_files", "bid_scoring_items", "bid_qualifications",
"bid_doc_requirements", "bid_chapters"):
"bid_doc_requirements", "bid_qc_reviews", "bid_chapters"):
counts[tbl] = await _count(
sor, "SELECT COUNT(*) AS c FROM " + tbl + " WHERE project_id=${p}$",
{"p": project_id})

View File

@ -1,307 +0,0 @@
"""招标邮件采集IMAP 拉取 → 解析 → 落库 bid_tenders。
凭据来自 bid_mail_accounts password appPublic.aes AES 加密存储明文不入库不写死代码
阿里云企业邮imap.qiye.aliyun.com:993 (SSL)若开启安全登录password 需填客户端专用密码
IMAP 是阻塞库 统一放线程池执行run_in_executor不阻塞事件循环
"""
import asyncio
import email
import email.header
import email.utils
import imaplib
import json
import logging
import re
from datetime import datetime, timedelta
from .bid_common import (get_db, new_id, rec_to_dict, to_int, TD_NEW)
logger = logging.getLogger("pipeline.bidding.mail")
# 默认招标关键词bid_mail_accounts.filter_keywords 未配置时使用)
DEFAULT_KEYWORDS = ["招标", "投标", "采购", "询价", "竞争性磋商", "邀标", "标书", "中标", "资格预审"]
MAX_BODY = 60000 # 单封邮件正文入库上限
MAX_MAILS = 200 # 单次最多处理邮件数
def _decode_header(raw):
"""MIME 邮件头解码(中文主题/发件人)。"""
if not raw:
return ""
try:
parts = email.header.decode_header(raw)
out = []
for txt, enc in parts:
if isinstance(txt, bytes):
out.append(txt.decode(enc or "utf-8", errors="ignore"))
else:
out.append(txt)
return "".join(out).strip()
except Exception:
return str(raw)
def _body_text(msg):
"""提取邮件正文纯文本(优先 text/plain回退 text/html 去标签)。"""
plain, html = "", ""
if msg.is_multipart():
for part in msg.walk():
ctype = part.get_content_type()
disp = str(part.get("Content-Disposition") or "")
if "attachment" in disp:
continue
if ctype not in ("text/plain", "text/html"):
continue
try:
payload = part.get_payload(decode=True) or b""
charset = part.get_content_charset() or "utf-8"
txt = payload.decode(charset, errors="ignore")
except Exception:
continue
if ctype == "text/plain":
plain += txt + "\n"
else:
html += txt + "\n"
else:
try:
payload = msg.get_payload(decode=True) or b""
charset = msg.get_content_charset() or "utf-8"
txt = payload.decode(charset, errors="ignore")
except Exception:
txt = ""
if (msg.get_content_type() or "") == "text/html":
html = txt
else:
plain = txt
body = plain.strip()
if not body and html:
body = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", html, flags=re.S | re.I)
body = re.sub(r"<[^>]+>", " ", body)
body = re.sub(r"&nbsp;?", " ", body)
body = re.sub(r"[ \t]+", " ", body)
body = re.sub(r"\n{3,}", "\n\n", body)
return body.strip()[:MAX_BODY]
def _attachments(msg):
"""附件清单 [{name, size, ctype}](只记录元信息,文件由人工上传到 bid_tender_files"""
out = []
if not msg.is_multipart():
return out
for part in msg.walk():
disp = str(part.get("Content-Disposition") or "")
fname = part.get_filename()
if "attachment" not in disp and not fname:
continue
name = _decode_header(fname) if fname else ""
try:
size = len(part.get_payload(decode=True) or b"")
except Exception:
size = 0
out.append({"name": name, "size": size, "ctype": part.get_content_type()})
return out
def _match_keywords(subject, body, keywords):
text = (subject or "") + "\n" + (body or "")
for k in keywords:
k = (k or "").strip()
if k and k in text:
return k
return ""
def _fetch_sync(host, port, use_ssl, username, password, folder, days, keywords):
"""同步 IMAP 拉取(在线程池中执行)。返回 (mails, error)。"""
mails = []
m = None
try:
if use_ssl:
m = imaplib.IMAP4_SSL(host, port)
else:
m = imaplib.IMAP4(host, port)
m.login(username, password)
m.select(folder or "INBOX")
since = (datetime.now() - timedelta(days=max(1, days))).strftime("%d-%b-%Y")
typ, data = m.search(None, "(SINCE %s)" % since)
if typ != "OK":
return [], "IMAP search 失败: %s" % typ
ids = (data[0] or b"").split()
ids = ids[-MAX_MAILS:]
for i in ids:
typ, msg_data = m.fetch(i, "(RFC822)")
if typ != "OK" or not msg_data:
continue
raw = None
for chunk in msg_data:
if isinstance(chunk, tuple) and len(chunk) > 1:
raw = chunk[1]
break
if not raw:
continue
msg = email.message_from_bytes(raw)
subject = _decode_header(msg.get("Subject"))
body = _body_text(msg)
hit = _match_keywords(subject, body, keywords)
if not hit:
continue
recv = ""
try:
dt = email.utils.parsedate_to_datetime(msg.get("Date"))
if dt:
recv = dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
recv = ""
mails.append({
"uid": (msg.get("Message-ID") or "").strip()[:64] or ("seq-" + i.decode()),
"seq": i.decode(),
"from": _decode_header(msg.get("From"))[:250],
"subject": subject[:480],
"received_at": recv,
"body": body,
"attachments": _attachments(msg),
"keyword": hit,
})
return mails, ""
except imaplib.IMAP4.error as e:
return [], "IMAP 认证/协议错误: %s(阿里云企业邮开启安全登录后需使用客户端专用密码)" % str(e)[:200]
except Exception as e:
return [], "%s: %s" % (type(e).__name__, str(e)[:200])
finally:
if m is not None:
try:
m.logout()
except Exception:
pass
async def get_accounts(sor, org_id="", account_id=""):
"""取启用的邮箱配置(解密密码)。"""
sql = "SELECT * FROM bid_mail_accounts WHERE enabled='1'"
p = {}
if account_id:
sql += " AND id=${aid}$"
p["aid"] = account_id
if org_id:
sql += " AND org_id=${oid}$"
p["oid"] = org_id
recs = await sor.sqlExe(sql, p)
await sor.sqlExe("COMMIT", {})
out = []
for r in (recs or []):
d = rec_to_dict(r)
d["password_plain"] = _decrypt(d.get("password", ""))
out.append(d)
return out
def _decrypt(enc):
"""DB 密码解密AES明文兼容解密失败按明文用"""
if not enc:
return ""
try:
from appPublic.jsonConfig import getConfig
from appPublic.aes import aes_decode_b64
key = getConfig().password_key
if key:
v = aes_decode_b64(key, enc)
if v:
return v
except Exception:
pass
return enc
def encrypt_password(plain):
"""页面/脚本写入密码时调用AES 加密),解密失败场景保持明文兼容。"""
if not plain:
return ""
try:
from appPublic.jsonConfig import getConfig
from appPublic.aes import aes_encode_b64
key = getConfig().password_key
if key:
return aes_encode_b64(key, plain)
except Exception:
pass
return plain
async def fetch_account_mails(account, days=None):
"""按单个邮箱配置拉取邮件(线程池执行 IMAP。返回 (mails, error)。"""
kws = [k.strip() for k in (account.get("filter_keywords") or "").split(",") if k.strip()]
if not kws:
kws = DEFAULT_KEYWORDS
d = to_int(days if days is not None else account.get("fetch_days"), 7)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, _fetch_sync,
account.get("host") or "imap.qiye.aliyun.com",
to_int(account.get("port"), 993),
str(account.get("use_ssl") or "1") == "1",
account.get("username") or "",
account.get("password_plain") or "",
account.get("folder") or "INBOX",
d, kws)
async def fetch_tenders_to_db(org_id="", account_id="", days=None):
"""采集入口:拉邮件 → 按 mail_uid 去重 → 落库 bid_tenders(status=new)。
返回 (ok, message)message 含每个邮箱的新增/跳过数与错误原因诚实报错不静默
"""
db, dbname = get_db()
lines = []
total_new = 0
async with db.sqlorContext(dbname) as sor:
accounts = await get_accounts(sor, org_id=org_id, account_id=account_id)
if not accounts:
return False, ("未配置启用的招标邮箱bid_mail_accounts"
"请在「招标邮箱配置」页面添加host=imap.qiye.aliyun.com port=993 "
"username=邮箱地址 password=客户端专用密码")
for acc in accounts:
mails, err = await fetch_account_mails(acc, days=days)
if err:
lines.append("[%s] 采集失败: %s" % (acc.get("account_name") or acc.get("username"), err))
continue
new_n, skip_n = 0, 0
for mail in mails:
exist = await sor.sqlExe(
"SELECT id FROM bid_tenders WHERE mail_uid=${uid}$ AND account_id=${aid}$ LIMIT 1",
{"uid": mail["uid"], "aid": acc.get("id") or ""})
if exist:
skip_n += 1
continue
data = {
"id": new_id(),
"org_id": acc.get("org_id") or "0",
"source": "mail",
"account_id": acc.get("id") or "",
"mail_uid": mail["uid"],
"mail_from": mail["from"],
"mail_subject": mail["subject"],
"raw_content": mail["body"],
"attach_json": json.dumps(mail["attachments"], ensure_ascii=False),
"title": mail["subject"][:250],
"status": TD_NEW,
"created_by": "agent.tender_scout",
}
if mail.get("received_at"):
data["received_at"] = mail["received_at"]
await sor.C("bid_tenders", data)
new_n += 1
await sor.sqlExe(
"UPDATE bid_mail_accounts SET last_fetch_at=NOW(), updated_at=NOW() "
"WHERE id=${aid}$", {"aid": acc.get("id") or ""})
await sor.sqlExe("COMMIT", {})
total_new += new_n
lines.append("[%s] 命中招标邮件 %d 封,新增 %d,已存在跳过 %d"
% (acc.get("account_name") or acc.get("username"),
len(mails), new_n, skip_n))
msg = "\n".join(lines)
if total_new == 0 and all("采集失败" in x for x in lines):
return False, msg
return True, msg

View File

@ -0,0 +1,270 @@
"""解析产出 QC 契合度审核能力agent.qc
对应业务招标文件解析的每类产出物评分项+得分规则 / 资质 / 投标文件要求 / 章节骨架
必须由 QC 对照招标文件原文逐项核对契合度 10 分制评分**得分高于通过分默认 9.5才算完成**
否则给出改进意见 清空该产出物 对账器重派解析任务带意见重做 重审超轮次上限抛人工
机制分工与产线既有约定一致
- 状态流转归机制bid_flow 对账器派 QC 任务判门禁清空产出物触发重做
- 本模块只固化操作原语开轮start_qc/ 落分finish_qc/ 查询qc_reportlist_qc
"""
import json
import logging
from .bid_common import (
get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int,
record, create_human_task, find_human_task, get_thresholds, resolve_project_id,
QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE, QC_TYPES,
HT_QC_ESCALATION,
)
logger = logging.getLogger("pipeline.bidding.qc")
# 每类产出的审核清单start_qc 时随评审记录返回,指导 LLM 对照原文核对)
QC_DIMENSIONS = {
QC_TYPE_SCORING: (
"评分项+得分规则契合度审核(对照招标文件评标办法原文):\n"
"1. 完整性:评标办法中的每个评分项是否全部录入,无漏项、无编造项;\n"
"2. 满分准确:每项满分与原文一致(商务/技术/价格分合计核对);\n"
"3. 得分规则:档位/扣分/加分细则与原文一致,无概括失真;\n"
"4. 否决项:废标/否决条款全部标 is_veto=1无漏标\n"
"5. 归属正确:业务/技术/价格归类与出处source_ref可回溯原文。"
),
QC_TYPE_QUALS: (
"所需资质契合度审核(对照招标文件投标人资格要求原文):\n"
"1. 完整性:资格要求中的每项资质/证书/业绩门槛全部录入,无漏项;\n"
"2. 原文一致:要求描述与原文一致(等级/有效期/联合体口径);\n"
"3. 必需性:强制性条款标 is_mandatory=1加分性资质不得误标必需\n"
"4. 无编造:招标文件未要求的资质不得出现。"
),
QC_TYPE_REQS: (
"投标文件要求契合度审核(对照招标文件投标文件格式/要求原文):\n"
"1. 章节结构structure完整且顺序与原文一致章节号/标题准确;\n"
"2. 格式/密封/份数/截止时间等要求format/seal/copies/deadline无遗漏\n"
"3. 页数限制与原文一致;\n"
"4. 出处source_ref可回溯。"
),
QC_TYPE_OUTLINE: (
"章节骨架契合度审核(对照投标文件要求与评分项):\n"
"1. 章节与 req_type=structure 的要求一一对应,无缺章、无多余章;\n"
"2. 章节号/标题/顺序与投标文件要求一致;\n"
"3. 评分项挂载正确每个评分项能落到对应章节target_chapter_no/名称匹配),无错挂漏挂;\n"
"4. 章节要点来自评分标准原文枚举的子维度,不是通用目录。"
),
}
# 每类产出对应的落库表QC 不通过时清空重做的对象)
QC_OUTPUT_TABLE = {
QC_TYPE_SCORING: "bid_scoring_items",
QC_TYPE_QUALS: "bid_qualifications",
QC_TYPE_REQS: "bid_doc_requirements",
QC_TYPE_OUTLINE: "bid_chapters",
}
async def _qc_type_valid(sor, qc_type):
if qc_type in QC_TYPES:
return qc_type, ""
return "", "qc_type 非法(应为 %s" % "/".join(QC_TYPES)
async def list_qc(project_id, qc_type="", limit=50):
"""列出项目 QC 审核记录。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError:
return []
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sql = ("SELECT id, qc_type, round, reviewer, fit_score, pass_score, passed, "
"LEFT(improvement, 500) AS improvement, task_id, created_at "
"FROM bid_qc_reviews WHERE project_id=${pid}$")
p = {"pid": project_id}
if qc_type:
sql += " AND qc_type=${qt}$"
p["qt"] = qc_type
sql += " ORDER BY qc_type, round DESC, created_at DESC LIMIT " + str(to_int(limit, 50))
recs = await sor.sqlExe(sql, p)
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)
async def start_qc(qc_type, project_id="", who=None, agent_id=None, task_id=""):
"""开启一轮 QC 审核:创建/复用当轮评审记录,返回审核清单 + 该产出物的待核内容。
LLM 随后读招标文件原文list_tender_files/read_tender_file+ 查产出物表
对照审核清单逐项核对得出 0~10 契合度分 finish_qc 落分
"""
qc_type, err = await _qc_type_valid(None, qc_type)
if err:
return False, err
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:
th = await get_thresholds(sor)
# 轮次 = 该类型已有审核数 + 1同轮未落分的记录复用幂等
rn = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${t}$",
{"p": project_id, "t": qc_type})
await sor.sqlExe("COMMIT", {})
rnd = to_int(getattr(rn[0], "c", 0) if rn else 0) + 1
open_r = await sor.sqlExe(
"SELECT id, round FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${t}$ "
"AND fit_score IS NULL ORDER BY created_at DESC LIMIT 1",
{"p": project_id, "t": qc_type})
await sor.sqlExe("COMMIT", {})
if open_r:
rid = getattr(open_r[0], "id", "")
rnd = to_int(getattr(open_r[0], "round", 1), 1)
else:
rid = new_id()
await sor.C("bid_qc_reviews", {
"id": rid, "project_id": project_id, "qc_type": qc_type, "round": rnd,
"reviewer": (who or "agent.qc")[:60], "review_type": "agent",
"pass_score": th["qc_pass_score"], "passed": "0",
"task_id": task_id or "",
})
await sor.sqlExe("COMMIT", {})
# 待核产出物(截断防上下文爆炸)
out_rows, note = [], ""
tbl = QC_OUTPUT_TABLE[qc_type]
if qc_type == QC_TYPE_OUTLINE:
recs = await sor.sqlExe(
"SELECT chapter_no, title, scoring_item_ids, max_score, LEFT(outline, 300) AS outline "
"FROM bid_chapters WHERE project_id=${p}$ ORDER BY order_no, chapter_no LIMIT 200",
{"p": project_id})
elif qc_type == QC_TYPE_SCORING:
recs = await sor.sqlExe(
"SELECT section, item_no, item_name, max_score, is_veto, target_chapter_no, "
"LEFT(scoring_rule, 400) AS scoring_rule, source_ref "
"FROM bid_scoring_items WHERE project_id=${p}$ ORDER BY section, order_no LIMIT 300",
{"p": project_id})
elif qc_type == QC_TYPE_QUALS:
recs = await sor.sqlExe(
"SELECT qual_name, is_mandatory, LEFT(requirement, 400) AS requirement "
"FROM bid_qualifications WHERE project_id=${p}$ ORDER BY is_mandatory DESC LIMIT 300",
{"p": project_id})
else:
recs = await sor.sqlExe(
"SELECT req_type, chapter_no, chapter_title, LEFT(requirement, 400) AS requirement, "
"page_limit, source_ref FROM bid_doc_requirements WHERE project_id=${p}$ "
"ORDER BY order_no LIMIT 300", {"p": project_id})
await sor.sqlExe("COMMIT", {})
out_rows = rows_to_dicts(recs, limit=300)
if not out_rows:
note = "该类产出物为空,无法审核(应等解析任务产出后再开审)。"
return True, json.dumps({
"review_id": rid, "qc_type": qc_type, "round": rnd,
"pass_score": th["qc_pass_score"], "max_round": th["qc_max_round"],
"checklist": QC_DIMENSIONS[qc_type],
"outputs_table": tbl, "outputs": out_rows, "note": note,
"instruction": ("请读招标文件原文list_tender_files → read_tender_file 分段)并逐条对照上述清单核对,"
"得出 0~10 的契合度分(可带一位小数),然后调 finish_qc 落分。"
"不通过(得分未高于通过分)时 improvement 必须写清:漏了什么/错在哪/对照原文哪一处,"
"否则工具拒绝提交。"),
}, ensure_ascii=False, default=str)
async def finish_qc(review_id, fit_score, improvement="", comments="",
who=None, agent_id=None, task_id=""):
"""落分并判定:得分 > 通过分 → 该类产出放行;否则清空产出物 + 触发解析重做。
fit_score0~1010 分制契合度分
"""
if not review_id:
return False, "缺少 review_id"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
rv = await sor.sqlExe("SELECT * FROM bid_qc_reviews WHERE id=${r}$", {"r": review_id})
await sor.sqlExe("COMMIT", {})
if not rv:
return False, "QC 评审记录不存在: %s" % review_id
review = rec_to_dict(rv[0])
pid = review.get("project_id")
qc_type = review.get("qc_type")
th = await get_thresholds(sor)
sc = to_float(fit_score, -1.0)
if sc < 0 or sc > 10:
return False, "fit_score 必须为 0~10 的数字"
passed = sc > th["qc_pass_score"] # 严格高于通过分才算完成9.5 → 需 >9.5
if not passed and not (improvement or "").strip():
return False, ("QC 不通过(%.2f 未高于 %.2f)必须给出 improvement\n"
"逐项写清缺了什么、错在哪、对照招标文件原文哪一处,解析角色据此重做。"
% (sc, th["qc_pass_score"]))
summary_lines = ["契合度 %.2f/10通过线 >%.2f)→ %s"
% (sc, th["qc_pass_score"], "通过" if passed else "不通过")]
if comments:
summary_lines.append("审核说明:" + comments)
if improvement:
summary_lines.append("改进意见:" + improvement)
await sor.sqlExe(
"UPDATE bid_qc_reviews SET fit_score=${sc}$, passed=${ps}$, improvement=${imp}$, "
"comments=${cm}$, updated_at=NOW() WHERE id=${r}$",
{"sc": sc, "ps": "1" if passed else "0", "imp": improvement or "",
"cm": "\n".join(summary_lines)[:60000], "r": review_id})
await sor.sqlExe("COMMIT", {})
await record(sor, pid, "bid_qc_reviews", review_id, "qc_" + ("pass" if passed else "reject"),
to_state="passed" if passed else "rejected", who=who, agent_id=agent_id,
detail="%s round=%s %.2f" % (qc_type, review.get("round"), sc))
if passed:
return True, ("QC 通过:%s 契合度 %.2f > %.2f,该类产出放行。"
% (qc_type, sc, th["qc_pass_score"]))
# ── 不通过:轮次门禁 ──
if to_int(review.get("round"), 1) >= th["qc_max_round"]:
exist = await find_human_task(sor, pid, HT_QC_ESCALATION, status="pending")
if not exist:
await create_human_task(
sor, pid, HT_QC_ESCALATION,
"解析产出 QC 连续不达标需人工介入(%s" % qc_type,
("%s」已审核 %s 轮,最近契合度 %.2f/10通过线 >%.2f),仍未达标:\n%s\n\n"
"请人工处理:核对招标文件修正产出物(可在对应管理页直接编辑),\n"
"或在 QC 审核记录页bid_qc_reviews把该记录 passed 改为 1 强制放行。\n"
"处理完成本任务后流程自动恢复。"
% (qc_type, review.get("round"), sc, th["qc_pass_score"],
(improvement or "")[:3000])),
assignee_role="owner.superuser")
await sor.sqlExe("COMMIT", {})
return True, ("QC 不通过且已达审核轮次上限 %d → 已抛人工介入(阻塞门禁)。"
% th["qc_max_round"])
# ── 清空该类产出物(章节骨架退回时章节表一并清空),触发解析重做 ──
tbl = QC_OUTPUT_TABLE.get(qc_type or "", "")
if not tbl:
return False, "QC 记录类型非法: %s" % qc_type
await sor.sqlExe("DELETE FROM " + tbl + " WHERE project_id=${p}$", {"p": pid})
await sor.sqlExe("COMMIT", {})
cleared = tbl if qc_type != QC_TYPE_OUTLINE else "bid_chapters章节骨架"
return True, ("QC 不通过:%s 契合度 %.2f 未高于 %.2f。已清空该类产出物(%s"
"系统将在下一轮对账重派解析任务(携带改进意见)重做后重审。"
% (qc_type, sc, th["qc_pass_score"], cleared))
async def qc_report(project_id):
"""项目 QC 总览:四类产出的最新审核状态 + 未通过时的改进意见。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return str(e)
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
th = await get_thresholds(sor)
out = {}
for t in QC_TYPES:
recs = await sor.sqlExe(
"SELECT round, fit_score, passed, LEFT(improvement, 600) AS improvement, created_at "
"FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${t}$ "
"ORDER BY round DESC LIMIT 1", {"p": project_id, "t": t})
await sor.sqlExe("COMMIT", {})
out[t] = rec_to_dict(recs[0]) if recs else {"round": 0, "note": "尚未审核"}
return json.dumps({
"project_id": project_id, "pass_score": th["qc_pass_score"],
"max_round": th["qc_max_round"], "latest": out,
}, ensure_ascii=False, default=str)

View File

@ -1,9 +1,10 @@
"""招标信息能力:采集 / 摘要要素抽取 / 审批 / 存档作废 / 立项 / 人员 / 等待招标文件。
"""投标项目能力:立项 / 参与人员 / 等待上传招标文件。
对应业务从邮箱获取招标信息 内容摘要投标开始时间/项目周期/招标金额 走审批流程
不通过则存档作废通过则创建项目设定参与人员等待上传招标文件
产线起点是上传招标文件招标信息的采集/摘要/审批归商机产线本产线不再包含
招标商务要素项目名称/招标人/截止时间/预算等由招标文件解析阶段从文件中抽取
bid_tenderssource=tender_doc bid_analysis_capability._upsert_tender_facts
状态机规范见技能 `tender`本模块只固化操作原语CAS 迁移 + 审计合法性由 LLM 读技能判断
状态机规范见技能 `bid-workflow`本模块只固化操作原语CAS 迁移 + 审计合法性由 LLM 读技能判断
"""
import json
@ -11,333 +12,21 @@ import logging
from .bid_common import (
get_db, new_id, rec_to_dict, rows_to_dicts, json_loads, to_float, to_int,
llm_json, record, create_human_task, find_human_task, get_project,
PIPELINE_ID, TD_NEW, TD_SUMMARIZED, TD_PENDING_APPROVAL, TD_APPROVED,
TD_REJECTED, TD_ARCHIVED, HT_TENDER_APPROVAL, HT_TENDER_FILE,
record, create_human_task, find_human_task, get_project,
PIPELINE_ID, HT_TENDER_FILE,
)
logger = logging.getLogger("pipeline.bidding.tender")
TABLE = "bid_tenders"
SUMMARY_PROMPT = """你是招标信息分析员。下面是一封招标邮件的主题与正文,请抽取结构化要素。
邮件主题
__SUBJECT__
邮件正文
__BODY__
严格只输出一个 JSON 对象字段如下缺失或正文未提及的填空字符串不要编造
{
"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": "风险提示:时间紧、资质缺口、否决项等;无则空"
}
注意金额只填数字 1200000不带单位和千分位日期只填 YYYY-MM-DD无法确定一律留空"""
async def _get_tender(sor, tender_id):
recs = await sor.sqlExe("SELECT * FROM " + TABLE + " WHERE id=${tid}$", {"tid": tender_id})
await sor.sqlExe("COMMIT", {})
return rec_to_dict(recs[0]) if recs else {}
async def _set_status(sor, tender_id, from_status, to_status, action,
who=None, agent_id=None, detail=None, extra=None):
"""CAS 状态迁移from_status 为 None 时不校验来源状态)+ 审计。"""
data = dict(extra or {})
data["status"] = to_status
sets = ", ".join("%s=${%s}$" % (k, k) for k in data)
p = dict(data)
p["tid"] = tender_id
sql = "UPDATE " + TABLE + " SET " + sets + ", updated_at=NOW() WHERE id=${tid}$"
if from_status:
sql += " AND status=${_from}$"
p["_from"] = from_status
await sor.sqlExe(sql, p)
cur = await _get_tender(sor, tender_id)
if not cur:
return False, "招标信息不存在: %s" % tender_id
if cur.get("status") != to_status:
return False, ("状态迁移失败(CAS): 期望 from=%s 实际 status=%s"
% (from_status, cur.get("status")))
await record(sor, cur.get("project_id") or tender_id, TABLE, tender_id, action,
from_state=from_status, to_state=to_status, who=who,
agent_id=agent_id, detail=detail)
return True, to_status
# ══════════════ 采集 ══════════════
async def fetch_tenders(org_id="", account_id="", days=None, who=None, agent_id=None):
"""从配置的招标邮箱采集招标信息入库(去重)。返回 (ok, message)。"""
from .bid_mail import fetch_tenders_to_db
return await fetch_tenders_to_db(org_id=org_id, account_id=account_id, days=days)
async def import_tender(title, content, purchaser="", tender_no="", org_id="0",
who=None, agent_id=None, created_by=""):
"""手工导入招标信息(邮箱不可用时的备用入口)。返回 (ok, tender_id)。"""
if not title or not str(title).strip():
return False, "缺少 title"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
tid = new_id()
await sor.C(TABLE, {
"id": tid, "org_id": org_id or "0", "source": "manual",
"title": str(title).strip()[:250], "mail_subject": str(title).strip()[:480],
"raw_content": content or "", "purchaser": purchaser or "",
"tender_no": tender_no or "", "status": TD_NEW,
"created_by": created_by or (who or ""),
})
await record(sor, tid, TABLE, tid, "import", to_state=TD_NEW,
who=who, agent_id=agent_id)
return True, tid
async def list_tenders(status="", org_id="", limit=50):
"""列出招标信息。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sql = ("SELECT id, title, purchaser, tender_no, status, bid_start_at, bid_deadline, "
"project_period, budget_amount, project_id, received_at "
"FROM " + TABLE + " WHERE 1=1")
p = {}
if status:
sql += " AND status=${st}$"
p["st"] = status
if org_id:
sql += " AND org_id=${oid}$"
p["oid"] = org_id
sql += " ORDER BY received_at DESC, created_at DESC LIMIT " + str(to_int(limit, 50))
recs = await sor.sqlExe(sql, p)
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)
async def tender_detail(tender_id):
"""招标信息详情(含正文,供分析用)。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
d = await _get_tender(sor, tender_id)
if not d:
return "招标信息不存在: %s" % tender_id
body = d.get("raw_content") or ""
d["raw_content"] = body[:8000]
return json.dumps(d, ensure_ascii=False, default=str)
# ══════════════ 摘要与要素抽取 ══════════════
async def summarize_tender(tender_id, model_name="", who=None, agent_id=None):
"""LLM 抽取摘要 + 投标开始时间/项目周期/招标金额等要素,落库并置 summarized。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
t = await _get_tender(sor, tender_id)
if not t:
return False, "招标信息不存在: %s" % tender_id
body = (t.get("raw_content") or "")[:20000]
if not body.strip():
return False, "招标信息正文为空,无法摘要(请先补充 raw_content 或上传招标文件)"
prompt = (SUMMARY_PROMPT
.replace("__SUBJECT__", (t.get("mail_subject") or t.get("title") or "")[:500])
.replace("__BODY__", body))
data, err = await llm_json(prompt, model_name=model_name, retries=1)
if data is None:
return False, "LLM 摘要失败: %s" % err
extra = {}
for k in ("title", "purchaser", "tender_no", "industry", "summary",
"project_period", "qualification_req", "risk_note"):
v = data.get(k)
if v:
extra[k] = str(v)[:2000] if k in ("summary", "qualification_req", "risk_note") else str(v)[:250]
for k in ("bid_start_at", "bid_deadline"):
v = (data.get(k) or "").strip()
if v and len(v) >= 8:
extra[k] = v[:10]
for k in ("budget_amount", "deposit_amount"):
v = data.get(k)
if v not in (None, "", ""):
amt = to_float(str(v).replace(",", "").replace("", "").strip(), 0.0)
if amt > 0:
extra[k] = amt
kd = data.get("key_dates")
if kd:
extra["key_dates"] = json.dumps(kd, ensure_ascii=False)[:4000]
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
ok, msg = await _set_status(sor, tender_id, None, TD_SUMMARIZED, "summarize",
who=who, agent_id=agent_id, extra=extra)
if not ok:
return False, msg
return True, ("摘要完成: %s | 投标开始=%s 截止=%s 周期=%s 金额=%s"
% (extra.get("title", t.get("title", "")),
extra.get("bid_start_at", ""), extra.get("bid_deadline", ""),
extra.get("project_period", ""), extra.get("budget_amount", "")))
async def set_tender_fields(tender_id, title="", purchaser="", tender_no="",
bid_start_at="", bid_deadline="", project_period="",
budget_amount="", deposit_amount="", summary="",
qualification_req="", who=None, agent_id=None):
"""人工/agent 修正招标要素LLM 抽错时用)。"""
vals = {"title": title, "purchaser": purchaser, "tender_no": tender_no,
"bid_start_at": bid_start_at, "bid_deadline": bid_deadline,
"project_period": project_period, "summary": summary,
"qualification_req": qualification_req}
data = {k: v for k, v in vals.items() if v not in (None, "")}
for k, v in (("budget_amount", budget_amount), ("deposit_amount", deposit_amount)):
if v not in (None, ""):
data[k] = to_float(str(v).replace(",", ""), 0.0)
if not data:
return False, "未提供任何要修改的字段"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sets = ", ".join("%s=${%s}$" % (k, k) for k in data)
p = dict(data)
p["tid"] = tender_id
await sor.sqlExe("UPDATE " + TABLE + " SET " + sets + ", updated_at=NOW() WHERE id=${tid}$", p)
await sor.sqlExe("COMMIT", {})
await record(sor, tender_id, TABLE, tender_id, "set_fields",
who=who, agent_id=agent_id, detail=json.dumps(list(data), ensure_ascii=False))
return True, "已更新字段: %s" % ", ".join(data)
# ══════════════ 审批流程 ══════════════
async def submit_tender_approval(tender_id, assignee_role="owner.superuser",
assignee_id="", note="", who=None, agent_id=None):
"""提交招标信息审批:创建人类审批任务 + 状态置 pending_approval。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
t = await _get_tender(sor, tender_id)
if not t:
return False, "招标信息不存在: %s" % tender_id
if t.get("status") == TD_PENDING_APPROVAL:
return True, "已在审批中human_task=%s" % (t.get("approval_task_id") or "")
if t.get("status") not in (TD_NEW, TD_SUMMARIZED):
return False, "当前状态 %s 不可提交审批(需 new/summarized" % t.get("status")
title = "招标信息审批:%s" % (t.get("title") or t.get("mail_subject") or tender_id)
desc_lines = [
"招标人:%s" % (t.get("purchaser") or "未识别"),
"招标编号:%s" % (t.get("tender_no") or "未识别"),
"投标开始:%s 截止:%s" % (t.get("bid_start_at") or "-", t.get("bid_deadline") or "-"),
"项目周期:%s" % (t.get("project_period") or "-"),
"招标金额:%s" % (t.get("budget_amount") or "-"),
"保证金:%s" % (t.get("deposit_amount") or "-"),
"资质要求:%s" % ((t.get("qualification_req") or "-")[:500]),
"摘要:%s" % ((t.get("summary") or "-")[:1000]),
"风险提示:%s" % ((t.get("risk_note") or "-")[:500]),
]
if note:
desc_lines.append("提交说明:%s" % note)
desc_lines.append("")
desc_lines.append("请决策:通过 → 立项投标;不通过 → 该招标信息存档作废。")
hid = await create_human_task(
sor, "", HT_TENDER_APPROVAL, title, "\n".join(desc_lines),
assignee_role=assignee_role, assignee_id=assignee_id,
form_schema={"tender_id": tender_id, "decision": ["approve", "reject"]})
ok, msg = await _set_status(sor, tender_id, None, TD_PENDING_APPROVAL,
"submit_approval", who=who, agent_id=agent_id,
extra={"approval_task_id": hid})
if not ok:
return False, msg
return True, "已提交审批human_task=%s,指派 %s" % (hid, assignee_role)
async def approve_tender(tender_id, comment="", who=None, agent_id=None,
auto_create_project=True):
"""审批通过pending_approval → approved默认自动立项 + 发上传招标文件任务。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
ok, msg = await _set_status(sor, tender_id, TD_PENDING_APPROVAL, TD_APPROVED,
"approve", who=who, agent_id=agent_id, detail=comment)
if not ok:
ok, msg = await _set_status(sor, tender_id, TD_SUMMARIZED, TD_APPROVED,
"approve", who=who, agent_id=agent_id, detail=comment)
if not ok:
return False, msg
await _close_approval_task(sor, tender_id, "done", comment)
if not auto_create_project:
return True, "审批通过(未自动立项)"
ok2, msg2 = await create_bid_project(tender_id, who=who, agent_id=agent_id)
if not ok2:
return True, "审批通过,但立项失败: %s" % msg2
return True, "审批通过并已立项: %s" % msg2
async def reject_tender(tender_id, comment="", who=None, agent_id=None):
"""审批不通过pending_approval → rejected → 立即存档作废(业务要求)。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
ok, msg = await _set_status(sor, tender_id, TD_PENDING_APPROVAL, TD_REJECTED,
"reject", who=who, agent_id=agent_id, detail=comment)
if not ok:
return False, msg
await _close_approval_task(sor, tender_id, "rejected", comment)
ok2, msg2 = await _set_status(
sor, tender_id, TD_REJECTED, TD_ARCHIVED, "archive",
who=who, agent_id=agent_id, detail="审批不通过自动存档作废",
extra={"archive_reason": ("审批不通过:" + (comment or ""))[:480]})
if not ok2:
return False, "已驳回但存档失败: %s" % msg2
return True, "审批不通过,招标信息已存档作废"
async def archive_tender(tender_id, reason="", who=None, agent_id=None):
"""存档作废(任何状态 → archived"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
return await _set_status(sor, tender_id, None, TD_ARCHIVED, "archive",
who=who, agent_id=agent_id, detail=reason,
extra={"archive_reason": (reason or "")[:480]})
async def reopen_tender(tender_id, reason="", who=None, agent_id=None):
"""存档/驳回后重新评估archived/rejected → summarized。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
ok, msg = await _set_status(sor, tender_id, TD_ARCHIVED, TD_SUMMARIZED, "reopen",
who=who, agent_id=agent_id, detail=reason)
if ok:
return ok, msg
return await _set_status(sor, tender_id, TD_REJECTED, TD_SUMMARIZED, "reopen",
who=who, agent_id=agent_id, detail=reason)
async def _close_approval_task(sor, tender_id, status, comment=""):
"""关闭招标审批人类任务(同步审批结论)。"""
t = await _get_tender(sor, tender_id)
hid = t.get("approval_task_id") or ""
if not hid:
return
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status=${st}$, qc_comment=${cm}$, "
"submitted_at=NOW() WHERE id=${hid}$ AND status='pending'",
{"st": status, "cm": (comment or "")[:480], "hid": hid})
await sor.sqlExe("COMMIT", {})
# ══════════════ 立项 / 人员 / 招标文件 ══════════════
async def create_bid_project(name, description="", org_id="", created_by="",
who=None, agent_id=None):
"""直接创建投标项目(不依赖招标信息前置+ 发「上传招标文件」任务。
"""直接创建投标项目(产线起点)+ 发「上传招标文件」任务。
产线入口改为从上传招标文件开始立项不再经过邮件采集/审批
而是用户/agent 直接创建投标项目随后上传招标文件进入解析
立项不经过邮件采集/审批那是商机产线的职责用户/agent 直接创建投标项目
随后上传招标文件进入解析
"""
if not name or not str(name).strip():
return False, "缺少项目名称name"
@ -358,13 +47,12 @@ async def create_bid_project(name, description="", org_id="", created_by="",
await sor.sqlExe("COMMIT", {})
await record(sor, pid, "sd_projects", pid, "create_bid_project",
to_state="in_progress", who=who, agent_id=agent_id,
detail="direct create (no tender)")
detail="direct create")
hid = await create_human_task(
sor, pid, HT_TENDER_FILE,
"上传招标文件:%s" % pname,
("项目已创建,请上传招标文件(含答疑/补遗文件)。\n"
"上传后系统自动进入招标文件解析(抽取评分项、得分规则、所需资质、投标文件要求、章节骨架)。\n"
"上传入口:投标产线 → 招标文件bid_tender_files"),
"上传后系统自动进入招标文件解析(抽取评分项、得分规则、所需资质、投标文件要求、章节骨架)。"),
assignee_role="owner.superuser",
form_schema={"project_id": pid})
return True, "project_id=%s(已发上传招标文件任务 %s" % (pid, hid)
@ -406,6 +94,7 @@ async def assign_members(project_id, members="", who=None, agent_id=None):
exist = await sor.sqlExe(
"SELECT id FROM bid_members WHERE project_id=${pid}$ AND user_name=${nm}$ LIMIT 1",
{"pid": project_id, "nm": nm})
await sor.sqlExe("COMMIT", {})
if exist:
continue
await sor.C("bid_members", {

View File

@ -192,6 +192,34 @@ BID_ROLE_TOOL_SCHEMAS = {
"chapter_id": "章节ID(可选)"},
"required": [],
},
# ── bid_qc_capability解析产出 QC 契合度审核agent.qc──
"start_qc": {
"module": f"{_M}.bid_qc_capability",
"description": "开启一轮解析产出契合度审核:返回审核清单+该类产出物待核内容(对照招标文件原文逐项核对后打分)",
"params": {"qc_type": "审核对象 scoring_items|qualifications|doc_requirements|chapter_outline",
"project_id": "项目ID(可选,默认当前)"},
"required": ["qc_type"],
},
"finish_qc": {
"module": f"{_M}.bid_qc_capability",
"description": "落契合度分并判定得分高于通过分默认9.510分制放行不通过必须给改进意见系统清空该类产出并触发解析重做",
"params": {"review_id": "start_qc 返回的评审ID", "fit_score": "契合度得分 0~10可带一位小数",
"improvement": "改进意见(不通过必填:缺了什么/错在哪/对照原文哪一处)",
"comments": "审核说明(可选)"},
"required": ["review_id", "fit_score"],
},
"list_qc": {
"module": f"{_M}.bid_qc_capability",
"description": "列出项目 QC 审核记录(可按审核对象过滤)",
"params": {"project_id": "项目ID(可选,默认当前)", "qc_type": "审核对象(可选)"},
"required": [],
},
"qc_report": {
"module": f"{_M}.bid_qc_capability",
"description": "项目 QC 总览:四类产出的最新契合度审核状态",
"params": {"project_id": "项目ID(可选,默认当前)"},
"required": [],
},
# ── bid_compose_capability标书合成工程师 ──
"compose_bid": {
"module": f"{_M}.bid_compose_capability",

View File

@ -14,8 +14,9 @@ MOD = "pipeline-bidding"
APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
TABLES = [
"bid_mail_accounts", "bid_tenders", "bid_members", "bid_tender_files",
"bid_tenders", "bid_members", "bid_tender_files",
"bid_scoring_items", "bid_qualifications", "bid_doc_requirements",
"bid_qc_reviews",
"bid_chapters", "bid_reviews", "bid_scores", "bid_kb_docs", "bid_documents",
]

View File

@ -1,47 +0,0 @@
ns = params_kw.copy()
debug(f'get_bid_mail_accounts.dspy:{ns=}')
if not ns.get('page'):
ns['page'] = 1
if not ns.get('sort'):
ns['sort'] = json.loads('["created_at desc"]')
sql = '''select * from bid_mail_accounts where 1=1 [[filterstr]]'''
filterjson = json.loads('{"AND": [{"field": "account_name", "op": "LIKE", "var": "name_input"}, {"field": "enabled", "op": "=", "var": "enabled_input"}]}')
filterdic = ns.copy()
filterdic['filterstr'] = ''
filterdic['userorgid'] = '${userorgid}$'
filterdic['userid'] = '${userid}$'
if filterjson:
dbf = DBFilter(filterjson)
conds = dbf.gen(ns)
if conds:
ns.update(dbf.consts)
conds = f' and {conds}'
filterdic['filterstr'] = conds
ac = ArgsConvert('[[', ']]')
vars = ac.findAllVariables(sql)
NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' }
filterdic.update(NameSpace)
sql = ac.convert(sql, filterdic)
debug(f'{sql=}')
db = DBPools()
dbname = get_module_dbname('pipeline_bidding')
async with db.sqlorContext(dbname) as sor:
r = await sor.sqlPaging(sql, ns)
return r
return {
"total":0,
"rows":[]
}

View File

@ -1,307 +0,0 @@
{
"widgettype":"VBox",
"options":{"height":"100%","width":"100%"},
"subwidgets":[
{
"id":"bid_mail_accounts_tbl",
"widgettype":"Tabular",
"options":{
"width":"100%",
"height":"100%",
"title":"招标邮箱配置",
"toolbar":{
"tools": []
},
"css":"card",
"editable":{
"new_data_url": "{{entire_url('add_bid_mail_accounts.dspy')}}",
"delete_data_url": "{{entire_url('delete_bid_mail_accounts.dspy')}}",
"update_data_url": "{{entire_url('update_bid_mail_accounts.dspy')}}"
},
"data_url":"{{entire_url('./get_bid_mail_accounts.dspy')}}",
"data_method":"GET",
"data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}},
"row_options":{
"browserfields": {
"exclouded": [
"password",
"last_uid"
]
},
"editexclouded":[
"id",
"created_at",
"updated_at",
"last_uid",
"last_fetch_at"
],
"fields":[
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no",
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "主键ID"
},
{
"name": "org_id",
"title": "机构ID",
"type": "str",
"length": 32,
"nullable": "no",
"default": "0",
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "机构ID"
},
{
"name": "account_name",
"title": "配置名称",
"type": "str",
"length": 128,
"nullable": "no",
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "配置名称"
},
{
"name": "protocol",
"title": "协议",
"type": "str",
"length": 16,
"nullable": "no",
"default": "imap",
"cwidth": 16,
"uitype": "str",
"datatype": "str",
"label": "协议"
},
{
"name": "host",
"title": "服务器地址",
"type": "str",
"length": 128,
"nullable": "no",
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "服务器地址"
},
{
"name": "port",
"title": "端口",
"type": "int",
"nullable": "no",
"default": "993",
"length": 0,
"uitype": "int",
"datatype": "int",
"label": "端口"
},
{
"name": "use_ssl",
"title": "使用SSL",
"type": "str",
"length": 1,
"nullable": "no",
"default": "1",
"cwidth": 4,
"uitype": "str",
"datatype": "str",
"label": "使用SSL"
},
{
"name": "username",
"title": "登录账号",
"type": "str",
"length": 128,
"nullable": "no",
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "登录账号"
},
{
"name": "password",
"title": "登录密码(AES加密)",
"type": "str",
"length": 500,
"nullable": "no",
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "登录密码(AES加密)"
},
{
"name": "folder",
"title": "邮件目录",
"type": "str",
"length": 64,
"default": "INBOX",
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "邮件目录"
},
{
"name": "filter_keywords",
"title": "招标关键词(逗号分隔)",
"type": "str",
"length": 500,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "招标关键词(逗号分隔)"
},
{
"name": "fetch_days",
"title": "回溯天数",
"type": "int",
"default": "7",
"length": 0,
"uitype": "int",
"datatype": "int",
"label": "回溯天数"
},
{
"name": "last_uid",
"title": "上次同步UID",
"type": "str",
"length": 64,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "上次同步UID"
},
{
"name": "last_fetch_at",
"title": "上次同步时间",
"type": "timestamp",
"length": 0,
"uitype": "str",
"datatype": "timestamp",
"label": "上次同步时间"
},
{
"name": "enabled",
"title": "启用",
"type": "str",
"length": 1,
"nullable": "no",
"default": "1",
"cwidth": 4,
"uitype": "str",
"datatype": "str",
"label": "启用"
},
{
"name": "status",
"title": "状态",
"type": "str",
"length": 16,
"default": "active",
"cwidth": 16,
"uitype": "str",
"datatype": "str",
"label": "状态"
},
{
"name": "created_by",
"title": "创建人",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "创建人"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no",
"length": 0,
"uitype": "str",
"datatype": "timestamp",
"label": "创建时间"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp",
"length": 0,
"uitype": "str",
"datatype": "timestamp",
"label": "更新时间"
}
]
},
"search_form":{
"css":"card",
"padding":"8px",
"show_label":false,
"submit_label":"搜索",
"submit_css":"primary",
"fields":[
{
"name": "name_input",
"uitype": "str",
"placeholder": "配置名称",
"cwidth": 15
},
{
"name": "enabled_input",
"uitype": "str",
"placeholder": "启用",
"cwidth": 15
}
]
},
"page_rows":160,
"cache_limit":5
}
,"binds":[]
}]
}

View File

@ -1,44 +0,0 @@
ns = params_kw.copy()
for k,v in list(ns.items()):
if v == 'NaN' or v == 'null' or v == '' or v is None:
if k != 'id':
del ns[k]
else:
ns[k] = None if v in ('NaN', 'null') else v
db = DBPools()
dbname = get_module_dbname('pipeline_bidding')
async with db.sqlorContext(dbname) as sor:
tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"}, {"name": "account_name", "title": "配置名称", "type": "str", "length": 128, "nullable": "no"}, {"name": "protocol", "title": "协议", "type": "str", "length": 16, "nullable": "no", "default": "imap"}, {"name": "host", "title": "服务器地址", "type": "str", "length": 128, "nullable": "no"}, {"name": "port", "title": "端口", "type": "int", "nullable": "no", "default": "993"}, {"name": "use_ssl", "title": "使用SSL", "type": "str", "length": 1, "nullable": "no", "default": "1"}, {"name": "username", "title": "登录账号", "type": "str", "length": 128, "nullable": "no"}, {"name": "password", "title": "登录密码(AES加密)", "type": "str", "length": 500, "nullable": "no"}, {"name": "folder", "title": "邮件目录", "type": "str", "length": 64, "default": "INBOX"}, {"name": "filter_keywords", "title": "招标关键词(逗号分隔)", "type": "str", "length": 500}, {"name": "fetch_days", "title": "回溯天数", "type": "int", "default": "7"}, {"name": "last_uid", "title": "上次同步UID", "type": "str", "length": 64}, {"name": "last_fetch_at", "title": "上次同步时间", "type": "timestamp"}, {"name": "enabled", "title": "启用", "type": "str", "length": 1, "nullable": "no", "default": "1"}, {"name": "status", "title": "状态", "type": "str", "length": 16, "default": "active"}, {"name": "created_by", "title": "创建人", "type": "str", "length": 32}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]')
tblfname = [ f['name'] for f in tblfields ]
other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ]
if len(other_keys) > 0:
r = await sor.U('bid_mail_accounts', ns)
debug('update success');
return {
"widgettype":"Message",
"options":{
"title":"Update Success",
"cwidth":16,
"cheight":9,
"timeout":3,
"message":"ok"
}
}
return {
"widgettype":"Error",
"options":{
"title":"Update Error",
"cwidth":16,
"cheight":9,
"timeout":3,
"message":"failed"
}
}

View File

@ -10,13 +10,10 @@ ns['id'] = id
db = DBPools()
dbname = get_module_dbname('pipeline_bidding')
async with db.sqlorContext(dbname) as sor:
r = await sor.C('bid_mail_accounts', ns.copy())
_new_rows = await sor.sqlExe("SELECT * FROM bid_mail_accounts WHERE id = ${id}$", {'id': id})
_user_data = _new_rows[0] if _new_rows else ns
r = await sor.C('bid_qc_reviews', ns.copy())
return {
"widgettype":"Message",
"options":{
@ -24,8 +21,7 @@ async with db.sqlorContext(dbname) as sor:
"cheight":9,
"title":"Add Success",
"timeout":3,
"message":"ok",
"user_data": _user_data
"message":"ok"
}
}

View File

@ -7,7 +7,7 @@ ns = {
db = DBPools()
dbname = get_module_dbname('pipeline_bidding')
async with db.sqlorContext(dbname) as sor:
r = await sor.D('bid_mail_accounts', ns)
r = await sor.D('bid_qc_reviews', ns)
debug('delete success');
return {
"widgettype":"Message",

View File

@ -0,0 +1,148 @@
ns = params_kw.copy()
debug(f'get_bid_qc_reviews.dspy:{ns=}')
if not ns.get('page'):
ns['page'] = 1
if not ns.get('sort'):
ns['sort'] = ["qc_type","round desc"]
sql = '''select a.*, b.qc_type_text
from (select * from bid_qc_reviews where 1=1 [[filterstr]]) a left join (select k as qc_type,
v as qc_type_text from appcodes_kv where parentid='bid_qc_type') b on a.qc_type = b.qc_type'''
filterjson = params_kw.get('data_filter')
if filterjson and isinstance(filterjson, str):
try:
filterjson = json.loads(filterjson)
except (json.JSONDecodeError, TypeError):
filterjson = None
fields_str=r'''[
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "project_id",
"title": "项目ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "qc_type",
"title": "审核对象",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "round",
"title": "审核轮次",
"type": "int",
"nullable": "no",
"default": "1"
},
{
"name": "reviewer",
"title": "审核方",
"type": "str",
"length": 64
},
{
"name": "review_type",
"title": "审核类型",
"type": "str",
"length": 16,
"default": "agent"
},
{
"name": "fit_score",
"title": "契合度得分(10分制)",
"type": "float",
"length": 4,
"dec": 2
},
{
"name": "pass_score",
"title": "通过分",
"type": "float",
"length": 4,
"dec": 2
},
{
"name": "passed",
"title": "是否通过",
"type": "str",
"length": 1,
"nullable": "no",
"default": "0"
},
{
"name": "improvement",
"title": "改进意见",
"type": "text"
},
{
"name": "comments",
"title": "审核说明",
"type": "text"
},
{
"name": "task_id",
"title": "关联任务ID",
"type": "str",
"length": 32
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
]'''
ori_fields = json.loads(fields_str)
if not filterjson:
fields = [ f['name'] for f in ori_fields ]
filterjson = default_filterjson(fields, ns)
filterdic = ns.copy()
filterdic['filterstr'] = ''
filterdic['userorgid'] = '${userorgid}$'
filterdic['userid'] = '${userid}$'
if filterjson:
dbf = DBFilter(filterjson)
conds = dbf.gen(ns)
if conds:
ns.update(dbf.consts)
conds = f' and {conds}'
filterdic['filterstr'] = conds
ac = ArgsConvert('[[', ']]')
vars = ac.findAllVariables(sql)
NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' }
filterdic.update(NameSpace)
sql = ac.convert(sql, filterdic)
debug(f'{sql=}')
db = DBPools()
dbname = get_module_dbname('pipeline_bidding')
async with db.sqlorContext(dbname) as sor:
r = await sor.sqlPaging(sql, ns)
return r
return {
"total":0,
"rows":[]
}

View File

@ -0,0 +1,250 @@
{
"widgettype":"VBox",
"options":{"cheight":40,"width":"100%"},
"subwidgets":[{
"id":"bid_qc_reviews_tbl",
"widgettype":"Tabular",
"options":{
"width":"100%",
"height":"100%",
"title":"解析产出QC审核记录",
"toolbar":{
"tools": []
},
"css":"card",
"editable":{
"new_data_url":"{{entire_url('add_bid_qc_reviews.dspy')}}",
"delete_data_url":"{{entire_url('delete_bid_qc_reviews.dspy')}}",
"update_data_url":"{{entire_url('update_bid_qc_reviews.dspy')}}"
},
"data_url":"{{entire_url('./get_bid_qc_reviews.dspy')}}",
"data_method":"GET",
"data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}},
"row_options":{
"browserfields": {
"exclouded": [
"comments"
]
},
"editexclouded":[
"id",
"created_at",
"updated_at",
"task_id",
"reviewer",
"review_type"
],
"fields":[
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no",
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "主键ID"
},
{
"name": "project_id",
"title": "项目ID",
"type": "str",
"length": 32,
"nullable": "no",
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "项目ID"
},
{
"name": "qc_type",
"title": "审核对象",
"type": "str",
"length": 32,
"nullable": "no",
"label": "审核对象",
"uitype": "code",
"valueField": "qc_type",
"textField": "qc_type_text",
"params": {
"dbname": "{{get_module_dbname('pipeline_bidding')}}",
"table": "appcodes_kv",
"tblvalue": "k",
"tbltext": "v",
"valueField": "qc_type",
"textField": "qc_type_text",
"cond": "parentid='bid_qc_type'"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
{
"name": "round",
"title": "审核轮次",
"type": "int",
"nullable": "no",
"default": "1",
"length": 0,
"uitype": "int",
"datatype": "int",
"label": "审核轮次"
},
{
"name": "reviewer",
"title": "审核方",
"type": "str",
"length": 64,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "审核方"
},
{
"name": "review_type",
"title": "审核类型",
"type": "str",
"length": 16,
"default": "agent",
"cwidth": 16,
"uitype": "str",
"datatype": "str",
"label": "审核类型"
},
{
"name": "fit_score",
"title": "契合度得分(10分制)",
"type": "float",
"length": 4,
"dec": 2,
"cwidth": 4,
"uitype": "float",
"datatype": "float",
"label": "契合度得分(10分制)"
},
{
"name": "pass_score",
"title": "通过分",
"type": "float",
"length": 4,
"dec": 2,
"cwidth": 4,
"uitype": "float",
"datatype": "float",
"label": "通过分"
},
{
"name": "passed",
"title": "是否通过",
"type": "str",
"length": 1,
"nullable": "no",
"default": "0",
"cwidth": 4,
"uitype": "str",
"datatype": "str",
"label": "是否通过"
},
{
"name": "improvement",
"title": "改进意见",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "改进意见"
},
{
"name": "comments",
"title": "审核说明",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "审核说明"
},
{
"name": "task_id",
"title": "关联任务ID",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "关联任务ID"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no",
"length": 0,
"uitype": "str",
"datatype": "timestamp",
"label": "创建时间"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp",
"length": 0,
"uitype": "str",
"datatype": "timestamp",
"label": "更新时间"
}
]
},
"data_filter":{
"AND": [
{
"field": "project_id",
"op": "=",
"var": "project_id"
},
{
"field": "qc_type",
"op": "=",
"var": "qc_type_input"
}
]
},
"page_rows":160,
"cache_limit":5
}
,"binds":[]
}]
}

View File

@ -0,0 +1,36 @@
ns = params_kw.copy()
for k,v in ns.items():
if v == 'NaN' or v == 'null':
ns[k] = None
db = DBPools()
dbname = get_module_dbname('pipeline_bidding')
async with db.sqlorContext(dbname) as sor:
r = await sor.U('bid_qc_reviews', ns)
debug('update success');
return {
"widgettype":"Message",
"options":{
"title":"Update Success",
"cwidth":16,
"cheight":9,
"timeout":3,
"message":"ok"
}
}
return {
"widgettype":"Error",
"options":{
"title":"Update Error",
"cwidth":16,
"cheight":9,
"timeout":3,
"message":"failed"
}
}

View File

@ -10,13 +10,10 @@ ns['id'] = id
db = DBPools()
dbname = get_module_dbname('pipeline_bidding')
async with db.sqlorContext(dbname) as sor:
r = await sor.C('bid_tenders', ns.copy())
_new_rows = await sor.sqlExe("SELECT * FROM bid_tenders WHERE id = ${id}$", {'id': id})
_user_data = _new_rows[0] if _new_rows else ns
return {
"widgettype":"Message",
"options":{
@ -24,8 +21,7 @@ async with db.sqlorContext(dbname) as sor:
"cheight":9,
"title":"Add Success",
"timeout":3,
"message":"ok",
"user_data": _user_data
"message":"ok"
}
}

View File

@ -8,18 +8,155 @@ if not ns.get('page'):
if not ns.get('sort'):
ns['sort'] = json.loads('["received_at desc"]')
ns['sort'] = ["created_at desc"]
sql = '''select a.*, b.status_text, c.source_text
from (select * from bid_tenders where 1=1 [[filterstr]]) a left join (select k as status,
v as status_text from appcodes_kv where parentid='bid_tender_status') b on a.status = b.status left join (select k as source,
v as source_text from appcodes_kv where parentid='bid_tender_source') c on a.source = c.source'''
filterjson = json.loads('{"AND": [{"field": "title", "op": "LIKE", "var": "title_input"}, {"field": "status", "op": "=", "var": "status_input"}, {"field": "purchaser", "op": "LIKE", "var": "purchaser_input"}]}')
sql = '''select a.*, b.source_text
from (select * from bid_tenders where 1=1 [[filterstr]]) a left join (select k as source,
v as source_text from appcodes_kv where parentid='bid_tender_source') b on a.source = b.source'''
filterjson = params_kw.get('data_filter')
if filterjson and isinstance(filterjson, str):
try:
filterjson = json.loads(filterjson)
except (json.JSONDecodeError, TypeError):
filterjson = None
fields_str=r'''[
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "机构ID",
"type": "str",
"length": 32,
"nullable": "no",
"default": "0"
},
{
"name": "project_id",
"title": "项目ID",
"type": "str",
"length": 32
},
{
"name": "source",
"title": "来源",
"type": "str",
"length": 16,
"nullable": "no",
"default": "tender_doc"
},
{
"name": "title",
"title": "项目名称",
"type": "str",
"length": 255
},
{
"name": "purchaser",
"title": "招标人/采购人",
"type": "str",
"length": 255
},
{
"name": "tender_no",
"title": "招标编号",
"type": "str",
"length": 64
},
{
"name": "industry",
"title": "行业",
"type": "str",
"length": 64
},
{
"name": "summary",
"title": "项目概况摘要",
"type": "text"
},
{
"name": "bid_start_at",
"title": "投标开始时间",
"type": "str",
"length": 16
},
{
"name": "bid_deadline",
"title": "投标截止时间",
"type": "str",
"length": 16
},
{
"name": "project_period",
"title": "项目周期",
"type": "str",
"length": 64
},
{
"name": "budget_amount",
"title": "招标金额",
"type": "float",
"length": 14,
"dec": 2
},
{
"name": "deposit_amount",
"title": "投标保证金",
"type": "float",
"length": 14,
"dec": 2
},
{
"name": "qualification_req",
"title": "资质要求摘要",
"type": "text"
},
{
"name": "key_dates",
"title": "关键节点(JSON)",
"type": "text"
},
{
"name": "risk_note",
"title": "风险提示",
"type": "text"
},
{
"name": "status",
"title": "状态",
"type": "str",
"length": 16,
"default": "summarized"
},
{
"name": "created_by",
"title": "创建人",
"type": "str",
"length": 64
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
]'''
ori_fields = json.loads(fields_str)
if not filterjson:
fields = [ f['name'] for f in ori_fields ]
filterjson = default_filterjson(fields, ns)
filterdic = ns.copy()
filterdic['filterstr'] = ''

View File

@ -1,9 +1,8 @@
{
"widgettype":"VBox",
"options":{"height":"100%","width":"100%"},
"subwidgets":[
{
"options":{"cheight":40,"width":"100%"},
"subwidgets":[{
"id":"bid_tenders_tbl",
"widgettype":"Tabular",
"options":{
@ -11,28 +10,23 @@
"height":"100%",
"title":"招标信息",
"title":"招标商务要素",
"toolbar":{
"tools": []
},
"css":"card",
"editable":{
"new_data_url": "{{entire_url('add_bid_tenders.dspy')}}",
"new_data_url":"{{entire_url('add_bid_tenders.dspy')}}",
"delete_data_url": "{{entire_url('delete_bid_tenders.dspy')}}",
"delete_data_url":"{{entire_url('delete_bid_tenders.dspy')}}",
"update_data_url": "{{entire_url('update_bid_tenders.dspy')}}"
"update_data_url":"{{entire_url('update_bid_tenders.dspy')}}"
},
@ -47,12 +41,8 @@
"browserfields": {
"exclouded": [
"raw_content",
"attach_json",
"key_dates",
"org_id",
"account_id",
"mail_uid",
"risk_note"
]
},
@ -62,7 +52,6 @@
"id",
"created_at",
"updated_at",
"approval_task_id",
"project_id"
],
@ -90,14 +79,24 @@
"datatype": "str",
"label": "机构ID"
},
{
"name": "project_id",
"title": "项目ID",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "项目ID"
},
{
"name": "source",
"title": "信息来源",
"title": "来源",
"type": "str",
"length": 16,
"nullable": "no",
"default": "mail",
"label": "信息来源",
"default": "tender_doc",
"label": "来源",
"uitype": "code",
"valueField": "source",
"textField": "source_text",
@ -112,73 +111,6 @@
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
{
"name": "account_id",
"title": "邮箱配置ID",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "邮箱配置ID"
},
{
"name": "mail_uid",
"title": "邮件UID",
"type": "str",
"length": 64,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "邮件UID"
},
{
"name": "mail_from",
"title": "发件人",
"type": "str",
"length": 255,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "发件人"
},
{
"name": "mail_subject",
"title": "邮件主题",
"type": "str",
"length": 500,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "邮件主题"
},
{
"name": "received_at",
"title": "收信时间",
"type": "timestamp",
"length": 0,
"uitype": "str",
"datatype": "timestamp",
"label": "收信时间"
},
{
"name": "raw_content",
"title": "邮件原文",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "邮件原文"
},
{
"name": "attach_json",
"title": "附件清单(JSON)",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "附件清单(JSON)"
},
{
"name": "title",
"title": "项目名称",
@ -221,29 +153,31 @@
},
{
"name": "summary",
"title": "内容摘要",
"title": "项目概况摘要",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "内容摘要"
"label": "项目概况摘要"
},
{
"name": "bid_start_at",
"title": "投标开始时间",
"type": "date",
"length": 0,
"uitype": "date",
"datatype": "date",
"type": "str",
"length": 16,
"cwidth": 16,
"uitype": "str",
"datatype": "str",
"label": "投标开始时间"
},
{
"name": "bid_deadline",
"title": "投标截止时间",
"type": "date",
"length": 0,
"uitype": "date",
"datatype": "date",
"type": "str",
"length": 16,
"cwidth": 16,
"uitype": "str",
"datatype": "str",
"label": "投标截止时间"
},
{
@ -260,9 +194,9 @@
"name": "budget_amount",
"title": "招标金额",
"type": "float",
"length": 18,
"length": 14,
"dec": 2,
"cwidth": 18,
"cwidth": 14,
"uitype": "float",
"datatype": "float",
"label": "招标金额"
@ -271,9 +205,9 @@
"name": "deposit_amount",
"title": "投标保证金",
"type": "float",
"length": 18,
"length": 14,
"dec": 2,
"cwidth": 18,
"cwidth": 14,
"uitype": "float",
"datatype": "float",
"label": "投标保证金"
@ -289,12 +223,12 @@
},
{
"name": "key_dates",
"title": "关键时间节点(JSON)",
"title": "关键节点(JSON)",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "关键时间节点(JSON)"
"label": "关键节点(JSON)"
},
{
"name": "risk_note",
@ -310,58 +244,17 @@
"title": "状态",
"type": "str",
"length": 16,
"nullable": "no",
"default": "new",
"label": "状态",
"uitype": "code",
"valueField": "status",
"textField": "status_text",
"params": {
"dbname": "{{get_module_dbname('pipeline_bidding')}}",
"table": "appcodes_kv",
"tblvalue": "k",
"tbltext": "v",
"valueField": "status",
"textField": "status_text",
"cond": "parentid='bid_tender_status'"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
{
"name": "approval_task_id",
"title": "审批任务ID",
"type": "str",
"length": 32,
"cwidth": 18,
"default": "summarized",
"cwidth": 16,
"uitype": "str",
"datatype": "str",
"label": "审批任务ID"
},
{
"name": "project_id",
"title": "立项项目ID",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "立项项目ID"
},
{
"name": "archive_reason",
"title": "作废/存档原因",
"type": "str",
"length": 500,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "作废/存档原因"
"label": "状态"
},
{
"name": "created_by",
"title": "创建人",
"type": "str",
"length": 32,
"length": 64,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
@ -391,45 +284,25 @@
"search_form":{
"css":"card",
"padding":"8px",
"show_label":false,
"submit_label":"搜索",
"submit_css":"primary",
"fields":[
{
"name": "title_input",
"uitype": "str",
"placeholder": "项目名称",
"cwidth": 15
},
{
"name": "status_input",
"uitype": "code",
"placeholder": "状态",
"cwidth": 15,
"valueField": "status_input",
"textField": "status_input_text",
"params": {
"dbname": "{{get_module_dbname('pipeline_bidding')}}",
"table": "appcodes_kv",
"tblvalue": "k",
"tbltext": "v",
"valueField": "status_input",
"textField": "status_input_text",
"cond": "parentid='bid_tender_status'"
"data_filter":{
"AND": [
{
"field": "title",
"op": "LIKE",
"var": "title_input"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}?prepend_all=1"
},
{
"name": "purchaser_input",
"uitype": "str",
"placeholder": "招标人/采购人",
"cwidth": 15
}
]
},
{
"field": "project_id",
"op": "=",
"var": "project_id"
},
{
"field": "purchaser",
"op": "LIKE",
"var": "purchaser_input"
}
]
},

View File

@ -1,12 +1,8 @@
ns = params_kw.copy()
for k,v in list(ns.items()):
if v == 'NaN' or v == 'null' or v == '' or v is None:
if k != 'id':
del ns[k]
else:
ns[k] = None if v in ('NaN', 'null') else v
for k,v in ns.items():
if v == 'NaN' or v == 'null':
ns[k] = None
@ -15,11 +11,7 @@ db = DBPools()
dbname = get_module_dbname('pipeline_bidding')
async with db.sqlorContext(dbname) as sor:
tblfields = json.loads('[{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"}, {"name": "source", "title": "信息来源", "type": "str", "length": 16, "nullable": "no", "default": "mail"}, {"name": "account_id", "title": "邮箱配置ID", "type": "str", "length": 32}, {"name": "mail_uid", "title": "邮件UID", "type": "str", "length": 64}, {"name": "mail_from", "title": "发件人", "type": "str", "length": 255}, {"name": "mail_subject", "title": "邮件主题", "type": "str", "length": 500}, {"name": "received_at", "title": "收信时间", "type": "timestamp"}, {"name": "raw_content", "title": "邮件原文", "type": "text"}, {"name": "attach_json", "title": "附件清单(JSON)", "type": "text"}, {"name": "title", "title": "项目名称", "type": "str", "length": 255}, {"name": "purchaser", "title": "招标人/采购人", "type": "str", "length": 255}, {"name": "tender_no", "title": "招标编号", "type": "str", "length": 64}, {"name": "industry", "title": "行业", "type": "str", "length": 64}, {"name": "summary", "title": "内容摘要", "type": "text"}, {"name": "bid_start_at", "title": "投标开始时间", "type": "date"}, {"name": "bid_deadline", "title": "投标截止时间", "type": "date"}, {"name": "project_period", "title": "项目周期", "type": "str", "length": 64}, {"name": "budget_amount", "title": "招标金额", "type": "float", "length": 18, "dec": 2}, {"name": "deposit_amount", "title": "投标保证金", "type": "float", "length": 18, "dec": 2}, {"name": "qualification_req", "title": "资质要求摘要", "type": "text"}, {"name": "key_dates", "title": "关键时间节点(JSON)", "type": "text"}, {"name": "risk_note", "title": "风险提示", "type": "text"}, {"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "new"}, {"name": "approval_task_id", "title": "审批任务ID", "type": "str", "length": 32}, {"name": "project_id", "title": "立项项目ID", "type": "str", "length": 32}, {"name": "archive_reason", "title": "作废/存档原因", "type": "str", "length": 500}, {"name": "created_by", "title": "创建人", "type": "str", "length": 32}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp"}]')
tblfname = [ f['name'] for f in tblfields ]
other_keys = [ k for k in ns.keys() if k in tblfname and k != 'id' ]
if len(other_keys) > 0:
r = await sor.U('bid_tenders', ns)
r = await sor.U('bid_tenders', ns)
debug('update success');
return {
"widgettype":"Message",

View File

@ -7,15 +7,15 @@
"options": {"width": "100%", "alignItems": "center", "padding": "16px 24px 8px 24px", "cheight": 6, "gap": "12px"},
"subwidgets": [
{"widgettype": "Title2", "options": {"text": "投标产线"}},
{"widgettype": "Text", "options": {"text": "招标采集 → 审批 → 立项 → 解析 → 编写 → 评审 → 评分 → 交付", "cfontsize": 0.9, "color": "#94a3b8"}},
{"widgettype": "Text", "options": {"text": "上传招标文件 → 解析 → QC契合度审核 → 编写 → 评审 → 评分 → 交付(招标信息采集/审批在商机产线)", "cfontsize": 0.9, "color": "#94a3b8"}},
{"widgettype": "Filler"},
{
"widgettype": "Button",
"options": {"label": "📨 招标信息", "css": "small"},
"options": {"label": "📨 招标商务要素", "css": "small"},
"binds": [{
"wid": "self", "event": "click", "actiontype": "urlwidget",
"target": "Popup",
"popup_options": {"title": "招标信息", "width": "88%", "height": "82%", "auto_open": true},
"popup_options": {"title": "招标商务要素(解析抽取)", "width": "88%", "height": "82%", "auto_open": true},
"url": "/pipeline-bidding/bid_tenders/index.ui"
}]
},
@ -28,16 +28,6 @@
"popup_options": {"title": "公司投标知识库", "width": "88%", "height": "82%", "auto_open": true},
"url": "/pipeline-bidding/bid_kb_docs/index.ui"
}]
},
{
"widgettype": "Button",
"options": {"label": "✉️ 邮箱配置", "css": "small"},
"binds": [{
"wid": "self", "event": "click", "actiontype": "urlwidget",
"target": "Popup",
"popup_options": {"title": "招标邮箱配置", "width": "88%", "height": "82%", "auto_open": true},
"url": "/pipeline-bidding/bid_mail_accounts/index.ui"
}]
}
]
},
@ -55,10 +45,10 @@
"options": {"width": "100%", "padding": "16px", "bgcolor": "#ffffff", "border": "1px solid #e2e8f0", "borderRadius": "8px", "spacing": "8px"},
"subwidgets": [
{"widgettype": "Title3", "options": {"text": "使用流程"}},
{"widgettype": "Text", "options": {"text": "1. 邮箱配置录入阿里云企业邮imap.qiye.aliyun.com:993开启安全登录需填客户端专用密码", "cfontsize": 0.95}},
{"widgettype": "Text", "options": {"text": "2. 投标驾驶舱:从「开发产线」同款入口(侧栏 产线管理→驾驶舱)切换到投标项目,对话式推进;或直接说「采集招标信息」", "cfontsize": 0.95}},
{"widgettype": "Text", "options": {"text": "3. 审批:招标信息摘要后提交审批,不通过自动存档作废;通过自动立项并发布上传招标文件任务", "cfontsize": 0.95}},
{"widgettype": "Text", "options": {"text": "4. 解析与编写:上传招标文件后自动抽取评分项/得分规则/资质/投标文件要求 → 章节骨架 → 一章一任务自动编写与评审循环", "cfontsize": 0.95}},
{"widgettype": "Text", "options": {"text": "1. 立项:投标驾驶舱(侧栏 产线管理→投标产线)说「创建投标项目」,设定参与人员", "cfontsize": 0.95}},
{"widgettype": "Text", "options": {"text": "2. 上传招标文件:按待办任务上传招标文件(含答疑/补遗),上传后自动进入解析", "cfontsize": 0.95}},
{"widgettype": "Text", "options": {"text": "3. 解析与QC自动抽取评分项/得分规则/资质/投标文件要求/章节骨架QC 对照原文按10分制审核契合度高于9.5分放行,否则退回重做", "cfontsize": 0.95}},
{"widgettype": "Text", "options": {"text": "4. 编写与评审:资质准备(知识库匹配+人工文件清单)→ 一章一任务自动编写与评审循环", "cfontsize": 0.95}},
{"widgettype": "Text", "options": {"text": "5. 评分与交付:合成标书 → 整书按评分规则打分 → 不达标自动退回问题章节 → 通过后人工交付确认", "cfontsize": 0.95}}
]
},
@ -106,6 +96,13 @@
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "Popup",
"popup_options": {"title": "评审记录", "width": "88%", "height": "82%", "auto_open": true},
"url": "/pipeline-bidding/bid_reviews/index.ui"}]
},
{
"widgettype": "Button",
"options": {"label": "✅ 解析QC审核", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "Popup",
"popup_options": {"title": "解析产出QC契合度审核", "width": "88%", "height": "82%", "auto_open": true},
"url": "/pipeline-bidding/bid_qc_reviews/index.ui"}]
}
]
}