diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 386bc70..416b9f8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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_documents(docx) | -| 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_documents(docx) | +| 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.qc(A2 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` 支持全路径 module(8 行) | 引擎零投标特判 | --- -## 八、验收口径 +## 八、门限参数(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)。 diff --git a/i18n/en/msg.txt b/i18n/en/msg.txt index c7bf072..0cb1ebd 100644 --- a/i18n/en/msg.txt +++ b/i18n/en/msg.txt @@ -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 diff --git a/i18n/zh/msg.txt b/i18n/zh/msg.txt index b59c2ce..b40e99f 100644 --- a/i18n/zh/msg.txt +++ b/i18n/zh/msg.txt @@ -1,23 +1,22 @@ -招标信息: 招标信息 -招标信息台: 招标信息台 -邮箱配置: 邮箱配置 +投标产线: 投标产线 +投标驾驶舱: 投标驾驶舱 +招标商务要素: 招标商务要素 投标知识库: 投标知识库 评分项: 评分项 标书章节: 标书章节 合成标书: 合成标书 -采集招标信息: 采集招标信息 -提交审批: 提交审批 -审批通过: 审批通过 -审批不通过: 审批不通过 -存档作废: 存档作废 -立项: 立项 +创建投标项目: 创建投标项目 参与人员: 参与人员 上传招标文件: 上传招标文件 +招标文件解析: 招标文件解析 +解析QC审核: 解析QC审核 +契合度得分: 契合度得分 +通过分: 通过分 +改进意见: 改进意见 资质要求: 资质要求 投标文件要求: 投标文件要求 章节评审: 章节评审 整书评分: 整书评分 -改进意见: 改进意见 得分规则: 得分规则 评审通过: 评审通过 退回重写: 退回重写 diff --git a/init/data.json b/init/data.json index c8d2268..dc2f87d 100644 --- a/init/data.json +++ b/init/data.json @@ -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": "手工录入"} ] }, diff --git a/json/bid_mail_accounts.json b/json/bid_mail_accounts.json deleted file mode 100644 index 77e0595..0000000 --- a/json/bid_mail_accounts.json +++ /dev/null @@ -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" - } - ] - } - } -} diff --git a/json/bid_qc_reviews.json b/json/bid_qc_reviews.json new file mode 100644 index 0000000..c418502 --- /dev/null +++ b/json/bid_qc_reviews.json @@ -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": [] + } +} diff --git a/json/bid_tenders.json b/json/bid_tenders.json index 88bc454..9165c9b 100644 --- a/json/bid_tenders.json +++ b/json/bid_tenders.json @@ -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", diff --git a/models/bid_mail_accounts.json b/models/bid_mail_accounts.json deleted file mode 100644 index 7a59fa8..0000000 --- a/models/bid_mail_accounts.json +++ /dev/null @@ -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": [] -} diff --git a/models/bid_qc_reviews.json b/models/bid_qc_reviews.json new file mode 100644 index 0000000..ca9f5be --- /dev/null +++ b/models/bid_qc_reviews.json @@ -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'" + } + ] +} diff --git a/models/bid_tenders.json b/models/bid_tenders.json index f785573..aedcf19 100644 --- a/models/bid_tenders.json +++ b/models/bid_tenders.json @@ -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", diff --git a/mysql.ddl.sql b/mysql.ddl.sql index 90c5eb8..82df75a 100644 --- a/mysql.ddl.sql +++ b/mysql.ddl.sql @@ -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); diff --git a/pipeline_bidding/bid_ability.py b/pipeline_bidding/bid_ability.py index 5104da9..94e5722 100644 --- a/pipeline_bidding/bid_ability.py +++ b/pipeline_bidding/bid_ability.py @@ -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", diff --git a/pipeline_bidding/bid_common.py b/pipeline_bidding/bid_common.py index 0e98447..194ab36 100644 --- a/pipeline_bidding/bid_common.py +++ b/pipeline_bidding/bid_common.py @@ -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), } diff --git a/pipeline_bidding/bid_flow.py b/pipeline_bidding/bid_flow.py index f4d73f4..cc5836a 100644 --- a/pipeline_bidding/bid_flow.py +++ b/pipeline_bidding/bid_flow.py @@ -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: 解析重做任务 %s(QC 改进意见 %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}) diff --git a/pipeline_bidding/bid_mail.py b/pipeline_bidding/bid_mail.py deleted file mode 100644 index 92a26a3..0000000 --- a/pipeline_bidding/bid_mail.py +++ /dev/null @@ -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)[^>]*>.*?", " ", html, flags=re.S | re.I) - body = re.sub(r"<[^>]+>", " ", body) - body = re.sub(r" ?", " ", 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 diff --git a/pipeline_bidding/bid_qc_capability.py b/pipeline_bidding/bid_qc_capability.py new file mode 100644 index 0000000..d16e13d --- /dev/null +++ b/pipeline_bidding/bid_qc_capability.py @@ -0,0 +1,270 @@ +"""解析产出 QC 契合度审核能力(agent.qc)。 + +对应业务:招标文件解析的每类产出物(评分项+得分规则 / 资质 / 投标文件要求 / 章节骨架) +必须由 QC 对照招标文件原文逐项核对契合度,按 10 分制评分;**得分高于通过分(默认 9.5)才算完成**, +否则给出改进意见 → 清空该产出物 → 对账器重派解析任务带意见重做 → 重审。超轮次上限抛人工。 + +机制分工(与产线既有约定一致): +- 状态流转归机制:bid_flow 对账器派 QC 任务、判门禁、清空产出物触发重做; +- 本模块只固化操作原语:开轮(start_qc)/ 落分(finish_qc)/ 查询(qc_report、list_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_score:0~10(10 分制契合度分)。 + """ + 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) diff --git a/pipeline_bidding/bid_tender_capability.py b/pipeline_bidding/bid_tender_capability.py index 780c20d..8e9f09a 100644 --- a/pipeline_bidding/bid_tender_capability.py +++ b/pipeline_bidding/bid_tender_capability.py @@ -1,9 +1,10 @@ -"""招标信息能力:采集 / 摘要要素抽取 / 审批 / 存档作废 / 立项 / 人员 / 等待招标文件。 +"""投标项目能力:立项 / 参与人员 / 等待上传招标文件。 -对应业务:从邮箱获取招标信息 → 内容摘要(投标开始时间/项目周期/招标金额…)→ 走审批流程 -→ 不通过则存档作废;通过则创建项目、设定参与人员、等待上传招标文件。 +产线起点是「上传招标文件」:招标信息的采集/摘要/审批归商机产线,本产线不再包含。 +招标商务要素(项目名称/招标人/截止时间/预算等)由招标文件解析阶段从文件中抽取, +落 bid_tenders(source=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", { diff --git a/pipeline_bidding/role_tool_schemas.py b/pipeline_bidding/role_tool_schemas.py index e4136c6..23c30f2 100644 --- a/pipeline_bidding/role_tool_schemas.py +++ b/pipeline_bidding/role_tool_schemas.py @@ -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.5,10分制)放行;不通过必须给改进意见,系统清空该类产出并触发解析重做", + "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", diff --git a/scripts/load_path.py b/scripts/load_path.py index e73cd34..8fd79f4 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -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", ] diff --git a/wwwroot/bid_mail_accounts/get_bid_mail_accounts.dspy b/wwwroot/bid_mail_accounts/get_bid_mail_accounts.dspy deleted file mode 100644 index eb2b073..0000000 --- a/wwwroot/bid_mail_accounts/get_bid_mail_accounts.dspy +++ /dev/null @@ -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":[] -} \ No newline at end of file diff --git a/wwwroot/bid_mail_accounts/index.ui b/wwwroot/bid_mail_accounts/index.ui deleted file mode 100644 index 0ef475a..0000000 --- a/wwwroot/bid_mail_accounts/index.ui +++ /dev/null @@ -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":[] - -}] -} \ No newline at end of file diff --git a/wwwroot/bid_mail_accounts/update_bid_mail_accounts.dspy b/wwwroot/bid_mail_accounts/update_bid_mail_accounts.dspy deleted file mode 100644 index be34502..0000000 --- a/wwwroot/bid_mail_accounts/update_bid_mail_accounts.dspy +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/wwwroot/bid_mail_accounts/add_bid_mail_accounts.dspy b/wwwroot/bid_qc_reviews/add_bid_qc_reviews.dspy similarity index 69% rename from wwwroot/bid_mail_accounts/add_bid_mail_accounts.dspy rename to wwwroot/bid_qc_reviews/add_bid_qc_reviews.dspy index 3300c0e..a5bb927 100644 --- a/wwwroot/bid_mail_accounts/add_bid_mail_accounts.dspy +++ b/wwwroot/bid_qc_reviews/add_bid_qc_reviews.dspy @@ -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" } } diff --git a/wwwroot/bid_mail_accounts/delete_bid_mail_accounts.dspy b/wwwroot/bid_qc_reviews/delete_bid_qc_reviews.dspy similarity index 91% rename from wwwroot/bid_mail_accounts/delete_bid_mail_accounts.dspy rename to wwwroot/bid_qc_reviews/delete_bid_qc_reviews.dspy index 93b767d..0261bf0 100644 --- a/wwwroot/bid_mail_accounts/delete_bid_mail_accounts.dspy +++ b/wwwroot/bid_qc_reviews/delete_bid_qc_reviews.dspy @@ -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", diff --git a/wwwroot/bid_qc_reviews/get_bid_qc_reviews.dspy b/wwwroot/bid_qc_reviews/get_bid_qc_reviews.dspy new file mode 100644 index 0000000..bbb7a8b --- /dev/null +++ b/wwwroot/bid_qc_reviews/get_bid_qc_reviews.dspy @@ -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":[] +} \ No newline at end of file diff --git a/wwwroot/bid_qc_reviews/index.ui b/wwwroot/bid_qc_reviews/index.ui new file mode 100644 index 0000000..af7bb4a --- /dev/null +++ b/wwwroot/bid_qc_reviews/index.ui @@ -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":[] + +}] +} \ No newline at end of file diff --git a/wwwroot/bid_qc_reviews/update_bid_qc_reviews.dspy b/wwwroot/bid_qc_reviews/update_bid_qc_reviews.dspy new file mode 100644 index 0000000..0919111 --- /dev/null +++ b/wwwroot/bid_qc_reviews/update_bid_qc_reviews.dspy @@ -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" + } +} \ No newline at end of file diff --git a/wwwroot/bid_tenders/add_bid_tenders.dspy b/wwwroot/bid_tenders/add_bid_tenders.dspy index b334e4f..a1ac34f 100644 --- a/wwwroot/bid_tenders/add_bid_tenders.dspy +++ b/wwwroot/bid_tenders/add_bid_tenders.dspy @@ -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" } } diff --git a/wwwroot/bid_tenders/get_bid_tenders.dspy b/wwwroot/bid_tenders/get_bid_tenders.dspy index a67d447..cdb7067 100644 --- a/wwwroot/bid_tenders/get_bid_tenders.dspy +++ b/wwwroot/bid_tenders/get_bid_tenders.dspy @@ -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'] = '' diff --git a/wwwroot/bid_tenders/index.ui b/wwwroot/bid_tenders/index.ui index 9f3099d..dd35590 100644 --- a/wwwroot/bid_tenders/index.ui +++ b/wwwroot/bid_tenders/index.ui @@ -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" + } + ] +}, diff --git a/wwwroot/bid_tenders/update_bid_tenders.dspy b/wwwroot/bid_tenders/update_bid_tenders.dspy index 66391d7..baffcbb 100644 --- a/wwwroot/bid_tenders/update_bid_tenders.dspy +++ b/wwwroot/bid_tenders/update_bid_tenders.dspy @@ -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", diff --git a/wwwroot/index.ui b/wwwroot/index.ui index c1aa85c..c20f9d5 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -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"}] } ] }