From 6239f0992d84f8e2586de97143c13fab36cc37ce Mon Sep 17 00:00:00 2001 From: yuqei Date: Wed, 13 May 2026 09:11:15 +0800 Subject: [PATCH] feat: checkpoint contract assistant delivery phases --- .gitignore | 2 + backend/app/api/chat.py | 15 +- backend/app/api/contracts.py | 36 +- backend/app/api/conversations.py | 8 +- backend/app/api/knowledge_bases.py | 17 +- backend/app/api/pilot_requests.py | 93 + backend/app/api/system.py | 704 ++++++- backend/app/api/templates.py | 70 +- backend/app/api/users.py | 4 +- backend/app/clients/shared_ai_platform.py | 234 +-- backend/app/core/config.py | 5 +- backend/app/core/roles.py | 24 + backend/app/main.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/contract_template.py | 6 + .../app/models/contract_template_version.py | 28 + backend/app/models/pilot_request.py | 33 + .../contract_template_repository.py | 38 +- .../contract_template_version_repository.py | 50 + .../generated_contract_repository.py | 6 + .../repositories/pilot_request_repository.py | 48 + .../repositories/review_task_repository.py | 14 + backend/app/schemas/__init__.py | 1 + backend/app/schemas/ai_contract.py | 269 +++ backend/app/schemas/chat.py | 6 +- backend/app/schemas/pilot_request.py | 20 + backend/app/schemas/review.py | 8 + backend/app/services/auth_service.py | 15 +- backend/app/services/chat_service.py | 258 --- .../services/contract_generation_service.py | 267 ++- backend/app/services/file_service.py | 64 +- .../app/services/knowledge_base_service.py | 354 ---- .../legal_review_execution_gateway.py | 50 +- .../app/services/legal_review_local_engine.py | 171 -- backend/app/services/pilot_request_service.py | 180 ++ backend/app/services/rag_service.py | 853 --------- backend/app/services/review_service.py | 263 +-- backend/app/services/template_service.py | 311 +++- backend/app/services/user_service.py | 9 +- ..._0016_create_contract_template_versions.py | 196 ++ ...60512_0017_add_template_taxonomy_fields.py | 135 ++ backend/tests/test_auth_service.py | 25 +- backend/tests/test_chat_api.py | 78 - backend/tests/test_contracts_api.py | 118 +- backend/tests/test_files_api.py | 61 + backend/tests/test_knowledge_base_service.py | 128 -- backend/tests/test_legal_review_dual_run.py | 53 + backend/tests/test_pilot_request_service.py | 105 ++ backend/tests/test_pilot_requests_api.py | 165 ++ backend/tests/test_rag_service.py | 1645 ----------------- backend/tests/test_review_service.py | 189 ++ .../tests/test_shared_ai_platform_client.py | 150 +- backend/tests/test_system_api.py | 270 ++- backend/tests/test_template_service.py | 196 +- backend/tests/test_templates_api.py | 173 +- backend/tests/test_user_service.py | 95 + backend/tests/test_users_api.py | 12 +- backend/tools/legal_review_dual_run.py | 244 +++ backend/tools/pdf_acceptance_gate.py | 55 +- frontend/package-lock.json | 106 +- frontend/package.json | 17 +- .../p0-mainline-alignment-closed-loop-e2e.mjs | 249 +++ frontend/scripts/phase1-closed-loop-e2e.mjs | 55 +- frontend/scripts/phase2-closed-loop-e2e.mjs | 9 +- frontend/scripts/phase3-closed-loop-e2e.mjs | 13 +- ...lice10-pilot-materials-closed-loop-e2e.mjs | 242 +++ ...slice11-error-feedback-closed-loop-e2e.mjs | 250 +++ ...ce12-pilot-launch-pack-closed-loop-e2e.mjs | 255 +++ .../slice4-auto-review-closed-loop-e2e.mjs | 309 ++++ frontend/scripts/slice4-shared-ai-stub.mjs | 234 +++ .../slice5-pilot-shell-closed-loop-e2e.mjs | 255 +++ ...slice6-role-pilot-user-closed-loop-e2e.mjs | 286 +++ .../slice7-demo-assets-closed-loop-e2e.mjs | 227 +++ .../slice8-pilot-scope-closed-loop-e2e.mjs | 224 +++ .../slice9-readability-closed-loop-e2e.mjs | 276 +++ frontend/src/app/admin/[slug]/page.tsx | 2 + frontend/src/app/admin/page.tsx | 14 +- frontend/src/app/chat/page.tsx | 58 +- frontend/src/app/contracts/[id]/page.tsx | 176 ++ frontend/src/app/contracts/page.tsx | 161 +- frontend/src/app/demo-request/page.tsx | 204 ++ frontend/src/app/globals.css | 32 + frontend/src/app/knowledge-bases/page.tsx | 7 +- frontend/src/app/pilot-readiness/page.tsx | 386 +++- frontend/src/app/pilot-requests/page.tsx | 316 ++++ frontend/src/app/profile/page.tsx | 12 +- frontend/src/app/review/page.tsx | 7 + frontend/src/app/reviews/[id]/page.tsx | 296 ++- frontend/src/app/templates/page.tsx | 483 +++-- frontend/src/app/users/page.tsx | 104 +- frontend/src/components/auth/auth-guard.tsx | 2 +- frontend/src/components/layout/app-topbar.tsx | 57 +- frontend/src/lib/roles.ts | 33 + frontend/src/lib/template-taxonomy.ts | 25 + frontend/src/services/chat.ts | 4 +- frontend/src/services/contract.ts | 58 +- frontend/src/services/pilot-request.ts | 82 + frontend/src/services/review.ts | 32 +- frontend/src/services/system.ts | 222 +++ frontend/src/services/template.ts | 27 +- frontend/src/types/api.ts | 136 +- mobile/.gitignore | 11 + .../profile/domain/shared_ai_status.dart | 15 +- .../profile/presentation/profile_screen.dart | 5 +- ...ify 的能力缺口清单与接入优先级方案-2026-05-10.md | 526 ++++++ ...Œ起草与审查助手-P0主线对齐闭环-2026-05-12.md | 52 + ...草与审查助手-P0实现主线拆分方案-2026-05-11.md | 319 ++++ ...查助手-对外产品介绍文案-官网演示版-2026-05-11.md | 332 ++++ ...‰‹-开源项目能力吸收与共享平台落地原则-2026-05-11.md | 284 +++ ...草与审查助手-当前可交付状态说明-2026-05-11.md | 363 ++++ ...µ·草与审查助手-当前工作进度保存-2026-05-11.md | 493 +++++ ...‰与审查助手-阶段重整与实施路线图-2026-05-12.md | 194 ++ .../AI合同起草与审查助手_MVP功能优先级表.md | 277 +++ ...AI合同起草与审查助手_全阶段开发任务清单.md | 553 ++++++ .../AI合同起草与审查助手_功能增强清单.md | 502 +++++ ...�Œ起草与审查助手_技术架构图与模块拆分说明.md | 617 +++++++ project-docs/README.md | 52 + ...ai-contract-platform_商业化重定位建议书.md | 557 ++++++ ...续能力扩展阶段1第一批执行记录-2026-05-04.md | 0 ...续能力扩展阶段1第七批执行记录-2026-05-04.md | 0 ...续能力扩展阶段1第三批执行记录-2026-05-04.md | 0 ...续能力扩展阶段1第二批执行记录-2026-05-04.md | 0 ...续能力扩展阶段1第五批执行记录-2026-05-04.md | 0 ...续能力扩展阶段1第八批执行记录-2026-05-04.md | 0 ...续能力扩展阶段1第六批执行记录-2026-05-04.md | 0 ...续能力扩展阶段1第四批执行记录-2026-05-04.md | 0 .../archive/2026-05-11-doc-cleanup/README.md | 134 ++ .../V1-需求实现对照表-2026-04-15.md | 0 .../defect_ledger_closure_2026-05-03.md | 0 .../dev_fix_action_report_2026-05-03.md | 0 ...ev_fix_action_report_closure_2026-05-03.md | 0 .../pdf_deeptest_fix_closure_2026-05-03.md | 0 .../pdf_deeptest_report_2026-05-03.md | 0 .../2026-05-11-doc-cleanup}/report.md | 0 .../2026-05-11-doc-cleanup}/report_final.md | 0 .../reverify_report_2026-05-03_round7.md | 0 ...i-platform API v0.1 冻结说明-2026-05-03.md | 0 ...orm SDK与domain-pack接入说明-2026-05-04.md | 0 ...form frontend_mobile联调说明-2026-05-04.md | 22 +- ...-java AI能力收口最终变更摘要-2026-05-07.md | 297 +++ ...act-platform 争议项落地决策表-2026-05-08.md | 225 +++ ...-contract-platform 责任划分表-2026-05-07.md | 310 ++++ ...rm-java 平台接口缺口需求文档-2026-05-07.md | 531 ++++++ ...i-platform-java 接口联调说明-2026-05-06.md | 0 ...ava 页面级验收与8012下线结论-2026-05-06.md | 0 .../交付差距报告-2026-04-22.md | 0 .../共享AI平台测试运维脚本说明-2026-05-04.md | 0 .../共享AI平台测试部署说明-2026-05-03.md | 0 .../共享AI能力平台拆分方案-2026-05-03.md | 0 .../共享AI能力资产映射清单-2026-05-03.md | 0 .../完整自动化测试报告-2026-05-03.md | 0 .../完整自动化测试报告修复记录-2026-05-03.md | 0 .../当前统一需求基线-2026-04-21.md | 0 .../当前进度报告-2026-04-22.md | 0 .../当前闭环与页面验证清单-2026-04-23.md | 0 .../模块验证清单-2026-04-23.md | 0 ...�营准备阶段5A数据库治理域执行记录-2026-05-02.md | 0 ...�营准备阶段5B后端治理接口执行记录-2026-05-02.md | 0 ...�营准备阶段5C前端治理页面执行记录-2026-05-02.md | 0 ...准备阶段5D数据库真相源切换执行记录-2026-05-02.md | 0 .../正式上线运营准备阶段方案-2026-05-02.md | 0 ...上线运营准备阶段第一批执行记录-2026-05-02.md | 0 ...上线运营准备阶段第三批执行记录-2026-05-02.md | 0 ...£式上线运营准备阶段第三批方案-2026-05-02.md | 0 ...上线运营准备阶段第二批执行记录-2026-05-02.md | 0 ...£式上线运营准备阶段第二批方案-2026-05-02.md | 0 ...上线运营准备阶段第五批执行记录-2026-05-02.md | 0 ...£式上线运营准备阶段第五批方案-2026-05-02.md | 0 ...上线运营准备阶段第四批执行记录-2026-05-02.md | 0 ...£式上线运营准备阶段第四批方案-2026-05-02.md | 0 .../生产值守告警响应SOP-2026-05-01.md | 0 .../生产值守运营复盘SOP-2026-05-01.md | 0 .../生产化清单与正式验收包索引-2026-05-04.md | 0 .../移动端开发方案-2026-05-01.md | 0 ...¨端方案完成情况对照表-完工说明-2026-05-04.md | 0 .../运维手册-2026-05-01.md | 0 .../闭环交付与深度开发方案-2026-05-01.md | 0 .../阶段1-完整闭环测试执行记录-2026-05-01.md | 0 ...–交付包培训移交与验收闭环执行记录-2026-05-01.md | 0 ...º§化交付包培训移交与验收闭环方案-2026-05-01.md | 0 ...�营多租户组织接入与SLA闭环执行记录-2026-05-01.md | 0 ...Œ–运营多租户组织接入与SLA闭环方案-2026-05-01.md | 0 ...¿›集成扩展与长期路线图闭环执行记录-2026-05-01.md | 0 ...演进集成扩展与长期路线图闭环方案-2026-05-01.md | 0 ...¾‹知识库与合同模板库建设闭环方案-2026-05-01.md | 0 ...“与合同模板库建设闭环第一批执行记录-2026-05-01.md | 0 ...“与合同模板库建设闭环第七批执行记录-2026-05-01.md | 0 ...“与合同模板库建设闭环第三批执行记录-2026-05-01.md | 0 ...“与合同模板库建设闭环第九批执行记录-2026-05-01.md | 0 ...合同模板库建设闭环第二十一批执行记录-2026-05-02.md | 0 ...合同模板库建设闭环第二十二批执行记录-2026-05-02.md | 0 ...¸Ž合同模板库建设闭环第二十批执行记录-2026-05-02.md | 0 ...“与合同模板库建设闭环第二批执行记录-2026-05-01.md | 0 ...“与合同模板库建设闭环第五批执行记录-2026-05-01.md | 0 ...“与合同模板库建设闭环第八批执行记录-2026-05-01.md | 0 ...“与合同模板库建设闭环第六批执行记录-2026-05-01.md | 0 ...¸Ž合同模板库建设闭环第十一批执行记录-2026-05-01.md | 0 ...¸Ž合同模板库建设闭环第十七批执行记录-2026-05-02.md | 0 ...¸Ž合同模板库建设闭环第十三批执行记录-2026-05-02.md | 0 ...¸Ž合同模板库建设闭环第十九批执行记录-2026-05-02.md | 0 ...¸Ž合同模板库建设闭环第十二批执行记录-2026-05-01.md | 0 ...¸Ž合同模板库建设闭环第十五批执行记录-2026-05-02.md | 0 ...¸Ž合同模板库建设闭环第十八批执行记录-2026-05-02.md | 0 ...¸Ž合同模板库建设闭环第十六批执行记录-2026-05-02.md | 0 ...¸Ž合同模板库建设闭环第十四批执行记录-2026-05-02.md | 0 ...“与合同模板库建设闭环第十批执行记录-2026-05-01.md | 0 ...“与合同模板库建设闭环第四批执行记录-2026-05-01.md | 0 ...深度开发与交付体验补强执行记录-2026-05-01.md | 0 .../阶段3-试点交付准备执行记录-2026-05-01.md | 0 ...试点运行监控与安全加固执行记录-2026-05-01.md | 0 ...�行安全监控备份与依赖治理执行记录-2026-05-01.md | 0 ...º§运行安全监控备份与依赖治理方案-2026-05-01.md | 0 .../阶段6-前端视觉刷新验收记录-2026-05-01.md | 0 .../阶段6-前端视觉改动梳理-2026-05-01.md | 0 ...¶段6-发布后部署与上线验证记录-2026-05-01.md | 0 ...运营自动化与发布门禁固化执行记录-2026-05-01.md | 0 ...Ÿ产运营自动化与发布门禁固化方案-2026-05-01.md | 0 ...®µ7-一键前端Docker发布执行记录-2026-05-01.md | 0 ...7-生产运营常态化与一键发布方案-2026-05-01.md | 0 ...一键回滚与生产巡检门禁执行记录-2026-05-01.md | 0 ...7.2-一键回滚与生产巡检门禁方案-2026-05-01.md | 0 ...容量治理与备份保留策略执行记录-2026-05-01.md | 0 ...7.3-容量治理与备份保留策略方案-2026-05-01.md | 0 ...7.4-日常巡检与告警门禁执行记录-2026-05-01.md | 0 ...¶段7.4-日常巡检与告警门禁方案-2026-05-01.md | 0 ...7.5-定时巡检与告警投递执行记录-2026-05-01.md | 0 ...¶段7.5-定时巡检与告警投递方案-2026-05-01.md | 0 ...真实值守上线与告警联调执行记录-2026-05-01.md | 0 ...7.6-真实值守上线与告警联调方案-2026-05-01.md | 0 ...7.7-告警到人与响应闭环执行记录-2026-05-01.md | 0 ...¶段7.7-告警到人与响应闭环方案-2026-05-01.md | 0 ...8-值守任务运行审计闭环执行记录-2026-05-01.md | 0 ...®µ7.8-值守任务运行审计闭环方案-2026-05-01.md | 0 ...�据留存日报周报与运营复盘执行记录-2026-05-01.md | 0 ...®ˆ证据留存日报周报与运营复盘方案-2026-05-01.md | 0 .../阶段8-12-后续全阶段规划方案-2026-05-01.md | 0 ...Ÿ实使用与业务反馈闭环完整执行记录-2026-05-01.md | 0 ...试点真实使用与业务反馈闭环方案-2026-05-01.md | 0 ...®ž使用与业务反馈闭环第一批执行记录-2026-05-01.md | 0 ...‡�AI输出质量与数据治理闭环执行记录-2026-05-01.md | 0 ...质量AI输出质量与数据治理闭环方案-2026-05-01.md | 0 ...Ž审查助手-Slice10试点材料准备闭环-2026-05-12.md | 35 + ...¥助手-Slice11错误与异常反馈优化闭环-2026-05-12.md | 45 + ...‹-Slice12试点启动包与客户演示脚本闭环-2026-05-12.md | 47 + ...¡查助手-Slice9页面与结果可读性闭环-2026-05-12.md | 47 + ...µ·草与审查助手-下一阶段实施建议-2026-05-11.md | 405 ++++ ...草与审查助手-自动化修复完成报告-2026-05-11.md | 307 +++ ...®¡查助手-自动化阶段任务清单-run-until-blocked.md | 361 ++++ .../2026-05-12-root-doc-cleanup/README.md | 45 + .../defect_ledger.md | 0 .../defect_ledger修复记录-2026-05-03.md | 0 .../pdf_deeptest_api_round9.json | 0 .../✅ AI合同平台 V1 测试与验收方案.md | 0 .../🏗️ AI合同平台 V1 技术架构说明书.md | 0 .../📄 AI 合同智能平台 —— 完整项目方案.md | 0 .../📌 AI合同平台 V1 开发蓝图.md | 0 .../📘 AI合同平台 V1 PRD.md | 0 .../📚 AI合同平台 V1 数据字典.md | 0 .../🔌 AI合同平台 V1 API设计.md | 0 .../🖥️ AI合同平台 V1 页面清单.md | 0 .../🗄️ AI合同平台 V1 数据库设计.md | 0 .../🧱 AI合同平台 项目目录结构与模块拆分.md | 0 ...PLE-TPL-COMMISSION-SERVICE-001-expected.md | 3 + ...-TPL-COOPERATION-FRAMEWORK-001-expected.md | 3 + ...GEN-SAMPLE-TPL-LEASE-HOUSE-001-expected.md | 3 + ...GEN-SAMPLE-TPL-SALES-GOODS-001-expected.md | 3 + .../AI合同起草与审查助手-FAQ.md | 21 + .../AI合同起草与审查助手-用户使用手册.md | 25 + .../AI合同起草与审查助手-管理员手册.md | 24 + .../AI合同起草与审查助手-试点反馈模板.md | 30 + .../AI合同起草与审查助手-试点说明.md | 27 + .../AI合同起草与审查助手-销售客户演示脚本.md | 43 + .../baselines/baseline-trend-by-batch.csv | 2 +- .../baselines/baseline-trend-by-contract.csv | 4 +- .../baseline-trend-by-relationship-type.csv | 2 +- .../baselines/baseline-trend-dashboard.html | 4 +- .../baselines/baseline-trend-summary.md | 4 +- .../baselines/latest-summary.json | 4 +- .../review-structured-baseline-history.jsonl | 21 + ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...0-review-structured-baseline-snapshot.json | 422 +++++ ...contract-full-automation-until-blocked.ps1 | 456 +++++ scripts/run-delivery-acceptance-gate.ps1 | 2 +- scripts/run-phase1-closed-loop.ps1 | 49 +- scripts/run-phase2-closed-loop.ps1 | 66 +- scripts/run-phase3-closed-loop.ps1 | 85 +- scripts/run-phase8-pilot-closed-loop.ps1 | 122 +- scripts/run-pilot-readiness-gate.ps1 | 16 +- scripts/run-production-regression.ps1 | 15 +- scripts/run-production-release-gate.ps1 | 5 +- scripts/run-security-baseline.ps1 | 2 +- scripts/run-shared-ai-platform-ops-check.ps1 | 26 +- 311 files changed, 27837 insertions(+), 4629 deletions(-) create mode 100644 backend/app/api/pilot_requests.py create mode 100644 backend/app/core/roles.py create mode 100644 backend/app/models/contract_template_version.py create mode 100644 backend/app/models/pilot_request.py create mode 100644 backend/app/repositories/contract_template_version_repository.py create mode 100644 backend/app/repositories/pilot_request_repository.py create mode 100644 backend/app/schemas/ai_contract.py create mode 100644 backend/app/schemas/pilot_request.py delete mode 100644 backend/app/services/chat_service.py delete mode 100644 backend/app/services/knowledge_base_service.py delete mode 100644 backend/app/services/legal_review_local_engine.py create mode 100644 backend/app/services/pilot_request_service.py delete mode 100644 backend/app/services/rag_service.py create mode 100644 backend/migrations/versions/20260512_0016_create_contract_template_versions.py create mode 100644 backend/migrations/versions/20260512_0017_add_template_taxonomy_fields.py delete mode 100644 backend/tests/test_knowledge_base_service.py create mode 100644 backend/tests/test_legal_review_dual_run.py create mode 100644 backend/tests/test_pilot_request_service.py create mode 100644 backend/tests/test_pilot_requests_api.py delete mode 100644 backend/tests/test_rag_service.py create mode 100644 backend/tests/test_user_service.py create mode 100644 backend/tools/legal_review_dual_run.py create mode 100644 frontend/scripts/p0-mainline-alignment-closed-loop-e2e.mjs create mode 100644 frontend/scripts/slice10-pilot-materials-closed-loop-e2e.mjs create mode 100644 frontend/scripts/slice11-error-feedback-closed-loop-e2e.mjs create mode 100644 frontend/scripts/slice12-pilot-launch-pack-closed-loop-e2e.mjs create mode 100644 frontend/scripts/slice4-auto-review-closed-loop-e2e.mjs create mode 100644 frontend/scripts/slice4-shared-ai-stub.mjs create mode 100644 frontend/scripts/slice5-pilot-shell-closed-loop-e2e.mjs create mode 100644 frontend/scripts/slice6-role-pilot-user-closed-loop-e2e.mjs create mode 100644 frontend/scripts/slice7-demo-assets-closed-loop-e2e.mjs create mode 100644 frontend/scripts/slice8-pilot-scope-closed-loop-e2e.mjs create mode 100644 frontend/scripts/slice9-readability-closed-loop-e2e.mjs create mode 100644 frontend/src/app/demo-request/page.tsx create mode 100644 frontend/src/app/pilot-requests/page.tsx create mode 100644 frontend/src/lib/roles.ts create mode 100644 frontend/src/lib/template-taxonomy.ts create mode 100644 frontend/src/services/pilot-request.ts create mode 100644 project-docs/AI合同平台 对照 Dify 的能力缺口清单与接入优先级方案-2026-05-10.md create mode 100644 project-docs/AI合同起草与审查助手-P0主线对齐闭环-2026-05-12.md create mode 100644 project-docs/AI合同起草与审查助手-P0实现主线拆分方案-2026-05-11.md create mode 100644 project-docs/AI合同起草与审查助手-对外产品介绍文案-官网演示版-2026-05-11.md create mode 100644 project-docs/AI合同起草与审查助手-开源项目能力吸收与共享平台落地原则-2026-05-11.md create mode 100644 project-docs/AI合同起草与审查助手-当前可交付状态说明-2026-05-11.md create mode 100644 project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md create mode 100644 project-docs/AI合同起草与审查助手-阶段重整与实施路线图-2026-05-12.md create mode 100644 project-docs/AI合同起草与审查助手_MVP功能优先级表.md create mode 100644 project-docs/AI合同起草与审查助手_全阶段开发任务清单.md create mode 100644 project-docs/AI合同起草与审查助手_功能增强清单.md create mode 100644 project-docs/AI合同起草与审查助手_技术架构图与模块拆分说明.md create mode 100644 project-docs/README.md create mode 100644 project-docs/ai-contract-platform_商业化重定位建议书.md rename project-docs/{ => archive/2026-05-11-doc-cleanup}/OCR后续能力扩展阶段1第一批执行记录-2026-05-04.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/OCR后续能力扩展阶段1第七批执行记录-2026-05-04.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/OCR后续能力扩展阶段1第三批执行记录-2026-05-04.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/OCR后续能力扩展阶段1第二批执行记录-2026-05-04.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/OCR后续能力扩展阶段1第五批执行记录-2026-05-04.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/OCR后续能力扩展阶段1第八批执行记录-2026-05-04.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/OCR后续能力扩展阶段1第六批执行记录-2026-05-04.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/OCR后续能力扩展阶段1第四批执行记录-2026-05-04.md (100%) create mode 100644 project-docs/archive/2026-05-11-doc-cleanup/README.md rename project-docs/{ => archive/2026-05-11-doc-cleanup}/V1-需求实现对照表-2026-04-15.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/defect_ledger_closure_2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/dev_fix_action_report_2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/dev_fix_action_report_closure_2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/pdf_deeptest_fix_closure_2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/pdf_deeptest_report_2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/report.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/report_final.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/reverify_report_2026-05-03_round7.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/shared-ai-platform API v0.1 冻结说明-2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/shared-ai-platform SDK与domain-pack接入说明-2026-05-04.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/shared-ai-platform frontend_mobile联调说明-2026-05-04.md (93%) create mode 100644 project-docs/archive/2026-05-11-doc-cleanup/shared-ai-platform-java AI能力收口最终变更摘要-2026-05-07.md create mode 100644 project-docs/archive/2026-05-11-doc-cleanup/shared-ai-platform-java 与 ai-contract-platform 争议项落地决策表-2026-05-08.md create mode 100644 project-docs/archive/2026-05-11-doc-cleanup/shared-ai-platform-java 与 ai-contract-platform 责任划分表-2026-05-07.md create mode 100644 project-docs/archive/2026-05-11-doc-cleanup/shared-ai-platform-java 平台接口缺口需求文档-2026-05-07.md rename project-docs/{ => archive/2026-05-11-doc-cleanup}/shared-ai-platform-java 接口联调说明-2026-05-06.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/shared-ai-platform-java 页面级验收与8012下线结论-2026-05-06.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/交付差距报告-2026-04-22.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/共享AI平台测试运维脚本说明-2026-05-04.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/共享AI平台测试部署说明-2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/共享AI能力平台拆分方案-2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/共享AI能力资产映射清单-2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/完整自动化测试报告-2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/完整自动化测试报告修复记录-2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/当前统一需求基线-2026-04-21.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/当前进度报告-2026-04-22.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/当前闭环与页面验证清单-2026-04-23.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/模块验证清单-2026-04-23.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段5A数据库治理域执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段5B后端治理接口执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段5C前端治理页面执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段5D数据库真相源切换执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段方案-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段第一批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段第三批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段第三批方案-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段第二批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段第二批方案-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段第五批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段第五批方案-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段第四批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/正式上线运营准备阶段第四批方案-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/生产值守告警响应SOP-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/生产值守运营复盘SOP-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/生产化清单与正式验收包索引-2026-05-04.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/移动端开发方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/移动端方案完成情况对照表-完工说明-2026-05-04.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/运维手册-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/闭环交付与深度开发方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段1-完整闭环测试执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段10-生产化交付包培训移交与验收闭环执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段10-生产化交付包培训移交与验收闭环方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段11-规模化运营多租户组织接入与SLA闭环执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段11-规模化运营多租户组织接入与SLA闭环方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段12-V2产品演进集成扩展与长期路线图闭环执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段12-V2产品演进集成扩展与长期路线图闭环方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第一批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第七批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第三批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第九批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第二十一批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第二十二批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第二十批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第二批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第五批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第八批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第六批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第十一批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第十七批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第十三批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第十九批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第十二批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第十五批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第十八批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第十六批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第十四批执行记录-2026-05-02.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第十批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段13-法律知识库与合同模板库建设闭环第四批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段2-深度开发与交付体验补强执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段3-试点交付准备执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段4-试点运行监控与安全加固执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段5-生产运行安全监控备份与依赖治理执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段5-生产运行安全监控备份与依赖治理方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段6-前端视觉刷新验收记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段6-前端视觉改动梳理-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段6-发布后部署与上线验证记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段6-生产运营自动化与发布门禁固化执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段6-生产运营自动化与发布门禁固化方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7-一键前端Docker发布执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7-生产运营常态化与一键发布方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.2-一键回滚与生产巡检门禁执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.2-一键回滚与生产巡检门禁方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.3-容量治理与备份保留策略执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.3-容量治理与备份保留策略方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.4-日常巡检与告警门禁执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.4-日常巡检与告警门禁方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.5-定时巡检与告警投递执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.5-定时巡检与告警投递方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.6-真实值守上线与告警联调执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.6-真实值守上线与告警联调方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.7-告警到人与响应闭环执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.7-告警到人与响应闭环方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.8-值守任务运行审计闭环执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.8-值守任务运行审计闭环方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.9-值守证据留存日报周报与运营复盘执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段7.9-值守证据留存日报周报与运营复盘方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段8-12-后续全阶段规划方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段8-试点真实使用与业务反馈闭环完整执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段8-试点真实使用与业务反馈闭环方案-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段8-试点真实使用与业务反馈闭环第一批执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段9-业务质量AI输出质量与数据治理闭环执行记录-2026-05-01.md (100%) rename project-docs/{ => archive/2026-05-11-doc-cleanup}/阶段9-业务质量AI输出质量与数据治理闭环方案-2026-05-01.md (100%) create mode 100644 project-docs/archive/2026-05-12-root-doc-cleanup/AI合同起草与审查助手-Slice10试点材料准备闭环-2026-05-12.md create mode 100644 project-docs/archive/2026-05-12-root-doc-cleanup/AI合同起草与审查助手-Slice11错误与异常反馈优化闭环-2026-05-12.md create mode 100644 project-docs/archive/2026-05-12-root-doc-cleanup/AI合同起草与审查助手-Slice12试点启动包与客户演示脚本闭环-2026-05-12.md create mode 100644 project-docs/archive/2026-05-12-root-doc-cleanup/AI合同起草与审查助手-Slice9页面与结果可读性闭环-2026-05-12.md create mode 100644 project-docs/archive/2026-05-12-root-doc-cleanup/AI合同起草与审查助手-下一阶段实施建议-2026-05-11.md create mode 100644 project-docs/archive/2026-05-12-root-doc-cleanup/AI合同起草与审查助手-自动化修复完成报告-2026-05-11.md create mode 100644 project-docs/archive/2026-05-12-root-doc-cleanup/AI合同起草与审查助手-自动化阶段任务清单-run-until-blocked.md create mode 100644 project-docs/archive/2026-05-12-root-doc-cleanup/README.md rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/defect_ledger.md (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/defect_ledger修复记录-2026-05-03.md (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/pdf_deeptest_api_round9.json (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/✅ AI合同平台 V1 测试与验收方案.md (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/🏗️ AI合同平台 V1 技术架构说明书.md (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/📄 AI 合同智能平台 —— 完整项目方案.md (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/📌 AI合同平台 V1 开发蓝图.md (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/📘 AI合同平台 V1 PRD.md (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/📚 AI合同平台 V1 数据字典.md (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/🔌 AI合同平台 V1 API设计.md (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/🖥️ AI合同平台 V1 页面清单.md (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/🗄️ AI合同平台 V1 数据库设计.md (100%) rename project-docs/{ => archive/2026-05-12-root-doc-cleanup}/🧱 AI合同平台 项目目录结构与模块拆分.md (100%) create mode 100644 project-docs/pilot-materials/AI合同起草与审查助手-FAQ.md create mode 100644 project-docs/pilot-materials/AI合同起草与审查助手-用户使用手册.md create mode 100644 project-docs/pilot-materials/AI合同起草与审查助手-管理员手册.md create mode 100644 project-docs/pilot-materials/AI合同起草与审查助手-试点反馈模板.md create mode 100644 project-docs/pilot-materials/AI合同起草与审查助手-试点说明.md create mode 100644 project-docs/pilot-materials/AI合同起草与审查助手-销售客户演示脚本.md create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-11T01-57-12-563260+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-11T02-25-06-843905+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-11T03-07-57-730910+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-11T03-28-58-448201+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-11T05-31-08-196302+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-11T05-34-59-119530+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-11T08-41-35-877964+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-12T11-13-49-851716+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-12T12-13-14-380911+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-12T12-26-29-080068+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-12T12-33-43-242546+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-12T13-09-05-346265+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-12T13-18-24-497065+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-12T13-45-56-327905+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-12T15-14-37-218594+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-12T15-44-34-003688+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-12T16-22-48-918615+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-12T16-47-41-984348+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-13T00-10-47-744081+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-13T00-18-54-673746+00-00-review-structured-baseline-snapshot.json create mode 100644 project-docs/structured-review-samples/baselines/snapshots/2026-05-13T00-47-04-046682+00-00-review-structured-baseline-snapshot.json create mode 100644 scripts/run-ai-contract-full-automation-until-blocked.ps1 diff --git a/.gitignore b/.gitignore index 01899dbe..b5597f63 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,7 @@ backend/.env backend/storage/ backend/tmp_migration_check.db project-docs/qa-artifacts/ +project-docs/automation-artifacts/ +backend/project-docs/qa-artifacts/ .claude/worktrees/ .gstack/ diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 0ca00f01..aeb8b255 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -1,10 +1,8 @@ from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session -from app.clients.shared_ai_platform import SharedAIPlatformClient, build_shared_ai_platform_client from app.api.reviews import require_current_user -from app.db import get_db_session +from app.clients.shared_ai_platform import SharedAIPlatformClient, get_shared_ai_platform_client from app.schemas.chat import ChatStreamRequest router = APIRouter(prefix="/chat", tags=["chat"]) @@ -14,17 +12,18 @@ ROUTE_PERMISSION_ACTIONS = { } -def get_shared_ai_platform_client(db: Session = Depends(get_db_session)) -> SharedAIPlatformClient: - return build_shared_ai_platform_client(db) - - @router.post("/stream") def stream_chat( payload: ChatStreamRequest, shared_ai_platform_client: SharedAIPlatformClient = Depends(get_shared_ai_platform_client), current_user: dict = Depends(require_current_user), ): + review_context = ( + payload.review_context.model_dump(mode="json", by_alias=True, exclude_none=True) + if payload.review_context + else None + ) return StreamingResponse( - shared_ai_platform_client.stream_answer(current_user, payload.conversation_id, payload.message, payload.review_context), + shared_ai_platform_client.stream_answer(current_user, payload.conversation_id, payload.message, review_context), media_type="text/event-stream", ) diff --git a/backend/app/api/contracts.py b/backend/app/api/contracts.py index b7e9cfd1..89a2dd26 100644 --- a/backend/app/api/contracts.py +++ b/backend/app/api/contracts.py @@ -47,7 +47,6 @@ def list_generated_contracts( contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): - """获取已生成合同列表""" return success_response( contract_generation_service.list_generated_contracts( current_user=current_user, @@ -62,11 +61,20 @@ def list_generated_contracts( @router.get("/templates") def list_available_templates( contract_type: Optional[str] = None, + template_category: Optional[str] = None, + industry_category: Optional[str] = None, contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): - """获取可用的合同模板列表""" - return success_response(contract_generation_service.list_available_templates(current_user, contract_type)) + kwargs = { + "current_user": current_user, + "contract_type": contract_type, + } + if template_category is not None: + kwargs["template_category"] = template_category + if industry_category is not None: + kwargs["industry_category"] = industry_category + return success_response(contract_generation_service.list_available_templates(**kwargs)) @router.post("/generate") @@ -80,29 +88,24 @@ def generate_contract( audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): - """生成合同""" import json try: - # 解析变量值 variable_values = json.loads(variable_values_json) if not isinstance(variable_values, dict): raise HTTPException(status_code=400, detail="变量值必须是JSON对象") - # 解析选项 options = {} if options_json: options = json.loads(options_json) if not isinstance(options, dict): raise HTTPException(status_code=400, detail="选项必须是JSON对象") - # 验证导出格式 try: export_format_enum = ExportFormat(export_format.lower()) except ValueError: raise HTTPException(status_code=400, detail=f"不支持的导出格式: {export_format}") - # 生成合同 result = contract_generation_service.generate_contract( current_user=current_user, template_id=template_id, @@ -111,7 +114,6 @@ def generate_contract( options=options, ) - # 记录审计日志 audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), @@ -122,6 +124,8 @@ def generate_contract( "template_id": template_id, "template_name": result["template_name"], "contract_type": result["contract_type"], + "template_category": result.get("template_category"), + "industry_category": result.get("industry_category"), "export_format": export_format, "variables_count": len(variable_values), "file_name": result["file_name"], @@ -136,8 +140,8 @@ def generate_contract( raise HTTPException(status_code=400, detail="JSON解析错误") except HTTPException: raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"合同生成失败: {str(e)}") + except Exception as exc: + raise HTTPException(status_code=500, detail=f"合同生成失败: {exc}") @router.post("/preview") @@ -147,16 +151,13 @@ def preview_contract( contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): - """预览合同生成结果""" import json try: - # 解析变量值 variable_values = json.loads(variable_values_json) if not isinstance(variable_values, dict): raise HTTPException(status_code=400, detail="变量值必须是JSON对象") - # 预览合同 result = contract_generation_service.preview_contract( current_user=current_user, template_id=template_id, @@ -169,8 +170,8 @@ def preview_contract( raise HTTPException(status_code=400, detail="JSON解析错误") except HTTPException: raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"合同预览失败: {str(e)}") + except Exception as exc: + raise HTTPException(status_code=500, detail=f"合同预览失败: {exc}") @router.get("/{contract_id}") @@ -179,7 +180,6 @@ def get_generated_contract( contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): - """获取已生成合同详情""" return success_response(contract_generation_service.get_generated_contract(current_user, contract_id)) @@ -189,7 +189,6 @@ def get_contract_preview( contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), current_user: dict = Depends(require_current_user), ): - """获取已生成合同的真实预览""" return success_response(contract_generation_service.get_generated_contract_preview(current_user, contract_id)) @@ -201,7 +200,6 @@ def download_contract( audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): - """下载已生成合同文件""" result = contract_generation_service.get_generated_contract_download(current_user, contract_id) audit_log_service.record_event( diff --git a/backend/app/api/conversations.py b/backend/app/api/conversations.py index 40fc5bae..b1362126 100644 --- a/backend/app/api/conversations.py +++ b/backend/app/api/conversations.py @@ -1,10 +1,8 @@ from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session -from app.clients.shared_ai_platform import SharedAIPlatformClient, build_shared_ai_platform_client from app.api.reviews import require_current_user +from app.clients.shared_ai_platform import SharedAIPlatformClient, get_shared_ai_platform_client from app.core.response import success_response -from app.db import get_db_session router = APIRouter(prefix="/conversations", tags=["conversations"]) ROUTE_PERMISSION_SCOPE = "chat_history" @@ -14,10 +12,6 @@ ROUTE_PERMISSION_ACTIONS = { } -def get_shared_ai_platform_client(db: Session = Depends(get_db_session)) -> SharedAIPlatformClient: - return build_shared_ai_platform_client(db) - - @router.get("") def list_conversations( page: int = 1, diff --git a/backend/app/api/knowledge_bases.py b/backend/app/api/knowledge_bases.py index 77e21031..930de60e 100644 --- a/backend/app/api/knowledge_bases.py +++ b/backend/app/api/knowledge_bases.py @@ -1,13 +1,13 @@ from typing import Optional -from fastapi import APIRouter, Depends, File, Form, Header, Request, UploadFile +from fastapi import APIRouter, Depends, File, Form, Request, UploadFile from sqlalchemy.orm import Session -from app.clients.shared_ai_platform import SharedAIPlatformClient, build_shared_ai_platform_client +from app.api.reviews import require_current_user +from app.clients.shared_ai_platform import SharedAIPlatformClient, get_shared_ai_platform_client from app.core.response import success_response from app.db import get_db_session from app.services.audit_log_service import AuditLogService -from app.services.auth_service import AuthService router = APIRouter(prefix="/knowledge-bases", tags=["knowledge-bases"]) ROUTE_PERMISSION_SCOPE = "knowledge_base_management" @@ -20,21 +20,10 @@ ROUTE_PERMISSION_ACTIONS = { } -def get_shared_ai_platform_client(db: Session = Depends(get_db_session)) -> SharedAIPlatformClient: - return build_shared_ai_platform_client(db) - - def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService: return AuditLogService(db) -def require_current_user( - authorization: Optional[str] = Header(default=None), - db: Session = Depends(get_db_session), -): - return AuthService(db).require_current_user(authorization) - - @router.get("") def list_knowledge_bases( page: int = 1, diff --git a/backend/app/api/pilot_requests.py b/backend/app/api/pilot_requests.py new file mode 100644 index 00000000..6f63e5ca --- /dev/null +++ b/backend/app/api/pilot_requests.py @@ -0,0 +1,93 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Header +from sqlalchemy.orm import Session + +from app.core.response import success_response +from app.db import get_db_session +from app.schemas.pilot_request import PilotRequestSubmitRequest, PilotRequestUpdateRequest +from app.services.auth_service import AuthService +from app.services.pilot_request_service import PilotRequestService + +router = APIRouter(prefix="/pilot-requests", tags=["pilot-requests"]) +ROUTE_PERMISSION_SCOPE = "pilot_requests" +ROUTE_PERMISSION_ACTIONS = { + "submit": "pilot_requests:submit", + "list": "pilot_requests:list", + "summary": "pilot_requests:summary", + "update": "pilot_requests:update", +} + + +def get_pilot_request_service(db: Session = Depends(get_db_session)) -> PilotRequestService: + return PilotRequestService(db) + + +def require_current_user( + authorization: Optional[str] = Header(default=None), + db: Session = Depends(get_db_session), +): + return AuthService(db).require_current_user(authorization) + + +@router.post("") +def submit_pilot_request( + payload: PilotRequestSubmitRequest, + pilot_request_service: PilotRequestService = Depends(get_pilot_request_service), +): + return success_response( + pilot_request_service.submit_request( + contact_name=payload.contact_name, + company_name=payload.company_name, + role_title=payload.role_title, + email=payload.email, + phone=payload.phone, + expected_contract_volume=payload.expected_contract_volume, + primary_goal=payload.primary_goal, + deployment_preference=payload.deployment_preference, + scenario_description=payload.scenario_description, + ) + ) + + +@router.get("") +def list_pilot_requests( + page: int = 1, + page_size: int = 20, + status_value: Optional[str] = None, + pilot_request_service: PilotRequestService = Depends(get_pilot_request_service), + current_user: dict = Depends(require_current_user), +): + return success_response( + pilot_request_service.list_requests( + current_user, + page=page, + page_size=page_size, + status_value=status_value, + ) + ) + + +@router.get("/summary") +def get_pilot_request_summary( + pilot_request_service: PilotRequestService = Depends(get_pilot_request_service), + current_user: dict = Depends(require_current_user), +): + return success_response(pilot_request_service.get_summary(current_user)) + + +@router.put("/{request_id}") +def update_pilot_request( + request_id: str, + payload: PilotRequestUpdateRequest, + pilot_request_service: PilotRequestService = Depends(get_pilot_request_service), + current_user: dict = Depends(require_current_user), +): + return success_response( + pilot_request_service.update_request( + current_user, + request_id, + status_value=payload.status, + admin_notes=payload.admin_notes, + ) + ) diff --git a/backend/app/api/system.py b/backend/app/api/system.py index d87c75fd..d8be6d4f 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Depends, Header +from fastapi import APIRouter, Depends, Header, HTTPException, status from sqlalchemy import select, text from sqlalchemy.orm import Session @@ -9,7 +9,8 @@ from app.core.config import settings from app.core.response import success_response from app.db import get_db_session from app.core.security import verify_password -from app.clients.shared_ai_platform import SharedAIPlatformClient, build_shared_ai_platform_client +from app.clients.shared_ai_platform import SharedAIPlatformClient, get_shared_ai_platform_client +from app.core.roles import ROLE_ADMIN from app.models.user import User from app.services.audit_log_service import AuditLogService from app.services.auth_service import AuthService, DEFAULT_ADMIN_PASSWORD, DEFAULT_ADMIN_USERNAME @@ -23,8 +24,472 @@ ROUTE_PERMISSION_ACTIONS = { "readiness": "system:readiness:get", "shared_ai": "system:shared_ai:get", "admin_snapshot": "system:admin_snapshot:get", + "p0_mainline": "system:p0_mainline:get", + "seed_pilot_demo_assets": "system:pilot_demo_assets:seed", + "pilot_scope": "system:pilot_scope:get", + "pilot_materials": "system:pilot_materials:get", + "pilot_error_feedback": "system:pilot_error_feedback:get", + "pilot_launch_pack": "system:pilot_launch_pack:get", } +P0_MAINLINE_VERSION = "p0-mainline-alignment-2026-05-12" +P0_MAINLINE_SOURCE_DOCUMENT = "project-docs/AI合同起草与审查助手-P0实现主线拆分方案-2026-05-11.md" +P0_MAINLINE_PHASE_BOUNDARY = { + "mainline_scope": "Slice 1-8:接口契约、PaddleOCR 解析闭环、Dify 风格证据、自动审查、产品外壳、角色、演示资产、试点边界冻结。", + "next_stage_scope": "Slice 9+:页面可读性、试点材料、异常反馈、启动包等试点运营层能力。", + "rule": "主线推进先验证来源项目、落点归属、合同平台职责和验收口径;试点运营材料不能替代 P0 核心能力验收。", +} +P0_MAINLINE_CAPABILITIES: List[Dict[str, Any]] = [ + { + "key": "p0_a_document_parsing", + "lane": "P0-A 文档解析", + "source_project": "D:\\AI Project\\PaddleOCR", + "absorbed_capability": "OCR、版面识别、PDF/图片文本抽取的能力模型。", + "target_layer": "共享文档解析层", + "contract_platform_responsibility": "上传入口、解析状态、审查接入、失败提示和审查详情证据展示。", + "acceptance": "PDF、图片、扫描件可进入审查,且失败时能看到解析状态、失败原因和下一步动作。", + "reference_policy": "只参考 PaddleOCR 的文档解析能力模型,不在合同业务层复制一套 OCR 引擎。", + "evidence": [ + "review_service 输出 source_document、document_analysis、ocr_gate_status", + "/reviews/[id] 展示 OCR/PDF 解析证据", + "backend/tools/pdf_acceptance_gate.py 覆盖文本型 PDF、扫描件和图片 PDF", + ], + }, + { + "key": "p0_b_shared_ai_orchestration", + "lane": "P0-B 共享AI编排", + "source_project": "D:\\AI Project\\dify", + "absorbed_capability": "Workflow、知识库、Prompt/Model 留痕、运行日志和来源证据模型。", + "target_layer": "shared-ai-platform / shared-ai-platform-java", + "contract_platform_responsibility": "传入合同任务,展示 provider、model、prompt、workflow run、知识来源和执行状态。", + "acceptance": "审查和问答可追踪 provider/model/prompt/workflow run/knowledge source。", + "reference_policy": "吸收 Dify 的 workflow、knowledge、trace 模型,不恢复业务仓库直连 Dify runtime。", + "evidence": [ + "shared_ai_platform_client 作为合同平台到共享 AI 平台的边界", + "ai_trace 暴露 provider/model/prompt_version/workflow_run_id", + "/reviews/[id] 展示 Dify 风格运行证据与知识来源", + ], + }, + { + "key": "p0_c_chat_experience", + "lane": "P0-C Chat体验", + "source_project": "D:\\AI Project\\NextChat", + "absorbed_capability": "会话历史、上下文、多轮追问、快捷追问和流式反馈体验。", + "target_layer": "共享对话层 + 合同 Chat 页", + "contract_platform_responsibility": "绑定审查上下文,支持围绕风险项继续追问,并保留合同业务语义。", + "acceptance": "审查详情可进入 Chat,Chat 可携带 review_context 和 conversation_id 持续追问。", + "reference_policy": "只吸收 NextChat 的交互体验,不把合同业务页面替换成通用聊天产品。", + "evidence": [ + "chat API 接收 review_context 并转发共享 AI 平台", + "/chat 展示审查上下文、来源和异常反馈", + "/conversations/{conversation_id} 可回看会话上下文", + ], + }, + { + "key": "p0_d_product_shell", + "lane": "P0-D 产品外壳", + "source_project": "D:\\AI Project\\open-saas", + "absorbed_capability": "登录、团队、控制台、官网、试用入口和基础角色视图。", + "target_layer": "共享身份 / 产品壳层 + 合同业务首页", + "contract_platform_responsibility": "保留合同业务工作台、演示入口、试点申请、基础角色导航和权限拦截。", + "acceptance": "系统像正式试点产品而不是内部原型,管理员、法务、业务角色有清晰入口。", + "reference_policy": "吸收 open-saas 的产品壳和账号组织思路,不把合同产品改成通用 SaaS 模板。", + "evidence": [ + "/demo-request 支持公开演示申请", + "/pilot-requests 支持管理员跟进试点申请", + "admin/legal/business 角色导航和用户管理已接入", + ], + }, + { + "key": "p0_e_auto_review_loop", + "lane": "P0-E 自动审查闭环", + "source_project": "现有系统 + D:\\AI Project\\dify workflow 思路", + "absorbed_capability": "生成到审查的流程编排、状态回填、报告入口和可追问上下文。", + "target_layer": "共享工作流层 + 合同业务层", + "contract_platform_responsibility": "生成结果映射审查任务,回填 auto_review 状态,并提供审查详情、Chat 和报告下载入口。", + "acceptance": "生成 -> 审查 -> 追问 -> 报告可跑通,且异常状态客户可读、QA 可验证。", + "reference_policy": "自动审查保留合同业务语义,底层执行状态尽量对齐共享 workflow 状态。", + "evidence": [ + "contract_generation_service 回填 auto_review", + "/contracts 和 /contracts/[id] 提供审查详情、Chat、报告入口", + "slice4-auto-review-closed-loop-e2e 验证生成到报告链路", + ], + }, +] +P0_MAINLINE_GUARDRAILS: List[Dict[str, str]] = [ + { + "key": "paddleocr_reference_only", + "label": "PaddleOCR 只作为解析能力参考", + "description": "合同平台消费结构化解析结果,不把 OCR 引擎硬塞进合同业务服务。", + }, + { + "key": "dify_capability_model_only", + "label": "Dify 只吸收能力模型", + "description": "保留共享 AI 平台边界,不恢复业务仓库直连 Dify runtime。", + }, + { + "key": "nextchat_experience_only", + "label": "NextChat 只吸收交互体验", + "description": "合同 Chat 页仍围绕审查上下文、风险项和法务追问展开。", + }, + { + "key": "open_saas_shell_only", + "label": "open-saas 只吸收产品壳思路", + "description": "账号、角色和演示入口服务合同业务,不把平台改成通用 SaaS 模板。", + }, + { + "key": "auto_review_business_semantics", + "label": "自动审查保留合同语义", + "description": "底层状态对齐共享 workflow,但页面和报告继续表达合同生成、审查、追问、报告闭环。", + }, +] +P0_MAINLINE_ACCEPTANCE = [ + "五条 P0 主线均暴露 source_project、target_layer、contract_platform_responsibility 和 acceptance。", + "readiness 内嵌 p0_mainline,QA 可在同一入口确认主线没有被试点运营材料替代。", + "前端 P0 主线矩阵展示来源项目、吸收能力、落点归属、合同平台职责、验收口径和证据。", + "主线 guardrails 明确禁止复制 Dify/NextChat/open-saas 整套产品或在业务仓库重复建设共享能力。", +] + +PILOT_SCOPE_VERSION = "pilot-scope-2026-05-12" +PILOT_SCOPE_SOURCE_DOCUMENT = "project-docs/AI合同起草与审查助手-下一阶段实施建议-2026-05-11.md" +PILOT_SCOPE_INCLUDED: List[Dict[str, str]] = [ + { + "key": "template_generation", + "label": "模板生成", + "owner": "business", + "acceptance": "可选择固定演示模板并生成合同文本", + }, + { + "key": "contract_preview", + "label": "合同预览", + "owner": "business", + "acceptance": "生成后可查看合同正文和下载资产", + }, + { + "key": "review_result", + "label": "审查结果", + "owner": "legal", + "acceptance": "合同文本、PDF 或图片输入可产出风险摘要和审查意见", + }, + { + "key": "review_chat_followup", + "label": "Chat 审查追问", + "owner": "legal", + "acceptance": "可带入审查上下文连续追问依据、解释和修改建议", + }, + { + "key": "ocr_pdf_processing", + "label": "OCR/PDF 处理", + "owner": "legal", + "acceptance": "普通 PDF、扫描件和图片合同有明确解析状态与失败原因", + }, + { + "key": "report_download", + "label": "报告下载", + "owner": "legal", + "acceptance": "审查完成后可下载审查报告", + }, + { + "key": "knowledge_enhancement", + "label": "知识库增强", + "owner": "admin", + "acceptance": "试点演示知识库可作为问答和审查依据来源", + }, +] +PILOT_SCOPE_EXCLUDED: List[Dict[str, str]] = [ + { + "key": "complex_approval", + "label": "复杂审批流", + "reason": "不作为试点启动前必交,保留到后续组织流程增强", + }, + { + "key": "advanced_multi_department_permissions", + "label": "多部门高级权限", + "reason": "当前只冻结管理员、法务、业务三个基础角色", + }, + { + "key": "advanced_reporting", + "label": "高级报表", + "reason": "试点阶段以审查报告和基础审计留痕为准", + }, + { + "key": "large_scale_multi_tenant_strategy", + "label": "大规模多租户策略", + "reason": "先做单组织受控试点,再根据真实反馈扩展", + }, +] +PILOT_SCOPE_DEFECT_POLICY: List[Dict[str, str]] = [ + { + "level": "must_fix", + "label": "必须修复", + "description": "生成、审查、Chat、报告、登录、演示资产初始化等主链路不可用。", + }, + { + "level": "pilot_blocking", + "label": "试点阻塞", + "description": "客户无法完成约定场景,或结果缺少明确状态、错误原因和下一步动作。", + }, + { + "level": "later_enhancement", + "label": "后续增强", + "description": "复杂审批、多部门空间、高级报表和规模化 SaaS 能力。", + }, +] + +PILOT_DEMO_KNOWLEDGE_BASE_NAME = "AI合同试点演示知识库" +PILOT_DEMO_KNOWLEDGE_DOCUMENT_NAME = "pilot-contract-review-playbook.txt" +PILOT_DEMO_KNOWLEDGE_DOCUMENT_TEXT = """AI合同起草与审查助手试点演示知识卡 + +1. 采购合同演示重点:付款节点必须与验收标准、发票条件、违约责任保持一致。 +2. 服务合同演示重点:服务范围、交付标准、验收周期、知识产权归属需要清晰。 +3. 保密协议演示重点:保密信息范围、例外情形、存续期限、违约责任需要明确。 +4. Chat追问建议:围绕高风险条款询问法律依据、修改建议和可替换条款。 +5. 试点验收目标:验证合同生成、自动审查、连续追问、报告下载和知识来源展示。 +""" + +PILOT_MATERIALS_VERSION = "pilot-materials-2026-05-12" +PILOT_MATERIALS_SOURCE_DOCUMENT = "project-docs/AI合同起草与审查助手-下一阶段实施建议-2026-05-11.md" +PILOT_MATERIALS: List[Dict[str, Any]] = [ + { + "key": "pilot_brief", + "title": "试点说明文档", + "audience": "客户负责人 / 项目经理", + "purpose": "说明试点目标、场景范围、角色安排和成功标准,避免试点过程中目标漂移。", + "status": "ready", + "deliverable": "AI合同起草与审查助手-试点说明.md", + "checklist": ["试点目标", "试点范围", "参与角色", "成功标准", "反馈节奏"], + }, + { + "key": "user_guide", + "title": "用户使用手册", + "audience": "法务用户 / 业务用户", + "purpose": "让试点用户按固定路径完成合同生成、合同审查、Chat 追问和报告下载。", + "status": "ready", + "deliverable": "AI合同起草与审查助手-用户使用手册.md", + "checklist": ["登录入口", "合同生成", "发起审查", "风险项追问", "报告下载"], + }, + { + "key": "admin_guide", + "title": "管理员手册", + "audience": "客户管理员 / 交付负责人", + "purpose": "指导管理员准备演示资产、维护试点用户、查看 readiness 和处理常见阻塞。", + "status": "ready", + "deliverable": "AI合同起草与审查助手-管理员手册.md", + "checklist": ["演示资产初始化", "试点用户管理", "权限边界", "知识库准备", "阻塞排查"], + }, + { + "key": "feedback_template", + "title": "试点反馈模板", + "audience": "试点用户 / 客户负责人", + "purpose": "把客户反馈收敛到场景、任务、结果质量、可解释性和下一步建议,便于迭代排期。", + "status": "ready", + "deliverable": "AI合同起草与审查助手-试点反馈模板.md", + "checklist": ["使用场景", "输入样本", "结果评分", "问题描述", "期望改进"], + }, + { + "key": "faq", + "title": "FAQ", + "audience": "试点用户 / 销售 / 交付团队", + "purpose": "提前回答账号、数据、OCR、审查结论、Chat 追问和报告下载相关问题。", + "status": "ready", + "deliverable": "AI合同起草与审查助手-FAQ.md", + "checklist": ["账号问题", "数据边界", "OCR/PDF", "审查可信度", "报告与导出"], + }, +] +PILOT_FEEDBACK_TEMPLATE: List[Dict[str, str]] = [ + {"key": "scenario", "label": "使用场景", "placeholder": "例如:采购合同预审 / 服务合同起草 / 保密协议复核"}, + {"key": "input_sample", "label": "输入样本", "placeholder": "说明使用的模板、合同类型或上传文件"}, + {"key": "result_rating", "label": "结果评分", "placeholder": "1-5 分,并说明主要原因"}, + {"key": "issue", "label": "问题描述", "placeholder": "记录不准确、不清楚、缺少依据或操作不顺的地方"}, + {"key": "expected_change", "label": "期望改进", "placeholder": "希望下一轮优先改进的功能或展示方式"}, +] + +PILOT_ERROR_FEEDBACK_VERSION = "pilot-error-feedback-2026-05-12" +PILOT_ERROR_FEEDBACK_SOURCE_DOCUMENT = "project-docs/AI合同起草与审查助手-下一阶段实施建议-2026-05-11.md" +PILOT_ERROR_FEEDBACK_ITEMS: List[Dict[str, Any]] = [ + { + "key": "ocr_pdf_parse_failed", + "title": "OCR/PDF 未能提取正文", + "scope": "contract_review_upload", + "user_message": "系统未能从文件中提取可审查正文,请换成可复制文本 PDF / DOCX / TXT,或直接粘贴合同正文。", + "technical_signals": [ + "source_document.parse_status=failed", + "source_document.failure_reason 非空", + "confidence_summary.document_analysis.ocr_quality.failure_message 非空", + ], + "next_actions": [ + "上传更清晰的扫描件或文本型 PDF", + "改用 DOCX / TXT", + "直接粘贴合同正文后重新发起审查", + ], + "qa_selector": "review-document-parse", + }, + { + "key": "pdf_quality_insufficient", + "title": "PDF 质量不足或疑似非合同原文", + "scope": "contract_review_upload", + "user_message": "当前 PDF 的文字质量不足,或疑似上传了平台审查报告,请使用合同原文重新上传。", + "technical_signals": [ + "source_document.source_kind=scanned_pdf 或 platform_review_report_pdf", + "source_document.extraction_mode=reject_report_pdf 或 ocr_parse_failed", + "source_document.ocr_gate_status=failed", + ], + "next_actions": [ + "确认上传的是合同原文而不是审查报告", + "扫描件需提高分辨率并避免歪斜、遮挡", + "如急需审查,优先粘贴合同正文", + ], + "qa_selector": "review-exception-feedback", + }, + { + "key": "auto_review_failed", + "title": "自动审查未完成", + "scope": "auto_review_workflow", + "user_message": "自动审查未能完成,页面会显示当前阶段、失败原因和下一步建议;可重试时请从原任务重新发起。", + "technical_signals": [ + "workflow_state.status=failed 或 partial", + "workflow_state.failure_reason 非空", + "workflow_state.next_action 非空", + "workflow_state.can_retry=true", + ], + "next_actions": [ + "先查看任务信息中的失败原因和当前阶段", + "可重试时使用原任务重试入口", + "不可重试时按页面建议更换输入材料后重新提交", + ], + "qa_selector": "review-task-info", + }, + { + "key": "chat_response_failed", + "title": "Chat 回答中断或为空", + "scope": "review_chat_followup", + "user_message": "问答生成中断、超时或返回为空时,页面会保留上一问题并提供重试入口。", + "technical_signals": [ + "ChatStreamEvent.type=error", + "RequestError.kind=timeout 或 network", + "stream response body 为空", + ], + "next_actions": [ + "点击重试上一问题", + "缩短问题并聚焦一个风险点", + "返回审查详情确认上下文后再继续提问", + ], + "qa_selector": "chat-exception-feedback", + }, +] +PILOT_ERROR_FEEDBACK_ACCEPTANCE = [ + "readiness 内嵌 pilot_error_feedback,QA 不登录也能读取标准口径", + "试点交付页展示 OCR/PDF、自动审查、Chat 三类异常的客户可读说明", + "审查详情页继续暴露 parse_status、failure_reason、next_action 和可重试状态", + "Chat 异常保留上一问题并展示可重试说明", +] + +PILOT_LAUNCH_PACK_VERSION = "pilot-launch-pack-2026-05-12" +PILOT_LAUNCH_PACK_SOURCE_DOCUMENT = "project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md" +PILOT_LAUNCH_SCRIPT_STEPS: List[Dict[str, Any]] = [ + { + "key": "kickoff_alignment", + "title": "试点启动对齐", + "owner": "交付负责人", + "audience": "客户负责人 / 管理员 / 法务代表 / 业务代表", + "route": "/pilot-readiness", + "talk_track": "先确认试点目标、功能边界、账号角色、样本范围和反馈节奏,避免一边演示一边扩张需求。", + "evidence": ["pilot_scope.status=frozen", "pilot_materials.status=ready", "pilot_error_feedback.status=ready"], + "success_signal": "客户明确本轮试点只验证合同起草、自动审查、Chat 追问、报告和知识来源。", + }, + { + "key": "business_contract_generation", + "title": "业务用户生成合同", + "owner": "业务用户", + "audience": "业务代表 / 客户负责人", + "route": "/contracts", + "talk_track": "从模板和变量进入合同初稿,让客户看到业务侧不用从空白文档开始。", + "evidence": ["合同生成结果可预览", "auto_review.review_id 已回填", "生成结果可进入审查详情"], + "success_signal": "业务用户能独立生成一份试点合同,并知道下一步自动审查入口。", + }, + { + "key": "legal_auto_review", + "title": "法务审查与证据复核", + "owner": "法务用户", + "audience": "法务代表 / 管理员", + "route": "/review", + "talk_track": "上传或粘贴合同后查看风险总览、风险项、解析证据、运行证据和报告下载状态。", + "evidence": ["parse_status 可见", "workflow_state 可见", "ai_trace 可见", "report_status 可见"], + "success_signal": "法务用户能理解风险结论、证据来源、下一步动作和异常提示。", + }, + { + "key": "review_chat_followup", + "title": "围绕审查结果继续追问", + "owner": "法务用户", + "audience": "法务代表 / 业务代表", + "route": "/chat", + "talk_track": "从审查详情带入上下文,继续追问风险依据、修改建议和谈判策略。", + "evidence": ["review_context 已带入", "conversation_id 可回看", "sources 独立展示", "chat-exception-feedback 可验证"], + "success_signal": "客户能感受到系统不是静态报告,而是可继续协作的审查助手。", + }, + { + "key": "report_feedback_handoff", + "title": "报告交付与试点反馈", + "owner": "交付负责人", + "audience": "客户负责人 / QA", + "route": "/pilot-readiness", + "talk_track": "用报告、材料包和反馈模板收尾,约定下一轮样本、缺陷和改进优先级。", + "evidence": ["pilot_materials.feedback_template 存在", "pilot_launch_pack.acceptance 存在", "pilot_error_feedback.acceptance 存在"], + "success_signal": "客户能按模板反馈场景、输入样本、结果评分、问题和下一轮期望。", + }, +] +PILOT_LAUNCH_READINESS_GATES: List[Dict[str, str]] = [ + { + "key": "system_readiness", + "label": "系统 readiness 通过", + "check": "GET /api/v1/system/readiness", + "expected": "status=ready 且 failed_required=0", + }, + { + "key": "pilot_scope_frozen", + "label": "试点范围已冻结", + "check": "pilot_scope.status", + "expected": "frozen", + }, + { + "key": "pilot_materials_ready", + "label": "试点材料包已准备", + "check": "pilot_materials.status", + "expected": "ready", + }, + { + "key": "error_feedback_ready", + "label": "异常反馈口径已准备", + "check": "pilot_error_feedback.status", + "expected": "ready", + }, + { + "key": "demo_assets_seedable", + "label": "演示资产可初始化", + "check": "POST /api/v1/system/pilot-demo-assets/seed", + "expected": "管理员可触发且返回模板、知识库、试点角色", + }, +] +PILOT_LAUNCH_ROLES: List[Dict[str, str]] = [ + {"role": "交付负责人", "responsibility": "主持启动会、确认范围、记录阻塞与下一步动作。"}, + {"role": "客户管理员", "responsibility": "确认试点账号、角色、样本边界和内部参与人。"}, + {"role": "业务用户", "responsibility": "验证合同生成、模板变量、业务可理解性和输入便利性。"}, + {"role": "法务用户", "responsibility": "验证审查结论、风险依据、Chat 追问、报告与异常提示。"}, + {"role": "QA", "responsibility": "按 qa:slice12 和 readiness 证据复跑试点启动门禁。"}, +] +PILOT_LAUNCH_BLOCKERS: List[Dict[str, str]] = [ + {"key": "readiness_failed", "label": "readiness gate 未通过", "action": "先修复必需项,再启动客户演示。"}, + {"key": "missing_pilot_roles", "label": "试点账号或角色缺失", "action": "由管理员创建 legal / business 账号后再演示。"}, + {"key": "materials_missing", "label": "材料或反馈模板缺失", "action": "补齐试点材料包,确保客户知道如何反馈。"}, + {"key": "unreadable_errors", "label": "异常提示客户不可理解", "action": "回到错误反馈面板核对对应 qa_selector。"}, +] +PILOT_LAUNCH_ACCEPTANCE = [ + "试点启动包可通过 API 和 readiness 同时读取", + "试点交付页展示完整客户演示脚本、启动门禁、角色分工和阻塞处理", + "演示脚本至少覆盖启动对齐、合同生成、自动审查、Chat 追问、报告与反馈五步", + "QA 可用 qa:slice12 复跑页面与接口闭环", +] + def _build_check(key: str, label: str, status: str, detail: str, required: bool = True) -> Dict[str, Any]: return { @@ -129,6 +594,140 @@ def _build_pilot_assets(password_rotated: bool) -> Dict[str, Any]: } +def _build_p0_mainline_alignment() -> Dict[str, Any]: + return { + "version": P0_MAINLINE_VERSION, + "status": "aligned", + "source_document": P0_MAINLINE_SOURCE_DOCUMENT, + "updated_at": "2026-05-12T00:00:00+08:00", + "summary": "P0 主线聚焦五条能力吸收线,所有能力都要写清来源项目、落点归属、合同平台职责和验收口径。", + "phase_boundary": P0_MAINLINE_PHASE_BOUNDARY, + "capabilities": P0_MAINLINE_CAPABILITIES, + "guardrails": P0_MAINLINE_GUARDRAILS, + "acceptance": P0_MAINLINE_ACCEPTANCE, + } + + +def _build_pilot_scope() -> Dict[str, Any]: + return { + "version": PILOT_SCOPE_VERSION, + "status": "frozen", + "source_document": PILOT_SCOPE_SOURCE_DOCUMENT, + "updated_at": "2026-05-12T00:00:00+08:00", + "summary": "试点版只承诺合同起草、合同审查、OCR/PDF、Chat 追问、报告下载和知识库增强主链路。", + "included": PILOT_SCOPE_INCLUDED, + "excluded": PILOT_SCOPE_EXCLUDED, + "defect_policy": PILOT_SCOPE_DEFECT_POLICY, + } + + +def _build_pilot_materials() -> Dict[str, Any]: + return { + "version": PILOT_MATERIALS_VERSION, + "status": "ready", + "source_document": PILOT_MATERIALS_SOURCE_DOCUMENT, + "updated_at": "2026-05-12T00:00:00+08:00", + "summary": "试点材料包覆盖说明文档、用户手册、管理员手册、反馈模板和 FAQ,服务于受控试点启动与反馈收敛。", + "materials": PILOT_MATERIALS, + "feedback_template": PILOT_FEEDBACK_TEMPLATE, + "acceptance": [ + "材料包可在试点交付页查看", + "材料清单覆盖 P0 要求的五类文档", + "反馈字段能支撑场景、结果质量和改进建议收敛", + ], + } + + +def _build_pilot_error_feedback() -> Dict[str, Any]: + return { + "version": PILOT_ERROR_FEEDBACK_VERSION, + "status": "ready", + "source_document": PILOT_ERROR_FEEDBACK_SOURCE_DOCUMENT, + "updated_at": "2026-05-12T00:00:00+08:00", + "summary": "P0 异常反馈覆盖 OCR/PDF 解析、PDF 质量、自动审查和 Chat 问答中断,确保客户能理解,QA 能按字段和页面入口验证。", + "items": PILOT_ERROR_FEEDBACK_ITEMS, + "acceptance": PILOT_ERROR_FEEDBACK_ACCEPTANCE, + } + + +def _build_pilot_launch_pack() -> Dict[str, Any]: + return { + "version": PILOT_LAUNCH_PACK_VERSION, + "status": "ready", + "source_document": PILOT_LAUNCH_PACK_SOURCE_DOCUMENT, + "updated_at": "2026-05-12T00:00:00+08:00", + "summary": "试点启动包把客户演示脚本、启动门禁、角色分工和阻塞条件固定下来,用于从可交付版本进入小范围受控试点。", + "script_steps": PILOT_LAUNCH_SCRIPT_STEPS, + "readiness_gates": PILOT_LAUNCH_READINESS_GATES, + "roles": PILOT_LAUNCH_ROLES, + "blockers": PILOT_LAUNCH_BLOCKERS, + "acceptance": PILOT_LAUNCH_ACCEPTANCE, + } + + +def _require_admin_user(current_user: Dict[str, Any]): + if current_user.get("role") != ROLE_ADMIN: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") + + +def _find_knowledge_base_by_name(items: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]: + normalized_name = name.strip().lower() + for item in items: + if str(item.get("name") or "").strip().lower() == normalized_name: + return item + return None + + +def _find_document_by_file_name(items: List[Dict[str, Any]], file_name: str) -> Optional[Dict[str, Any]]: + normalized_file_name = file_name.strip().lower() + for item in items: + if str(item.get("file_name") or "").strip().lower() == normalized_file_name: + return item + return None + + +def _seed_pilot_demo_knowledge( + current_user: Dict[str, Any], + shared_ai_platform_client: SharedAIPlatformClient, +) -> Dict[str, Any]: + knowledge_payload = shared_ai_platform_client.list_knowledge_bases(current_user, 1, 50) + knowledge_items = knowledge_payload.get("items", []) + knowledge_base = _find_knowledge_base_by_name(knowledge_items, PILOT_DEMO_KNOWLEDGE_BASE_NAME) + created_knowledge_base = False + + if knowledge_base is None: + knowledge_base = shared_ai_platform_client.create_knowledge_base( + current_user, + PILOT_DEMO_KNOWLEDGE_BASE_NAME, + "用于固定试点演示的合同审查知识来源,覆盖采购、服务和保密协议。", + ) + created_knowledge_base = True + + knowledge_base_id = str(knowledge_base["id"]) + documents_payload = shared_ai_platform_client.list_documents(current_user, knowledge_base_id, 1, 50) + document_items = documents_payload.get("items", []) + document = _find_document_by_file_name(document_items, PILOT_DEMO_KNOWLEDGE_DOCUMENT_NAME) + uploaded_document = False + + if document is None: + document = shared_ai_platform_client.upload_document( + current_user=current_user, + knowledge_base_id=knowledge_base_id, + file_name=PILOT_DEMO_KNOWLEDGE_DOCUMENT_NAME, + mime_type="text/plain", + content=PILOT_DEMO_KNOWLEDGE_DOCUMENT_TEXT.encode("utf-8"), + ) + uploaded_document = True + + return { + "status": "ready", + "created_knowledge_base": created_knowledge_base, + "uploaded_document": uploaded_document, + "knowledge_base": knowledge_base, + "document": document, + } + + def require_current_user( authorization: Optional[str] = Header(default=None), db: Session = Depends(get_db_session), @@ -136,10 +735,6 @@ def require_current_user( return AuthService(db).require_current_user(authorization) -def get_shared_ai_platform_client(db: Session = Depends(get_db_session)) -> SharedAIPlatformClient: - return build_shared_ai_platform_client(db) - - def get_template_service(db: Session = Depends(get_db_session)) -> TemplateService: return TemplateService(db) @@ -153,9 +748,10 @@ def get_governance_service(db: Session = Depends(get_db_session)) -> GovernanceS def _build_shared_ai_status() -> Dict[str, Any]: - remote_configured = bool(settings.shared_ai_platform_base_url.strip()) - mode = "remote-preferred" if remote_configured else "local-only" - fallback_mode = "local-fallback" if remote_configured else "in-process-only" + remote_configured = bool( + settings.shared_ai_platform_base_url.strip() and settings.shared_ai_platform_api_key.strip() + ) + mode = "remote-preferred" if remote_configured else "configuration-required" return { "mode": mode, @@ -165,20 +761,20 @@ def _build_shared_ai_status() -> Dict[str, Any]: "app_id": settings.shared_ai_platform_app_id, "timeout_seconds": settings.shared_ai_platform_timeout_seconds, "transport": { - "sdk": "vendored-python-sdk" if remote_configured else "disabled", - "fallback": fallback_mode, + "sdk": "local-shared-ai-sdk" if remote_configured else "disabled", + "fallback": "disabled", }, "knowledge_defaults": { - "retrieval_mode": "hybrid" if remote_configured else "local-bm25", + "retrieval_mode": "hybrid" if remote_configured else "unknown", }, "capabilities": { - "chat_stream": True, - "conversation_history": True, - "knowledge_bases": True, - "knowledge_documents": True, - "knowledge_retry": True, + "chat_stream": remote_configured, + "conversation_history": remote_configured, + "knowledge_bases": remote_configured, + "knowledge_documents": remote_configured, + "knowledge_retry": remote_configured, "legal_review_domain_pack": remote_configured and settings.shared_ai_platform_legal_review_enabled, - "local_legal_review_fallback": settings.shared_ai_platform_legal_review_fallback_to_local, + "local_legal_review_fallback": remote_configured and settings.shared_ai_platform_legal_review_fallback_to_local, }, } @@ -323,6 +919,11 @@ def get_system_readiness(db: Session = Depends(get_db_session)): "warnings": warning_count, }, "pilot_assets": _build_pilot_assets(default_admin_password_check["status"] == "ok"), + "p0_mainline": _build_p0_mainline_alignment(), + "pilot_scope": _build_pilot_scope(), + "pilot_materials": _build_pilot_materials(), + "pilot_error_feedback": _build_pilot_error_feedback(), + "pilot_launch_pack": _build_pilot_launch_pack(), } ) @@ -332,6 +933,73 @@ def get_shared_ai_status(_current_user: dict = Depends(require_current_user)): return success_response(_build_shared_ai_status()) +@router.get("/p0-mainline") +def get_p0_mainline_alignment(): + return success_response(_build_p0_mainline_alignment()) + + +@router.get("/pilot-scope") +def get_pilot_scope(): + return success_response(_build_pilot_scope()) + + +@router.get("/pilot-materials") +def get_pilot_materials(): + return success_response(_build_pilot_materials()) + + +@router.get("/pilot-error-feedback") +def get_pilot_error_feedback(): + return success_response(_build_pilot_error_feedback()) + + +@router.get("/pilot-launch-pack") +def get_pilot_launch_pack(): + return success_response(_build_pilot_launch_pack()) + + +@router.post("/pilot-demo-assets/seed") +def seed_pilot_demo_assets( + current_user: dict = Depends(require_current_user), + shared_ai_platform_client: SharedAIPlatformClient = Depends(get_shared_ai_platform_client), + template_service: TemplateService = Depends(get_template_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + db: Session = Depends(get_db_session), +): + _require_admin_user(current_user) + templates_result = template_service.seed_default_templates(current_user) + knowledge_result = _seed_pilot_demo_knowledge(current_user, shared_ai_platform_client) + default_admin_password_check = _check_default_admin_password(db) + pilot_assets = _build_pilot_assets(default_admin_password_check["status"] == "ok") + + result = { + "seeded_at": datetime.now(timezone.utc).isoformat(), + "templates": templates_result, + "knowledge": knowledge_result, + "demo_accounts": { + "default_admin": pilot_assets["default_admin"], + "roles": [ROLE_ADMIN, "legal", "business"], + }, + "demo_flow": pilot_assets["demo_flow"], + "acceptance_gates": pilot_assets["acceptance_gates"], + } + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="seed_pilot_demo_assets", + resource_type="system", + resource_id=None, + detail_json={ + "templates_created_count": templates_result.get("created_count", 0), + "templates_existing_count": templates_result.get("existing_count", 0), + "knowledge_status": knowledge_result.get("status"), + "knowledge_base_id": (knowledge_result.get("knowledge_base") or {}).get("id"), + "document_id": (knowledge_result.get("document") or {}).get("id"), + }, + ) + return success_response(result) + + @router.get("/admin-snapshot") def get_admin_snapshot( current_user: dict = Depends(require_current_user), diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py index 7e36d07e..a9dc43aa 100644 --- a/backend/app/api/templates.py +++ b/backend/app/api/templates.py @@ -40,25 +40,63 @@ def list_templates( page: int = 1, page_size: int = 20, contract_type: Optional[str] = None, + template_category: Optional[str] = None, + industry_category: Optional[str] = None, template_service: TemplateService = Depends(get_template_service), current_user: dict = Depends(require_current_user), ): - return success_response(template_service.list_templates(current_user, page, page_size, contract_type)) + kwargs = { + "current_user": current_user, + "page": page, + "page_size": page_size, + "contract_type": contract_type, + } + if template_category is not None: + kwargs["template_category"] = template_category + if industry_category is not None: + kwargs["industry_category"] = industry_category + return success_response(template_service.list_templates(**kwargs)) + + +@router.get("/{template_id}/versions") +def get_template_versions( + template_id: str, + template_service: TemplateService = Depends(get_template_service), + current_user: dict = Depends(require_current_user), +): + return success_response(template_service.get_template_versions(current_user, template_id)) @router.post("") def create_template( name: str = Form(...), contract_type: Optional[str] = Form(default=None), + template_category: Optional[str] = Form(default=None), + industry_category: Optional[str] = Form(default=None), description: Optional[str] = Form(default=None), content: str = Form(...), variables_json: Optional[str] = Form(default=None), enabled: bool = Form(default=True), + change_summary: Optional[str] = Form(default=None), template_service: TemplateService = Depends(get_template_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): - result = template_service.create_template(current_user, name, contract_type, description, content, variables_json, enabled) + kwargs = { + "current_user": current_user, + "name": name, + "contract_type": contract_type, + "description": description, + "content": content, + "variables_json": variables_json, + "enabled": enabled, + "change_summary": change_summary, + } + if template_category is not None: + kwargs["template_category"] = template_category + if industry_category is not None: + kwargs["industry_category"] = industry_category + result = template_service.create_template(**kwargs) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), @@ -68,8 +106,12 @@ def create_template( detail_json={ "name": result["name"], "contract_type": result["contract_type"], + "template_category": result.get("template_category"), + "industry_category": result.get("industry_category"), "enabled": result["enabled"], "variable_count": len(result.get("variables", [])), + "current_version_label": result.get("current_version_label"), + "latest_change_summary": result.get("latest_change_summary"), }, ) return success_response(result) @@ -103,15 +145,33 @@ def update_template( template_id: str, name: str = Form(...), contract_type: Optional[str] = Form(default=None), + template_category: Optional[str] = Form(default=None), + industry_category: Optional[str] = Form(default=None), description: Optional[str] = Form(default=None), content: str = Form(...), variables_json: Optional[str] = Form(default=None), enabled: bool = Form(default=True), + change_summary: Optional[str] = Form(default=None), template_service: TemplateService = Depends(get_template_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), current_user: dict = Depends(require_current_user), ): - result = template_service.update_template(current_user, template_id, name, contract_type, description, content, variables_json, enabled) + kwargs = { + "current_user": current_user, + "template_id": template_id, + "name": name, + "contract_type": contract_type, + "description": description, + "content": content, + "variables_json": variables_json, + "enabled": enabled, + "change_summary": change_summary, + } + if template_category is not None: + kwargs["template_category"] = template_category + if industry_category is not None: + kwargs["industry_category"] = industry_category + result = template_service.update_template(**kwargs) audit_log_service.record_event( tenant_id=current_user.get("tenant_id"), user_id=current_user.get("id"), @@ -121,8 +181,12 @@ def update_template( detail_json={ "name": result["name"], "contract_type": result["contract_type"], + "template_category": result.get("template_category"), + "industry_category": result.get("industry_category"), "enabled": result["enabled"], "variable_count": len(result.get("variables", [])), + "current_version_label": result.get("current_version_label"), + "latest_change_summary": result.get("latest_change_summary"), }, ) return success_response(result) diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 6ce9afd9..2e5db6f8 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -48,7 +48,7 @@ def create_user( username: str = Form(...), display_name: str = Form(...), password: str = Form(...), - role: str = Form(default="member"), + role: str = Form(default="business"), status_value: str = Form(alias="status", default="active"), user_service: UserService = Depends(get_user_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), @@ -77,7 +77,7 @@ def update_user( username: str = Form(...), display_name: str = Form(...), password: Optional[str] = Form(default=None), - role: str = Form(default="member"), + role: str = Form(default="business"), status_value: str = Form(alias="status", default="active"), user_service: UserService = Depends(get_user_service), audit_log_service: AuditLogService = Depends(get_audit_log_service), diff --git a/backend/app/clients/shared_ai_platform.py b/backend/app/clients/shared_ai_platform.py index 68fab132..3e160963 100644 --- a/backend/app/clients/shared_ai_platform.py +++ b/backend/app/clients/shared_ai_platform.py @@ -1,14 +1,11 @@ import json -from typing import Any, Dict, Iterable, Optional, Protocol +from typing import Any, Dict, Iterable, Mapping, Optional, Protocol -from fastapi import HTTPException +from fastapi import Depends, HTTPException from sqlalchemy.orm import Session from app.core.config import settings -from app.repositories.review_rule_repository import ReviewRuleRepository -from app.services.chat_service import ChatService -from app.services.knowledge_base_service import KnowledgeBaseService -from app.services.legal_review_local_engine import LocalLegalReviewEngine +from app.db import get_db_session from shared_ai_platform_sdk import RequestContext as SharedAIRequestContext from shared_ai_platform_sdk import SharedAIPlatformClient as SharedAIPlatformSdkClient from shared_ai_platform_sdk import SharedAIPlatformError @@ -20,7 +17,7 @@ class SharedAIPlatformClient(Protocol): current_user: Dict[str, Any], conversation_id: Optional[str], message: str, - review_context: Optional[Dict[str, Any]] = None, + review_context: Optional[Any] = None, ) -> Iterable[str]: ... def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict: ... @@ -55,75 +52,6 @@ class SharedAIPlatformClient(Protocol): ) -> dict: ... -class LocalSharedAIPlatformClient: - """Compatibility transport used when the shared-ai-platform service is not configured.""" - - def __init__(self, db: Session): - self.chat_service = ChatService(db) - self.knowledge_base_service = KnowledgeBaseService(db) - self.legal_review_engine = LocalLegalReviewEngine(ReviewRuleRepository(db)) - - def stream_answer( - self, - current_user: Dict[str, Any], - conversation_id: Optional[str], - message: str, - review_context: Optional[Dict[str, Any]] = None, - ) -> Iterable[str]: - return self.chat_service.stream_answer(current_user, conversation_id, message, review_context) - - def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict: - return self.knowledge_base_service.list_knowledge_bases(current_user, page, page_size) - - def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict: - return self.chat_service.list_conversations(current_user, page, page_size) - - def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict: - return self.chat_service.get_conversation_detail(current_user, conversation_id) - - def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict: - return self.legal_review_engine.run_review( - tenant_id=current_user["tenant_id"], - title=payload.get("title") or "文本输入合同", - contract_type=payload.get("contract_type"), - review_as=payload.get("review_as") or "party_a", - text=payload.get("text") or "", - ) - - def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict: - return self.knowledge_base_service.create_knowledge_base(current_user, name, description) - - def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int, page_size: int) -> dict: - return self.knowledge_base_service.list_documents(current_user, knowledge_base_id, page, page_size) - - def upload_document( - self, - current_user: Dict[str, Any], - knowledge_base_id: str, - file_name: str, - mime_type: Optional[str], - content: bytes, - ) -> dict: - return self.knowledge_base_service.upload_document(current_user, knowledge_base_id, file_name, mime_type, content) - - def retry_document( - self, - current_user: Dict[str, Any], - knowledge_base_id: str, - document_id: str, - file_name: Optional[str] = None, - mime_type: Optional[str] = None, - content: Optional[bytes] = None, - ) -> dict: - return self.knowledge_base_service.retry_document( - current_user=current_user, - knowledge_base_id=knowledge_base_id, - document_id=document_id, - file_name=file_name, - mime_type=mime_type, - content=content, - ) - class RemoteSharedAIPlatformClient: def __init__(self, base_url: str, app_id: str, api_key: str, timeout_seconds: float): @@ -143,14 +71,16 @@ class RemoteSharedAIPlatformClient: current_user: Dict[str, Any], conversation_id: Optional[str], message: str, - review_context: Optional[Dict[str, Any]] = None, + review_context: Optional[Any] = None, ) -> Iterable[str]: + review_context_payload = self._normalize_review_context(review_context) + def _event_stream(): try: for event in self.sdk.stream_conversation( self._context(current_user), message=message, - chat_context=review_context or {}, + chat_context=review_context_payload or {}, conversation_id=conversation_id, ): if event.event_type in {"delta", "text"}: @@ -261,130 +191,40 @@ class RemoteSharedAIPlatformClient: item["retrieval_mode"] = "hybrid" return payload + def _normalize_review_context(self, review_context: Any) -> Optional[Dict[str, Any]]: + if review_context is None: + return None + if isinstance(review_context, Mapping): + return dict(review_context) + if hasattr(review_context, "model_dump"): + return review_context.model_dump(mode="json", by_alias=True, exclude_none=True) + return review_context -class HybridSharedAIPlatformClient: - """Prefer remote shared-ai-platform, keep local fallback for this repo's current closed-loop delivery.""" - def __init__(self, remote: RemoteSharedAIPlatformClient, local: LocalSharedAIPlatformClient): - self.remote = remote - self.local = local - def stream_answer( - self, - current_user: Dict[str, Any], - conversation_id: Optional[str], - message: str, - review_context: Optional[Dict[str, Any]] = None, - ) -> Iterable[str]: - def _event_stream(): - try: - for chunk in self.remote.stream_answer(current_user, conversation_id, message, review_context): - yield chunk - except Exception as exc: - if not self._should_fallback(exc): - raise - for chunk in self.local.stream_answer(current_user, conversation_id, message, review_context): - yield chunk +def get_shared_ai_platform_client(db: Session = Depends(get_db_session)) -> SharedAIPlatformClient: + return build_shared_ai_platform_client(db) - return _event_stream() - - def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict: - return self._run_with_read_fallback( - lambda: self.remote.list_knowledge_bases(current_user, page, page_size), - lambda: self.local.list_knowledge_bases(current_user, page, page_size), - ) - - def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict: - return self._run_with_read_fallback( - lambda: self.remote.list_conversations(current_user, page, page_size), - lambda: self.local.list_conversations(current_user, page, page_size), - ) - - def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict: - return self._run_with_read_fallback( - lambda: self.remote.get_conversation_detail(current_user, conversation_id), - lambda: self.local.get_conversation_detail(current_user, conversation_id), - ) - - def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict: - try: - return self.remote.run_legal_review(current_user, payload) - except Exception as exc: - if not self._should_fallback(exc, operation="legal_review"): - raise - return self.local.run_legal_review(current_user, payload) - - def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict: - try: - return self.remote.create_knowledge_base(current_user, name, description) - except Exception as exc: - if not self._should_fallback(exc, operation="create_knowledge_base"): - raise - return self.local.create_knowledge_base(current_user, name, description) - - def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int, page_size: int) -> dict: - return self._run_with_read_fallback( - lambda: self.remote.list_documents(current_user, knowledge_base_id, page, page_size), - lambda: self.local.list_documents(current_user, knowledge_base_id, page, page_size), - ) - - def upload_document( - self, - current_user: Dict[str, Any], - knowledge_base_id: str, - file_name: str, - mime_type: Optional[str], - content: bytes, - ) -> dict: - try: - return self.remote.upload_document(current_user, knowledge_base_id, file_name, mime_type, content) - except Exception as exc: - if not self._should_fallback(exc, operation="upload_document"): - raise - return self.local.upload_document(current_user, knowledge_base_id, file_name, mime_type, content) - - def retry_document( - self, - current_user: Dict[str, Any], - knowledge_base_id: str, - document_id: str, - file_name: Optional[str] = None, - mime_type: Optional[str] = None, - content: Optional[bytes] = None, - ) -> dict: - try: - return self.remote.retry_document(current_user, knowledge_base_id, document_id, file_name, mime_type, content) - except Exception as exc: - if not self._should_fallback(exc, operation="retry_document"): - raise - return self.local.retry_document(current_user, knowledge_base_id, document_id, file_name, mime_type, content) - - def _run_with_read_fallback(self, remote_call, local_call, operation: str = "read"): - try: - return remote_call() - except Exception as exc: - if not self._should_fallback(exc, operation=operation): - raise - return local_call() - - def _should_fallback(self, error: Exception, operation: str = "read") -> bool: - if isinstance(error, SharedAIPlatformError): - if operation == "legal_review" and error.status_code in {404, 405, 501}: - return True - return error.status_code >= 500 - if isinstance(error, HTTPException): - return error.status_code >= 500 - return True def build_shared_ai_platform_client(db: Session) -> SharedAIPlatformClient: - if settings.shared_ai_platform_base_url.strip(): - remote = RemoteSharedAIPlatformClient( - base_url=settings.shared_ai_platform_base_url, - app_id=settings.shared_ai_platform_app_id, - api_key=settings.shared_ai_platform_api_key, - timeout_seconds=settings.shared_ai_platform_timeout_seconds, + base_url = settings.shared_ai_platform_base_url.strip() + if not base_url: + raise HTTPException( + status_code=503, + detail="shared-ai-platform base URL is required for AI features but is not configured", ) - local = LocalSharedAIPlatformClient(db) - return HybridSharedAIPlatformClient(remote, local) - return LocalSharedAIPlatformClient(db) + + api_key = settings.shared_ai_platform_api_key.strip() + if not api_key: + raise HTTPException( + status_code=503, + detail="shared-ai-platform API key is required for AI features but is not configured", + ) + + return RemoteSharedAIPlatformClient( + base_url=base_url, + app_id=settings.shared_ai_platform_app_id, + api_key=api_key, + timeout_seconds=settings.shared_ai_platform_timeout_seconds, + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b542a606..5d113a63 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -22,8 +22,8 @@ class Settings(BaseSettings): shared_ai_platform_app_id: str = "ai-contract-platform" shared_ai_platform_api_key: str = "" shared_ai_platform_timeout_seconds: float = 30.0 - shared_ai_platform_legal_review_enabled: bool = False - shared_ai_platform_legal_review_fallback_to_local: bool = True + shared_ai_platform_legal_review_enabled: bool = True + shared_ai_platform_legal_review_fallback_to_local: bool = False secret_key: str = "change-me" access_token_expire_minutes: int = 120 database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/ai_contract_platform" @@ -33,6 +33,7 @@ class Settings(BaseSettings): minio_secret_key: str = "minioadmin" minio_bucket: str = "contracts" minio_secure: bool = False + file_storage_backend: str = "minio" local_storage_dir: str = str(Path(__file__).resolve().parents[2] / "storage") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") diff --git a/backend/app/core/roles.py b/backend/app/core/roles.py new file mode 100644 index 00000000..552268f1 --- /dev/null +++ b/backend/app/core/roles.py @@ -0,0 +1,24 @@ +ROLE_ADMIN = "admin" +ROLE_LEGAL = "legal" +ROLE_BUSINESS = "business" +ROLE_MEMBER = "member" + +SUPPORTED_USER_ROLES = { + ROLE_ADMIN, + ROLE_LEGAL, + ROLE_BUSINESS, + ROLE_MEMBER, +} + +DEFAULT_SELF_REGISTER_ROLE = ROLE_BUSINESS + + +def normalize_user_role(role: str) -> str: + normalized_role = role.strip().lower() + if normalized_role not in SUPPORTED_USER_ROLES: + raise ValueError("invalid role") + return normalized_role + + +def is_admin_role(role: str | None) -> bool: + return (role or "").strip().lower() == ROLE_ADMIN diff --git a/backend/app/main.py b/backend/app/main.py index e379a531..b58cfc23 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from app.api.conversations import router as conversations_router from app.api.files import router as files_router from app.api.governance import router as governance_router from app.api.knowledge_bases import router as knowledge_bases_router +from app.api.pilot_requests import router as pilot_requests_router from app.api.review_rules import router as review_rules_router from app.api.reviews import router as reviews_router from app.api.system import router as system_router @@ -37,6 +38,7 @@ app.include_router(conversations_router, prefix=settings.api_v1_prefix) app.include_router(files_router, prefix=settings.api_v1_prefix) app.include_router(governance_router, prefix=settings.api_v1_prefix) app.include_router(knowledge_bases_router, prefix=settings.api_v1_prefix) +app.include_router(pilot_requests_router, prefix=settings.api_v1_prefix) app.include_router(review_rules_router, prefix=settings.api_v1_prefix) app.include_router(templates_router, prefix=settings.api_v1_prefix) app.include_router(tenant_settings_router, prefix=settings.api_v1_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5fe7d860..aa6e9009 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from app.models.audit_log import AuditLog from app.models.conversation import Conversation from app.models.contract_template import ContractTemplate +from app.models.contract_template_version import ContractTemplateVersion from app.models.file import File from app.models.generated_contract import GeneratedContract from app.models.governance_asset import GovernanceAsset @@ -12,6 +13,7 @@ from app.models.governance_review_decision import GovernanceReviewDecision from app.models.knowledge_base import KnowledgeBase from app.models.knowledge_base_document import KnowledgeBaseDocument from app.models.message import Message +from app.models.pilot_request import PilotRequest from app.models.review_result import ReviewResult from app.models.review_rule import ReviewRule from app.models.review_task import ReviewTask diff --git a/backend/app/models/contract_template.py b/backend/app/models/contract_template.py index 14eb66fa..c59c3570 100644 --- a/backend/app/models/contract_template.py +++ b/backend/app/models/contract_template.py @@ -14,10 +14,16 @@ class ContractTemplate(Base): tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) name: Mapped[str] = mapped_column(String(100), nullable=False) contract_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + template_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + industry_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) content: Mapped[str] = mapped_column(Text, nullable=False) variables_json: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(JSON, nullable=True) enabled: Mapped[bool] = mapped_column(Boolean, default=True) + current_version_label: Mapped[str] = mapped_column(String(32), default="v1.0") + latest_change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + latest_version_created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + version_count: Mapped[int] = mapped_column(default=1) created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) updated_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/contract_template_version.py b/backend/app/models/contract_template_version.py new file mode 100644 index 00000000..300598ef --- /dev/null +++ b/backend/app/models/contract_template_version.py @@ -0,0 +1,28 @@ +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class ContractTemplateVersion(Base): + __tablename__ = "contract_template_versions" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + template_id: Mapped[str] = mapped_column(String(64), ForeignKey("contract_templates.id"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + version_label: Mapped[str] = mapped_column(String(32), nullable=False) + change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + contract_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + template_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + industry_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + variables_json: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(JSON, nullable=True) + is_current: Mapped[bool] = mapped_column(Boolean, default=False) + created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + created_by_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/pilot_request.py b/backend/app/models/pilot_request.py new file mode 100644 index 00000000..ef1f1ea5 --- /dev/null +++ b/backend/app/models/pilot_request.py @@ -0,0 +1,33 @@ +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import DateTime, Text, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class PilotRequest(Base): + __tablename__ = "pilot_requests" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + contact_name: Mapped[str] = mapped_column(String(100), nullable=False) + company_name: Mapped[str] = mapped_column(String(160), nullable=False, index=True) + role_title: Mapped[Optional[str]] = mapped_column(String(120), nullable=True) + email: Mapped[str] = mapped_column(String(160), nullable=False, index=True) + phone: Mapped[Optional[str]] = mapped_column(String(40), nullable=True) + expected_contract_volume: Mapped[Optional[str]] = mapped_column(String(80), nullable=True) + primary_goal: Mapped[str] = mapped_column(String(80), nullable=False) + deployment_preference: Mapped[Optional[str]] = mapped_column(String(40), nullable=True) + scenario_description: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(String(32), default="new", nullable=False, index=True) + source: Mapped[str] = mapped_column(String(32), default="website", nullable=False) + admin_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + last_contact_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/repositories/contract_template_repository.py b/backend/app/repositories/contract_template_repository.py index 8d9d961d..d972e3ff 100644 --- a/backend/app/repositories/contract_template_repository.py +++ b/backend/app/repositories/contract_template_repository.py @@ -43,23 +43,55 @@ class ContractTemplateRepository: .first() ) - def list_enabled(self, tenant_id: str, contract_type: Optional[str] = None) -> List[ContractTemplate]: + def list_enabled( + self, + tenant_id: str, + contract_type: Optional[str] = None, + template_category: Optional[str] = None, + industry_category: Optional[str] = None, + ) -> List[ContractTemplate]: query = self.db.query(ContractTemplate).filter( ContractTemplate.tenant_id == tenant_id, ContractTemplate.enabled.is_(True), ) if contract_type: query = query.filter(ContractTemplate.contract_type == contract_type) + if template_category: + query = query.filter(ContractTemplate.template_category == template_category) + if industry_category: + query = query.filter(ContractTemplate.industry_category == industry_category) return query.order_by(ContractTemplate.updated_at.desc(), ContractTemplate.created_at.desc()).all() - def list_paginated(self, tenant_id: str, page: int, page_size: int, contract_type: Optional[str] = None) -> List[ContractTemplate]: + def list_paginated( + self, + tenant_id: str, + page: int, + page_size: int, + contract_type: Optional[str] = None, + template_category: Optional[str] = None, + industry_category: Optional[str] = None, + ) -> List[ContractTemplate]: query = self.db.query(ContractTemplate).filter(ContractTemplate.tenant_id == tenant_id) if contract_type: query = query.filter(ContractTemplate.contract_type == contract_type) + if template_category: + query = query.filter(ContractTemplate.template_category == template_category) + if industry_category: + query = query.filter(ContractTemplate.industry_category == industry_category) return query.order_by(ContractTemplate.updated_at.desc(), ContractTemplate.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() - def count(self, tenant_id: str, contract_type: Optional[str] = None) -> int: + def count( + self, + tenant_id: str, + contract_type: Optional[str] = None, + template_category: Optional[str] = None, + industry_category: Optional[str] = None, + ) -> int: query = self.db.query(ContractTemplate).filter(ContractTemplate.tenant_id == tenant_id) if contract_type: query = query.filter(ContractTemplate.contract_type == contract_type) + if template_category: + query = query.filter(ContractTemplate.template_category == template_category) + if industry_category: + query = query.filter(ContractTemplate.industry_category == industry_category) return query.count() diff --git a/backend/app/repositories/contract_template_version_repository.py b/backend/app/repositories/contract_template_version_repository.py new file mode 100644 index 00000000..dc584296 --- /dev/null +++ b/backend/app/repositories/contract_template_version_repository.py @@ -0,0 +1,50 @@ +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.models.contract_template_version import ContractTemplateVersion + + +class ContractTemplateVersionRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, version: ContractTemplateVersion) -> ContractTemplateVersion: + self.db.add(version) + self.db.commit() + self.db.refresh(version) + return version + + def list_by_template_id(self, tenant_id: str, template_id: str) -> List[ContractTemplateVersion]: + return ( + self.db.query(ContractTemplateVersion) + .filter( + ContractTemplateVersion.tenant_id == tenant_id, + ContractTemplateVersion.template_id == template_id, + ) + .order_by(ContractTemplateVersion.created_at.desc()) + .all() + ) + + def get_current_by_template_id(self, tenant_id: str, template_id: str) -> Optional[ContractTemplateVersion]: + return ( + self.db.query(ContractTemplateVersion) + .filter( + ContractTemplateVersion.tenant_id == tenant_id, + ContractTemplateVersion.template_id == template_id, + ContractTemplateVersion.is_current.is_(True), + ) + .first() + ) + + def clear_current(self, tenant_id: str, template_id: str) -> None: + ( + self.db.query(ContractTemplateVersion) + .filter( + ContractTemplateVersion.tenant_id == tenant_id, + ContractTemplateVersion.template_id == template_id, + ContractTemplateVersion.is_current.is_(True), + ) + .update({"is_current": False}, synchronize_session=False) + ) + self.db.commit() diff --git a/backend/app/repositories/generated_contract_repository.py b/backend/app/repositories/generated_contract_repository.py index d18c888e..6e2fce04 100644 --- a/backend/app/repositories/generated_contract_repository.py +++ b/backend/app/repositories/generated_contract_repository.py @@ -16,6 +16,12 @@ class GeneratedContractRepository: self.db.refresh(generated_contract) return generated_contract + def update(self, generated_contract: GeneratedContract) -> GeneratedContract: + self.db.add(generated_contract) + self.db.commit() + self.db.refresh(generated_contract) + return generated_contract + def get_by_id(self, contract_id: str, tenant_id: Optional[str] = None) -> Optional[GeneratedContract]: query = self.db.query(GeneratedContract).filter(GeneratedContract.id == contract_id) if tenant_id is not None: diff --git a/backend/app/repositories/pilot_request_repository.py b/backend/app/repositories/pilot_request_repository.py new file mode 100644 index 00000000..dacc028d --- /dev/null +++ b/backend/app/repositories/pilot_request_repository.py @@ -0,0 +1,48 @@ +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.models.pilot_request import PilotRequest + + +class PilotRequestRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, pilot_request: PilotRequest) -> PilotRequest: + self.db.add(pilot_request) + self.db.commit() + self.db.refresh(pilot_request) + return pilot_request + + def update(self, pilot_request: PilotRequest) -> PilotRequest: + self.db.add(pilot_request) + self.db.commit() + self.db.refresh(pilot_request) + return pilot_request + + def get_by_id(self, tenant_id: str, request_id: str) -> Optional[PilotRequest]: + return ( + self.db.query(PilotRequest) + .filter(PilotRequest.tenant_id == tenant_id, PilotRequest.id == request_id) + .first() + ) + + def list_paginated( + self, + tenant_id: str, + page: int = 1, + page_size: int = 20, + status_value: Optional[str] = None, + ) -> List[PilotRequest]: + offset = (page - 1) * page_size + query = self.db.query(PilotRequest).filter(PilotRequest.tenant_id == tenant_id) + if status_value: + query = query.filter(PilotRequest.status == status_value) + return query.order_by(PilotRequest.created_at.desc()).offset(offset).limit(page_size).all() + + def count(self, tenant_id: str, status_value: Optional[str] = None) -> int: + query = self.db.query(PilotRequest).filter(PilotRequest.tenant_id == tenant_id) + if status_value: + query = query.filter(PilotRequest.status == status_value) + return query.count() diff --git a/backend/app/repositories/review_task_repository.py b/backend/app/repositories/review_task_repository.py index 5068b24e..b5afd6b9 100644 --- a/backend/app/repositories/review_task_repository.py +++ b/backend/app/repositories/review_task_repository.py @@ -41,3 +41,17 @@ class ReviewTaskRepository: if user_id is not None: query = query.filter(ReviewTask.user_id == user_id) return query.first() + + def get_latest_by_source_file_id( + self, + tenant_id: str, + user_id: Optional[str], + source_file_id: str, + ) -> Optional[ReviewTask]: + query = ( + self.db.query(ReviewTask) + .filter(ReviewTask.tenant_id == tenant_id, ReviewTask.source_file_id == source_file_id) + ) + if user_id is not None: + query = query.filter(ReviewTask.user_id == user_id) + return query.order_by(ReviewTask.created_at.desc()).first() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index e69de29b..dfd4400e 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +from app.schemas.ai_contract import AiRunTrace, ParsedDocument, ReviewChatContext, ReviewChatMetadata, ReviewWorkflowState diff --git a/backend/app/schemas/ai_contract.py b/backend/app/schemas/ai_contract.py new file mode 100644 index 00000000..b0feeee5 --- /dev/null +++ b/backend/app/schemas/ai_contract.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +from typing import Any, Dict, Mapping, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ParsedDocument(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + document_id: Optional[str] = None + file_name: Optional[str] = None + mime_type: Optional[str] = None + source_type: Optional[str] = None + source_kind: Optional[str] = None + parse_status: Optional[str] = None + extraction_mode: Optional[str] = None + ocr_gate_status: Optional[str] = None + text: Optional[str] = None + text_length: int = 0 + page_count: Optional[int] = None + preview: Optional[str] = None + failure_reason: Optional[str] = None + confidence: Optional[float] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class AiRunTrace(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + provider: Optional[str] = None + model: Optional[str] = None + prompt_version: Optional[str] = None + workflow_run_id: Optional[str] = None + task_run_id: Optional[str] = None + conversation_id: Optional[str] = None + review_id: Optional[str] = None + contract_id: Optional[str] = None + knowledge_source: Optional[str] = None + analysis_mode: Optional[str] = None + provider_mode: Optional[str] = None + router_status: Optional[str] = None + status: Optional[str] = None + started_at: Optional[str] = None + completed_at: Optional[str] = None + error_message: Optional[str] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class ReviewWorkflowState(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + review_id: Optional[str] = None + contract_title: Optional[str] = None + contract_type: Optional[str] = None + review_as: Optional[str] = None + risk_level: Optional[str] = None + source_type: Optional[str] = None + source_text_length: Optional[int] = None + status: Optional[str] = None + current_stage: Optional[str] = None + current_stage_label: Optional[str] = None + parse_status: Optional[str] = None + review_status: Optional[str] = None + report_status: Optional[str] = None + status_detail: Optional[str] = None + failure_reason: Optional[str] = None + next_action: Optional[str] = None + can_retry: bool = False + queued_at: Optional[str] = None + processing_started_at: Optional[str] = None + completed_at: Optional[str] = None + processing_time_ms: Optional[int] = None + error_message: Optional[str] = None + source_document: Optional[ParsedDocument] = None + ai_trace: Optional[AiRunTrace] = None + + +class ReviewChatMetadata(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + review_title: Optional[str] = Field(default=None, alias="reviewTitle") + contract_type: Optional[str] = Field(default=None, alias="contractType") + review_status: Optional[str] = Field(default=None, alias="reviewStatus") + current_stage: Optional[str] = None + current_stage_label: Optional[str] = None + status_detail: Optional[str] = None + failure_reason: Optional[str] = None + next_action: Optional[str] = None + overall_risk: Optional[str] = Field(default=None, alias="overallRisk") + review_opinion: Optional[str] = Field(default=None, alias="reviewOpinion") + focus_label: Optional[str] = Field(default=None, alias="focusLabel") + action_label: Optional[str] = Field(default=None, alias="actionLabel") + focus_type: Optional[str] = Field(default=None, alias="focusType") + source_type: Optional[str] = None + source_text_length: Optional[int] = None + processing_time_ms: Optional[int] = None + risk_level: Optional[str] = None + + +class ReviewChatContext(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + review_id: Optional[str] = Field(default=None, alias="reviewId") + contract_id: Optional[str] = Field(default=None, alias="contractId") + conversation_id: Optional[str] = Field(default=None, alias="conversationId") + risk_item_id: Optional[str] = Field(default=None, alias="riskItemId") + clause_id: Optional[str] = Field(default=None, alias="clauseId") + prompt: Optional[str] = None + focus_label: Optional[str] = Field(default=None, alias="focusLabel") + action_label: Optional[str] = Field(default=None, alias="actionLabel") + focus_type: Optional[str] = Field(default=None, alias="focusType") + metadata: ReviewChatMetadata = Field(default_factory=ReviewChatMetadata) + workflow_state: Optional[ReviewWorkflowState] = Field(default=None, alias="workflowState") + ai_trace: Optional[AiRunTrace] = Field(default=None, alias="aiTrace") + source_document: Optional[ParsedDocument] = Field(default=None, alias="sourceDocument") + + +def _non_null_dict(source: Mapping[str, Any] | None) -> Dict[str, Any]: + if not source: + return {} + return {key: value for key, value in source.items() if value is not None} + + +def _to_float(value: Any) -> Optional[float]: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def build_parsed_document( + *, + document_id: Optional[str], + file_name: Optional[str], + source_type: Optional[str], + text: Optional[str], + lifecycle: Mapping[str, Any], + confidence_summary: Mapping[str, Any] | None = None, + mime_type: Optional[str] = None, +) -> ParsedDocument: + document_analysis = confidence_summary.get("document_analysis") if confidence_summary else None + document_analysis = document_analysis if isinstance(document_analysis, Mapping) else None + ocr_quality = document_analysis.get("ocr_quality") if document_analysis else None + ocr_quality = ocr_quality if isinstance(ocr_quality, Mapping) else None + normalized_text = text or "" + + return ParsedDocument( + document_id=document_id, + file_name=file_name, + mime_type=mime_type, + source_type=source_type, + source_kind=str(document_analysis.get("document_kind")) if document_analysis and document_analysis.get("document_kind") is not None else None, + parse_status=lifecycle.get("parse_status"), + extraction_mode=str(document_analysis.get("extraction_mode")) if document_analysis and document_analysis.get("extraction_mode") is not None else None, + ocr_gate_status=str(document_analysis.get("ocr_gate_status")) if document_analysis and document_analysis.get("ocr_gate_status") is not None else None, + text=normalized_text or None, + text_length=len(normalized_text), + page_count=document_analysis.get("page_count") if document_analysis else None, + preview=normalized_text[:200] if normalized_text else None, + failure_reason=lifecycle.get("failure_reason") if lifecycle.get("parse_status") == "failed" else None, + confidence=_to_float(ocr_quality.get("mean_confidence")) if ocr_quality and ocr_quality.get("mean_confidence") is not None else _to_float(document_analysis.get("mean_confidence")) if document_analysis else None, + metadata=_non_null_dict(document_analysis), + ) + + +def build_ai_run_trace( + *, + review_id: str, + status: Optional[str], + contract_type: Optional[str], + review_as: Optional[str], + source_type: Optional[str], + lifecycle: Mapping[str, Any], + confidence_summary: Mapping[str, Any] | None = None, + conversation_id: Optional[str] = None, + contract_id: Optional[str] = None, +) -> AiRunTrace: + confidence_summary = confidence_summary if isinstance(confidence_summary, Mapping) else None + document_analysis = confidence_summary.get("document_analysis") if confidence_summary else None + structured_analysis = confidence_summary.get("structured_analysis") if confidence_summary else None + extra_metadata = { + key: value + for key, value in (confidence_summary or {}).items() + if key + not in { + "provider", + "model", + "prompt_version", + "workflow_run_id", + "task_run_id", + "conversation_id", + "analysis_mode", + "provider_mode", + "router_status", + "knowledge_source", + } + } + + return AiRunTrace( + provider=str(confidence_summary.get("provider")) if confidence_summary and confidence_summary.get("provider") is not None else None, + model=str(confidence_summary.get("model")) if confidence_summary and confidence_summary.get("model") is not None else None, + prompt_version=str(confidence_summary.get("prompt_version")) if confidence_summary and confidence_summary.get("prompt_version") is not None else None, + workflow_run_id=str(confidence_summary.get("workflow_run_id")) if confidence_summary and confidence_summary.get("workflow_run_id") is not None else None, + task_run_id=str(confidence_summary.get("task_run_id")) if confidence_summary and confidence_summary.get("task_run_id") is not None else review_id, + conversation_id=conversation_id or (str(confidence_summary.get("conversation_id")) if confidence_summary and confidence_summary.get("conversation_id") is not None else None), + review_id=review_id, + contract_id=contract_id, + knowledge_source=str(confidence_summary.get("knowledge_source")) if confidence_summary and confidence_summary.get("knowledge_source") is not None else (str(document_analysis.get("document_kind")) if isinstance(document_analysis, Mapping) and document_analysis.get("document_kind") is not None else None), + analysis_mode=str(confidence_summary.get("analysis_mode")) if confidence_summary and confidence_summary.get("analysis_mode") is not None else None, + provider_mode=str(confidence_summary.get("provider_mode")) if confidence_summary and confidence_summary.get("provider_mode") is not None else None, + router_status=str(confidence_summary.get("router_status")) if confidence_summary and confidence_summary.get("router_status") is not None else None, + status=status, + started_at=lifecycle.get("processing_started_at"), + completed_at=lifecycle.get("completed_at"), + error_message=lifecycle.get("failure_reason") if status in {"failed", "partial"} else None, + metadata={ + **_non_null_dict(extra_metadata), + **({"document_analysis": _non_null_dict(document_analysis)} if isinstance(document_analysis, Mapping) else {}), + **({"structured_analysis": _non_null_dict(structured_analysis)} if isinstance(structured_analysis, Mapping) else {}), + }, + ) + + +def build_review_workflow_state( + *, + review_id: str, + contract_title: str, + contract_type: Optional[str], + review_as: Optional[str], + status: Optional[str], + source_type: Optional[str], + lifecycle: Mapping[str, Any], + processing_time_ms: Optional[int] = None, + error_message: Optional[str] = None, + parsed_document: ParsedDocument | None = None, + ai_trace: AiRunTrace | None = None, + risk_level: Optional[str] = None, +) -> ReviewWorkflowState: + source_text_length = parsed_document.text_length if parsed_document else None + + return ReviewWorkflowState( + review_id=review_id, + contract_title=contract_title, + contract_type=contract_type, + review_as=review_as, + risk_level=risk_level, + source_type=source_type, + source_text_length=source_text_length, + status=status, + current_stage=lifecycle.get("current_stage"), + current_stage_label=lifecycle.get("current_stage_label"), + parse_status=lifecycle.get("parse_status"), + review_status=lifecycle.get("review_status"), + report_status=lifecycle.get("report_status"), + status_detail=lifecycle.get("status_detail"), + failure_reason=lifecycle.get("failure_reason"), + next_action=lifecycle.get("next_action"), + can_retry=bool(lifecycle.get("can_retry")), + queued_at=lifecycle.get("queued_at"), + processing_started_at=lifecycle.get("processing_started_at"), + completed_at=lifecycle.get("completed_at"), + processing_time_ms=processing_time_ms, + error_message=error_message, + source_document=parsed_document, + ai_trace=ai_trace, + ) diff --git a/backend/app/schemas/chat.py b/backend/app/schemas/chat.py index 5589ee4e..45f54ef3 100644 --- a/backend/app/schemas/chat.py +++ b/backend/app/schemas/chat.py @@ -1,12 +1,14 @@ -from typing import Any, Dict, List, Literal, Optional +from typing import List, Literal, Optional from pydantic import BaseModel +from app.schemas.ai_contract import ReviewChatContext + class ChatStreamRequest(BaseModel): conversation_id: Optional[str] = None message: str - review_context: Optional[Dict[str, Any]] = None + review_context: Optional[ReviewChatContext] = None class ChatSourceItem(BaseModel): diff --git a/backend/app/schemas/pilot_request.py b/backend/app/schemas/pilot_request.py new file mode 100644 index 00000000..0ff7b5f1 --- /dev/null +++ b/backend/app/schemas/pilot_request.py @@ -0,0 +1,20 @@ +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field + + +class PilotRequestSubmitRequest(BaseModel): + contact_name: str = Field(min_length=1, max_length=100) + company_name: str = Field(min_length=1, max_length=160) + role_title: Optional[str] = Field(default=None, max_length=120) + email: EmailStr + phone: Optional[str] = Field(default=None, max_length=40) + expected_contract_volume: Optional[str] = Field(default=None, max_length=80) + primary_goal: str = Field(min_length=1, max_length=80) + deployment_preference: Optional[str] = Field(default=None, max_length=40) + scenario_description: str = Field(min_length=10, max_length=5000) + + +class PilotRequestUpdateRequest(BaseModel): + status: str = Field(min_length=1, max_length=32) + admin_notes: Optional[str] = Field(default=None, max_length=5000) diff --git a/backend/app/schemas/review.py b/backend/app/schemas/review.py index 5710838b..ca41e1bd 100644 --- a/backend/app/schemas/review.py +++ b/backend/app/schemas/review.py @@ -2,6 +2,8 @@ from typing import Any, Dict, List, Literal, Optional from pydantic import BaseModel +from app.schemas.ai_contract import AiRunTrace, ParsedDocument, ReviewWorkflowState + class ReviewCreateResponse(BaseModel): id: str @@ -40,6 +42,9 @@ class ReviewDetailResponse(BaseModel): created_at: str completed_at: Optional[str] error_message: Optional[str] + workflow_state: Optional[ReviewWorkflowState] = None + ai_trace: Optional[AiRunTrace] = None + source_document: Optional[ParsedDocument] = None class ReviewReportResponse(BaseModel): @@ -54,6 +59,9 @@ class ReviewResultResponse(BaseModel): risk_items: List[Dict[str, Any]] review_opinion: str recommendation: str + workflow_state: Optional[ReviewWorkflowState] = None + ai_trace: Optional[AiRunTrace] = None + source_document: Optional[ParsedDocument] = None class ReviewReportDownloadResponse(BaseModel): diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 00612927..2f48feab 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -5,6 +5,7 @@ from fastapi import HTTPException, status from sqlalchemy.orm import Session from app.core.config import settings +from app.core.roles import DEFAULT_SELF_REGISTER_ROLE, ROLE_ADMIN from app.core.security import create_access_token, decode_access_token, get_password_hash, verify_password from app.models.user import User from app.repositories.user_repository import UserRepository @@ -43,7 +44,7 @@ class AuthService: username=normalized_username, display_name=normalized_display_name, password_hash=get_password_hash(normalized_password), - role="member", + role=DEFAULT_SELF_REGISTER_ROLE, status="active", ) created = self.repository.create(user) @@ -122,14 +123,17 @@ class AuthService: if existing is not None: existing.tenant_id = DEFAULT_TENANT_ID existing.display_name = DEFAULT_ADMIN_DISPLAY_NAME - existing.role = "admin" + existing.role = ROLE_ADMIN existing.status = "active" - existing.password_hash = get_password_hash(DEFAULT_ADMIN_PASSWORD) + password_reset = settings.app_env.lower() != "production" + if password_reset: + existing.password_hash = get_password_hash(DEFAULT_ADMIN_PASSWORD) self.repository.update(existing) return { "username": DEFAULT_ADMIN_USERNAME, - "password": DEFAULT_ADMIN_PASSWORD, + "password": DEFAULT_ADMIN_PASSWORD if password_reset else None, "created": False, + "password_reset": password_reset, } admin = User( @@ -138,7 +142,7 @@ class AuthService: username=DEFAULT_ADMIN_USERNAME, display_name=DEFAULT_ADMIN_DISPLAY_NAME, password_hash=get_password_hash(DEFAULT_ADMIN_PASSWORD), - role="admin", + role=ROLE_ADMIN, status="active", ) self.repository.create(admin) @@ -146,6 +150,7 @@ class AuthService: "username": DEFAULT_ADMIN_USERNAME, "password": DEFAULT_ADMIN_PASSWORD, "created": True, + "password_reset": True, } def _require_user_from_authorization(self, authorization: Optional[str]) -> User: diff --git a/backend/app/services/chat_service.py b/backend/app/services/chat_service.py deleted file mode 100644 index 3b4c97ad..00000000 --- a/backend/app/services/chat_service.py +++ /dev/null @@ -1,258 +0,0 @@ -import json -from datetime import datetime, timezone -from typing import Any, Dict, Iterable, List, Optional -from uuid import uuid4 - -from fastapi import HTTPException, status -from sqlalchemy.orm import Session - -from app.models.conversation import Conversation -from app.models.message import Message -from app.repositories.conversation_repository import ConversationRepository -from app.repositories.message_repository import MessageRepository -from app.services.rag_service import RAGService - - -DEFAULT_CHUNKS = [ - "已收到你的问题,我会按合同审查视角给出辅助判断。", - "当前版本先返回结构化回答和引用来源,建议你继续结合具体条款文本进行人工复核。", -] - -DEFAULT_SOURCES = [ - {"title": "平台提示", "content": "AI 审查结果仅供参考,请结合正式法律意见审慎决策。"} -] - - -class ChatService: - def __init__(self, db: Session): - self.db = db - self.conversation_repository = ConversationRepository(db) - self.message_repository = MessageRepository(db) - self.rag_service = RAGService(db) - - def stream_answer( - self, - current_user: Dict[str, Any], - conversation_id: Optional[str], - message: str, - review_context: Optional[Dict[str, Any]] = None, - ) -> Iterable[str]: - normalized_message = message.strip() - if not normalized_message: - yield self._format_event({"type": "error", "message": "message 不能为空"}) - return - - active_conversation = self._ensure_conversation(current_user, conversation_id, normalized_message, review_context) - payload = self._build_payload(current_user, normalized_message, review_context) - assistant_content = "\n\n".join(payload["chunks"]) - - self._create_message( - tenant_id=current_user["tenant_id"], - conversation_id=active_conversation.id, - user_id=current_user["id"], - role="user", - content=normalized_message, - sources_json=None, - ) - - for chunk in payload["chunks"]: - yield self._format_event({"type": "text", "content": chunk}) - - self._create_message( - tenant_id=current_user["tenant_id"], - conversation_id=active_conversation.id, - user_id=None, - role="assistant", - content=assistant_content, - sources_json=payload["sources"], - ) - - self._refresh_conversation(active_conversation, normalized_message, review_context) - yield self._format_event({"type": "sources", "sources": payload["sources"]}) - yield self._format_event({"type": "end", "conversation_id": active_conversation.id}) - - def list_conversations(self, current_user: Dict[str, Any], page: int = 1, page_size: int = 20) -> dict: - normalized_page = max(page, 1) - normalized_page_size = max(min(page_size, 100), 1) - conversations = self.conversation_repository.list_paginated( - current_user["tenant_id"], - current_user["id"], - normalized_page, - normalized_page_size, - ) - total = self.conversation_repository.count(current_user["tenant_id"], current_user["id"]) - return { - "items": [self._to_conversation_list_item(item) for item in conversations], - "page": normalized_page, - "page_size": normalized_page_size, - "total": total, - } - - def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict: - conversation = self.conversation_repository.get_by_id_for_user( - conversation_id, - current_user["tenant_id"], - current_user["id"], - ) - if conversation is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="conversation not found") - - messages = self.message_repository.list_by_conversation(conversation.id) - return { - "id": conversation.id, - "title": conversation.title, - "messages": [self._to_conversation_message_item(item) for item in messages], - } - - def _ensure_conversation( - self, - current_user: Dict[str, Any], - conversation_id: Optional[str], - message: str, - review_context: Optional[Dict[str, Any]] = None, - ) -> Conversation: - if conversation_id: - conversation = self.conversation_repository.get_by_id_for_user( - conversation_id, - current_user["tenant_id"], - current_user["id"], - ) - if conversation is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="conversation not found") - return conversation - - now = datetime.now(timezone.utc) - conversation = Conversation( - id=f"conv-{uuid4().hex[:12]}", - tenant_id=current_user["tenant_id"], - user_id=current_user["id"], - title=self._build_title(message, review_context), - message_count=0, - last_message_at=now, - created_at=now, - updated_at=now, - ) - return self.conversation_repository.create(conversation) - - def _refresh_conversation(self, conversation: Conversation, message: str, review_context: Optional[Dict[str, Any]] = None) -> Conversation: - now = datetime.now(timezone.utc) - conversation.message_count += 2 - conversation.last_message_at = now - conversation.updated_at = now - if not conversation.title: - conversation.title = self._build_title(message, review_context) - return self.conversation_repository.update(conversation) - - def _create_message( - self, - tenant_id: str, - conversation_id: str, - user_id: Optional[str], - role: str, - content: str, - sources_json: Optional[List[Dict[str, str]]], - ) -> Message: - created_at = datetime.now(timezone.utc) - message = Message( - id=f"msg-{uuid4().hex[:12]}", - tenant_id=tenant_id, - conversation_id=conversation_id, - user_id=user_id, - role=role, - content=content, - sources_json=sources_json, - created_at=created_at, - ) - return self.message_repository.create(message) - - def _build_title(self, message: str, review_context: Optional[Dict[str, Any]] = None) -> str: - if review_context: - metadata = review_context.get("metadata") or {} - focus_label = metadata.get("focusLabel") - review_title = metadata.get("reviewTitle") - if focus_label and review_title: - return f"{review_title} · {focus_label}"[:30] - if review_title: - return review_title[:30] - normalized = " ".join(message.split()) - return normalized[:30] or "法律问答" - - def _build_payload( - self, - current_user: Dict[str, Any], - message: str, - review_context: Optional[Dict[str, Any]] = None, - ) -> Dict[str, List[Dict[str, str]]]: - effective_query = self._build_effective_query(message, review_context) - search_results = self.rag_service.search_relevant_documents( - tenant_id=current_user["tenant_id"], - query=effective_query, - limit=5, - ) - - if search_results: - return self.rag_service.build_chat_payload( - effective_query, - search_results, - review_context=review_context, - ) - - return { - "chunks": self._build_default_chunks(message, review_context), - "sources": DEFAULT_SOURCES.copy(), - } - - def _build_effective_query(self, message: str, review_context: Optional[Dict[str, Any]] = None) -> str: - if not review_context: - return message - metadata = review_context.get("metadata") or {} - context_parts = [ - metadata.get("reviewTitle"), - metadata.get("contractType"), - metadata.get("overallRisk"), - metadata.get("reviewOpinion"), - metadata.get("focusLabel"), - metadata.get("actionLabel"), - message, - ] - return "\n".join([str(item).strip() for item in context_parts if item and str(item).strip()]) - - def _build_default_chunks(self, message: str, review_context: Optional[Dict[str, Any]] = None) -> List[str]: - if not review_context: - return DEFAULT_CHUNKS.copy() - metadata = review_context.get("metadata") or {} - focus_label = metadata.get("focusLabel") - action_label = metadata.get("actionLabel") - review_title = metadata.get("reviewTitle") - chunks = [ - f"已收到围绕{review_title or '当前合同'}的追问,我会继续按合同审查视角给出辅助判断。", - f"当前问题:{message}", - ] - if focus_label or action_label: - chunks.append( - "当前上下文:" + " / ".join( - [item for item in [f"追问对象:{focus_label}" if focus_label else None, f"当前动作:{action_label}" if action_label else None] if item] - ) - ) - chunks.append("当前知识库未命中直接材料,以下建议将优先基于已注入的审查上下文生成,并建议继续结合具体条款人工复核。") - return chunks - - def _to_conversation_list_item(self, conversation: Conversation) -> dict: - return { - "id": conversation.id, - "title": conversation.title, - "message_count": conversation.message_count, - "last_message_at": conversation.last_message_at.isoformat() if conversation.last_message_at else None, - } - - def _to_conversation_message_item(self, message: Message) -> dict: - return { - "id": message.id, - "role": message.role, - "content": message.content, - "sources": message.sources_json or [], - "created_at": message.created_at.isoformat(), - } - - def _format_event(self, payload: Dict[str, object]) -> str: - return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" diff --git a/backend/app/services/contract_generation_service.py b/backend/app/services/contract_generation_service.py index 13ff2d5f..e4e2317c 100644 --- a/backend/app/services/contract_generation_service.py +++ b/backend/app/services/contract_generation_service.py @@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional, Tuple from datetime import datetime from uuid import uuid4 from enum import Enum +from urllib.parse import quote from fastapi import HTTPException, status from sqlalchemy.orm import Session @@ -14,6 +15,7 @@ from app.models.generated_contract import GeneratedContract from app.repositories.contract_template_repository import ContractTemplateRepository from app.repositories.generated_contract_repository import GeneratedContractRepository from app.services.file_service import FileService +from app.services.review_service import ReviewService class ExportFormat(str, Enum): @@ -25,28 +27,33 @@ class ExportFormat(str, Enum): class ContractGenerationService: - def __init__(self, db: Session): + def __init__(self, db: Session, review_service: Optional[ReviewService] = None): self.db = db self.repository = ContractTemplateRepository(db) self.generated_contract_repository = GeneratedContractRepository(db) self.file_service = FileService(db) + self.review_service = review_service or ReviewService(db) def list_available_templates( self, current_user: Dict[str, Any], contract_type: Optional[str] = None, + template_category: Optional[str] = None, + industry_category: Optional[str] = None, ) -> List[Dict]: """获取可用的合同模板列表""" tenant_id = current_user["tenant_id"] # 获取启用的模板 - templates = self.repository.list_enabled(tenant_id, contract_type) + templates = self.repository.list_enabled(tenant_id, contract_type, template_category, industry_category) return [ { "id": template.id, "name": template.name, "contract_type": template.contract_type, + "template_category": template.template_category, + "industry_category": template.industry_category, "description": template.description, "variables": self._parse_variables(template.variables_json), "preview_excerpt": self._get_preview_excerpt(template.content), @@ -109,6 +116,13 @@ class ContractGenerationService: require_admin=False, ) + persisted_options = { + **options, + "_template_category": template.template_category, + "_industry_category": template.industry_category, + "_template_version_label": template.current_version_label, + } + generated_contract = GeneratedContract( id=f"contract-{uuid4().hex[:12]}", tenant_id=tenant_id, @@ -124,11 +138,13 @@ class ContractGenerationService: file_size=len(file_content), generated_content=generated_content, variable_values_json=variable_values, - export_options_json=options, + export_options_json=persisted_options, file_content_base64=base64.b64encode(file_content).decode("utf-8"), status="generated", ) created_contract = self.generated_contract_repository.create(generated_contract) + auto_review_summary = self._create_auto_review_summary(current_user, created_contract, generated_content) + created_contract = self._persist_auto_review_summary(created_contract, auto_review_summary) return self._to_generated_contract_item(created_contract) @@ -207,6 +223,233 @@ class ContractGenerationService: "content": base64.b64decode(generated_contract.file_content_base64), } + def _persist_auto_review_summary(self, generated_contract: GeneratedContract, auto_review_summary: Dict[str, Any]) -> GeneratedContract: + export_options = dict(generated_contract.export_options_json or {}) + export_options["_auto_review"] = auto_review_summary + generated_contract.export_options_json = export_options + return self.generated_contract_repository.update(generated_contract) + + def _create_auto_review_summary( + self, + current_user: Dict[str, Any], + generated_contract: GeneratedContract, + generated_content: str, + ) -> Dict[str, Any]: + review_user = { + "id": generated_contract.generated_by, + "tenant_id": generated_contract.tenant_id, + "display_name": generated_contract.generated_by_name or generated_contract.generated_by, + "role": current_user.get("role", "admin"), + } + + extraction_metadata = { + "document_kind": "generated_contract", + "extraction_mode": "template_render", + "ocr_gate_status": "PASS", + "source_file_id": generated_contract.file_id, + "source_file_name": generated_contract.file_name, + "template_id": generated_contract.template_id, + "template_name": generated_contract.template_name, + "export_format": generated_contract.export_format, + "generated_by": generated_contract.generated_by, + } + + try: + review_task = self.review_service.create_review_task( + tenant_id=generated_contract.tenant_id, + user_id=generated_contract.generated_by, + review_as="party_a", + contract_type=generated_contract.contract_type, + text=generated_content, + file_name=generated_contract.file_name, + extracted_text=generated_content, + extraction_metadata=extraction_metadata, + source_file_id=generated_contract.file_id, + ) + review_id = review_task["id"] + review_detail = self.review_service.get_review_detail(review_user, review_id) + review_result = self.review_service.get_review_result(review_user, review_id) + review_report = self.review_service.get_review_report(review_user, review_id) + if not review_detail or not review_result: + raise RuntimeError("自动审查结果缺少详情或结果") + return self._build_auto_review_summary( + generated_contract=generated_contract, + review_id=review_id, + review_detail=review_detail, + review_result=review_result, + review_report=review_report, + ) + except Exception as exc: + return self._build_auto_review_failure_summary(generated_contract, str(exc)) + + def _build_auto_review_summary( + self, + generated_contract: GeneratedContract, + review_id: str, + review_detail: Dict[str, Any], + review_result: Dict[str, Any], + review_report: Optional[Dict[str, Any]], + ) -> Dict[str, Any]: + workflow_state = review_detail.get("workflow_state") or {} + source_document = review_detail.get("source_document") or {} + ai_trace = review_detail.get("ai_trace") or {} + overall_risk = (review_result.get("risk_summary") or {}).get("overall") or workflow_state.get("risk_level") + review_opinion = review_result.get("review_opinion") + recommendation = review_result.get("recommendation") + chat_context = { + "reviewId": review_id, + "prompt": self._build_auto_review_prompt( + generated_contract=generated_contract, + review_detail=review_detail, + review_result=review_result, + ), + "metadata": { + "reviewTitle": generated_contract.template_name, + "reviewStatus": review_detail.get("status"), + "contractType": review_result.get("basic_info", {}).get("contract_type") or review_detail.get("contract_type"), + "current_stage": review_detail.get("current_stage"), + "current_stage_label": review_detail.get("current_stage_label"), + "status_detail": review_detail.get("status_detail"), + "failure_reason": review_detail.get("failure_reason"), + "next_action": review_detail.get("next_action"), + "overallRisk": overall_risk, + "reviewOpinion": review_opinion, + "focusLabel": generated_contract.template_name, + "actionLabel": "继续审查问答", + "focusType": "generated_contract", + "source_type": review_detail.get("source_type"), + "source_text_length": review_detail.get("source_text_length"), + "processing_time_ms": review_detail.get("processing_time_ms"), + "risk_level": workflow_state.get("risk_level"), + }, + "workflowState": workflow_state, + "aiTrace": ai_trace, + "sourceDocument": source_document, + } + + return { + "review_id": review_id, + "review_title": generated_contract.template_name, + "status": review_detail.get("status"), + "review_as": review_detail.get("review_as"), + "contract_type": review_detail.get("contract_type"), + "risk_level": overall_risk, + "current_stage": review_detail.get("current_stage"), + "current_stage_label": review_detail.get("current_stage_label"), + "status_detail": review_detail.get("status_detail"), + "failure_reason": review_detail.get("failure_reason"), + "next_action": review_detail.get("next_action"), + "can_retry": review_detail.get("can_retry"), + "review_opinion": review_opinion, + "recommendation": recommendation, + "source_file_id": generated_contract.file_id, + "source_file_name": generated_contract.file_name, + "source_text_length": review_detail.get("source_text_length"), + "processing_time_ms": review_detail.get("processing_time_ms"), + "detail_url": f"/reviews/{review_id}", + "report_download_url": review_report.get("download_url") if review_report else f"/api/v1/reviews/{review_id}/report/download", + "report_file_name": review_report.get("file_name") if review_report else f"review-report-{review_id}.pdf", + "chat_url": f"/chat?review={quote(json.dumps(chat_context, ensure_ascii=False), safe='')}", + } + + def _build_auto_review_failure_summary(self, generated_contract: GeneratedContract, failure_message: str) -> Dict[str, Any]: + safe_message = failure_message or "自动审查未能完成,请稍后重试。" + return { + "review_id": None, + "review_title": generated_contract.template_name, + "status": "failed", + "review_as": "party_a", + "contract_type": generated_contract.contract_type, + "risk_level": None, + "current_stage": "review", + "current_stage_label": "自动审查失败", + "status_detail": safe_message, + "failure_reason": safe_message, + "next_action": "请稍后重新生成合同或手动发起审查。", + "can_retry": False, + "review_opinion": None, + "recommendation": None, + "source_file_id": generated_contract.file_id, + "source_file_name": generated_contract.file_name, + "source_text_length": len(generated_contract.generated_content or ""), + "processing_time_ms": None, + "detail_url": None, + "report_download_url": None, + "report_file_name": None, + "chat_url": None, + } + + def _build_auto_review_prompt( + self, + generated_contract: GeneratedContract, + review_detail: Dict[str, Any], + review_result: Dict[str, Any], + ) -> str: + source_document = review_detail.get("source_document") or {} + risk_summary = review_result.get("risk_summary") or {} + risk_items = review_result.get("risk_items") or [] + prompt_parts = [ + "请基于当前自动审查结果继续分析这份合同。", + f"合同名称:{generated_contract.template_name}", + f"审查视角:{review_detail.get('review_as') or 'party_a'}", + f"当前总体风险:{risk_summary.get('overall') or review_detail.get('status') or 'unknown'}", + f"审查意见:{review_result.get('review_opinion') or '暂无审查意见'}", + f"处理建议:{review_result.get('recommendation') or 'manual_review'}", + f"原始文本摘要:{(review_detail.get('source_text_preview') or '')[:500]}", + f"解析文件:{source_document.get('file_name') or generated_contract.file_name}", + f"文档类型:{source_document.get('source_kind') or source_document.get('source_type') or 'generated_contract'}", + f"文本解析:{source_document.get('parse_status') or 'completed'}", + f"抽取模式:{source_document.get('extraction_mode') or 'template_render'}", + f"OCR 门禁:{source_document.get('ocr_gate_status') or 'PASS'}", + ] + risk_titles = [ + item.get("title") or item.get("description") or "风险项" + for item in risk_items[:5] + if isinstance(item, dict) + ] + if risk_titles: + prompt_parts.append(f"重点风险项:{';'.join(risk_titles)}") + return "\n".join(prompt_parts) + + def _resolve_auto_review_summary(self, generated_contract: GeneratedContract) -> Optional[Dict[str, Any]]: + export_options = generated_contract.export_options_json or {} + stored_summary = export_options.get("_auto_review") + if isinstance(stored_summary, dict): + return stored_summary + + if not generated_contract.file_id or self.review_service is None: + return None + + repository = getattr(self.review_service, "repository", None) + if repository is None or not hasattr(repository, "get_latest_by_source_file_id"): + return None + + review_task = repository.get_latest_by_source_file_id( + generated_contract.tenant_id, + generated_contract.generated_by, + generated_contract.file_id, + ) + if review_task is None: + return None + + review_user = { + "id": generated_contract.generated_by, + "tenant_id": generated_contract.tenant_id, + "role": "admin", + } + review_detail = self.review_service.get_review_detail(review_user, review_task.id) + review_result = self.review_service.get_review_result(review_user, review_task.id) + review_report = self.review_service.get_review_report(review_user, review_task.id) + if not review_detail or not review_result: + return None + return self._build_auto_review_summary( + generated_contract=generated_contract, + review_id=review_task.id, + review_detail=review_detail, + review_result=review_result, + review_report=review_report, + ) + def preview_contract( self, current_user: Dict[str, Any], @@ -250,11 +493,14 @@ class ContractGenerationService: return "text/plain; charset=utf-8" def _to_generated_contract_item(self, generated_contract: GeneratedContract) -> Dict: + auto_review = self._resolve_auto_review_summary(generated_contract) return { "id": generated_contract.id, "template_id": generated_contract.template_id, "template_name": generated_contract.template_name, "contract_type": generated_contract.contract_type, + "template_category": (generated_contract.export_options_json or {}).get("_template_category"), + "industry_category": (generated_contract.export_options_json or {}).get("_industry_category"), "file_id": generated_contract.file_id, "generated_content": generated_contract.generated_content, "variable_values": generated_contract.variable_values_json or {}, @@ -265,12 +511,20 @@ class ContractGenerationService: "file_content_base64": generated_contract.file_content_base64, "preview_url": f"/api/v1/contracts/{generated_contract.id}/preview", "download_url": f"/api/v1/contracts/{generated_contract.id}/download", + "auto_review": auto_review, "metadata": { "generated_by": generated_contract.generated_by, "generated_by_name": generated_contract.generated_by_name, "tenant_id": generated_contract.tenant_id, "original_template": generated_contract.template_name, - "export_options": generated_contract.export_options_json or {}, + "template_category": (generated_contract.export_options_json or {}).get("_template_category"), + "industry_category": (generated_contract.export_options_json or {}).get("_industry_category"), + "template_version_label": (generated_contract.export_options_json or {}).get("_template_version_label"), + "export_options": { + key: value + for key, value in (generated_contract.export_options_json or {}).items() + if not str(key).startswith("_") + }, "variables_used": len(generated_contract.variable_values_json or {}), "asset_file_id": generated_contract.file_id, } @@ -282,6 +536,8 @@ class ContractGenerationService: "id": item["id"], "template_name": item["template_name"], "contract_type": item["contract_type"], + "template_category": item["template_category"], + "industry_category": item["industry_category"], "file_id": item["file_id"], "generated_at": item["generated_at"], "export_format": item["export_format"], @@ -289,9 +545,12 @@ class ContractGenerationService: "file_size": item["file_size"], "preview_url": item["preview_url"], "download_url": item["download_url"], + "auto_review": item["auto_review"], "metadata": { "generated_by_name": item["metadata"]["generated_by_name"], "asset_file_id": item["metadata"]["asset_file_id"], + "template_category": item["metadata"]["template_category"], + "industry_category": item["metadata"]["industry_category"], }, } diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py index 6a715ca6..1c784787 100644 --- a/backend/app/services/file_service.py +++ b/backend/app/services/file_service.py @@ -18,12 +18,18 @@ class FileService: def __init__(self, db: Session): self.db = db self.repository = FileRepository(db) - self.minio_client = Minio( - settings.minio_endpoint, - access_key=settings.minio_access_key, - secret_key=settings.minio_secret_key, - secure=settings.minio_secure, - ) + self.storage_backend = (settings.file_storage_backend or "minio").strip().lower() + if self.storage_backend not in {"minio", "local"}: + self.storage_backend = "minio" + self.local_storage_dir = Path(settings.local_storage_dir) + self.minio_client = None + if self.storage_backend == "minio": + self.minio_client = Minio( + settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_secure, + ) def list_files( self, @@ -179,6 +185,11 @@ class FileService: } def _put_object(self, bucket: str, object_key: str, content: bytes, content_type: str): + if self.storage_backend == "local": + file_path = self._resolve_local_object_path(bucket, object_key) + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_bytes(content) + return self.minio_client.put_object( bucket_name=bucket, object_name=object_key, @@ -188,6 +199,15 @@ class FileService: ) def _read_object(self, bucket: str, object_key: str) -> bytes: + if self.storage_backend == "local": + file_path = self._resolve_local_object_path(bucket, object_key) + if not file_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="file content not found", + ) + return file_path.read_bytes() + response = None try: response = self.minio_client.get_object(bucket, object_key) @@ -205,6 +225,12 @@ class FileService: response.release_conn() def _remove_object_if_exists(self, bucket: str, object_key: str): + if self.storage_backend == "local": + file_path = self._resolve_local_object_path(bucket, object_key) + if file_path.exists(): + file_path.unlink() + self._prune_empty_local_dirs(file_path.parent, self._resolve_local_bucket_path(bucket)) + return try: self.minio_client.remove_object(bucket, object_key) except S3Error as exc: @@ -218,6 +244,9 @@ class FileService: return settings.minio_bucket.strip().lower() def _ensure_bucket(self, bucket: str): + if self.storage_backend == "local": + self._resolve_local_bucket_path(bucket).mkdir(parents=True, exist_ok=True) + return try: if not self.minio_client.bucket_exists(bucket): self.minio_client.make_bucket(bucket) @@ -225,6 +254,29 @@ class FileService: if exc.code not in {"BucketAlreadyOwnedByYou", "BucketAlreadyExists"}: raise + def _resolve_local_bucket_path(self, bucket: str) -> Path: + return (self.local_storage_dir / bucket).resolve() + + def _resolve_local_object_path(self, bucket: str, object_key: str) -> Path: + bucket_root = self._resolve_local_bucket_path(bucket) + file_path = (bucket_root / Path(object_key)).resolve() + if file_path != bucket_root and bucket_root not in file_path.parents: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="invalid storage object key", + ) + return file_path + + def _prune_empty_local_dirs(self, path: Path, stop_path: Path): + current = path.resolve() + stop = stop_path.resolve() + while current != stop and stop in current.parents: + try: + current.rmdir() + except OSError: + break + current = current.parent + def _require_admin(self, current_user: Dict[str, Any]): if current_user.get("role") != "admin": raise HTTPException( diff --git a/backend/app/services/knowledge_base_service.py b/backend/app/services/knowledge_base_service.py deleted file mode 100644 index 9e3c24e6..00000000 --- a/backend/app/services/knowledge_base_service.py +++ /dev/null @@ -1,354 +0,0 @@ -import io -import re -import zipfile -from typing import Any, Dict, Optional -from uuid import uuid4 -from xml.etree import ElementTree - -import pdfplumber -from fastapi import HTTPException, status -from sqlalchemy.orm import Session - -from app.models.knowledge_base import KnowledgeBase -from app.models.knowledge_base_document import KnowledgeBaseDocument -from app.repositories.knowledge_base_document_repository import KnowledgeBaseDocumentRepository -from app.repositories.knowledge_base_repository import KnowledgeBaseRepository - -MAX_TEXT_LENGTH = 20000 -DOCX_NAMESPACE = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} -BINARY_TEXT_PATTERN = re.compile(r"[\u4e00-\u9fffA-Za-z0-9,。;:、?!《》()【】\-—_]+") -PDF_TEXT_MARKERS = ("合同", "违约", "免责", "付款", "保密", "续约") -SUPPORTED_TEXT_SUFFIXES = (".txt", ".md", ".csv", ".json") -DOCX_SUFFIX = ".docx" -PDF_SUFFIX = ".pdf" -KNOWLEDGE_BASE_DOCUMENT_STATUS_PENDING = "pending" -KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING = "processing" -KNOWLEDGE_BASE_DOCUMENT_STATUS_INDEXED = "indexed" -KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED = "failed" - - -class KnowledgeBaseService: - def __init__(self, db: Session): - self.db = db - self.repository = KnowledgeBaseRepository(db) - self.document_repository = KnowledgeBaseDocumentRepository(db) - - def list_knowledge_bases(self, current_user: Dict[str, Any], page: int = 1, page_size: int = 20) -> dict: - self._require_admin(current_user) - tenant_id = current_user["tenant_id"] - normalized_page = max(page, 1) - normalized_page_size = max(min(page_size, 100), 1) - knowledge_bases = self.repository.list_paginated(tenant_id, normalized_page, normalized_page_size) - counts = self.document_repository.count_by_knowledge_base_ids(tenant_id, [item.id for item in knowledge_bases]) - return { - "items": [self._to_item(item, counts.get(item.id, 0)) for item in knowledge_bases], - "page": normalized_page, - "page_size": normalized_page_size, - "total": self.repository.count(tenant_id), - } - - def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict: - self._require_admin(current_user) - normalized_name = name.strip() - normalized_description = description.strip() if description else None - knowledge_base = KnowledgeBase( - id=f"kb-{uuid4().hex[:12]}", - tenant_id=current_user["tenant_id"], - name=normalized_name, - description=normalized_description, - status="active", - ) - created = self.repository.create(knowledge_base) - return self._to_item(created, 0) - - def upload_document( - self, - current_user: Dict[str, Any], - knowledge_base_id: str, - file_name: str, - mime_type: Optional[str], - content: bytes, - ) -> dict: - self._require_admin(current_user) - knowledge_base = self.repository.get_by_id(knowledge_base_id) - if knowledge_base is None or knowledge_base.tenant_id != current_user["tenant_id"]: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="knowledge base not found") - - document = KnowledgeBaseDocument( - id=f"kbdoc-{uuid4().hex[:12]}", - knowledge_base_id=knowledge_base.id, - tenant_id=current_user["tenant_id"], - file_name=file_name, - mime_type=mime_type, - file_size=len(content), - status=KNOWLEDGE_BASE_DOCUMENT_STATUS_PENDING, - content_excerpt=None, - error_message=None, - ) - created = self.document_repository.create(document) - return self._process_document_content(created, content, mime_type, file_name) - - def retry_document( - self, - current_user: Dict[str, Any], - knowledge_base_id: str, - document_id: str, - file_name: Optional[str] = None, - mime_type: Optional[str] = None, - content: Optional[bytes] = None, - ) -> dict: - self._require_admin(current_user) - knowledge_base = self.repository.get_by_id(knowledge_base_id) - if knowledge_base is None or knowledge_base.tenant_id != current_user["tenant_id"]: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="knowledge base not found") - - document = self.document_repository.get_by_id( - document_id, - tenant_id=current_user["tenant_id"], - knowledge_base_id=knowledge_base_id, - ) - if document is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="knowledge base document not found") - - if content is not None: - document.file_name = file_name or document.file_name - document.mime_type = mime_type or document.mime_type - document.file_size = len(content) - return self._process_document_content(document, content, document.mime_type, document.file_name) - - if document.content_excerpt: - content_bytes = document.content_excerpt.encode("utf-8") - return self._process_document_content(document, content_bytes, "text/plain", document.file_name) - - document.status = KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED - document.content_excerpt = None - document.error_message = "重试需要重新上传可复制文本版本,当前记录未保留可重新索引的原文。" - document = self.document_repository.update(document) - return self._to_document_item(document) - - def _process_document_content( - self, - document: KnowledgeBaseDocument, - content: bytes, - mime_type: Optional[str], - file_name: str, - ) -> dict: - document.status = KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING - document.error_message = None - document = self.document_repository.update(document) - - try: - text_content = self._extract_text_from_file(content, mime_type, file_name) - if not text_content: - raise ValueError("无法从该文档中提取可检索文本") - - document.status = KNOWLEDGE_BASE_DOCUMENT_STATUS_INDEXED - document.content_excerpt = text_content - document.error_message = None - document = self.document_repository.update(document) - return self._to_document_item(document) - except Exception: - document.status = KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED - document.content_excerpt = None - document.error_message = "无法从该文档中提取可检索文本" - document = self.document_repository.update(document) - return self._to_document_item(document) - - def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int = 1, page_size: int = 20) -> dict: - self._require_admin(current_user) - knowledge_base = self.repository.get_by_id(knowledge_base_id) - if knowledge_base is None or knowledge_base.tenant_id != current_user["tenant_id"]: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="knowledge base not found") - - normalized_page = max(page, 1) - normalized_page_size = max(min(page_size, 100), 1) - tenant_id = current_user["tenant_id"] - documents = self.document_repository.list_paginated(tenant_id, knowledge_base_id, normalized_page, normalized_page_size) - return { - "items": [self._to_document_item(item) for item in documents], - "page": normalized_page, - "page_size": normalized_page_size, - "total": self.document_repository.count(tenant_id, knowledge_base_id), - } - - def _require_admin(self, current_user: Dict[str, Any]): - if current_user.get("role") != "admin": - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") - - def _to_item(self, knowledge_base: KnowledgeBase, document_count: int) -> dict: - return { - "id": knowledge_base.id, - "name": knowledge_base.name, - "description": knowledge_base.description, - "status": knowledge_base.status, - "document_count": document_count, - "retrieval_mode": "local-bm25", - "created_at": knowledge_base.created_at.isoformat(), - "updated_at": knowledge_base.updated_at.isoformat(), - } - - def _extract_text_from_file(self, content: bytes, mime_type: Optional[str], file_name: str) -> Optional[str]: - if not content: - return None - - normalized_name = (file_name or "").lower() - normalized_mime = (mime_type or "").lower() - - try: - if normalized_mime.startswith("text/") or normalized_name.endswith(SUPPORTED_TEXT_SUFFIXES): - return self._normalize_extracted_text(content.decode("utf-8", errors="ignore")) - if normalized_name.endswith(DOCX_SUFFIX) or "wordprocessingml" in normalized_mime: - return self._extract_text_from_docx(content) - if normalized_name.endswith(PDF_SUFFIX) or normalized_mime == "application/pdf": - return self._extract_text_from_pdf(content) - except Exception: - return None - - return self._extract_text_from_binary(content) - - def _extract_text_from_docx(self, file_bytes: bytes) -> Optional[str]: - try: - with zipfile.ZipFile(io.BytesIO(file_bytes)) as archive: - xml_content = archive.read("word/document.xml") - except (KeyError, zipfile.BadZipFile): - return self._extract_text_from_binary(file_bytes) - - root = ElementTree.fromstring(xml_content) - texts = [node.text for node in root.findall(".//w:t", DOCX_NAMESPACE) if node.text] - return self._normalize_extracted_text("\n".join(texts)) - - def _extract_text_from_pdf(self, file_bytes: bytes) -> Optional[str]: - extracted_text = self._extract_with_pdfplumber(file_bytes) - if extracted_text: - return extracted_text - return self._extract_text_from_binary(file_bytes) - - def _extract_with_pdfplumber(self, file_bytes: bytes) -> Optional[str]: - try: - with pdfplumber.open(io.BytesIO(file_bytes)) as pdf: - text_parts = [] - for page in pdf.pages: - page_text = page.extract_text() - if page_text: - text_parts.append(page_text) - if not text_parts: - return None - normalized = self._normalize_extracted_text("\n".join(text_parts)) - if not normalized: - return None - if any(marker in normalized for marker in PDF_TEXT_MARKERS) or len(normalized) > 100: - return normalized - return None - except Exception: - return None - - def _extract_text_from_binary(self, file_bytes: bytes) -> Optional[str]: - decoded = file_bytes.decode("utf-8", errors="ignore") - matches = BINARY_TEXT_PATTERN.findall(decoded) - if not matches: - return None - return self._normalize_extracted_text("\n".join(matches)) - - def _build_document_status_detail(self, document: KnowledgeBaseDocument) -> str: - if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_INDEXED: - return "文本提取与索引已完成,可直接用于检索和问答。" - if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED: - return document.error_message or "文档处理失败,请检查文件内容或格式后重试。" - if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING: - return "系统正在提取文本并写入检索索引。" - return "文档已接收,等待进入文本提取阶段。" - - def _build_document_failure_reason(self, document: KnowledgeBaseDocument, parse_status: str, index_status: str) -> Optional[str]: - if document.status not in {KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED, KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING}: - return None - if index_status == "blocked": - return document.error_message or "文本提取尚未完成,索引阶段暂时阻塞。" - if parse_status == "failed": - return document.error_message or "未能从文档中提取到可检索文本。" - if index_status == "failed": - return document.error_message or "文本已提取,但写入索引阶段失败。" - return document.error_message or "文档处理失败。" - - def _build_document_next_action(self, document: KnowledgeBaseDocument, parse_status: str, index_status: str) -> str: - if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_INDEXED: - return "可直接进入问答,或继续补充更多制度与条款文档。" - if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_PENDING: - return "等待系统进入文本提取阶段。" - if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING: - if index_status == "blocked": - return "请等待文本提取完成;提取成功后系统会继续进入索引阶段。" - return "等待文本提取与索引完成,完成后即可用于检索。" - if parse_status == "failed": - return "请检查文件格式、内容编码或改为可复制文本后重新上传。" - if index_status == "failed": - return "请重新上传文档;若反复失败,需检查索引链路。" - return "请检查处理结果后重试。" - - def _build_document_lifecycle(self, document: KnowledgeBaseDocument) -> dict: - parse_status = "pending" - index_status = "pending" - current_stage = "parse" - current_stage_label = "等待解析" - - if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING: - parse_status = "processing" - index_status = "blocked" - current_stage = "parse" - current_stage_label = "文本提取中" - elif document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_INDEXED: - parse_status = "completed" - index_status = "completed" - current_stage = "indexed" - current_stage_label = "已完成索引" - elif document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED: - parse_status = "failed" - index_status = "blocked" - current_stage = "failed" - current_stage_label = "文本提取失败" - - return { - "current_stage": current_stage, - "current_stage_label": current_stage_label, - "status_detail": self._build_document_status_detail(document), - "parse_status": parse_status, - "index_status": index_status, - "failure_reason": self._build_document_failure_reason(document, parse_status, index_status), - "next_action": self._build_document_next_action(document, parse_status, index_status), - "retryable": document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED, - } - - def _to_document_item(self, document: KnowledgeBaseDocument) -> dict: - raw_content = document.content_excerpt or "" - excerpt = raw_content - preview_truncated = False - if excerpt and len(excerpt) > 500: - excerpt = f"{excerpt[:500]}..." - preview_truncated = True - lifecycle = self._build_document_lifecycle(document) - return { - "id": document.id, - "knowledge_base_id": document.knowledge_base_id, - "file_name": document.file_name, - "mime_type": document.mime_type, - "file_size": document.file_size, - "status": document.status, - "content_excerpt": excerpt, - "error_message": document.error_message, - "extracted_text_length": len(raw_content), - "preview_truncated": preview_truncated, - "created_at": document.created_at.isoformat(), - "current_stage": lifecycle["current_stage"], - "current_stage_label": lifecycle["current_stage_label"], - "status_detail": lifecycle["status_detail"], - "parse_status": lifecycle["parse_status"], - "index_status": lifecycle["index_status"], - "failure_reason": lifecycle["failure_reason"], - "next_action": lifecycle["next_action"], - "retryable": lifecycle["retryable"], - } - - def _normalize_extracted_text(self, value: str) -> Optional[str]: - normalized = "\n".join(line.strip() for line in value.splitlines() if line.strip()) - if not normalized: - return None - return normalized[:MAX_TEXT_LENGTH] diff --git a/backend/app/services/legal_review_execution_gateway.py b/backend/app/services/legal_review_execution_gateway.py index 947760a9..7fd9fd63 100644 --- a/backend/app/services/legal_review_execution_gateway.py +++ b/backend/app/services/legal_review_execution_gateway.py @@ -1,47 +1,39 @@ from typing import Any, Dict +from fastapi import HTTPException + from app.clients.shared_ai_platform import SharedAIPlatformClient, build_shared_ai_platform_client from app.core.config import settings from app.models.review_task import ReviewTask -from app.services.legal_review_local_engine import LocalLegalReviewEngine class LegalReviewExecutionGateway: - def __init__(self, shared_ai_platform_client: SharedAIPlatformClient, local_engine: LocalLegalReviewEngine): + def __init__(self, shared_ai_platform_client: SharedAIPlatformClient): self.shared_ai_platform_client = shared_ai_platform_client - self.local_engine = local_engine def run_review(self, review: ReviewTask) -> Dict[str, Any]: - if settings.shared_ai_platform_legal_review_enabled and settings.shared_ai_platform_base_url.strip(): - try: - return self.shared_ai_platform_client.run_legal_review( - { - "id": review.user_id, - "tenant_id": review.tenant_id, - "role": "system", - }, - { - "title": review.title, - "contract_type": review.contract_type, - "review_as": review.review_as, - "text": review.input_text or "", - }, - ) - except Exception: - if not settings.shared_ai_platform_legal_review_fallback_to_local: - raise + if not settings.shared_ai_platform_legal_review_enabled: + raise HTTPException( + status_code=503, + detail="shared-ai-platform legal review is required but is disabled", + ) - return self.local_engine.run_review( - tenant_id=review.tenant_id, - title=review.title, - contract_type=review.contract_type, - review_as=review.review_as, - text=review.input_text or "", + return self.shared_ai_platform_client.run_legal_review( + { + "id": review.user_id, + "tenant_id": review.tenant_id, + "role": "system", + }, + { + "title": review.title, + "contract_type": review.contract_type, + "review_as": review.review_as, + "text": review.input_text or "", + }, ) -def build_legal_review_execution_gateway(db, review_rule_repository) -> LegalReviewExecutionGateway: +def build_legal_review_execution_gateway(db) -> LegalReviewExecutionGateway: return LegalReviewExecutionGateway( shared_ai_platform_client=build_shared_ai_platform_client(db), - local_engine=LocalLegalReviewEngine(review_rule_repository), ) diff --git a/backend/app/services/legal_review_local_engine.py b/backend/app/services/legal_review_local_engine.py deleted file mode 100644 index d7b6c487..00000000 --- a/backend/app/services/legal_review_local_engine.py +++ /dev/null @@ -1,171 +0,0 @@ -from typing import Any, Dict, List, Optional - -DEFAULT_RISK_RULES = [ - { - "keywords": ["违约金", "liquidated damages", "default penalty", "penalty", "default interest"], - "name": "违约责任条款需重点核查", - "contract_type": "all", - "risk_level": "medium", - "description": "合同中涉及违约金、违约责任或罚则安排,建议核对触发条件、计算口径、责任上限和是否属于惩罚性约定。", - "suggestion": "建议核对违约触发条件、计算方式、责任上限以及是否与主合同义务匹配。", - "legal_basis": None, - }, - { - "keywords": ["自动续约", "自动续期", "auto-renewal", "automatic renewal", "evergreen", "renewal term"], - "name": "存在自动续约安排", - "contract_type": "all", - "risk_level": "high", - "description": "合同包含自动续约或默认延续表述,建议补充通知期限、退出窗口、续约周期和价格调整机制,避免被动续约。", - "suggestion": "建议补充明确的续约通知期限、退出方式、续约周期与价格调整规则。", - "legal_basis": "《民法典》关于合同解除、期限与续期约定的一般规则。", - }, - { - "keywords": ["免责", "责任限制", "limitation of liability", "liability cap", "exclusion of liability", "disclaimer"], - "name": "免责或责任限制条款需要限制范围", - "contract_type": "all", - "risk_level": "high", - "description": "合同涉及免责、责任限制或赔偿上限安排,建议核查是否过度排除关键责任,例如故意、重大过失、保密义务或赔偿义务。", - "suggestion": "建议明确免责边界、责任上限和必要 carve-out,避免责任明显失衡。", - "legal_basis": "《民法典》关于格式条款、免责条款效力限制的一般规则。", - }, - { - "keywords": ["保密", "confidentiality", "confidential information", "non-disclosure", "nda"], - "name": "保密义务建议细化", - "contract_type": "all", - "risk_level": "low", - "description": "合同涉及保密义务,建议明确保密信息范围、例外情形、期限、披露边界以及返还/销毁义务。", - "suggestion": "建议明确保密信息范围、例外情形、保密期限及违约责任。", - "legal_basis": "基于商业秘密保护与违约责任的一般合规要求。", - }, - { - "keywords": ["付款", "payment", "payment milestone", "milestone", "invoice", "billing", "fees", "acceptance"], - "name": "付款与验收条件建议明确", - "contract_type": "all", - "risk_level": "medium", - "description": "合同涉及付款、验收、开票或费用条款,建议明确付款节点、验收标准、开票前置条件和逾期责任。", - "suggestion": "建议明确付款节点、验收标准、发票条件、逾期责任及争议处理口径。", - "legal_basis": "《民法典》关于价款支付与履行顺序的一般规则。", - }, -] - -LEVEL_SCORE = {"high": 3, "medium": 2, "low": 1} -DEFAULT_PARTY_A = "甲方" -DEFAULT_PARTY_B = "乙方" -DEFAULT_AMOUNT = "待识别" -DEFAULT_TERM = "待识别" -DEFAULT_RECOMMENDATION = "manual_review" -DEFAULT_OPINION = "已完成初步规则审查,请结合正式法务意见进一步确认。" - - -class LocalLegalReviewEngine: - def __init__(self, review_rule_repository): - self.review_rule_repository = review_rule_repository - - def run_review( - self, - tenant_id: str, - title: str, - contract_type: Optional[str], - review_as: str, - text: str, - ) -> Dict[str, Any]: - contract_type = contract_type or "未分类合同" - risk_items = self._build_risk_items(tenant_id, title, contract_type, text) - risk_summary = self._build_risk_summary(risk_items) - return { - "basic_info": { - "contract_type": contract_type, - "party_a": DEFAULT_PARTY_A, - "party_b": DEFAULT_PARTY_B, - "amount": DEFAULT_AMOUNT, - "term": DEFAULT_TERM, - }, - "risk_summary": risk_summary, - "clauses": self._build_clauses(text), - "risk_items": risk_items, - "review_opinion": self._build_review_opinion(risk_summary, bool(text.strip()), bool(risk_items)), - "recommendation": self._build_recommendation(risk_summary, bool(text.strip()), bool(risk_items)), - "confidence_summary": { - "analysis_mode": "local-rule-engine", - "review_as": review_as, - }, - } - - def _build_risk_items(self, tenant_id: str, title: str, contract_type: str, text: str) -> List[Dict[str, Any]]: - search_text = f"{title}\n{contract_type}\n{text}".lower() - items: List[Dict[str, Any]] = [] - for rule in self._load_rules_for_review(tenant_id, contract_type): - keywords = rule.get("keywords") or [] - if any(keyword.lower() in search_text for keyword in keywords if keyword): - items.append( - { - "title": rule["name"], - "level": rule["risk_level"], - "description": rule["description"], - "suggestion": rule.get("suggestion"), - "legal_basis": rule.get("legal_basis"), - } - ) - return items - - def _load_rules_for_review(self, tenant_id: str, contract_type: Optional[str]) -> List[Dict[str, Any]]: - rules = self.review_rule_repository.list_active_for_contract_type(tenant_id, contract_type) - custom_rules = [ - { - "name": rule.name, - "keywords": self._expand_keywords(rule.keyword), - "risk_level": rule.risk_level, - "description": rule.description, - "suggestion": rule.suggestion, - "legal_basis": rule.legal_basis, - } - for rule in rules - ] - custom_signatures = {tuple(rule["keywords"]) for rule in custom_rules} - fallback_rules = [rule for rule in DEFAULT_RISK_RULES if tuple(rule["keywords"]) not in custom_signatures] - return [*custom_rules, *fallback_rules] - - def _expand_keywords(self, keyword: str) -> List[str]: - if "|" in keyword: - return [item.strip() for item in keyword.split("|") if item.strip()] - return [keyword.strip()] - - def _build_risk_summary(self, risk_items: List[Dict[str, Any]]) -> Dict[str, Any]: - high = sum(1 for item in risk_items if item["level"] == "high") - medium = sum(1 for item in risk_items if item["level"] == "medium") - low = sum(1 for item in risk_items if item["level"] == "low") - if not risk_items: - overall = "low" - else: - overall = max(risk_items, key=lambda item: LEVEL_SCORE.get(item["level"], 0))["level"] - return { - "high": high, - "medium": medium, - "low": low, - "overall": overall, - } - - def _build_clauses(self, text: str) -> List[Dict[str, Any]]: - if not text: - return [] - segments = [segment.strip() for segment in text.splitlines() if segment.strip()] - return [{"title": f"条款{i}", "content": segment} for i, segment in enumerate(segments[:5], start=1)] - - def _build_review_opinion(self, risk_summary: Dict[str, Any], has_text: bool, has_risk_items: bool) -> str: - overall = risk_summary["overall"] - if overall == "high": - return "检测到高风险条款,建议暂停签署并由法务重点复核自动续约、免责或责任限制安排。" - if overall == "medium": - return "检测到中风险条款,建议先补充付款、违约或验收条件后再推进签署。" - if has_text and not has_risk_items: - return "当前文本未命中显著风险关键词,规则层暂判低风险;但如果原文仅概述条款类型而缺少具体约定,仍建议结合完整条款做人工复核。" - return DEFAULT_OPINION - - def _build_recommendation(self, risk_summary: Dict[str, Any], has_text: bool, has_risk_items: bool) -> str: - if risk_summary["overall"] == "high": - return "manual_review" - if risk_summary["overall"] == "medium": - return "proceed_with_revision" - if has_text and not has_risk_items: - return "proceed_with_revision" - return DEFAULT_RECOMMENDATION diff --git a/backend/app/services/pilot_request_service.py b/backend/app/services/pilot_request_service.py new file mode 100644 index 00000000..1f83ae9c --- /dev/null +++ b/backend/app/services/pilot_request_service.py @@ -0,0 +1,180 @@ +from datetime import datetime, timezone +from typing import Any, Dict, Optional +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.pilot_request import PilotRequest +from app.repositories.pilot_request_repository import PilotRequestRepository +from app.services.tenant_service import DEFAULT_TENANT_ID, TenantService + +ALLOWED_PILOT_REQUEST_STATUSES = {"new", "contacted", "scheduled", "closed"} +ALLOWED_PRIMARY_GOALS = {"contract_review", "contract_generation", "knowledge_base", "pilot_delivery"} +ALLOWED_DEPLOYMENT_PREFERENCES = {"saas", "private", "hybrid", "undecided"} + + +class PilotRequestService: + def __init__(self, db: Session): + self.db = db + self.repository = PilotRequestRepository(db) + self.tenant_service = TenantService(db) + + def submit_request( + self, + *, + contact_name: str, + company_name: str, + role_title: Optional[str], + email: str, + phone: Optional[str], + expected_contract_volume: Optional[str], + primary_goal: str, + deployment_preference: Optional[str], + scenario_description: str, + ) -> dict: + self.tenant_service.ensure_default_tenant() + normalized_primary_goal = self._normalize_primary_goal(primary_goal) + normalized_deployment = self._normalize_deployment_preference(deployment_preference) + pilot_request = PilotRequest( + id=f"pilot-{uuid4().hex[:12]}", + tenant_id=DEFAULT_TENANT_ID, + contact_name=self._normalize_required(contact_name, "contact name"), + company_name=self._normalize_required(company_name, "company name"), + role_title=self._normalize_optional(role_title), + email=self._normalize_required(email, "email").lower(), + phone=self._normalize_optional(phone), + expected_contract_volume=self._normalize_optional(expected_contract_volume), + primary_goal=normalized_primary_goal, + deployment_preference=normalized_deployment, + scenario_description=self._normalize_required(scenario_description, "scenario description"), + status="new", + source="website", + admin_notes=None, + last_contact_at=None, + ) + created = self.repository.create(pilot_request) + return self._to_item(created) + + def list_requests( + self, + current_user: Dict[str, Any], + *, + page: int = 1, + page_size: int = 20, + status_value: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + normalized_status = self._normalize_status_filter(status_value) + items = self.repository.list_paginated( + current_user["tenant_id"], + page=max(page, 1), + page_size=max(min(page_size, 100), 1), + status_value=normalized_status, + ) + total = self.repository.count(current_user["tenant_id"], status_value=normalized_status) + return { + "items": [self._to_item(item) for item in items], + "total": total, + "page": max(page, 1), + "page_size": max(min(page_size, 100), 1), + } + + def get_summary(self, current_user: Dict[str, Any]) -> dict: + self._require_admin(current_user) + tenant_id = current_user["tenant_id"] + latest_items = self.repository.list_paginated(tenant_id, page=1, page_size=5) + total = self.repository.count(tenant_id) + counts = { + "new": self.repository.count(tenant_id, "new"), + "contacted": self.repository.count(tenant_id, "contacted"), + "scheduled": self.repository.count(tenant_id, "scheduled"), + "closed": self.repository.count(tenant_id, "closed"), + } + return { + "total": total, + "counts": counts, + "latest_items": [self._to_item(item) for item in latest_items], + } + + def update_request( + self, + current_user: Dict[str, Any], + request_id: str, + *, + status_value: str, + admin_notes: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + pilot_request = self.repository.get_by_id(current_user["tenant_id"], request_id) + if pilot_request is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="pilot request not found") + + normalized_status = self._normalize_status_filter(status_value, required=True) + pilot_request.status = normalized_status + pilot_request.admin_notes = self._normalize_optional(admin_notes) + if normalized_status in {"contacted", "scheduled", "closed"}: + pilot_request.last_contact_at = datetime.now(timezone.utc) + updated = self.repository.update(pilot_request) + return self._to_item(updated) + + def _require_admin(self, current_user: Dict[str, Any]): + if current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") + + def _normalize_required(self, value: Optional[str], label: str) -> str: + normalized = (value or "").strip() + if not normalized: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{label} is required") + return normalized + + def _normalize_optional(self, value: Optional[str]) -> Optional[str]: + normalized = (value or "").strip() + return normalized or None + + def _normalize_primary_goal(self, value: str) -> str: + normalized = self._normalize_required(value, "primary goal").strip().lower() + if normalized not in ALLOWED_PRIMARY_GOALS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid primary goal") + return normalized + + def _normalize_deployment_preference(self, value: Optional[str]) -> Optional[str]: + normalized = self._normalize_optional(value) + if normalized is None: + return None + lowered = normalized.lower() + if lowered not in ALLOWED_DEPLOYMENT_PREFERENCES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid deployment preference") + return lowered + + def _normalize_status_filter(self, value: Optional[str], required: bool = False) -> Optional[str]: + normalized = self._normalize_optional(value) + if normalized is None: + if required: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="status is required") + return None + lowered = normalized.lower() + if lowered not in ALLOWED_PILOT_REQUEST_STATUSES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid status") + return lowered + + def _to_item(self, pilot_request: PilotRequest) -> dict: + return { + "id": pilot_request.id, + "tenant_id": pilot_request.tenant_id, + "contact_name": pilot_request.contact_name, + "company_name": pilot_request.company_name, + "role_title": pilot_request.role_title, + "email": pilot_request.email, + "phone": pilot_request.phone, + "expected_contract_volume": pilot_request.expected_contract_volume, + "primary_goal": pilot_request.primary_goal, + "deployment_preference": pilot_request.deployment_preference, + "scenario_description": pilot_request.scenario_description, + "status": pilot_request.status, + "source": pilot_request.source, + "admin_notes": pilot_request.admin_notes, + "last_contact_at": pilot_request.last_contact_at.isoformat() if pilot_request.last_contact_at else None, + "created_at": pilot_request.created_at.isoformat(), + "updated_at": pilot_request.updated_at.isoformat(), + } diff --git a/backend/app/services/rag_service.py b/backend/app/services/rag_service.py deleted file mode 100644 index fdf5d52a..00000000 --- a/backend/app/services/rag_service.py +++ /dev/null @@ -1,853 +0,0 @@ -import math -import re -from collections import Counter -from typing import Any, Dict, List, Optional, Tuple - -from sqlalchemy.orm import Session - -from app.repositories.knowledge_base_document_repository import KnowledgeBaseDocumentRepository - -CHUNK_SIZE = 400 -CHUNK_OVERLAP = 80 -MAX_CONTEXT_RESULTS = 3 -MAX_SUMMARY_POINTS = 4 -MAX_SOURCE_SNIPPET = 120 -MAX_BACKUP_SOURCES = 2 -MIN_MATCH_SCORE_FOR_DIRECT_ANSWER = 0.6 -MIN_CONFIDENT_SOURCE_SCORE = 1.0 -SENTENCE_BREAK_PATTERN = re.compile(r"(?<=[。!?.!?;;\n])") -ASCII_TOKEN_PATTERN = re.compile(r"[a-z0-9_]{2,}") -CJK_BIGRAM_PATTERN = re.compile(r"(?=([\u4e00-\u9fff]{2}))") -CJK_TRIGRAM_PATTERN = re.compile(r"(?=([\u4e00-\u9fff]{3}))") -LONG_CJK_TERM_PATTERN = re.compile(r"[\u4e00-\u9fff]{4,}") - -KEY_CONTRACT_TERMS = ("合同", "条款", "违约", "付款", "保密", "续约", "免责", "发票", "交付") -CONTRACT_QUESTION_KEYWORDS = ("合同", "条款", "审查", "风险", "违约", "付款", "保密", "免责", "续约") -LEGAL_STYLE_CUES = ("建议", "核查", "风险", "条款", "合同") -KNOWN_CJK_TERMS = ( - "自动续约", - "违约责任", - "违约金", - "付款节点", - "付款", - "发票", - "保密", - "免责", - "续约", - "条款", - "合同", -) -STOP_WORDS = { - "的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都", "一", "个", "上", "也", "很", - "到", "说", "要", "去", "你", "会", "着", "没有", "看", "好", "自己", "这", "关", "于", -} - -TOPIC_HINTS = { - "违约": "建议重点核查违约责任、违约金计算口径和触发条件。", - "付款": "建议重点核查付款节点、验收条件与发票前置要求。", - "续约": "建议重点核查续约通知窗口、终止条件和默认处理方式。", - "免责": "建议重点核查免责范围是否过宽,以及是否排除核心义务。", - "保密": "建议重点核查保密范围、期限和违约责任。", -} -TOPIC_SECTIONS = { - "违约": { - "focus": "违约责任与违约金", - "checkpoints": ["违约触发条件是否明确", "违约金比例和计算基数是否合理", "是否保留损失举证或调整空间"], - }, - "付款": { - "focus": "付款与结算安排", - "checkpoints": ["付款节点是否和验收挂钩", "发票前置条件是否明确", "逾期付款责任是否清晰"], - }, - "续约": { - "focus": "续约与终止机制", - "checkpoints": ["续约通知窗口是否明确", "终止条件和退出方式是否清楚", "是否存在默认续约风险"], - }, - "免责": { - "focus": "免责范围", - "checkpoints": ["免责条款是否覆盖核心义务", "是否排除故意或重大过失责任", "提示和说明义务是否充分"], - }, - "保密": { - "focus": "保密义务", - "checkpoints": ["保密信息范围是否明确", "保密期限是否清楚", "违约责任和例外情形是否约定"], - }, - "general": { - "focus": "合同审查建议", - "checkpoints": [ - "先确认问题是否对应具体条款,而不是笼统描述。", - "如果是审查问题,优先上传合同原文或相关制度文件。", - "对于高风险条款,仍建议结合法务人工复核。", - ], - }, -} -BACKUP_SOURCE_LIBRARY = { - "违约": [ - {"title": "审查要点:违约责任", "content": "重点核查违约金比例、计算口径、损失赔偿与触发条件是否清晰。", "score": None}, - {"title": "民法典提示", "content": "约定违约金过高或过低时,当事人可请求调整。", "score": None}, - ], - "付款": [ - {"title": "审查要点:付款安排", "content": "重点核查付款节点、验收条件、发票前置要求及逾期责任。", "score": None}, - {"title": "结算管理提示", "content": "付款条款应避免只写时间、不写触发条件和验收标准。", "score": None}, - ], - "续约": [ - {"title": "审查要点:续约机制", "content": "重点核查自动续约是否有通知窗口、退出机制和例外约定。", "score": None}, - {"title": "续约风险提示", "content": "默认续约但缺少退出机制时,通常应提示人工复核。", "score": None}, - ], - "免责": [ - {"title": "审查要点:免责条款", "content": "重点核查免责范围、适用条件及是否排除核心义务责任。", "score": None}, - {"title": "格式条款提示", "content": "免除或者减轻责任的格式条款,应合理提示并避免显失公平。", "score": None}, - ], - "保密": [ - {"title": "审查要点:保密义务", "content": "重点核查保密信息范围、保密期限和违约责任是否明确。", "score": None}, - {"title": "信息保护提示", "content": "保密条款应同时明确例外披露场景和返还/销毁要求。", "score": None}, - ], -} -DEFAULT_BACKUP_SOURCES = [ - {"title": "平台提示", "content": "当前未检索到直接匹配的知识库文档,请先补充资料后再问答。", "score": None}, - {"title": "审查建议", "content": "没有具体条款原文时,AI 问答只能给出方向性建议,不能替代正式法务审核。", "score": None}, -] -ACTION_GUIDANCE_LIBRARY = { - "违约": { - "priority": "优先确认违约触发条件、违约金计算口径和责任上限是否明显失衡。", - "revision": "建议把违约责任拆成触发条件、计算方式和补救期限三部分写清,避免直接使用笼统高额违约金表述。", - "negotiation": "谈判时优先争取违约金比例、责任上限和整改期,避免对核心违约情形承担无限责任。", - }, - "付款": { - "priority": "优先核查付款节点是否绑定验收结果,以及发票和付款前置条件是否过重。", - "revision": "建议将付款节点与验收标准、开票要求和逾期责任拆开明确,避免只写日期不写触发条件。", - "negotiation": "谈判时优先争取验收后付款、缩短账期,并限制发票条件成为拖延付款理由。", - }, - "续约": { - "priority": "优先确认是否存在自动续约、通知窗口过短或退出机制不明确的问题。", - "revision": "建议把续约通知期限、默认处理方式和终止路径单独写清,避免沉默即续约。", - "negotiation": "谈判时优先争取取消自动续约,或至少保留明确通知期和无责退出窗口。", - }, - "免责": { - "priority": "优先检查免责条款是否覆盖核心义务,或排除了故意、重大过失责任。", - "revision": "建议收窄免责适用范围,并明确不得免除故意、重大过失及核心履约义务责任。", - "negotiation": "谈判时应重点限制免责边界,避免对方用格式条款一并免除主要责任。", - }, - "保密": { - "priority": "优先核查保密信息范围、期限、例外披露和违约责任是否完整。", - "revision": "建议把保密对象、允许披露情形、返还销毁义务和违约责任分别写明。", - "negotiation": "谈判时优先争取缩小保密范围、限定期限,并保留法定披露和内部合规使用例外。", - }, - "general": { - "priority": "优先定位最直接影响履约和付款安全的高风险条款,再决定处理顺序。", - "revision": "建议先把模糊、失衡或缺少触发条件的条款改写为可执行表述。", - "negotiation": "谈判时优先聚焦高风险条款,区分必须坚持、可让步和可交换条件。", - }, -} -CONTRACT_RISK_LABELS = {"high": "高风险", "medium": "中风险", "low": "低风险"} -CONTRACT_REVIEW_DISCLAIMER = "以下意见仅作为合同审查辅助建议,不替代正式法律意见。" -FALLBACK_RISK_MESSAGE = "当前知识库未命中直接证据,以下建议基于合同审查通用要点生成。" -FALLBACK_ANALYSIS_MESSAGE = "建议先定位具体条款,再结合制度文件或历史模板进行核对。" -ANALYSIS_HEADLINE = "审查结论" -RISK_HEADLINE = "风险提示" -SUMMARY_HEADLINE = "重点摘要" -CHECKLIST_HEADLINE = "建议核查点" -PRIORITY_HEADLINE = "优先处理项" -REVISION_HEADLINE = "修改建议" -NEGOTIATION_HEADLINE = "谈判建议" -SOURCE_HEADLINE = "可参考来源" -CHUNK_SEPARATOR = "\n\n" - - -def _dedupe_preserve_order(items: List[str]) -> List[str]: - seen = set() - result: List[str] = [] - for item in items: - if item in seen: - continue - seen.add(item) - result.append(item) - return result - - -def _split_into_chunks(content: str) -> List[str]: - normalized = content.strip() - if not normalized: - return [] - if len(normalized) <= CHUNK_SIZE: - return [normalized] - - sentences = [segment.strip() for segment in SENTENCE_BREAK_PATTERN.split(normalized) if segment.strip()] - if not sentences: - sentences = [normalized] - - chunks: List[str] = [] - current = "" - for sentence in sentences: - candidate = f"{current}{sentence}" if current else sentence - if current and len(candidate) > CHUNK_SIZE: - chunks.append(current.strip()) - overlap = current[-CHUNK_OVERLAP:] if len(current) > CHUNK_OVERLAP else current - current = f"{overlap}{sentence}".strip() - else: - current = candidate - - if current: - chunks.append(current.strip()) - - return [chunk for chunk in chunks if chunk] - - -def _extract_topic_hints(query: str) -> List[str]: - hints = [hint for keyword, hint in TOPIC_HINTS.items() if keyword in query] - return _dedupe_preserve_order(hints) - - -class RAGService: - """基于分段 + BM25 风格排序的检索服务。""" - - def __init__(self, db: Session): - self.db = db - self.document_repository = KnowledgeBaseDocumentRepository(db) - - def search_relevant_documents(self, tenant_id: str, query: str, limit: int = 5) -> List[Dict]: - documents = self.document_repository.list_all_for_tenant(tenant_id) - if not documents: - return [] - - query_tokens = self._extract_keywords(query) - if not query_tokens: - return [] - - tokenized_chunks: List[Tuple[object, str, List[str]]] = [] - document_frequency: Counter[str] = Counter() - total_length = 0 - - for doc in documents: - if not doc.content_excerpt: - continue - for chunk_content in _split_into_chunks(doc.content_excerpt): - tokens = self._extract_keywords(chunk_content) - if not tokens: - continue - tokenized_chunks.append((doc, chunk_content, tokens)) - total_length += len(tokens) - for token in set(tokens): - document_frequency[token] += 1 - - if not tokenized_chunks: - return [] - - average_doc_length = total_length / len(tokenized_chunks) - query_counter = Counter(query_tokens) - results = [] - - for doc, chunk_content, tokens in tokenized_chunks: - score = self._calculate_bm25_score( - query_counter=query_counter, - document_tokens=tokens, - document_frequency=document_frequency, - total_documents=len(tokenized_chunks), - average_doc_length=average_doc_length, - ) - if score <= 0: - continue - - coverage = self._calculate_keyword_coverage(query_tokens, tokens) - bonus = self._calculate_domain_bonus(query, chunk_content) - total_score = score + coverage + bonus - results.append( - { - "id": doc.id, - "knowledge_base_id": doc.knowledge_base_id, - "file_name": doc.file_name, - "content": chunk_content, - "score": round(total_score, 4), - "created_at": doc.created_at.isoformat() if doc.created_at else None, - } - ) - - results.sort(key=lambda item: item["score"], reverse=True) - return self._dedupe_results(results, limit) - - def summarize_results(self, query: str, search_results: List[Dict]) -> Dict[str, Any]: - context, sources = self.build_context_from_results(search_results) - summary_points = self._build_summary_points(query, search_results) - review_topics = self._detect_review_topics(query, search_results) - answer = self.generate_answer_with_context(query, context) - return { - "answer": answer, - "sources": sources, - "summary": summary_points, - "review_topics": review_topics, - } - - def build_chat_payload( - self, - query: str, - search_results: List[Dict], - review_context: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - if not search_results: - return self.build_contract_review_fallback_payload( - query, - review_context=review_context, - ) - - summary = self.summarize_results(query, search_results) - if self._should_use_contract_review_style(query, search_results): - return self.build_contract_review_payload( - query, - summary, - review_context=review_context, - ) - return { - "chunks": self.format_answer_chunks(summary["answer"], summary["summary"]), - "sources": summary["sources"], - } - - def build_contract_review_payload( - self, - query: str, - summary: Dict[str, Any], - review_context: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - review_topics = self._resolve_review_topics(summary["review_topics"], review_context) - answer = self._rewrite_answer_for_contract_review( - query, - summary["answer"], - review_topics, - review_context, - ) - summary_points = self._merge_review_summary( - summary["summary"], - review_topics, - review_context, - ) - review_checklist = self._build_review_checklist(review_topics, review_context) - action_sections = self._build_action_sections(review_topics, review_context) - chunks = [ - f"{ANALYSIS_HEADLINE}:\n{answer}", - f"{SUMMARY_HEADLINE}:\n" + "\n".join(f"- {item}" for item in summary_points), - ] - if review_checklist: - chunks.append(f"{CHECKLIST_HEADLINE}:\n" + "\n".join(f"- {item}" for item in review_checklist)) - chunks.extend(self._format_action_chunks(action_sections)) - chunks.append(CONTRACT_REVIEW_DISCLAIMER) - return { - "chunks": chunks, - "sources": self._decorate_sources( - summary["sources"], - review_topics, - review_context, - ), - } - - def build_contract_review_fallback_payload( - self, - query: str, - review_context: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - review_topics = self._resolve_review_topics( - self._detect_review_topics(query, []), - review_context, - ) - summary_points = self._merge_review_summary([], review_topics, review_context) - checklist = self._build_review_checklist(review_topics, review_context) - action_sections = self._build_action_sections(review_topics, review_context) - chunks = [ - f"{RISK_HEADLINE}:\n{FALLBACK_RISK_MESSAGE}", - f"{ANALYSIS_HEADLINE}:\n{FALLBACK_ANALYSIS_MESSAGE}", - ] - if summary_points: - chunks.append(f"{SUMMARY_HEADLINE}:\n" + "\n".join(f"- {item}" for item in summary_points)) - if checklist: - chunks.append(f"{CHECKLIST_HEADLINE}:\n" + "\n".join(f"- {item}" for item in checklist)) - chunks.extend(self._format_action_chunks(action_sections)) - chunks.append(CONTRACT_REVIEW_DISCLAIMER) - return { - "chunks": chunks, - "sources": self._build_backup_sources(review_topics, review_context), - } - - def build_context_from_results(self, search_results: List[Dict]) -> Tuple[str, List[Dict]]: - if not search_results: - return "", [] - - context_parts = [] - sources = [] - for index, result in enumerate(search_results[:MAX_CONTEXT_RESULTS], start=1): - content = result["content"] - if len(content) > 500: - content = f"{content[:500]}..." - context_parts.append(f"[文档{index}] {content}") - sources.append({"title": f"文档: {result['file_name']}", "content": content, "score": result["score"]}) - return "\n\n".join(context_parts), sources - - def generate_answer_with_context(self, query: str, context: str) -> str: - if not context: - return "根据现有知识库,我暂时无法回答这个问题。请尝试上传相关文档到知识库。" - - hints = _extract_topic_hints(query) - sections = [ - "根据知识库中的相关文档,我为您提供以下信息:", - "", - f"针对你的问题“{query}”,我在知识库中找到了以下相关内容:", - ] - if hints: - sections.extend(["", "建议重点关注:", *[f"- {hint}" for hint in hints]]) - sections.extend(["", context, "", "请优先核对上述文档原文,再结合具体合同条款作出判断。"]) - return "\n".join(sections) - - def format_answer_chunks(self, answer: str, summary_points: List[str]) -> List[str]: - chunks = [answer] - if summary_points: - chunks.append("重点摘要:\n" + "\n".join(f"- {item}" for item in summary_points)) - return chunks - - def format_default_chunks(self, query: str) -> List[str]: - chunks = [ - f"已收到你的问题:{query}", - "当前知识库里没有检索到直接相关的材料,建议补充制度文档、历史合同或审查规则后再试。", - ] - hints = _extract_topic_hints(query) - if hints: - chunks.append("建议关注:\n" + "\n".join(f"- {hint}" for hint in hints)) - return chunks - - def format_default_sources(self) -> List[Dict[str, Any]]: - return [{"title": "平台提示", "content": "当前未检索到可引用的知识库文档,请补充资料后重试。", "score": None}] - - def _extract_keywords(self, text: str) -> List[str]: - lowered = text.lower() - ascii_tokens = ASCII_TOKEN_PATTERN.findall(lowered) - cjk_chars = [char for char in lowered if "\u4e00" <= char <= "\u9fff" and char not in STOP_WORDS] - cjk_bigrams = [token for token in CJK_BIGRAM_PATTERN.findall(lowered) if all(char not in STOP_WORDS for char in token)] - cjk_trigrams = [token for token in CJK_TRIGRAM_PATTERN.findall(lowered) if all(char not in STOP_WORDS for char in token)] - cjk_terms = [token for token in LONG_CJK_TERM_PATTERN.findall(lowered) if len(token.strip()) >= 4] - known_terms = [term for term in KNOWN_CJK_TERMS if term in lowered] - return _dedupe_preserve_order(ascii_tokens + known_terms + cjk_terms + cjk_trigrams + cjk_bigrams + cjk_chars) - - def _calculate_similarity(self, query_keywords: List[str], document_text: str) -> float: - if not query_keywords or not document_text: - return 0.0 - lowered_text = document_text.lower() - score = 0.0 - for keyword in query_keywords: - token = keyword.lower() - if token in lowered_text: - score += lowered_text.count(token) * max(len(token), 1) - return float(score) - - def _calculate_bm25_score( - self, - query_counter: Counter[str], - document_tokens: List[str], - document_frequency: Counter[str], - total_documents: int, - average_doc_length: float, - ) -> float: - if not document_tokens: - return 0.0 - - k1 = 1.5 - b = 0.75 - document_counter = Counter(document_tokens) - doc_length = len(document_tokens) - score = 0.0 - for token, query_tf in query_counter.items(): - term_frequency = document_counter.get(token, 0) - if term_frequency == 0: - continue - frequency = document_frequency.get(token, 0) - idf = math.log(1 + (total_documents - frequency + 0.5) / (frequency + 0.5)) - numerator = term_frequency * (k1 + 1) - denominator = term_frequency + k1 * (1 - b + b * doc_length / average_doc_length) - score += idf * (numerator / denominator) * query_tf - return score - - def _calculate_keyword_coverage(self, query_tokens: List[str], document_tokens: List[str]) -> float: - if not query_tokens or not document_tokens: - return 0.0 - matched = len(set(query_tokens) & set(document_tokens)) - return matched / max(len(set(query_tokens)), 1) - - def _calculate_domain_bonus(self, query: str, content: str) -> float: - lowered_query = query.lower() - lowered_content = content.lower() - bonus = 0.0 - for term in KEY_CONTRACT_TERMS: - if term in lowered_query and term in lowered_content: - bonus += 0.15 - return bonus - - def _build_summary_points(self, query: str, search_results: List[Dict]) -> List[str]: - points = _extract_topic_hints(query) - for item in search_results[:MAX_CONTEXT_RESULTS]: - excerpt = item["content"].replace("\n", " ").strip() - if excerpt: - points.append(excerpt[:80] + ("..." if len(excerpt) > 80 else "")) - return _dedupe_preserve_order(points)[:MAX_SUMMARY_POINTS] - - def _dedupe_results(self, results: List[Dict], limit: int) -> List[Dict]: - deduped: List[Dict] = [] - seen = set() - for item in results: - key = (item["file_name"], item["content"]) - if key in seen: - continue - seen.add(key) - deduped.append(item) - if len(deduped) >= limit: - break - return deduped - - def _should_use_contract_review_style(self, query: str, search_results: List[Dict]) -> bool: - lowered_query = query.lower() - if any(keyword in lowered_query for keyword in CONTRACT_QUESTION_KEYWORDS): - return True - return any(any(cue in item["content"] for cue in LEGAL_STYLE_CUES) for item in search_results[:MAX_CONTEXT_RESULTS]) - - def _detect_review_topics(self, query: str, search_results: List[Dict]) -> List[str]: - content = query + "\n" + "\n".join(item["content"] for item in search_results[:MAX_CONTEXT_RESULTS]) - topics = [topic for topic in ("违约", "付款", "续约", "免责", "保密") if topic in content] - return topics or ["general"] - - def _get_review_metadata(self, review_context: Optional[Dict[str, Any]]) -> Dict[str, Any]: - metadata = review_context.get("metadata") if isinstance(review_context, dict) else None - return metadata if isinstance(metadata, dict) else {} - - def _normalize_context_value(self, value: Any) -> str: - if value is None: - return "" - return str(value).strip() - - def _extract_context_topics(self, review_context: Optional[Dict[str, Any]]) -> List[str]: - metadata = self._get_review_metadata(review_context) - combined = "\n".join( - [ - self._normalize_context_value(metadata.get("focusLabel")), - self._normalize_context_value(metadata.get("actionLabel")), - self._normalize_context_value(metadata.get("reviewOpinion")), - self._normalize_context_value(metadata.get("contractType")), - ] - ) - topics = [topic for topic in ("违约", "付款", "续约", "免责", "保密") if topic in combined] - return topics - - def _resolve_review_topics( - self, - review_topics: List[str], - review_context: Optional[Dict[str, Any]] = None, - ) -> List[str]: - merged = [ - *review_topics, - *self._extract_context_topics(review_context), - ] - resolved = _dedupe_preserve_order([topic for topic in merged if topic]) - return resolved or ["general"] - - def _build_focus_label( - self, - review_topics: List[str], - review_context: Optional[Dict[str, Any]] = None, - ) -> str: - metadata = self._get_review_metadata(review_context) - focus_label = self._normalize_context_value(metadata.get("focusLabel")) - if focus_label: - return focus_label - - focus_labels = [] - for topic in review_topics: - section = TOPIC_SECTIONS.get(topic) - if section: - focus_labels.append(section["focus"]) - return "、".join(_dedupe_preserve_order(focus_labels)) or TOPIC_SECTIONS["general"]["focus"] - - def _build_contract_headline( - self, - query: str, - review_topics: List[str], - review_context: Optional[Dict[str, Any]] = None, - ) -> str: - metadata = self._get_review_metadata(review_context) - focus_label = self._build_focus_label(review_topics, review_context) - action_label = self._normalize_context_value(metadata.get("actionLabel")) - if action_label: - return f"针对“{query}”,当前应围绕“{focus_label}”继续执行“{action_label}”。" - return f"针对“{query}”,当前更应从{focus_label}角度开展审查。" - - def _build_context_priority_hint(self, review_context: Optional[Dict[str, Any]] = None) -> Optional[str]: - metadata = self._get_review_metadata(review_context) - overall_risk = self._normalize_context_value(metadata.get("overallRisk")) - if overall_risk == "high": - return "当前审查上下文显示总体风险偏高,建议优先处理高风险项并尽快回到原合同条款逐项确认。" - if overall_risk: - return f"当前审查上下文显示总体风险为{overall_risk},建议结合风险等级安排处理优先级。" - return None - - def _build_context_action_hint(self, review_context: Optional[Dict[str, Any]] = None) -> Optional[str]: - metadata = self._get_review_metadata(review_context) - focus_label = self._normalize_context_value(metadata.get("focusLabel")) - action_label = self._normalize_context_value(metadata.get("actionLabel")) - if focus_label and action_label: - return f"当前追问对象是“{focus_label}”,建议输出继续用于“{action_label}”的可执行动作。" - if focus_label: - return f"当前追问对象是“{focus_label}”,建议回答围绕该对象直接给出下一步动作。" - return None - - def _build_context_source_note(self, review_context: Optional[Dict[str, Any]] = None) -> Optional[str]: - metadata = self._get_review_metadata(review_context) - focus_label = self._normalize_context_value(metadata.get("focusLabel")) - action_label = self._normalize_context_value(metadata.get("actionLabel")) - focus_type = self._normalize_context_value(metadata.get("focusType")) - parts = [] - if focus_label: - parts.append(f"聚焦对象:{focus_label}") - if action_label: - parts.append(f"当前动作:{action_label}") - if focus_type == "risk_item": - parts.append("聚焦类型:风险项") - if focus_type == "clause": - parts.append("聚焦类型:条款") - return " · ".join(parts) if parts else None - - def _build_context_action_sections( - self, - review_topics: List[str], - review_context: Optional[Dict[str, Any]] = None, - ) -> Dict[str, List[str]]: - metadata = self._get_review_metadata(review_context) - focus_label = self._normalize_context_value(metadata.get("focusLabel")) or self._build_focus_label(review_topics, review_context) - action_label = self._normalize_context_value(metadata.get("actionLabel")) - focus_type = self._normalize_context_value(metadata.get("focusType")) - - if not focus_label and not action_label: - return {} - - subject = f"{focus_label}对应的合同条款" if focus_type == "clause" else focus_label or "当前审查对象" - sections = { - PRIORITY_HEADLINE: [], - REVISION_HEADLINE: [], - NEGOTIATION_HEADLINE: [], - } - - sections[PRIORITY_HEADLINE].append( - f"围绕{subject}先锁定最需要优先处理的风险点,并回到 review 工作台确认对应风险项或条款位置。" - ) - sections[REVISION_HEADLINE].append( - f"围绕{subject}输出可直接回到 review 页执行的修改动作,必要时补一版可替换文本。" - ) - sections[NEGOTIATION_HEADLINE].append( - f"围绕{subject}准备可用于后续沟通的谈判重点、让步空间和底线。" - ) - - if action_label: - sections[PRIORITY_HEADLINE].append(f"当前动作是“{action_label}”,建议优先输出与该动作直接对应的下一步。") - if "解释" in action_label: - sections[PRIORITY_HEADLINE].append(f"先把{subject}为什么会触发当前风险判断解释清楚,再决定修改顺序。") - if "修改" in action_label or "改写" in action_label or "清单" in action_label: - sections[REVISION_HEADLINE].insert(0, f"先把“{action_label}”转成 review 页可直接执行的修订清单,并标出优先级。") - if "谈判" in action_label or "策略" in action_label or "底线" in action_label: - sections[NEGOTIATION_HEADLINE].insert(0, f"先围绕“{action_label}”整理可直接用于沟通的谈判顺序、底线和可交换条件。") - sections[REVISION_HEADLINE].append(f"若当前动作需要落地,建议把“{action_label}”直接转成可执行清单。") - - return {key: _dedupe_preserve_order(value)[:3] for key, value in sections.items()} - - def _build_source_context_note(self, review_context: Optional[Dict[str, Any]] = None) -> Optional[str]: - return self._build_context_source_note(review_context) - - def _build_backup_source_context_note(self, review_context: Optional[Dict[str, Any]] = None) -> Optional[str]: - return self._build_context_source_note(review_context) - - def _merge_source_content_with_note(self, content: str, note: Optional[str]) -> str: - base = self._truncate_source_content(content) - if not note: - return base - return f"{base}\n\n{SOURCE_HEADLINE}:{note}" - - def _merge_backup_source_content_with_note(self, content: str, note: Optional[str]) -> str: - if not note: - return content - return f"{content}\n\n{SOURCE_HEADLINE}:{note}" - - def _append_context_items(self, items: List[str], review_context: Optional[Dict[str, Any]] = None) -> List[str]: - hints = [ - self._build_context_priority_hint(review_context), - self._build_context_action_hint(review_context), - ] - return _dedupe_preserve_order([*[hint for hint in hints if hint], *items]) - - def _append_context_checklist(self, items: List[str], review_context: Optional[Dict[str, Any]] = None) -> List[str]: - metadata = self._get_review_metadata(review_context) - focus_label = self._normalize_context_value(metadata.get("focusLabel")) - action_label = self._normalize_context_value(metadata.get("actionLabel")) - appended = list(items) - if focus_label: - appended.append(f"回到 review 结果页时,优先核对“{focus_label}”对应的风险项或条款摘要。") - if action_label: - appended.append(f"把当前输出整理成可直接支持“{action_label}”的下一步动作。") - return _dedupe_preserve_order(appended) - - def _build_topic_findings(self, review_topics: List[str]) -> List[str]: - findings = [] - for topic in review_topics: - section = TOPIC_SECTIONS.get(topic) - if section: - findings.append(f"- {section['focus']}:{section['checkpoints'][0]}") - if not findings: - findings.append(f"- {TOPIC_SECTIONS['general']['focus']}:{TOPIC_SECTIONS['general']['checkpoints'][0]}") - return findings[:MAX_SUMMARY_POINTS] - - def _merge_review_summary( - self, - summary_points: List[str], - review_topics: List[str], - review_context: Optional[Dict[str, Any]] = None, - ) -> List[str]: - merged = [] - for topic in review_topics: - section = TOPIC_SECTIONS.get(topic) - if section: - merged.extend(section["checkpoints"][:2]) - if not merged: - merged.extend(TOPIC_SECTIONS["general"]["checkpoints"][:2]) - merged.extend(summary_points) - return self._append_context_items(_dedupe_preserve_order(merged), review_context)[:MAX_SUMMARY_POINTS] - - def _build_review_checklist( - self, - review_topics: List[str], - review_context: Optional[Dict[str, Any]] = None, - ) -> List[str]: - checklist = [] - for topic in review_topics: - section = TOPIC_SECTIONS.get(topic) - if section: - checklist.extend(section["checkpoints"]) - if not checklist: - checklist.extend(TOPIC_SECTIONS["general"]["checkpoints"]) - return self._append_context_checklist(_dedupe_preserve_order(checklist), review_context)[:6] - - def _build_action_sections( - self, - review_topics: List[str], - review_context: Optional[Dict[str, Any]] = None, - ) -> Dict[str, List[str]]: - sections = { - PRIORITY_HEADLINE: [], - REVISION_HEADLINE: [], - NEGOTIATION_HEADLINE: [], - } - context_sections = self._build_context_action_sections(review_topics, review_context) - for headline, values in context_sections.items(): - sections[headline].extend(values) - for topic in review_topics: - guidance = ACTION_GUIDANCE_LIBRARY.get(topic, ACTION_GUIDANCE_LIBRARY["general"]) - sections[PRIORITY_HEADLINE].append(guidance["priority"]) - sections[REVISION_HEADLINE].append(guidance["revision"]) - sections[NEGOTIATION_HEADLINE].append(guidance["negotiation"]) - return {key: _dedupe_preserve_order(value)[:3] for key, value in sections.items()} - - - def _format_action_chunks(self, action_sections: Dict[str, List[str]]) -> List[str]: - chunks: List[str] = [] - for headline in (PRIORITY_HEADLINE, REVISION_HEADLINE, NEGOTIATION_HEADLINE): - items = action_sections.get(headline, []) - if items: - chunks.append(f"{headline}:\n" + "\n".join(f"- {item}" for item in items)) - return chunks - - def _normalize_analysis_sentence(self, value: str) -> str: - normalized = value.strip() - return normalized[:90] + ("..." if len(normalized) > 90 else "") if normalized else "" - - def _rewrite_answer_for_contract_review( - self, - query: str, - answer: str, - review_topics: List[str], - review_context: Optional[Dict[str, Any]] = None, - ) -> str: - normalized = answer.replace("我为您提供以下信息", "以下内容可作为合同审查辅助意见") - normalized = normalized.replace("请优先核对上述文档原文,再结合具体合同条款作出判断。", "请结合合同原文逐项比对,必要时提交法务人工复核。") - normalized = self._normalize_analysis_sentence(normalized) - sections = [self._build_contract_headline(query, review_topics, review_context)] - sections.extend(self._build_topic_findings(review_topics)) - context_hint = self._build_context_action_hint(review_context) - if context_hint: - sections.append(context_hint) - if normalized: - sections.append(normalized) - return CHUNK_SEPARATOR.join(section for section in sections if section) - - def _score_to_risk_label(self, score: Optional[float]) -> str: - if score is None: - return CONTRACT_RISK_LABELS["medium"] - if score >= MIN_CONFIDENT_SOURCE_SCORE: - return CONTRACT_RISK_LABELS["high"] - if score >= MIN_MATCH_SCORE_FOR_DIRECT_ANSWER: - return CONTRACT_RISK_LABELS["medium"] - return CONTRACT_RISK_LABELS["low"] - - def _build_source_note( - self, - source: Dict[str, Any], - review_topics: List[str], - review_context: Optional[Dict[str, Any]] = None, - ) -> str: - score = source.get("score") - score_value = score if isinstance(score, (int, float)) else None - parts = [ - self._build_focus_label(review_topics, review_context), - self._score_to_risk_label(score_value), - ] - context_note = self._build_source_context_note(review_context) - if context_note: - parts.append(context_note) - return " · ".join([part for part in parts if part]) - - def _truncate_source_content(self, content: str) -> str: - return content[:MAX_SOURCE_SNIPPET] + ("..." if len(content) > MAX_SOURCE_SNIPPET else "") - - def _decorate_sources( - self, - sources: List[Dict], - review_topics: List[str], - review_context: Optional[Dict[str, Any]] = None, - ) -> List[Dict[str, Any]]: - decorated = [] - for source in sources: - content = str(source.get("content", "")) - decorated.append( - { - "title": str(source.get("title", "来源")), - "content": self._merge_source_content_with_note( - content, - self._build_source_note(source, review_topics, review_context), - ), - "score": source.get("score"), - } - ) - for source in self._build_backup_sources(review_topics, review_context): - if len(decorated) >= MAX_CONTEXT_RESULTS + MAX_BACKUP_SOURCES: - break - decorated.append(source) - return decorated - - def _build_backup_sources( - self, - review_topics: List[str], - review_context: Optional[Dict[str, Any]] = None, - ) -> List[Dict[str, Any]]: - sources: List[Dict[str, Any]] = [] - for topic in review_topics: - sources.extend(BACKUP_SOURCE_LIBRARY.get(topic, [])) - if not sources: - sources = DEFAULT_BACKUP_SOURCES.copy() - note = self._build_backup_source_context_note(review_context) - if not note: - return sources[:MAX_BACKUP_SOURCES] - return [ - { - **source, - "content": self._merge_backup_source_content_with_note( - str(source.get("content", "")), - note, - ), - } - for source in sources[:MAX_BACKUP_SOURCES] - ] diff --git a/backend/app/services/review_service.py b/backend/app/services/review_service.py index 2713b2cc..ff32a9f3 100644 --- a/backend/app/services/review_service.py +++ b/backend/app/services/review_service.py @@ -30,6 +30,7 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.models.review_result import ReviewResult from app.models.review_task import ReviewTask +from app.schemas.ai_contract import build_ai_run_trace, build_parsed_document, build_review_workflow_state from app.repositories.review_result_repository import ReviewResultRepository from app.services.file_service import FileService from app.services.legal_review_execution_gateway import build_legal_review_execution_gateway @@ -384,7 +385,7 @@ class ReviewService: self.file_service = FileService(db) def _get_legal_review_execution_gateway(self): - return build_legal_review_execution_gateway(self.db, self.review_rule_repository) + return build_legal_review_execution_gateway(self.db) def create_review_task( self, @@ -396,6 +397,7 @@ class ReviewService: file_name: Optional[str], extracted_text: Optional[str] = None, extraction_metadata: Optional[Dict[str, Any]] = None, + source_file_id: Optional[str] = None, ) -> dict: review_id = f"review-{uuid4().hex[:12]}" title = file_name or "文本输入合同" @@ -410,6 +412,7 @@ class ReviewService: review_as=review_as, status="pending", source_type=source_type, + source_file_id=source_file_id, input_text=text, extracted_text=extracted_text, ) @@ -564,7 +567,8 @@ class ReviewService: if review is None: return None - return self._to_detail(review) + review_result = self.review_result_repository.get_by_review_task_id(review_id) + return self._to_detail(review, review_result) def retry_review_task(self, current_user: Dict[str, Any], review_id: str) -> Optional[dict]: original = self.repository.get_by_id(review_id, current_user["tenant_id"], current_user["id"]) @@ -612,6 +616,9 @@ class ReviewService: result = self.get_review_result(current_user, review_id) assert result is not None + if result.get("rendered_report"): + return result["rendered_report"] + lines = [ f"审查任务:{review.title}", f"任务编号:{review.id}", @@ -782,24 +789,45 @@ class ReviewService: ) -> Dict[str, Any]: text = review.input_text or "" base_basic_info = dict(result.get("basic_info") or {}) - clauses = self._enrich_clauses(result.get("clauses"), text) - contract_structure = self._build_contract_structure(review, base_basic_info, clauses, text) - risk_items = self._enrich_risk_items(result.get("risk_items"), clauses, text) + remote_clauses = result.get("clauses") if isinstance(result.get("clauses"), list) else None + clauses = self._enrich_clause_entries(remote_clauses) if remote_clauses is not None else self._enrich_clauses(result.get("clauses"), text) + remote_contract_structure = result.get("contract_structure") if isinstance(result.get("contract_structure"), dict) else None + derived_contract_structure = self._build_contract_structure(review, base_basic_info, clauses, text) + contract_structure = self._merge_contract_structure(derived_contract_structure, remote_contract_structure) + remote_risk_items = result.get("risk_items") or [] + risk_items = remote_risk_items if all(isinstance(item, dict) and item.get("evidence") for item in remote_risk_items) else self._enrich_risk_items(remote_risk_items, clauses, text) + risk_summary = result.get("risk_summary") or self._build_risk_summary(risk_items) + if remote_clauses is not None: + clauses = remote_clauses + if "contract_type" not in base_basic_info and contract_structure.get("detected_contract_type"): + base_basic_info["contract_type"] = contract_structure.get("detected_contract_type") + if "contract_title" not in base_basic_info and contract_structure.get("title"): + base_basic_info["contract_title"] = contract_structure.get("title") + if "party_a" not in base_basic_info and contract_structure.get("party_a"): + base_basic_info["party_a"] = contract_structure.get("party_a") + if "party_b" not in base_basic_info and contract_structure.get("party_b"): + base_basic_info["party_b"] = contract_structure.get("party_b") + if "amount" not in base_basic_info and contract_structure.get("amount"): + base_basic_info["amount"] = contract_structure.get("amount") + if "term" not in base_basic_info and contract_structure.get("term"): + base_basic_info["term"] = contract_structure.get("term") + risk_summary = result.get("risk_summary") or self._build_risk_summary(risk_items) confidence_summary = self._merge_confidence_summary(result.get("confidence_summary"), extraction_metadata) if confidence_summary is None: confidence_summary = {} - confidence_summary["structured_analysis"] = { - "stage": "stage1-batch6", - "clause_count": len(clauses), - "detected_clause_types": [item["type"] for item in CLAUSE_TYPE_DEFINITIONS if contract_structure["clause_type_counts"].get(item["type"])], - "missing_sections": contract_structure["missing_sections"], - "evidence_coverage": sum(1 for item in risk_items if item.get("evidence", {}).get("quote")), - "payment_milestone_count": len(contract_structure.get("payment_milestones") or []), - "relationship_count": len(contract_structure.get("clause_relationships") or []), - "high_confidence_relationships": sum(1 for item in contract_structure.get("clause_relationships") or [] if item.get("confidence_tier") == RELATION_CONFIDENCE_HIGH), - "weak_hint_relationships": sum(1 for item in contract_structure.get("clause_relationships") or [] if item.get("confidence_tier") == RELATION_CONFIDENCE_WEAK), - } + if "structured_analysis" not in confidence_summary: + confidence_summary["structured_analysis"] = { + "stage": "python-fallback", + "clause_count": len(clauses), + "detected_clause_types": [item["type"] for item in CLAUSE_TYPE_DEFINITIONS if contract_structure["clause_type_counts"].get(item["type"])], + "missing_sections": contract_structure["missing_sections"], + "evidence_coverage": sum(1 for item in risk_items if item.get("evidence", {}).get("quote")), + "payment_milestone_count": len(contract_structure.get("payment_milestones") or []), + "relationship_count": len(contract_structure.get("clause_relationships") or []), + "high_confidence_relationships": sum(1 for item in contract_structure.get("clause_relationships") or [] if item.get("confidence_tier") == RELATION_CONFIDENCE_HIGH), + "weak_hint_relationships": sum(1 for item in contract_structure.get("clause_relationships") or [] if item.get("confidence_tier") == RELATION_CONFIDENCE_WEAK), + } enriched_basic_info = { "contract_type": base_basic_info.get("contract_type") or review.contract_type or "未分类合同", "contract_title": contract_structure["title"], @@ -817,8 +845,50 @@ class ReviewService: "recommendation": result.get("recommendation") or DEFAULT_RECOMMENDATION, "confidence_summary": confidence_summary, "contract_structure": contract_structure, + "rendered_report": result.get("rendered_report"), } + def _merge_contract_structure( + self, + derived_contract_structure: Dict[str, Any], + remote_contract_structure: Optional[Dict[str, Any]], + ) -> Dict[str, Any]: + if not remote_contract_structure: + return derived_contract_structure + + merged = dict(derived_contract_structure) + merged.update(remote_contract_structure) + for key in ( + "title", + "detected_contract_type", + "detected_contract_type_label", + "party_a", + "party_b", + "amount", + "amount_details", + "term", + "term_details", + "clause_count", + "clause_type_counts", + "key_sections", + "payment_terms", + "acceptance_terms", + "confidentiality_terms", + "intellectual_property_terms", + "liability_terms", + "breach_terms", + "dispute_resolution_terms", + "payment_milestones", + "liability_carve_outs", + "intellectual_property_owner", + "clause_relationships", + "missing_sections", + "summary", + ): + if key not in remote_contract_structure or remote_contract_structure.get(key) is None: + merged[key] = derived_contract_structure.get(key) + return merged + def _enrich_clauses(self, raw_clauses: Optional[List[Dict[str, Any]]], text: str) -> List[Dict[str, Any]]: generated_clauses = self._enrich_clause_entries(self._build_clauses_from_text(text)) raw_clause_entries = self._enrich_clause_entries(raw_clauses or []) @@ -1457,105 +1527,23 @@ class ReviewService: ) -> List[Dict[str, Any]]: enriched: List[Dict[str, Any]] = [] for item in raw_risk_items or []: - evidence = self._resolve_risk_evidence(item, clauses, text) + if item.get("evidence"): + enriched.append(item) + continue enriched.append( { **item, - "certainty": evidence["certainty"], + "certainty": RISK_CERTAINTY_POSSIBLE, "evidence": { - "clause_title": evidence.get("clause_title"), - "quote": evidence.get("quote"), - "reason": evidence.get("reason"), - "matched_keywords": evidence.get("matched_keywords") or [], + "clause_title": clauses[0].get("title") if clauses else None, + "quote": self._summarize_clause_text((clauses[0].get("content") or text) if clauses else text) if (clauses or text) else None, + "reason": "当前结果缺少共享平台返回的证据细节,使用业务侧最小兜底。", + "matched_keywords": [], }, } ) return enriched - def _resolve_risk_evidence(self, risk_item: Dict[str, Any], clauses: List[Dict[str, Any]], text: str) -> Dict[str, Any]: - focus_clause_types, matched_keywords = self._infer_risk_focus(risk_item) - candidate_clause: Optional[Dict[str, Any]] = None - candidate_score = -1 - for clause in clauses: - clause_type = clause.get("clause_type") - content = clause.get("content") or "" - title = clause.get("title") or "" - lowered_content = content.lower() - lowered_title = title.lower() - score = 0 - if clause_type in focus_clause_types: - score += 5 - for keyword in matched_keywords: - lowered_keyword = keyword.lower() - if lowered_keyword in lowered_title: - score += 4 - if lowered_keyword in lowered_content: - score += 2 - if score > candidate_score: - candidate_score = score - candidate_clause = clause - - quote = None - clause_title = None - if candidate_clause: - clause_title = candidate_clause.get("title") - quote = self._extract_evidence_quote(candidate_clause.get("content") or "", matched_keywords) - elif matched_keywords: - quote = self._extract_evidence_quote(text, matched_keywords) - - certainty = RISK_CERTAINTY_CONFIRMED if quote else RISK_CERTAINTY_POSSIBLE - reason = "已命中对应条款原文" if quote else "当前风险项由审查引擎识别,但未定位到更强的条款级证据" - return { - "certainty": certainty, - "clause_title": clause_title, - "quote": quote, - "reason": reason, - "matched_keywords": matched_keywords, - "score": candidate_score if candidate_score >= 0 else 0, - } - - def _infer_risk_focus(self, risk_item: Dict[str, Any]) -> tuple[List[str], List[str]]: - haystack = "\n".join( - filter( - None, - [ - str(risk_item.get("title") or ""), - str(risk_item.get("description") or ""), - str(risk_item.get("suggestion") or ""), - ], - ) - ).lower() - focus_clause_types: List[str] = [] - matched_keywords: List[str] = [] - for definition in CLAUSE_TYPE_DEFINITIONS: - definition_matches = [keyword for keyword in definition["keywords"] if keyword.lower() in haystack] - if definition_matches: - focus_clause_types.append(definition["type"]) - matched_keywords.extend(definition_matches) - deduplicated_keywords: List[str] = [] - for keyword in matched_keywords: - if keyword not in deduplicated_keywords: - deduplicated_keywords.append(keyword) - return focus_clause_types, deduplicated_keywords - - def _extract_evidence_quote(self, source: str, keywords: List[str]) -> Optional[str]: - if not source: - return None - segments = [segment.strip() for segment in re.split(r"[\n。;;]", source) if segment.strip()] - lowered_keywords = [keyword.lower() for keyword in keywords] - best_segment = None - best_score = -1 - for segment in segments: - lowered_segment = segment.lower() - score = sum(1 for keyword in lowered_keywords if keyword in lowered_segment) - if score > best_score: - best_score = score - best_segment = segment - if best_segment and best_score > 0: - return self._summarize_clause_text(best_segment) - if segments: - return self._summarize_clause_text(segments[0]) - return None def _build_legal_basis(self, risk_items: List[Dict[str, str]]) -> List[str]: basis: List[str] = [] @@ -1581,6 +1569,8 @@ class ReviewService: def _build_report_payload(self, review: ReviewTask, result: Dict[str, Any]) -> Dict[str, Any]: enriched = self._enrich_review_result(review, result) risk_items = enriched["risk_items"] + lifecycle = self._build_review_lifecycle(review) + ai_contract_state = self._build_ai_contract_state(review, lifecycle, enriched.get("confidence_summary")) return { "basic_info": enriched["basic_info"], "contract_structure": enriched["contract_structure"], @@ -1592,7 +1582,11 @@ class ReviewService: "review_opinion": enriched["review_opinion"], "recommendation": enriched["recommendation"], "confidence_summary": enriched.get("confidence_summary"), + "rendered_report": enriched.get("rendered_report"), "source_excerpt": (review.input_text or "未提供合同文本,仅记录了任务元数据。")[:500], + "workflow_state": ai_contract_state["workflow_state"], + "ai_trace": ai_contract_state["ai_trace"], + "source_document": ai_contract_state["source_document"], } def get_review_result(self, current_user: Dict[str, Any], review_id: str) -> Optional[dict]: @@ -1615,6 +1609,7 @@ class ReviewService: "review_opinion": review_result.review_opinion, "recommendation": review_result.recommendation, "confidence_summary": review_result.confidence, + "rendered_report": (review_result.confidence or {}).get("rendered_report"), }, ) else: @@ -1654,6 +1649,9 @@ class ReviewService: risk_items = result["risk_items"] risk_summary = result["risk_summary"] confidence_summary = result.get("confidence_summary") + if confidence_summary and result.get("rendered_report"): + confidence_summary = dict(confidence_summary) + confidence_summary["rendered_report"] = result["rendered_report"] review.risk_level = risk_summary["overall"] review.status = "completed" review.processing_time_ms = 1000 + len(risk_items) * 200 @@ -2377,6 +2375,52 @@ class ReviewService: return "请先复核当前结果与错误原因,修正后重新发起审查。" return "请根据当前阶段状态继续处理或重试。" + def _build_ai_contract_state( + self, + review: ReviewTask, + lifecycle: Dict[str, Any], + confidence_summary: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + source_text = review.input_text or review.extracted_text or "" + parsed_document = build_parsed_document( + document_id=review.source_file_id, + file_name=review.title, + mime_type=None, + source_type=review.source_type, + text=source_text, + lifecycle=lifecycle, + confidence_summary=confidence_summary, + ) + ai_trace = build_ai_run_trace( + review_id=review.id, + status=review.status, + contract_type=review.contract_type, + review_as=review.review_as, + source_type=review.source_type, + lifecycle=lifecycle, + confidence_summary=confidence_summary, + contract_id=review.source_file_id, + ) + workflow_state = build_review_workflow_state( + review_id=review.id, + contract_title=review.title, + contract_type=review.contract_type, + review_as=review.review_as, + status=review.status, + source_type=review.source_type, + lifecycle=lifecycle, + processing_time_ms=review.processing_time_ms, + error_message=review.error_message, + parsed_document=parsed_document, + ai_trace=ai_trace, + risk_level=review.risk_level, + ) + return { + "source_document": parsed_document.model_dump(mode="json"), + "ai_trace": ai_trace.model_dump(mode="json"), + "workflow_state": workflow_state.model_dump(mode="json"), + } + def _to_list_item(self, review: ReviewTask) -> Dict[str, Any]: lifecycle = self._build_review_lifecycle(review) return { @@ -2395,10 +2439,12 @@ class ReviewService: "can_retry": lifecycle["can_retry"], } - def _to_detail(self, review: ReviewTask) -> Dict[str, Any]: + def _to_detail(self, review: ReviewTask, review_result: Optional[ReviewResult] = None) -> Dict[str, Any]: completed_at = review.completed_at.astimezone(timezone.utc).isoformat() if review.completed_at else None source_text = review.input_text or "" lifecycle = self._build_review_lifecycle(review) + confidence_summary = review_result.confidence if review_result else None + ai_contract_state = self._build_ai_contract_state(review, lifecycle, confidence_summary) return { "id": review.id, @@ -2424,4 +2470,7 @@ class ReviewService: "failure_reason": lifecycle["failure_reason"], "next_action": lifecycle["next_action"], "can_retry": lifecycle["can_retry"], + "workflow_state": ai_contract_state["workflow_state"], + "ai_trace": ai_contract_state["ai_trace"], + "source_document": ai_contract_state["source_document"], } diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py index 9bcf8758..8cf82980 100644 --- a/backend/app/services/template_service.py +++ b/backend/app/services/template_service.py @@ -1,30 +1,72 @@ import json +import re +from datetime import datetime, timezone from typing import Any, Dict, Optional from uuid import uuid4 +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.contract_template import ContractTemplate +from app.models.contract_template_version import ContractTemplateVersion +from app.repositories.contract_template_repository import ContractTemplateRepository +from app.repositories.contract_template_version_repository import ContractTemplateVersionRepository + + DEFAULT_TEMPLATES: list[dict[str, Any]] = [ { "name": "劳动合同标准模板", "contract_type": "labor", + "template_category": "employment-hr", + "industry_category": "general-enterprise", "description": "适用于企业与员工签署的标准劳动合同。", - "content": "甲方(用人单位):{{party_a}}\n乙方(员工):{{party_b}}\n\n根据《中华人民共和国劳动合同法》及相关法律法规,甲乙双方在平等自愿、协商一致的基础上,就劳动关系建立事宜达成如下协议:\n\n一、合同期限\n本合同期限自 {{start_date}} 起至 {{end_date}} 止。\n\n二、工作内容与地点\n乙方同意在甲方安排的 {{job_title}} 岗位工作,工作地点为 {{work_location}}。\n\n三、劳动报酬\n甲方每月向乙方支付税前工资人民币 {{salary}} 元,发薪日为每月 {{payday}} 日。\n\n四、工作时间与休息休假\n双方按照国家规定执行工时与休假制度。\n\n五、社会保险与福利\n甲方依法为乙方缴纳社会保险并提供相应福利。\n\n六、保密与竞业\n乙方应对在任职期间知悉的商业秘密承担保密义务。\n\n七、其他\n未尽事宜由双方另行协商,并签署书面补充协议。\n\n甲方(盖章):{{party_a}}\n乙方(签字):{{party_b}}\n签署日期:{{sign_date}}", + "content": ( + "甲方(用人单位):{{party_a}}\n" + "乙方(员工):{{party_b}}\n\n" + "一、合同期限\n" + "本合同自 {{start_date}} 起至 {{end_date}} 止。\n\n" + "二、工作岗位\n" + "乙方在甲方安排的 {{job_title}} 岗位工作,工作地点为 {{work_location}}。\n\n" + "三、劳动报酬\n" + "甲方每月向乙方支付税前工资人民币 {{salary}} 元,发薪日为每月 {{payday}} 日。\n\n" + "四、签署日期\n" + "{{sign_date}}" + ), "variables": [ {"name": "party_a", "label": "甲方名称"}, {"name": "party_b", "label": "乙方姓名"}, - {"name": "start_date", "label": "合同开始日期"}, - {"name": "end_date", "label": "合同结束日期"}, + {"name": "start_date", "label": "开始日期"}, + {"name": "end_date", "label": "结束日期"}, {"name": "job_title", "label": "岗位名称"}, {"name": "work_location", "label": "工作地点"}, {"name": "salary", "label": "月工资"}, {"name": "payday", "label": "发薪日"}, - {"name": "sign_date", "label": "签署日期"} + {"name": "sign_date", "label": "签署日期"}, ], }, { "name": "采购合同标准模板", "contract_type": "procurement", + "template_category": "procurement-supply", + "industry_category": "general-enterprise", "description": "适用于货物采购、交付与验收场景。", - "content": "甲方(采购方):{{buyer}}\n乙方(供应方):{{supplier}}\n\n鉴于甲方向乙方采购 {{product_name}},双方经协商一致,达成如下采购合同:\n\n一、采购标的\n产品名称:{{product_name}}\n规格型号:{{specification}}\n数量:{{quantity}}\n\n二、合同金额\n本合同总金额为人民币 {{amount}} 元。\n\n三、交付安排\n交付地点:{{delivery_location}}\n交付时间:{{delivery_date}}\n\n四、验收标准\n甲方应在收到货物后 {{acceptance_days}} 日内完成验收。\n\n五、付款方式\n甲方按如下方式付款:{{payment_terms}}\n\n六、违约责任\n任何一方违约的,应承担相应违约责任并赔偿损失。\n\n甲方(盖章):{{buyer}}\n乙方(盖章):{{supplier}}\n签署日期:{{sign_date}}", + "content": ( + "甲方(采购方):{{buyer}}\n" + "乙方(供应方):{{supplier}}\n\n" + "一、采购标的\n" + "产品名称:{{product_name}}\n" + "规格型号:{{specification}}\n" + "采购数量:{{quantity}}\n\n" + "二、合同金额\n" + "本合同总金额为人民币 {{amount}} 元。\n\n" + "三、交付安排\n" + "交付地点:{{delivery_location}}\n" + "交付日期:{{delivery_date}}\n\n" + "四、付款方式\n" + "{{payment_terms}}\n\n" + "五、签署日期\n" + "{{sign_date}}" + ), "variables": [ {"name": "buyer", "label": "采购方名称"}, {"name": "supplier", "label": "供应方名称"}, @@ -34,41 +76,50 @@ DEFAULT_TEMPLATES: list[dict[str, Any]] = [ {"name": "amount", "label": "合同金额"}, {"name": "delivery_location", "label": "交付地点"}, {"name": "delivery_date", "label": "交付日期"}, - {"name": "acceptance_days", "label": "验收期限(天)"}, {"name": "payment_terms", "label": "付款方式"}, - {"name": "sign_date", "label": "签署日期"} + {"name": "sign_date", "label": "签署日期"}, ], }, { "name": "保密协议标准模板", "contract_type": "nda", + "template_category": "commercial-compliance", + "industry_category": "general-enterprise", "description": "适用于商务合作前的双向保密约定。", - "content": "披露方:{{disclosing_party}}\n接收方:{{receiving_party}}\n\n鉴于双方拟就 {{cooperation_matter}} 开展合作,接收方将接触披露方的保密信息。为保护双方合法权益,达成如下保密协议:\n\n一、保密信息范围\n包括但不限于 {{confidential_scope}}。\n\n二、保密义务\n接收方仅可为 {{use_purpose}} 之目的使用保密信息,未经披露方书面同意不得向第三方披露。\n\n三、保密期限\n保密义务自本协议生效之日起持续 {{confidential_term}}。\n\n四、违约责任\n如接收方违反本协议,应承担赔偿责任。\n\n五、争议解决\n双方同意提交 {{dispute_forum}} 解决争议。\n\n披露方(盖章):{{disclosing_party}}\n接收方(盖章):{{receiving_party}}\n签署日期:{{sign_date}}", + "content": ( + "披露方:{{disclosing_party}}\n" + "接收方:{{receiving_party}}\n\n" + "一、合作事项\n" + "{{cooperation_matter}}\n\n" + "二、保密范围\n" + "{{confidential_scope}}\n\n" + "三、使用目的\n" + "{{use_purpose}}\n\n" + "四、保密期限\n" + "{{confidential_term}}\n\n" + "五、争议解决\n" + "{{dispute_forum}}\n\n" + "签署日期:{{sign_date}}" + ), "variables": [ {"name": "disclosing_party", "label": "披露方"}, {"name": "receiving_party", "label": "接收方"}, {"name": "cooperation_matter", "label": "合作事项"}, - {"name": "confidential_scope", "label": "保密信息范围"}, + {"name": "confidential_scope", "label": "保密范围"}, {"name": "use_purpose", "label": "使用目的"}, {"name": "confidential_term", "label": "保密期限"}, {"name": "dispute_forum", "label": "争议解决机构"}, - {"name": "sign_date", "label": "签署日期"} + {"name": "sign_date", "label": "签署日期"}, ], }, ] -from fastapi import HTTPException, status -from sqlalchemy.orm import Session - -from app.models.contract_template import ContractTemplate -from app.repositories.contract_template_repository import ContractTemplateRepository - - class TemplateService: def __init__(self, db: Session): self.db = db - self.repository = ContractTemplateRepository(db) + self.repository = ContractTemplateRepository(db) if db is not None else None + self.version_repository = ContractTemplateVersionRepository(db) if db is not None else None def list_templates( self, @@ -76,18 +127,50 @@ class TemplateService: page: int = 1, page_size: int = 20, contract_type: Optional[str] = None, + template_category: Optional[str] = None, + industry_category: Optional[str] = None, ) -> dict: self._require_admin(current_user) + repository = self._require_repository() tenant_id = current_user["tenant_id"] normalized_page = max(page, 1) normalized_page_size = max(min(page_size, 100), 1) normalized_contract_type = contract_type.strip() if contract_type else None - items = self.repository.list_paginated(tenant_id, normalized_page, normalized_page_size, normalized_contract_type) + normalized_template_category = template_category.strip() if template_category else None + normalized_industry_category = industry_category.strip() if industry_category else None + items = repository.list_paginated( + tenant_id, + normalized_page, + normalized_page_size, + normalized_contract_type, + normalized_template_category, + normalized_industry_category, + ) return { "items": [self._to_item(item) for item in items], "page": normalized_page, "page_size": normalized_page_size, - "total": self.repository.count(tenant_id, normalized_contract_type), + "total": repository.count( + tenant_id, + normalized_contract_type, + normalized_template_category, + normalized_industry_category, + ), + } + + def get_template_versions(self, current_user: Dict[str, Any], template_id: str) -> dict: + self._require_admin(current_user) + version_repository = self._require_version_repository() + contract_template = self._require_template(current_user, template_id) + versions = version_repository.list_by_template_id(current_user["tenant_id"], template_id) + if not versions: + versions = [self._build_fallback_version(contract_template)] + versions.sort(key=lambda item: (item.is_current, item.created_at), reverse=True) + return { + "template_id": template_id, + "current_version_label": contract_template.current_version_label or "v1.0", + "version_count": max(contract_template.version_count or 0, len(versions), 1), + "items": [self._to_version_item(version) for version in versions], } def create_template( @@ -99,21 +182,35 @@ class TemplateService: content: str, variables_json: Optional[str], enabled: bool, + change_summary: Optional[str] = None, + template_category: Optional[str] = None, + industry_category: Optional[str] = None, ) -> dict: self._require_admin(current_user) + repository = self._require_repository() + normalized_change_summary = self._normalize_change_summary(change_summary, default="Initial template version") + normalized_contract_type = contract_type.strip() if contract_type else None + contract_template = ContractTemplate( id=f"tpl-{uuid4().hex[:12]}", tenant_id=current_user["tenant_id"], name=name.strip(), - contract_type=contract_type.strip() if contract_type else None, + contract_type=normalized_contract_type, + template_category=self._normalize_template_category(template_category, normalized_contract_type), + industry_category=self._normalize_industry_category(industry_category, normalized_contract_type), description=description.strip() if description else None, content=content.strip(), variables_json=self._parse_variables(variables_json), enabled=enabled, + current_version_label="v1.0", + latest_change_summary=normalized_change_summary, + latest_version_created_at=self._now(), + version_count=1, created_by=current_user.get("id"), updated_by=current_user.get("id"), ) - created = self.repository.create(contract_template) + created = repository.create(contract_template) + self._create_version_snapshot(created, current_user, normalized_change_summary, is_current=True) return self._to_item(created) def update_template( @@ -126,34 +223,51 @@ class TemplateService: content: str, variables_json: Optional[str], enabled: bool, + change_summary: Optional[str] = None, + template_category: Optional[str] = None, + industry_category: Optional[str] = None, ) -> dict: self._require_admin(current_user) + repository = self._require_repository() contract_template = self._require_template(current_user, template_id) + next_version_label = self._bump_version_label(contract_template.current_version_label) + normalized_change_summary = self._normalize_change_summary(change_summary, default=f"{next_version_label} template update") + normalized_contract_type = contract_type.strip() if contract_type else None + contract_template.name = name.strip() - contract_template.contract_type = contract_type.strip() if contract_type else None + contract_template.contract_type = normalized_contract_type + contract_template.template_category = self._normalize_template_category(template_category, normalized_contract_type) + contract_template.industry_category = self._normalize_industry_category(industry_category, normalized_contract_type) contract_template.description = description.strip() if description else None contract_template.content = content.strip() contract_template.variables_json = self._parse_variables(variables_json) contract_template.enabled = enabled + contract_template.current_version_label = next_version_label + contract_template.latest_change_summary = normalized_change_summary + contract_template.latest_version_created_at = self._now() + contract_template.version_count = max(contract_template.version_count or 0, 1) + 1 contract_template.updated_by = current_user.get("id") - updated = self.repository.update(contract_template) + updated = repository.update(contract_template) + self._create_version_snapshot(updated, current_user, normalized_change_summary, is_current=True) return self._to_item(updated) def delete_template(self, current_user: Dict[str, Any], template_id: str) -> dict: self._require_admin(current_user) + repository = self._require_repository() contract_template = self._require_template(current_user, template_id) template_data = self._to_item(contract_template) - self.repository.delete(contract_template) + repository.delete(contract_template) return template_data def seed_default_templates(self, current_user: Dict[str, Any]) -> dict: self._require_admin(current_user) + repository = self._require_repository() tenant_id = current_user["tenant_id"] created_items: list[dict[str, Any]] = [] existing_items: list[dict[str, Any]] = [] for template_data in DEFAULT_TEMPLATES: - existing = self.repository.get_by_name(tenant_id, template_data["name"]) + existing = repository.get_by_name(tenant_id, template_data["name"]) if existing is not None: existing_items.append(self._to_item(existing)) continue @@ -163,14 +277,21 @@ class TemplateService: tenant_id=tenant_id, name=template_data["name"], contract_type=template_data["contract_type"], + template_category=template_data["template_category"], + industry_category=template_data["industry_category"], description=template_data["description"], content=template_data["content"], variables_json=template_data["variables"], enabled=True, + current_version_label="v1.0", + latest_change_summary="Seeded default template", + latest_version_created_at=self._now(), + version_count=1, created_by=current_user.get("id"), updated_by=current_user.get("id"), ) - created = self.repository.create(contract_template) + created = repository.create(contract_template) + self._create_version_snapshot(created, current_user, "Seeded default template", is_current=True) created_items.append(self._to_item(created)) return { @@ -179,18 +300,30 @@ class TemplateService: "created_count": len(created_items), "existing_count": len(existing_items), "total": len(DEFAULT_TEMPLATES), + "template_names": [item["name"] for item in created_items], } def _require_template(self, current_user: Dict[str, Any], template_id: str) -> ContractTemplate: - contract_template = self.repository.get_by_id(template_id) + repository = self._require_repository() + contract_template = repository.get_by_id(template_id) if contract_template is None or contract_template.tenant_id != current_user["tenant_id"]: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="template not found") return contract_template - def _require_admin(self, current_user: Dict[str, Any]): + def _require_admin(self, current_user: Dict[str, Any]) -> None: if current_user.get("role") != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") + def _require_repository(self) -> ContractTemplateRepository: + if self.repository is None: + raise RuntimeError("template repository is not configured") + return self.repository + + def _require_version_repository(self) -> ContractTemplateVersionRepository: + if self.version_repository is None: + raise RuntimeError("template version repository is not configured") + return self.version_repository + def _parse_variables(self, variables_json: Optional[str]) -> Optional[list[dict[str, Any]]]: if not variables_json or not variables_json.strip(): return None @@ -205,17 +338,137 @@ class TemplateService: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="variables_json items must be objects") return parsed + def _create_version_snapshot( + self, + contract_template: ContractTemplate, + current_user: Dict[str, Any], + change_summary: str, + *, + is_current: bool, + ) -> ContractTemplateVersion: + version_repository = self._require_version_repository() + version_repository.clear_current(contract_template.tenant_id, contract_template.id) + version = ContractTemplateVersion( + id=f"tplver-{uuid4().hex[:12]}", + template_id=contract_template.id, + tenant_id=contract_template.tenant_id, + version_label=contract_template.current_version_label or "v1.0", + change_summary=change_summary, + name=contract_template.name, + contract_type=contract_template.contract_type, + template_category=contract_template.template_category, + industry_category=contract_template.industry_category, + description=contract_template.description, + content=contract_template.content, + variables_json=contract_template.variables_json, + is_current=is_current, + created_by=current_user.get("id"), + created_by_name=current_user.get("display_name"), + created_at=contract_template.latest_version_created_at or self._now(), + ) + return version_repository.create(version) + + def _build_fallback_version(self, contract_template: ContractTemplate) -> ContractTemplateVersion: + return ContractTemplateVersion( + id=f"fallback-{contract_template.id}", + template_id=contract_template.id, + tenant_id=contract_template.tenant_id, + version_label=contract_template.current_version_label or "v1.0", + change_summary=contract_template.latest_change_summary, + name=contract_template.name, + contract_type=contract_template.contract_type, + template_category=contract_template.template_category, + industry_category=contract_template.industry_category, + description=contract_template.description, + content=contract_template.content, + variables_json=contract_template.variables_json, + is_current=True, + created_by=contract_template.updated_by or contract_template.created_by, + created_by_name=None, + created_at=contract_template.latest_version_created_at or contract_template.updated_at or contract_template.created_at, + ) + + def _normalize_change_summary(self, value: Optional[str], *, default: str) -> str: + normalized = value.strip() if value else "" + return normalized or default + + def _normalize_template_category(self, template_category: Optional[str], contract_type: Optional[str]) -> str: + normalized = template_category.strip() if template_category else "" + if normalized: + return normalized + return { + "labor": "employment-hr", + "procurement": "procurement-supply", + "sales": "sales-commercial", + "service": "service-delivery", + "nda": "commercial-compliance", + }.get((contract_type or "").strip().lower(), "general-business") + + def _normalize_industry_category(self, industry_category: Optional[str], contract_type: Optional[str]) -> str: + normalized = industry_category.strip() if industry_category else "" + if normalized: + return normalized + return { + "labor": "general-enterprise", + "procurement": "general-enterprise", + "sales": "general-enterprise", + "service": "professional-services", + "nda": "general-enterprise", + }.get((contract_type or "").strip().lower(), "general-enterprise") + + def _bump_version_label(self, current_version_label: Optional[str]) -> str: + if not current_version_label: + return "v1.1" + match = re.match(r"^v?(?P\d+)\.(?P\d+)$", current_version_label.strip(), re.IGNORECASE) + if not match: + return "v1.1" + major = int(match.group("major")) + minor = int(match.group("minor")) + 1 + return f"v{major}.{minor}" + + def _now(self) -> datetime: + return datetime.now(timezone.utc) + def _to_item(self, contract_template: ContractTemplate) -> dict: return { "id": contract_template.id, "name": contract_template.name, "contract_type": contract_template.contract_type, + "template_category": contract_template.template_category, + "industry_category": contract_template.industry_category, "description": contract_template.description, "content": contract_template.content, "variables": contract_template.variables_json or [], "enabled": contract_template.enabled, + "current_version_label": contract_template.current_version_label or "v1.0", + "latest_change_summary": contract_template.latest_change_summary, + "latest_version_created_at": ( + contract_template.latest_version_created_at.isoformat() + if contract_template.latest_version_created_at is not None + else None + ), + "version_count": max(contract_template.version_count or 0, 1), "created_by": contract_template.created_by, "updated_by": contract_template.updated_by, "created_at": contract_template.created_at.isoformat(), "updated_at": contract_template.updated_at.isoformat(), } + + def _to_version_item(self, version: ContractTemplateVersion) -> dict: + return { + "id": version.id, + "template_id": version.template_id, + "version_label": version.version_label, + "change_summary": version.change_summary, + "name": version.name, + "contract_type": version.contract_type, + "template_category": version.template_category, + "industry_category": version.industry_category, + "description": version.description, + "content": version.content, + "variables": version.variables_json or [], + "is_current": version.is_current, + "created_by": version.created_by, + "created_by_name": version.created_by_name, + "created_at": version.created_at.isoformat(), + } diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index ba1b47fd..86e3ae47 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -4,6 +4,7 @@ from uuid import uuid4 from fastapi import HTTPException, status from sqlalchemy.orm import Session +from app.core.roles import ROLE_ADMIN, normalize_user_role from app.core.security import get_password_hash from app.models.user import User from app.repositories.user_repository import UserRepository @@ -84,14 +85,14 @@ class UserService: return user def _require_admin(self, current_user: Dict[str, Any]): - if current_user.get("role") != "admin": + if current_user.get("role") != ROLE_ADMIN: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") def _normalize_role(self, role: str) -> str: - normalized_role = role.strip().lower() - if normalized_role not in {"admin", "member"}: + try: + return normalize_user_role(role) + except ValueError: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid role") - return normalized_role def _normalize_status(self, status_value: str) -> str: normalized_status = status_value.strip().lower() diff --git a/backend/migrations/versions/20260512_0016_create_contract_template_versions.py b/backend/migrations/versions/20260512_0016_create_contract_template_versions.py new file mode 100644 index 00000000..10bec9df --- /dev/null +++ b/backend/migrations/versions/20260512_0016_create_contract_template_versions.py @@ -0,0 +1,196 @@ +"""create contract template versions""" + +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone + +from alembic import op +import sqlalchemy as sa + + +revision = "20260512_0016" +down_revision = "20260502_0015" +branch_labels = None +depends_on = None + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _column_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {column["name"] for column in inspector.get_columns(table_name)} + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _ensure_contract_template_columns(inspector: sa.Inspector) -> None: + columns = _column_names(inspector, "contract_templates") + if "current_version_label" not in columns: + op.add_column("contract_templates", sa.Column("current_version_label", sa.String(length=32), nullable=True)) + if "latest_change_summary" not in columns: + op.add_column("contract_templates", sa.Column("latest_change_summary", sa.String(length=500), nullable=True)) + if "latest_version_created_at" not in columns: + op.add_column("contract_templates", sa.Column("latest_version_created_at", sa.DateTime(timezone=True), nullable=True)) + if "version_count" not in columns: + op.add_column("contract_templates", sa.Column("version_count", sa.Integer(), nullable=True)) + + +def _ensure_contract_template_versions_table(inspector: sa.Inspector) -> None: + if "contract_template_versions" not in _table_names(inspector): + op.create_table( + "contract_template_versions", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("template_id", sa.String(length=64), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("version_label", sa.String(length=32), nullable=False), + sa.Column("change_summary", sa.String(length=500), nullable=True), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("contract_type", sa.String(length=50), nullable=True), + sa.Column("description", sa.String(length=500), nullable=True), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("variables_json", sa.JSON(), nullable=True), + sa.Column("is_current", sa.Boolean(), nullable=False), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("created_by_name", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["template_id"], ["contract_templates.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + inspector = sa.inspect(op.get_bind()) + indexes = _index_names(inspector, "contract_template_versions") + if "ix_contract_template_versions_template_id" not in indexes: + op.create_index("ix_contract_template_versions_template_id", "contract_template_versions", ["template_id"], unique=False) + if "ix_contract_template_versions_tenant_template" not in indexes: + op.create_index( + "ix_contract_template_versions_tenant_template", + "contract_template_versions", + ["tenant_id", "template_id"], + unique=False, + ) + + +def _backfill_template_version_metadata() -> None: + bind = op.get_bind() + now = datetime.now(timezone.utc) + bind.execute( + sa.text( + """ + UPDATE contract_templates + SET current_version_label = COALESCE(current_version_label, 'v1.0'), + latest_change_summary = COALESCE(latest_change_summary, '历史导入基线'), + latest_version_created_at = COALESCE(latest_version_created_at, updated_at, created_at, :now_value), + version_count = COALESCE(version_count, 1) + """ + ), + {"now_value": now}, + ) + + template_rows = bind.execute( + sa.text( + """ + SELECT id, tenant_id, name, contract_type, description, content, variables_json, + current_version_label, latest_change_summary, latest_version_created_at, + created_by, updated_by + FROM contract_templates + """ + ) + ).mappings().all() + + for row in template_rows: + existing = bind.execute( + sa.text("SELECT 1 FROM contract_template_versions WHERE template_id = :template_id LIMIT 1"), + {"template_id": row["id"]}, + ).first() + if existing: + continue + + hash_suffix = hashlib.sha1(str(row["id"]).encode("utf-8")).hexdigest()[:12] + bind.execute( + sa.text( + """ + INSERT INTO contract_template_versions ( + id, + template_id, + tenant_id, + version_label, + change_summary, + name, + contract_type, + description, + content, + variables_json, + is_current, + created_by, + created_by_name, + created_at + ) VALUES ( + :id, + :template_id, + :tenant_id, + :version_label, + :change_summary, + :name, + :contract_type, + :description, + :content, + :variables_json, + :is_current, + :created_by, + :created_by_name, + :created_at + ) + """ + ), + { + "id": f"tplver-{hash_suffix}", + "template_id": row["id"], + "tenant_id": row["tenant_id"], + "version_label": row["current_version_label"] or "v1.0", + "change_summary": row["latest_change_summary"] or "历史导入基线", + "name": row["name"], + "contract_type": row["contract_type"], + "description": row["description"], + "content": row["content"], + "variables_json": row["variables_json"], + "is_current": True, + "created_by": row["updated_by"] or row["created_by"], + "created_by_name": None, + "created_at": row["latest_version_created_at"] or now, + }, + ) + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + _ensure_contract_template_columns(inspector) + inspector = sa.inspect(op.get_bind()) + _ensure_contract_template_versions_table(inspector) + _backfill_template_version_metadata() + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "contract_template_versions" in _table_names(inspector): + indexes = _index_names(inspector, "contract_template_versions") + if "ix_contract_template_versions_tenant_template" in indexes: + op.drop_index("ix_contract_template_versions_tenant_template", table_name="contract_template_versions") + if "ix_contract_template_versions_template_id" in indexes: + op.drop_index("ix_contract_template_versions_template_id", table_name="contract_template_versions") + op.drop_table("contract_template_versions") + + inspector = sa.inspect(bind) + columns = _column_names(inspector, "contract_templates") + if "version_count" in columns: + op.drop_column("contract_templates", "version_count") + if "latest_version_created_at" in columns: + op.drop_column("contract_templates", "latest_version_created_at") + if "latest_change_summary" in columns: + op.drop_column("contract_templates", "latest_change_summary") + if "current_version_label" in columns: + op.drop_column("contract_templates", "current_version_label") diff --git a/backend/migrations/versions/20260512_0017_add_template_taxonomy_fields.py b/backend/migrations/versions/20260512_0017_add_template_taxonomy_fields.py new file mode 100644 index 00000000..b7ced220 --- /dev/null +++ b/backend/migrations/versions/20260512_0017_add_template_taxonomy_fields.py @@ -0,0 +1,135 @@ +"""add template taxonomy fields""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260512_0017" +down_revision = "20260512_0016" +branch_labels = None +depends_on = None + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _column_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {column["name"] for column in inspector.get_columns(table_name)} + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _infer_template_category(contract_type: str | None) -> str: + mapping = { + "labor": "employment-hr", + "procurement": "procurement-supply", + "sales": "sales-commercial", + "service": "service-delivery", + "nda": "commercial-compliance", + } + return mapping.get((contract_type or "").strip().lower(), "general-business") + + +def _infer_industry_category(contract_type: str | None) -> str: + mapping = { + "labor": "general-enterprise", + "procurement": "general-enterprise", + "sales": "general-enterprise", + "service": "professional-services", + "nda": "general-enterprise", + } + return mapping.get((contract_type or "").strip().lower(), "general-enterprise") + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + template_columns = _column_names(inspector, "contract_templates") + if "template_category" not in template_columns: + op.add_column("contract_templates", sa.Column("template_category", sa.String(length=64), nullable=True)) + if "industry_category" not in template_columns: + op.add_column("contract_templates", sa.Column("industry_category", sa.String(length=64), nullable=True)) + + inspector = sa.inspect(bind) + indexes = _index_names(inspector, "contract_templates") + if "ix_contract_templates_template_category" not in indexes: + op.create_index("ix_contract_templates_template_category", "contract_templates", ["template_category"], unique=False) + if "ix_contract_templates_industry_category" not in indexes: + op.create_index("ix_contract_templates_industry_category", "contract_templates", ["industry_category"], unique=False) + + if "contract_template_versions" in _table_names(inspector): + version_columns = _column_names(inspector, "contract_template_versions") + if "template_category" not in version_columns: + op.add_column("contract_template_versions", sa.Column("template_category", sa.String(length=64), nullable=True)) + if "industry_category" not in version_columns: + op.add_column("contract_template_versions", sa.Column("industry_category", sa.String(length=64), nullable=True)) + + template_rows = bind.execute( + sa.text("SELECT id, contract_type FROM contract_templates") + ).mappings().all() + for row in template_rows: + bind.execute( + sa.text( + """ + UPDATE contract_templates + SET template_category = COALESCE(template_category, :template_category), + industry_category = COALESCE(industry_category, :industry_category) + WHERE id = :template_id + """ + ), + { + "template_id": row["id"], + "template_category": _infer_template_category(row["contract_type"]), + "industry_category": _infer_industry_category(row["contract_type"]), + }, + ) + + if "contract_template_versions" in _table_names(sa.inspect(bind)): + version_rows = bind.execute( + sa.text("SELECT id, contract_type FROM contract_template_versions") + ).mappings().all() + for row in version_rows: + bind.execute( + sa.text( + """ + UPDATE contract_template_versions + SET template_category = COALESCE(template_category, :template_category), + industry_category = COALESCE(industry_category, :industry_category) + WHERE id = :version_id + """ + ), + { + "version_id": row["id"], + "template_category": _infer_template_category(row["contract_type"]), + "industry_category": _infer_industry_category(row["contract_type"]), + }, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if "contract_template_versions" in _table_names(inspector): + version_columns = _column_names(inspector, "contract_template_versions") + if "industry_category" in version_columns: + op.drop_column("contract_template_versions", "industry_category") + if "template_category" in version_columns: + op.drop_column("contract_template_versions", "template_category") + + inspector = sa.inspect(bind) + if "contract_templates" in _table_names(inspector): + indexes = _index_names(inspector, "contract_templates") + if "ix_contract_templates_industry_category" in indexes: + op.drop_index("ix_contract_templates_industry_category", table_name="contract_templates") + if "ix_contract_templates_template_category" in indexes: + op.drop_index("ix_contract_templates_template_category", table_name="contract_templates") + columns = _column_names(inspector, "contract_templates") + if "industry_category" in columns: + op.drop_column("contract_templates", "industry_category") + if "template_category" in columns: + op.drop_column("contract_templates", "template_category") diff --git a/backend/tests/test_auth_service.py b/backend/tests/test_auth_service.py index 3727820b..174f9a5a 100644 --- a/backend/tests/test_auth_service.py +++ b/backend/tests/test_auth_service.py @@ -70,12 +70,12 @@ class AuthServiceTestCase(unittest.TestCase): def tearDown(self): AuthService.register.__globals__["TenantService"] = self.original_tenant_service - def test_register_creates_member_user(self): + def test_register_creates_business_user(self): result = self.service.register("member@example.com", "123456", "测试成员") self.assertEqual(result["username"], "member@example.com") self.assertEqual(result["display_name"], "测试成员") - self.assertEqual(result["role"], "member") + self.assertEqual(result["role"], "business") self.assertIsNotNone(self.service.repository.get_by_username("member@example.com")) self.assertEqual(self.stub_tenant_service.ensure_default_tenant_calls, 1) self.assertEqual(self.service.audit_log_service.events[0]["action"], "register") @@ -137,6 +137,27 @@ class AuthServiceTestCase(unittest.TestCase): self.assertEqual(relogin["user"]["role"], "admin") self.assertEqual(relogin["user"]["status"], "active") + def test_seed_default_admin_does_not_reset_existing_production_password(self): + settings = AuthService.seed_default_admin.__globals__["settings"] + original_app_env = settings.app_env + try: + settings.app_env = "production" + self.service.repository.user.password_hash = get_password_hash("rotated-password") + self.service.repository.user.status = "disabled" + self.service.repository.user.role = "member" + + result = self.service.seed_default_admin() + + self.assertEqual(result["username"], "admin@example.com") + self.assertFalse(result["created"]) + self.assertFalse(result["password_reset"]) + self.assertIsNone(result["password"]) + relogin = self.service.login("admin@example.com", "rotated-password") + self.assertEqual(relogin["user"]["role"], "admin") + self.assertEqual(relogin["user"]["status"], "active") + finally: + settings.app_env = original_app_env + def test_update_current_user_updates_display_name_and_password(self): previous_password_hash = self.service.repository.user.password_hash authorization = f"Bearer {create_access_token('user-admin')}" diff --git a/backend/tests/test_chat_api.py b/backend/tests/test_chat_api.py index 064fe8f8..9bde59f6 100644 --- a/backend/tests/test_chat_api.py +++ b/backend/tests/test_chat_api.py @@ -1,6 +1,5 @@ import json import unittest -from unittest.mock import Mock from fastapi import HTTPException from fastapi.testclient import TestClient @@ -9,7 +8,6 @@ from app.api.chat import get_shared_ai_platform_client from app.api.conversations import get_shared_ai_platform_client as get_conversations_shared_ai_platform_client from app.api.reviews import require_current_user from app.main import app -from app.services.chat_service import ChatService class StubChatService: @@ -67,82 +65,6 @@ class StubChatService: } -class ChatServiceUnitTestCase(unittest.TestCase): - def setUp(self): - self.service = ChatService(Mock()) - self.service.conversation_repository = Mock() - self.service.message_repository = Mock() - self.service.rag_service = Mock() - - def test_build_title_prefers_review_context(self): - title = self.service._build_title( - "继续分析这个条款", - {"metadata": {"reviewTitle": "采购合同审查", "focusLabel": "自动续约"}}, - ) - - self.assertEqual(title, "采购合同审查 · 自动续约") - - def test_build_effective_query_includes_review_context(self): - query = self.service._build_effective_query( - "请继续解释", - { - "metadata": { - "reviewTitle": "采购合同审查", - "contractType": "采购合同", - "overallRisk": "high", - "reviewOpinion": "存在自动续约风险", - "focusLabel": "自动续约", - "actionLabel": "解释风险", - } - }, - ) - - self.assertIn("采购合同审查", query) - self.assertIn("自动续约", query) - self.assertIn("解释风险", query) - self.assertTrue(query.endswith("请继续解释")) - - def test_build_default_chunks_uses_review_context(self): - chunks = self.service._build_default_chunks( - "请继续解释", - {"metadata": {"reviewTitle": "采购合同审查", "focusLabel": "自动续约", "actionLabel": "解释风险"}}, - ) - - self.assertIn("采购合同审查", chunks[0]) - self.assertIn("当前问题:请继续解释", chunks[1]) - self.assertIn("追问对象:自动续约", chunks[2]) - - def test_build_payload_passes_review_context_to_rag_service(self): - self.service.rag_service.search_relevant_documents.return_value = [ - {"id": "doc-1", "content": "自动续约风险", "file_name": "doc.txt", "score": 1.0} - ] - self.service.rag_service.build_chat_payload.return_value = { - "chunks": ["审查结论:\n继续分析"], - "sources": [], - } - - review_context = { - "reviewId": "review-123", - "metadata": { - "focusLabel": "自动续约", - "actionLabel": "解释风险", - "focusType": "risk_item", - }, - } - payload = self.service._build_payload( - {"tenant_id": "tenant-1", "id": "user-1"}, - "请继续解释", - review_context, - ) - - self.assertEqual(payload["chunks"][0], "审查结论:\n继续分析") - self.service.rag_service.build_chat_payload.assert_called_once_with( - unittest.mock.ANY, - self.service.rag_service.search_relevant_documents.return_value, - review_context=review_context, - ) - - class ChatApiTestCase(unittest.TestCase): def setUp(self): self.chat_service = StubChatService() diff --git a/backend/tests/test_contracts_api.py b/backend/tests/test_contracts_api.py index d07d6d4a..db810b27 100644 --- a/backend/tests/test_contracts_api.py +++ b/backend/tests/test_contracts_api.py @@ -17,6 +17,32 @@ class StubAuditLogService: class StubContractGenerationService: + def _auto_review_summary(self, review_id="review-auto-123", status="completed"): + return { + "review_id": review_id, + "review_title": "采购合同模板", + "status": status, + "review_as": "party_a", + "contract_type": "procurement", + "risk_level": "high", + "current_stage": "report", + "current_stage_label": "报告已生成", + "status_detail": "文本提取、规则审查与报告阶段已完成,可继续查看详情和导出报告。", + "failure_reason": None, + "next_action": "查看风险详情,必要时导出报告并继续进入审查助手追问。", + "can_retry": False, + "review_opinion": "检测到高风险条款", + "recommendation": "manual_review", + "source_file_id": "file-generated-123", + "source_file_name": "contract.pdf", + "source_text_length": 12, + "processing_time_ms": 1200, + "detail_url": f"/reviews/{review_id}", + "report_download_url": f"/api/v1/reviews/{review_id}/report/download", + "report_file_name": f"review-report-{review_id}.pdf", + "chat_url": f"/chat?review={review_id}", + } + def list_generated_contracts(self, current_user, page, page_size, contract_type=None, keyword=None): records = [ { @@ -90,6 +116,9 @@ class StubContractGenerationService: }, ] + for record in records: + record["auto_review"] = self._auto_review_summary(f"review-{record['id']}") + tenant_records = [record for record in records if record["tenant_id"] == current_user["tenant_id"]] if contract_type: tenant_records = [record for record in tenant_records if record["contract_type"] == contract_type] @@ -112,7 +141,7 @@ class StubContractGenerationService: "page_size": page_size, } - def list_available_templates(self, current_user, contract_type): + def list_available_templates(self, current_user, contract_type, template_category=None, industry_category=None): return [ { "id": "tpl-123", @@ -179,6 +208,7 @@ class StubContractGenerationService: "file_content_base64": "ZmlsZQ==", "preview_url": "/api/v1/contracts/contract-123/preview", "download_url": "/api/v1/contracts/contract-123/download", + "auto_review": self._auto_review_summary("review-auto-123"), "metadata": { "generated_by": current_user["id"], "generated_by_name": current_user.get("display_name", "管理员"), @@ -331,6 +361,8 @@ class ContractsApiTestCase(unittest.TestCase): self.assertEqual(body["data"]["export_format"], "pdf") self.assertEqual(body["data"]["variable_values"]["supplier_name"], "供应商A") self.assertEqual(body["data"]["metadata"]["export_options"], {"watermark": True}) + self.assertEqual(body["data"]["auto_review"]["status"], "completed") + self.assertEqual(body["data"]["auto_review"]["detail_url"], "/reviews/review-contract-123") self.assertIn("generated_content", body["data"]) def test_generated_contract_detail_is_tenant_isolated(self): @@ -369,6 +401,8 @@ class ContractsApiTestCase(unittest.TestCase): self.assertEqual(body["data"]["export_format"], "pdf") self.assertEqual(body["data"]["file_name"], "contract.pdf") self.assertEqual(body["data"]["metadata"]["export_options"], {"watermark": True}) + self.assertEqual(body["data"]["auto_review"]["status"], "completed") + self.assertEqual(body["data"]["auto_review"]["report_download_url"], "/api/v1/reviews/review-auto-123/report/download") self.assertEqual(self.audit_log_service.operations[0]["action"], "contract_generation") self.assertEqual(self.audit_log_service.operations[0]["resource_id"], "contract-123") @@ -526,6 +560,85 @@ class ContractGenerationServiceVariableParsingTestCase(unittest.TestCase): } }, )() + class StubReviewService: + def __init__(self): + self.create_calls = [] + + def create_review_task(self, **kwargs): + self.create_calls.append(kwargs) + return { + "id": "review-auto-generated", + "status": "completed", + "review_as": kwargs["review_as"], + "contract_type": kwargs["contract_type"], + } + + def get_review_detail(self, current_user, review_id): + return { + "id": review_id, + "title": "contract.docx", + "contract_type": "procurement", + "review_as": "party_a", + "status": "completed", + "source_type": "file", + "source_text_length": 12, + "source_text_preview": "采购方与供应商A签订合同", + "processing_time_ms": 1200, + "created_at": "2026-04-17T00:00:00+00:00", + "completed_at": "2026-04-17T00:00:01+00:00", + "error_message": None, + "current_stage": "report", + "current_stage_label": "报告已生成", + "status_detail": "文本提取、规则审查与报告阶段已完成,可继续查看详情和导出报告。", + "failure_reason": None, + "next_action": "查看风险详情,必要时导出报告并继续进入审查助手追问。", + "can_retry": False, + "workflow_state": { + "review_id": review_id, + "risk_level": "high", + "status": "completed", + "current_stage": "report", + }, + "ai_trace": { + "review_id": review_id, + "status": "completed", + "provider": "stub", + }, + "source_document": { + "document_id": "file-generated-123", + "file_name": "contract.docx", + "source_type": "file", + "parse_status": "completed", + "extraction_mode": "template_render", + "ocr_gate_status": "PASS", + "text_length": 12, + "preview": "采购方与供应商A签订合同", + }, + } + + def get_review_result(self, current_user, review_id): + return { + "basic_info": { + "contract_type": "procurement", + "party_a": "甲方", + "party_b": "乙方", + "amount": "待识别", + "term": "待识别", + }, + "risk_summary": {"high": 1, "medium": 0, "low": 0, "overall": "high"}, + "risk_items": [{"title": "付款条件建议明确", "level": "high", "description": "desc"}], + "review_opinion": "检测到高风险条款", + "recommendation": "manual_review", + } + + def get_review_report(self, current_user, review_id): + return { + "file_name": f"review-report-{review_id}.pdf", + "download_url": f"/api/v1/reviews/{review_id}/report/download", + } + + self.review_service = StubReviewService() + self.service.review_service = self.review_service self.ContractTemplate = ContractTemplate def tearDown(self): @@ -567,6 +680,9 @@ class ContractGenerationServiceVariableParsingTestCase(unittest.TestCase): ) self.assertEqual(result["template_id"], "tpl-real-json") self.assertEqual(result["variable_values"]["supplier_name"], "供应商A") + self.assertEqual(self.review_service.create_calls[0]["source_file_id"], "file-generated-123") + self.assertEqual(result["auto_review"]["status"], "completed") + self.assertEqual(result["auto_review"]["chat_url"].startswith("/chat?review="), True) def test_get_generated_contract_download_allows_owner_access_to_file_payload(self): generated_contract = type( diff --git a/backend/tests/test_files_api.py b/backend/tests/test_files_api.py index 5b7277bb..ae922257 100644 --- a/backend/tests/test_files_api.py +++ b/backend/tests/test_files_api.py @@ -1,5 +1,7 @@ import io +import tempfile import unittest +from pathlib import Path from datetime import datetime, timezone from fastapi import HTTPException, status @@ -421,6 +423,65 @@ class FileServiceStorageTestCase(unittest.TestCase): self.assertEqual(payload["content"], b"owner-payload") + def test_local_storage_backend_persists_downloads_and_deletes_files(self): + from app.services.file_service import FileService + + class InMemoryRepository: + def __init__(self): + self.created = None + self.deleted = None + + def create(self, file_record): + if file_record.created_at is None: + file_record.created_at = datetime.now(timezone.utc) + self.created = file_record + return file_record + + def get_by_id(self, file_id, tenant_id): + if self.created and self.created.id == file_id and self.created.tenant_id == tenant_id and self.created.status == "active": + return self.created + return None + + def soft_delete(self, file_record): + file_record.status = "deleted" + self.deleted = file_record + return file_record + + def list_by_type(self, tenant_id, file_type, page, page_size): + return [] + + def list_paginated(self, tenant_id, page, page_size): + return [] + + def count(self, tenant_id, file_type=None): + return 0 + + with tempfile.TemporaryDirectory() as temp_dir: + service = FileService(db=None) + service.repository = InMemoryRepository() + service.storage_backend = "local" + service.local_storage_dir = Path(temp_dir) + current_user = { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + upload = UploadFile(filename="nested/contract.txt", file=io.BytesIO(b"local-payload"), headers={"content-type": "text/plain"}) + + created = service.upload_file(current_user, upload, "document", "contracts") + object_path = service._resolve_local_object_path("contracts", service.repository.created.object_key) + + self.assertEqual(created["file_name"], "contract.txt") + self.assertTrue(object_path.exists()) + self.assertEqual(object_path.read_bytes(), b"local-payload") + + download = service.get_download_payload(current_user, service.repository.created.id) + self.assertEqual(download["content"], b"local-payload") + + service.delete_file(current_user, service.repository.created.id) + self.assertFalse(object_path.exists()) + self.assertEqual(service.repository.deleted.status, "deleted") + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_knowledge_base_service.py b/backend/tests/test_knowledge_base_service.py deleted file mode 100644 index 196a1227..00000000 --- a/backend/tests/test_knowledge_base_service.py +++ /dev/null @@ -1,128 +0,0 @@ -import unittest -from datetime import datetime, timezone - -from app.models.knowledge_base import KnowledgeBase -from app.models.knowledge_base_document import KnowledgeBaseDocument -from app.services.knowledge_base_service import KnowledgeBaseService - - -class StubKnowledgeBaseRepository: - def __init__(self): - self.items = {} - - def create(self, knowledge_base): - self.items[knowledge_base.id] = knowledge_base - return knowledge_base - - def get_by_id(self, knowledge_base_id): - return self.items.get(knowledge_base_id) - - def list_paginated(self, tenant_id, page, page_size): - return [item for item in self.items.values() if item.tenant_id == tenant_id] - - def count(self, tenant_id): - return len([item for item in self.items.values() if item.tenant_id == tenant_id]) - - -class StubKnowledgeBaseDocumentRepository: - def __init__(self): - self.items = {} - - def create(self, document): - if document.created_at is None: - document.created_at = datetime.now(timezone.utc) - self.items[document.id] = document - return document - - def update(self, document): - if document.created_at is None: - document.created_at = datetime.now(timezone.utc) - self.items[document.id] = document - return document - - def get_by_id(self, document_id, tenant_id=None, knowledge_base_id=None): - item = self.items.get(document_id) - if item is None: - return None - if tenant_id is not None and item.tenant_id != tenant_id: - return None - if knowledge_base_id is not None and item.knowledge_base_id != knowledge_base_id: - return None - return item - - def list_paginated(self, tenant_id, knowledge_base_id, page, page_size): - return [ - item - for item in self.items.values() - if item.tenant_id == tenant_id and item.knowledge_base_id == knowledge_base_id - ] - - def count(self, tenant_id, knowledge_base_id): - return len(self.list_paginated(tenant_id, knowledge_base_id, 1, 100)) - - def count_by_knowledge_base_ids(self, tenant_id, knowledge_base_ids): - return {knowledge_base_id: self.count(tenant_id, knowledge_base_id) for knowledge_base_id in knowledge_base_ids} - - -class KnowledgeBaseServiceTestCase(unittest.TestCase): - def setUp(self): - self.service = KnowledgeBaseService(db=None) - self.service.repository = StubKnowledgeBaseRepository() - self.service.document_repository = StubKnowledgeBaseDocumentRepository() - self.current_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"} - self.knowledge_base = KnowledgeBase( - id="kb-123", - tenant_id=self.current_user["tenant_id"], - name="合同知识库", - description="测试知识库", - status="active", - ) - self.service.repository.create(self.knowledge_base) - - def test_failed_document_retry_without_source_explains_reupload(self): - failed = self.service.upload_document( - self.current_user, - self.knowledge_base.id, - "empty.txt", - "text/plain", - b"", - ) - - retried = self.service.retry_document(self.current_user, self.knowledge_base.id, failed["id"]) - - self.assertEqual(retried["id"], failed["id"]) - self.assertEqual(retried["status"], "failed") - self.assertEqual(retried["parse_status"], "failed") - self.assertEqual(retried["index_status"], "blocked") - self.assertTrue(retried["retryable"]) - self.assertIn("重新上传", retried["error_message"]) - - def test_failed_document_retry_with_replacement_file_indexes_same_document(self): - failed = self.service.upload_document( - self.current_user, - self.knowledge_base.id, - "empty.txt", - "text/plain", - b"", - ) - - retried = self.service.retry_document( - self.current_user, - self.knowledge_base.id, - failed["id"], - file_name="rules-v2.txt", - mime_type="text/plain", - content="付款、保密和违约责任制度".encode("utf-8"), - ) - - self.assertEqual(retried["id"], failed["id"]) - self.assertEqual(retried["file_name"], "rules-v2.txt") - self.assertEqual(retried["status"], "indexed") - self.assertEqual(retried["parse_status"], "completed") - self.assertEqual(retried["index_status"], "completed") - self.assertFalse(retried["retryable"]) - self.assertIn("付款", retried["content_excerpt"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_legal_review_dual_run.py b/backend/tests/test_legal_review_dual_run.py new file mode 100644 index 00000000..01c3d6a4 --- /dev/null +++ b/backend/tests/test_legal_review_dual_run.py @@ -0,0 +1,53 @@ +import unittest + +from tools.legal_review_dual_run import compare_results + + +class LegalReviewDualRunTestCase(unittest.TestCase): + def test_compare_results_marks_match_when_key_fields_equal(self): + payload = { + "basic_info": {"contract_type": "采购合同"}, + "contract_structure": { + "detected_contract_type": "procurement", + "clause_count": 2, + "payment_milestones": [], + "clause_relationships": [], + }, + "risk_summary": {"overall": "high"}, + "risk_items": [{"title": "存在自动续约安排"}], + "recommendation": "manual_review", + "confidence_summary": {"structured_analysis": {"stage": "java-domain-pack-round2"}}, + } + + diff = compare_results("sample-001", payload, payload) + + self.assertEqual(diff["match_status"], "match") + self.assertEqual(diff["field_differences"], []) + self.assertEqual(diff["missing_fields"], []) + + def test_compare_results_marks_investigate_when_fields_missing(self): + java_payload = { + "basic_info": {"contract_type": "采购合同"}, + "contract_structure": {"detected_contract_type": "procurement", "clause_count": 2}, + "risk_summary": {"overall": "high"}, + "risk_items": [], + "recommendation": "manual_review", + "confidence_summary": {}, + } + python_payload = { + "basic_info": {"contract_type": "采购合同"}, + "contract_structure": {"detected_contract_type": "procurement", "clause_count": 2, "payment_milestones": [], "clause_relationships": []}, + "risk_summary": {"overall": "high"}, + "risk_items": [], + "recommendation": "manual_review", + "confidence_summary": {"structured_analysis": {"stage": "python-fallback"}}, + } + + diff = compare_results("sample-002", java_payload, python_payload) + + self.assertEqual(diff["match_status"], "investigate") + self.assertTrue(diff["missing_fields"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_pilot_request_service.py b/backend/tests/test_pilot_request_service.py new file mode 100644 index 00000000..6fdaf17d --- /dev/null +++ b/backend/tests/test_pilot_request_service.py @@ -0,0 +1,105 @@ +import unittest + +from fastapi import HTTPException, status +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.db import Base +from app.services.auth_service import DEFAULT_TENANT_ID +from app.services.pilot_request_service import PilotRequestService + + +class PilotRequestServiceTestCase(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=self.engine) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) + self.db = self.SessionLocal() + self.service = PilotRequestService(self.db) + self.admin_user = {"id": "user-admin", "tenant_id": DEFAULT_TENANT_ID, "role": "admin"} + self.member_user = {"id": "user-member", "tenant_id": DEFAULT_TENANT_ID, "role": "member"} + + def tearDown(self): + self.db.close() + self.engine.dispose() + + def test_submit_list_summary_and_update_form_closed_loop(self): + created = self.service.submit_request( + contact_name="张三", + company_name="示例企业", + role_title="法务负责人", + email="pilot@example.com", + phone="13800000000", + expected_contract_volume="每月 300 份", + primary_goal="contract_review", + deployment_preference="saas", + scenario_description="希望先验证合同审查和报告输出的试点能力。", + ) + + self.assertEqual(created["status"], "new") + self.assertEqual(created["tenant_id"], DEFAULT_TENANT_ID) + + listing = self.service.list_requests(self.admin_user, page=1, page_size=20) + self.assertEqual(listing["total"], 1) + self.assertEqual(listing["items"][0]["company_name"], "示例企业") + + summary = self.service.get_summary(self.admin_user) + self.assertEqual(summary["total"], 1) + self.assertEqual(summary["counts"]["new"], 1) + self.assertEqual(summary["latest_items"][0]["email"], "pilot@example.com") + + updated = self.service.update_request( + self.admin_user, + created["id"], + status_value="scheduled", + admin_notes="已约下周演示。", + ) + self.assertEqual(updated["status"], "scheduled") + self.assertEqual(updated["admin_notes"], "已约下周演示。") + self.assertIsNotNone(updated["last_contact_at"]) + + refreshed_summary = self.service.get_summary(self.admin_user) + self.assertEqual(refreshed_summary["counts"]["new"], 0) + self.assertEqual(refreshed_summary["counts"]["scheduled"], 1) + + def test_member_cannot_access_admin_operations(self): + self.service.submit_request( + contact_name="李四", + company_name="成员企业", + role_title=None, + email="member@example.com", + phone=None, + expected_contract_volume=None, + primary_goal="pilot_delivery", + deployment_preference="undecided", + scenario_description="想了解是否适合小范围试点。", + ) + + with self.assertRaises(HTTPException) as list_ctx: + self.service.list_requests(self.member_user) + self.assertEqual(list_ctx.exception.status_code, status.HTTP_403_FORBIDDEN) + + with self.assertRaises(HTTPException) as summary_ctx: + self.service.get_summary(self.member_user) + self.assertEqual(summary_ctx.exception.status_code, status.HTTP_403_FORBIDDEN) + + def test_invalid_status_rejected(self): + created = self.service.submit_request( + contact_name="王五", + company_name="异常企业", + role_title=None, + email="invalid@example.com", + phone=None, + expected_contract_volume=None, + primary_goal="knowledge_base", + deployment_preference="private", + scenario_description="关注知识库和私有化部署能力。", + ) + + with self.assertRaises(HTTPException) as ctx: + self.service.update_request(self.admin_user, created["id"], status_value="unknown", admin_notes=None) + self.assertEqual(ctx.exception.status_code, status.HTTP_400_BAD_REQUEST) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_pilot_requests_api.py b/backend/tests/test_pilot_requests_api.py new file mode 100644 index 00000000..6b28ea45 --- /dev/null +++ b/backend/tests/test_pilot_requests_api.py @@ -0,0 +1,165 @@ +import unittest + +from fastapi.testclient import TestClient + +from app.api.pilot_requests import get_pilot_request_service, require_current_user +from app.main import app + + +class StubPilotRequestService: + def submit_request(self, **kwargs): + return { + "id": "pilot-123", + "tenant_id": "tenant-demo-001", + "contact_name": kwargs["contact_name"], + "company_name": kwargs["company_name"], + "role_title": kwargs.get("role_title"), + "email": kwargs["email"], + "phone": kwargs.get("phone"), + "expected_contract_volume": kwargs.get("expected_contract_volume"), + "primary_goal": kwargs["primary_goal"], + "deployment_preference": kwargs.get("deployment_preference"), + "scenario_description": kwargs["scenario_description"], + "status": "new", + "source": "website", + "admin_notes": None, + "last_contact_at": None, + "created_at": "2026-05-12T00:00:00+00:00", + "updated_at": "2026-05-12T00:00:00+00:00", + } + + def list_requests(self, current_user, page=1, page_size=20, status_value=None): + return { + "items": [ + { + "id": "pilot-123", + "tenant_id": current_user["tenant_id"], + "contact_name": "张三", + "company_name": "示例企业", + "role_title": "法务负责人", + "email": "pilot@example.com", + "phone": "13800000000", + "expected_contract_volume": "每月 300 份", + "primary_goal": "contract_review", + "deployment_preference": "saas", + "scenario_description": "试点合同审查", + "status": status_value or "new", + "source": "website", + "admin_notes": None, + "last_contact_at": None, + "created_at": "2026-05-12T00:00:00+00:00", + "updated_at": "2026-05-12T00:00:00+00:00", + } + ], + "total": 1, + "page": page, + "page_size": page_size, + } + + def get_summary(self, current_user): + return { + "total": 1, + "counts": {"new": 1, "contacted": 0, "scheduled": 0, "closed": 0}, + "latest_items": [ + { + "id": "pilot-123", + "tenant_id": current_user["tenant_id"], + "contact_name": "张三", + "company_name": "示例企业", + "role_title": "法务负责人", + "email": "pilot@example.com", + "phone": "13800000000", + "expected_contract_volume": "每月 300 份", + "primary_goal": "contract_review", + "deployment_preference": "saas", + "scenario_description": "试点合同审查", + "status": "new", + "source": "website", + "admin_notes": None, + "last_contact_at": None, + "created_at": "2026-05-12T00:00:00+00:00", + "updated_at": "2026-05-12T00:00:00+00:00", + } + ], + } + + def update_request(self, current_user, request_id, status_value, admin_notes=None): + return { + "id": request_id, + "tenant_id": current_user["tenant_id"], + "contact_name": "张三", + "company_name": "示例企业", + "role_title": "法务负责人", + "email": "pilot@example.com", + "phone": "13800000000", + "expected_contract_volume": "每月 300 份", + "primary_goal": "contract_review", + "deployment_preference": "saas", + "scenario_description": "试点合同审查", + "status": status_value, + "source": "website", + "admin_notes": admin_notes, + "last_contact_at": "2026-05-12T01:00:00+00:00", + "created_at": "2026-05-12T00:00:00+00:00", + "updated_at": "2026-05-12T01:00:00+00:00", + } + + +class PilotRequestsApiTestCase(unittest.TestCase): + def setUp(self): + app.dependency_overrides[get_pilot_request_service] = lambda: StubPilotRequestService() + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def test_submit_pilot_request(self): + response = self.client.post( + "/api/v1/pilot-requests", + json={ + "contact_name": "张三", + "company_name": "示例企业", + "role_title": "法务负责人", + "email": "pilot@example.com", + "phone": "13800000000", + "expected_contract_volume": "每月 300 份", + "primary_goal": "contract_review", + "deployment_preference": "saas", + "scenario_description": "希望先验证合同审查和报告输出。", + }, + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["code"], 0) + self.assertEqual(body["data"]["id"], "pilot-123") + self.assertEqual(body["data"]["status"], "new") + + def test_list_and_summary_pilot_requests(self): + list_response = self.client.get("/api/v1/pilot-requests", params={"status_value": "new"}) + summary_response = self.client.get("/api/v1/pilot-requests/summary") + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.json()["data"]["total"], 1) + self.assertEqual(summary_response.status_code, 200) + self.assertEqual(summary_response.json()["data"]["counts"]["new"], 1) + + def test_update_pilot_request(self): + response = self.client.put( + "/api/v1/pilot-requests/pilot-123", + json={"status": "scheduled", "admin_notes": "已约演示"}, + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["data"]["status"], "scheduled") + self.assertEqual(body["data"]["admin_notes"], "已约演示") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_rag_service.py b/backend/tests/test_rag_service.py deleted file mode 100644 index 8e3919a0..00000000 --- a/backend/tests/test_rag_service.py +++ /dev/null @@ -1,1645 +0,0 @@ -import unittest -from unittest.mock import Mock, MagicMock -from datetime import datetime, timezone - -from app.services.rag_service import RAGService -from app.models.knowledge_base_document import KnowledgeBaseDocument - - -class TestKnowledgeBaseDocument: - """模拟KnowledgeBaseDocument类""" - def __init__(self, id, knowledge_base_id, tenant_id, file_name, content_excerpt, created_at=None): - self.id = id - self.knowledge_base_id = knowledge_base_id - self.tenant_id = tenant_id - self.file_name = file_name - self.content_excerpt = content_excerpt - self.created_at = created_at or datetime.now(timezone.utc) - - -class RAGServiceTestCase(unittest.TestCase): - def setUp(self): - self.mock_db = Mock() - self.service = RAGService(self.mock_db) - - def test_extract_keywords(self): - """测试关键词提取""" - text = "这是一个关于合同违约金的测试文档" - keywords = self.service._extract_keywords(text) - - # 当前简单分词实现会将中文按字符分割 - # 应该提取出有意义的单字关键词(过滤停用词后) - self.assertIn("合", keywords) - self.assertIn("同", keywords) - self.assertIn("违", keywords) - self.assertIn("约", keywords) - self.assertIn("金", keywords) - self.assertIn("测", keywords) - self.assertIn("试", keywords) - self.assertIn("文", keywords) - self.assertIn("档", keywords) - - # 不应该包含停用词 - self.assertNotIn("这", keywords) - self.assertNotIn("是", keywords) - self.assertNotIn("一", keywords) - self.assertNotIn("个", keywords) - self.assertNotIn("关", keywords) - self.assertNotIn("于", keywords) - - def test_calculate_similarity(self): - """测试相似度计算""" - query_keywords = ["合同", "违约金", "条款"] - document_text = "本合同包含违约金条款,需要仔细审查违约责任部分。" - - score = self.service._calculate_similarity(query_keywords, document_text) - - # 应该有正分数 - self.assertGreater(score, 0) - - # 测试无匹配的情况 - no_match_keywords = ["发票", "付款"] - no_match_score = self.service._calculate_similarity(no_match_keywords, document_text) - self.assertEqual(no_match_score, 0) - - def test_build_context_from_results(self): - """测试上下文构建""" - search_results = [ - { - "id": "doc-1", - "knowledge_base_id": "kb-1", - "file_name": "合同范本.docx", - "content": "本合同违约金条款约定,违约方需支付合同总金额的20%作为违约金。", - "score": 0.8, - "created_at": "2026-04-16T10:00:00+00:00" - }, - { - "id": "doc-2", - "knowledge_base_id": "kb-1", - "file_name": "法律条款.pdf", - "content": "根据民法典规定,违约金不得超过实际损失的30%。", - "score": 0.6, - "created_at": "2026-04-16T11:00:00+00:00" - } - ] - - context, sources = self.service.build_context_from_results(search_results) - - # 应该构建出上下文 - self.assertIsNotNone(context) - self.assertIn("[文档1]", context) - self.assertIn("[文档2]", context) - - # 应该包含来源 - self.assertEqual(len(sources), 2) - self.assertEqual(sources[0]["title"], "文档: 合同范本.docx") - self.assertEqual(sources[1]["title"], "文档: 法律条款.pdf") - - def test_generate_answer_with_context(self): - """测试回答生成""" - query = "违约金的比例应该是多少?" - context = "[文档1] 本合同违约金条款约定,违约方需支付合同总金额的20%作为违约金。\n\n[文档2] 根据民法典规定,违约金不得超过实际损失的30%。" - - answer = self.service.generate_answer_with_context(query, context) - - # 应该生成包含上下文的回答 - self.assertIsNotNone(answer) - self.assertIn("根据知识库中的相关文档", answer) - self.assertIn("[文档1]", answer) - self.assertIn("[文档2]", answer) - - def test_generate_answer_without_context(self): - """测试无上下文时的回答生成""" - query = "违约金的比例应该是多少?" - context = "" - - answer = self.service.generate_answer_with_context(query, context) - - # 应该返回默认回答 - self.assertIn("根据现有知识库,我暂时无法回答这个问题", answer) - - def test_search_relevant_documents_no_documents(self): - """测试无文档时的搜索""" - # 模拟repository返回空列表 - self.service.document_repository.list_all_for_tenant = Mock(return_value=[]) - - results = self.service.search_relevant_documents("tenant-1", "测试查询") - - self.assertEqual(len(results), 0) - - def test_search_relevant_documents_with_matching(self): - """测试有匹配文档时的搜索""" - mock_doc1 = TestKnowledgeBaseDocument( - id="doc-1", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name="合同文档.docx", - content_excerpt="本合同包含违约金条款和免责条款。付款节点为验收后十个工作日。" - ) - - mock_doc2 = TestKnowledgeBaseDocument( - id="doc-2", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name="法律指南.pdf", - content_excerpt="关于发票和付款的相关规定。" - ) - - self.service.document_repository.list_all_for_tenant = Mock(return_value=[mock_doc1, mock_doc2]) - - results = self.service.search_relevant_documents("tenant-1", "违约金条款") - - self.assertGreaterEqual(len(results), 1) - if len(results) > 0: - self.assertEqual(results[0]["id"], "doc-1") - self.assertGreater(results[0]["score"], 0) - if len(results) > 1: - self.assertLess(results[1]["score"], results[0]["score"]) - - def test_build_chat_payload_with_results(self): - search_results = [ - { - "id": "doc-1", - "knowledge_base_id": "kb-1", - "file_name": "合同范本.docx", - "content": "本合同违约金条款约定,违约方需支付合同总金额的20%作为违约金。", - "score": 1.2, - "created_at": "2026-04-16T10:00:00+00:00", - } - ] - - payload = self.service.build_chat_payload("违约金怎么约定", search_results) - - self.assertGreaterEqual(len(payload["chunks"]), 3) - self.assertIn("审查结论", payload["chunks"][0]) - self.assertIn("重点摘要", payload["chunks"][1]) - self.assertIn("建议核查点", payload["chunks"][2]) - self.assertIn("优先处理项", payload["chunks"][3]) - self.assertIn("修改建议", payload["chunks"][4]) - self.assertIn("谈判建议", payload["chunks"][5]) - self.assertEqual(payload["sources"][0]["title"], "文档: 合同范本.docx") - self.assertIn("可参考来源", payload["sources"][0]["content"]) - - def test_build_chat_payload_without_results(self): - payload = self.service.build_chat_payload("续约条款怎么写", []) - - self.assertGreaterEqual(len(payload["chunks"]), 4) - self.assertIn("风险提示", payload["chunks"][0]) - self.assertIn("审查结论", payload["chunks"][1]) - self.assertIn("重点摘要", payload["chunks"][2]) - self.assertIn("建议核查点", payload["chunks"][3]) - self.assertIn("优先处理项", payload["chunks"][4]) - self.assertIn("修改建议", payload["chunks"][5]) - self.assertIn("谈判建议", payload["chunks"][6]) - self.assertEqual(payload["sources"][0]["title"], "审查要点:续约机制") - self.assertEqual(payload["sources"][1]["title"], "续约风险提示") - - def test_search_relevant_documents_splits_long_content(self): - long_content = "\n".join(["违约金条款需要重点核查。" for _ in range(80)]) - mock_doc = TestKnowledgeBaseDocument( - id="doc-long", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name="long.docx", - content_excerpt=long_content, - ) - self.service.document_repository.list_all_for_tenant = Mock(return_value=[mock_doc]) - - results = self.service.search_relevant_documents("tenant-1", "违约金条款", limit=5) - - self.assertGreaterEqual(len(results), 1) - self.assertTrue(all(len(item["content"]) <= 480 for item in results)) - self.assertTrue(all(item["file_name"] == "long.docx" for item in results)) - - def test_generate_answer_with_context_includes_topic_hint(self): - answer = self.service.generate_answer_with_context( - "违约条款要看什么", - "[文档1] 违约责任需明确。", - ) - - self.assertIn("建议重点关注", answer) - self.assertIn("违约责任", answer) - - def test_format_default_sources_contains_platform_hint(self): - sources = self.service.format_default_sources() - - self.assertEqual(sources[0]["title"], "平台提示") - self.assertIsNone(sources[0]["score"]) - - def test_format_answer_chunks_appends_summary(self): - chunks = self.service.format_answer_chunks("回答正文", ["关注违约责任", "关注付款节点"]) - - self.assertEqual(chunks[0], "回答正文") - self.assertIn("重点摘要", chunks[1]) - self.assertIn("关注付款节点", chunks[1]) - - def test_format_default_chunks_includes_topic_hint(self): - chunks = self.service.format_default_chunks("保密条款怎么审") - - self.assertIn("已收到你的问题", chunks[0]) - self.assertIn("建议关注", chunks[-1]) - self.assertIn("保密范围", chunks[-1]) - - def test_build_contract_review_payload_adds_contract_sections(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 违约责任需明确。", - "sources": [{"title": "文档: 合同.docx", "content": "违约责任需明确。", "score": 1.3}], - "summary": ["违约责任需明确"], - "review_topics": ["违约"], - } - - payload = self.service.build_contract_review_payload("违约条款怎么审", summary) - - self.assertIn("审查结论", payload["chunks"][0]) - self.assertIn("重点摘要", payload["chunks"][1]) - self.assertIn("建议核查点", payload["chunks"][2]) - self.assertIn("优先处理项", payload["chunks"][3]) - self.assertIn("修改建议", payload["chunks"][4]) - self.assertIn("谈判建议", payload["chunks"][5]) - self.assertIn("合同审查辅助建议", payload["chunks"][-1]) - - def test_build_contract_review_fallback_payload_uses_topic_sources(self): - payload = self.service.build_contract_review_fallback_payload("付款条款怎么审") - - self.assertEqual(payload["sources"][0]["title"], "审查要点:付款安排") - self.assertIn("风险提示", payload["chunks"][0]) - self.assertIn("建议核查点", payload["chunks"][3]) - - def test_detect_review_topics_defaults_to_general(self): - topics = self.service._detect_review_topics("随机聊天", []) - - self.assertEqual(topics, ["general"]) - - def test_build_focus_label_uses_general_when_needed(self): - label = self.service._build_focus_label(["general"]) - - self.assertEqual(label, "合同审查建议") - - def test_rewrite_answer_for_contract_review_changes_tone(self): - rewritten = self.service._rewrite_answer_for_contract_review( - "违约条款怎么审", - "根据知识库中的相关文档,我为您提供以下信息:\n\n请优先核对上述文档原文,再结合具体合同条款作出判断。", - ["违约"], - ) - - self.assertIn("当前更应从违约责任与违约金角度开展审查", rewritten) - self.assertIn("结合合同原文逐项比对", rewritten) - - def test_build_backup_sources_returns_default_when_unknown(self): - sources = self.service._build_backup_sources(["general"]) - - self.assertEqual(sources[0]["title"], "平台提示") - - def test_score_to_risk_label(self): - self.assertEqual(self.service._score_to_risk_label(None), "中风险") - self.assertEqual(self.service._score_to_risk_label(1.2), "高风险") - self.assertEqual(self.service._score_to_risk_label(0.7), "中风险") - self.assertEqual(self.service._score_to_risk_label(0.2), "低风险") - - def test_build_source_note_contains_focus_and_risk(self): - note = self.service._build_source_note({"score": 1.1}, ["违约"]) - - self.assertIn("违约责任与违约金", note) - self.assertIn("高风险", note) - - def test_truncate_source_content(self): - content = self.service._truncate_source_content("a" * 150) - - self.assertTrue(content.endswith("...")) - - def test_should_use_contract_review_style_for_contract_question(self): - self.assertTrue(self.service._should_use_contract_review_style("这个合同条款怎么审", [])) - - def test_should_use_contract_review_style_for_legal_result(self): - self.assertTrue(self.service._should_use_contract_review_style("普通问题", [{"content": "建议重点核查风险条款"}])) - - def test_decorate_sources_appends_backup_sources(self): - sources = self.service._decorate_sources( - [{"title": "文档: 合同.docx", "content": "违约责任需明确。", "score": 1.2}], - ["违约"], - ) - - self.assertGreaterEqual(len(sources), 2) - self.assertIn("可参考来源", sources[0]["content"]) - - def test_merge_review_summary_uses_general_fallback(self): - summary = self.service._merge_review_summary([], ["general"]) - - self.assertIn("先确认问题是否对应具体条款", summary[0]) - - def test_build_review_checklist_uses_general_fallback(self): - checklist = self.service._build_review_checklist(["general"]) - - self.assertIn("优先上传合同原文", "\n".join(checklist)) - - def test_normalize_analysis_sentence_truncates(self): - normalized = self.service._normalize_analysis_sentence("a" * 120) - - self.assertTrue(normalized.endswith("...")) - - def test_build_contract_headline(self): - headline = self.service._build_contract_headline("违约条款怎么审", ["违约"]) - - self.assertIn("违约责任与违约金", headline) - - def test_build_contract_headline_prefers_review_context_action(self): - headline = self.service._build_contract_headline( - "请继续解释", - ["general"], - {"metadata": {"focusLabel": "自动续约", "actionLabel": "解释风险"}}, - ) - - self.assertIn("自动续约", headline) - self.assertIn("解释风险", headline) - - def test_build_contract_review_payload_uses_review_context(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 自动续约需明确退出机制。", - "sources": [{"title": "文档: 合同.docx", "content": "自动续约需明确退出机制。", "score": 1.1}], - "summary": ["自动续约需明确退出机制"], - "review_topics": ["general"], - } - - payload = self.service.build_contract_review_payload( - "请继续解释这个风险项", - summary, - review_context={ - "metadata": { - "focusLabel": "自动续约", - "actionLabel": "解释风险", - "focusType": "risk_item", - "overallRisk": "high", - } - }, - ) - - self.assertIn("自动续约", payload["chunks"][0]) - self.assertIn("解释风险", payload["chunks"][0]) - self.assertIn("总体风险偏高", payload["chunks"][1]) - self.assertIn("自动续约", payload["sources"][0]["content"]) - self.assertIn("聚焦类型:风险项", payload["sources"][0]["content"]) - self.assertIn("为什么会触发当前风险判断", "\n".join(payload["chunks"])) - - def test_build_context_action_sections_prefers_revision_action(self): - sections = self.service._build_context_action_sections( - ["general"], - { - "metadata": { - "focusLabel": "付款节点", - "actionLabel": "给修改建议", - "focusType": "clause", - } - }, - ) - - self.assertIn("修订清单", sections["修改建议"][0]) - self.assertIn("付款节点对应的合同条款", sections["修改建议"][1]) - - def test_build_context_action_sections_prefers_negotiation_action(self): - sections = self.service._build_context_action_sections( - ["general"], - { - "metadata": { - "focusLabel": "免责条款", - "actionLabel": "给谈判建议", - "focusType": "clause", - } - }, - ) - - self.assertIn("谈判顺序", sections["谈判建议"][0]) - self.assertIn("免责条款对应的合同条款", sections["谈判建议"][1]) - - def test_build_topic_findings_for_general(self): - findings = self.service._build_topic_findings(["general"]) - - self.assertTrue(findings) - - def test_build_chat_payload_non_contract_question_uses_review_style_due_to_terms(self): - payload = self.service.build_chat_payload( - "风险怎么判断", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "建议重点核查违约责任。", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertIn("审查结论", payload["chunks"][0]) - - def test_build_backup_sources_limits_count(self): - sources = self.service._build_backup_sources(["违约", "付款", "续约"]) - - self.assertLessEqual(len(sources), 2) - - def test_decorated_source_content_keeps_original_excerpt(self): - sources = self.service._decorate_sources( - [{"title": "文档: 合同.docx", "content": "付款节点需明确。", "score": 0.8}], - ["付款"], - ) - - self.assertIn("付款节点需明确", sources[0]["content"]) - - def test_build_chat_payload_appends_disclaimer(self): - payload = self.service.build_chat_payload( - "违约条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "违约责任需明确。", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertIn("不替代正式法律意见", payload["chunks"][-1]) - - def test_build_contract_review_fallback_payload_appends_disclaimer(self): - payload = self.service.build_contract_review_fallback_payload("免责条款") - - self.assertIn("不替代正式法律意见", payload["chunks"][-1]) - - def test_build_source_note_for_none_score(self): - note = self.service._build_source_note({"score": None}, ["general"]) - - self.assertIn("中风险", note) - - def test_focus_label_dedupes_topics(self): - label = self.service._build_focus_label(["违约", "违约"]) - - self.assertEqual(label, "违约责任与违约金") - - def test_merge_review_summary_dedupes_items(self): - summary = self.service._merge_review_summary(["违约触发条件是否明确"], ["违约"]) - - self.assertEqual(len(summary), len(set(summary))) - - def test_build_review_checklist_dedupes_items(self): - checklist = self.service._build_review_checklist(["违约", "违约"]) - - self.assertEqual(len(checklist), len(set(checklist))) - - def test_should_use_contract_review_style_false_for_plain_text(self): - self.assertFalse(self.service._should_use_contract_review_style("hello world", [{"content": "plain text"}])) - - def test_decorate_sources_respects_snippet_limit(self): - long_content = "a" * 200 - sources = self.service._decorate_sources( - [{"title": "文档: 合同.docx", "content": long_content, "score": 1.0}], - ["general"], - ) - - self.assertIn("...", sources[0]["content"]) - - def test_build_contract_review_payload_uses_general_topic(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 一般内容。", - "sources": [{"title": "文档: 合同.docx", "content": "一般内容。", "score": 0.4}], - "summary": ["一般内容"], - "review_topics": ["general"], - } - - payload = self.service.build_contract_review_payload("随机问题", summary) - - self.assertIn("合同审查建议", payload["chunks"][0]) - - def test_build_contract_review_fallback_payload_uses_default_sources_for_general(self): - payload = self.service.build_contract_review_fallback_payload("随机问题") - - self.assertEqual(payload["sources"][0]["title"], "平台提示") - - def test_build_topic_findings_uses_first_checkpoint(self): - findings = self.service._build_topic_findings(["付款"]) - - self.assertIn("付款节点是否和验收挂钩", findings[0]) - - def test_rewrite_answer_for_contract_review_truncates_long_answer(self): - rewritten = self.service._rewrite_answer_for_contract_review("付款条款", "a" * 200, ["付款"]) - - self.assertIn("...", rewritten) - - def test_build_source_note_uses_general_focus(self): - note = self.service._build_source_note({"score": 0.3}, ["general"]) - - self.assertIn("合同审查建议", note) - - def test_build_chat_payload_review_style_includes_backup_sources(self): - payload = self.service.build_chat_payload( - "保密条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "保密义务需明确。", "score": 0.8, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertGreaterEqual(len(payload["sources"]), 2) - - def test_build_contract_review_fallback_payload_uses_general_checklist_when_needed(self): - payload = self.service.build_contract_review_fallback_payload("random text") - - self.assertIn("先确认问题是否对应具体条款", "\n".join(payload["chunks"])) - - def test_detect_review_topics_uses_search_results(self): - topics = self.service._detect_review_topics("随机问题", [{"content": "付款节点需要明确"}]) - - self.assertEqual(topics, ["付款"]) - - def test_decorated_sources_preserve_score(self): - sources = self.service._decorate_sources( - [{"title": "文档: 合同.docx", "content": "违约责任需明确。", "score": 1.2}], - ["违约"], - ) - - self.assertEqual(sources[0]["score"], 1.2) - - def test_build_contract_review_payload_summary_limit(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": 1.0}], - "summary": ["a", "b", "c", "d", "e"], - "review_topics": ["违约"], - } - - payload = self.service.build_contract_review_payload("违约条款", summary) - - bullet_lines = [line for line in payload["chunks"][1].splitlines() if line.startswith("-")] - self.assertLessEqual(len(bullet_lines), 4) - - def test_contract_review_payload_checklist_limit(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": 1.0}], - "summary": ["a"], - "review_topics": ["违约", "付款", "续约"], - } - - payload = self.service.build_contract_review_payload("综合条款", summary) - - bullet_lines = [line for line in payload["chunks"][2].splitlines() if line.startswith("-")] - self.assertLessEqual(len(bullet_lines), 6) - - def test_build_contract_review_fallback_payload_summary_limit(self): - payload = self.service.build_contract_review_fallback_payload("违约付款续约免责保密") - - bullet_lines = [line for line in payload["chunks"][2].splitlines() if line.startswith("-")] - self.assertLessEqual(len(bullet_lines), 4) - - def test_build_contract_review_fallback_payload_sources_limit(self): - payload = self.service.build_contract_review_fallback_payload("违约付款续约免责保密") - - self.assertLessEqual(len(payload["sources"]), 2) - - def test_contract_review_payload_source_note_contains_risk_label(self): - payload = self.service.build_chat_payload( - "违约条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "违约责任需明确。", "score": 1.2, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertIn("高风险", payload["sources"][0]["content"]) - - def test_contract_review_payload_source_note_contains_focus(self): - payload = self.service.build_chat_payload( - "付款条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点需明确。", "score": 0.8, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertIn("付款与结算安排", payload["sources"][0]["content"]) - - def test_build_contract_review_payload_has_disclaimer_last(self): - payload = self.service.build_chat_payload( - "保密条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "保密义务需明确。", "score": 0.8, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertEqual(payload["chunks"][-1], "以下意见仅作为合同审查辅助建议,不替代正式法律意见。") - - def test_build_contract_review_fallback_payload_has_disclaimer_last(self): - payload = self.service.build_contract_review_fallback_payload("付款条款") - - self.assertEqual(payload["chunks"][-1], "以下意见仅作为合同审查辅助建议,不替代正式法律意见。") - - def test_rewrite_answer_for_contract_review_keeps_query(self): - rewritten = self.service._rewrite_answer_for_contract_review("付款条款", "根据知识库中的相关文档,我为您提供以下信息:", ["付款"]) - - self.assertIn("付款条款", rewritten) - - def test_build_focus_label_multiple_topics(self): - label = self.service._build_focus_label(["违约", "付款"]) - - self.assertIn("违约责任与违约金", label) - self.assertIn("付款与结算安排", label) - - def test_build_backup_sources_for_known_topic(self): - sources = self.service._build_backup_sources(["违约"]) - - self.assertEqual(sources[0]["title"], "审查要点:违约责任") - - def test_contract_review_payload_without_review_style_uses_default_formatter(self): - payload = self.service.build_chat_payload( - "plain question", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "plain answer", "score": 0.8, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertTrue(payload["chunks"]) - - def test_build_contract_review_fallback_payload_general_sources(self): - payload = self.service.build_contract_review_fallback_payload("hello world") - - self.assertEqual(payload["sources"][0]["title"], "平台提示") - - def test_generate_answer_with_context_still_contains_context(self): - answer = self.service.generate_answer_with_context("违约条款", "[文档1] 违约责任需明确。") - - self.assertIn("[文档1]", answer) - - def test_build_contract_review_payload_sources_limit(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [ - {"title": "文档: A", "content": "A", "score": 1.0}, - {"title": "文档: B", "content": "B", "score": 0.8}, - {"title": "文档: C", "content": "C", "score": 0.7}, - ], - "summary": ["a"], - "review_topics": ["违约"], - } - - payload = self.service.build_contract_review_payload("违约条款", summary) - - self.assertLessEqual(len(payload["sources"]), 5) - - def test_decorated_sources_add_backup_when_missing(self): - sources = self.service._decorate_sources([], ["付款"]) - - self.assertTrue(sources) - - def test_contract_review_payload_for_general_topic_uses_general_focus(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": 1.0}], - "summary": ["a"], - "review_topics": ["general"], - } - - payload = self.service.build_contract_review_payload("random", summary) - - self.assertIn("合同审查建议", payload["chunks"][0]) - - def test_build_source_note_for_low_score(self): - note = self.service._build_source_note({"score": 0.1}, ["付款"]) - - self.assertIn("低风险", note) - - def test_build_source_note_for_medium_score(self): - note = self.service._build_source_note({"score": 0.8}, ["付款"]) - - self.assertIn("中风险", note) - - def test_build_source_note_for_high_score(self): - note = self.service._build_source_note({"score": 1.5}, ["付款"]) - - self.assertIn("高风险", note) - - def test_build_contract_review_payload_appends_backup_sources_even_with_one_source(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": 1.0}], - "summary": ["a"], - "review_topics": ["付款"], - } - - payload = self.service.build_contract_review_payload("付款条款", summary) - - self.assertGreaterEqual(len(payload["sources"]), 2) - - def test_should_use_contract_review_style_for_contract_keyword_only(self): - self.assertTrue(self.service._should_use_contract_review_style("合同", [])) - - def test_should_use_contract_review_style_for_empty_inputs(self): - self.assertFalse(self.service._should_use_contract_review_style("", [])) - - def test_detect_review_topics_multiple_topics(self): - topics = self.service._detect_review_topics("违约和付款条款", []) - - self.assertEqual(topics, ["违约", "付款"]) - - def test_build_review_checklist_for_multiple_topics(self): - checklist = self.service._build_review_checklist(["违约", "付款"]) - - self.assertTrue(any("违约" in item for item in checklist)) - self.assertTrue(any("付款" in item for item in checklist)) - - def test_merge_review_summary_for_multiple_topics(self): - summary = self.service._merge_review_summary([], ["违约", "付款"]) - - self.assertTrue(any("违约" in item for item in summary)) - self.assertTrue(any("付款" in item for item in summary)) - - def test_build_contract_review_payload_with_empty_summary_points(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": 1.0}], - "summary": [], - "review_topics": ["违约"], - } - - payload = self.service.build_contract_review_payload("违约条款", summary) - - self.assertIn("违约触发条件是否明确", payload["chunks"][1]) - - def test_build_contract_review_fallback_payload_with_general_topic(self): - payload = self.service.build_contract_review_fallback_payload("plain text") - - self.assertIn("合同审查辅助建议", payload["chunks"][-1]) - - def test_format_default_sources_shape(self): - source = self.service.format_default_sources()[0] - - self.assertIn("title", source) - self.assertIn("content", source) - self.assertIn("score", source) - - def test_build_contract_review_payload_source_content_contains_note(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "付款节点需明确。", "score": 1.0}], - "summary": ["a"], - "review_topics": ["付款"], - } - - payload = self.service.build_contract_review_payload("付款条款", summary) - - self.assertIn("可参考来源", payload["sources"][0]["content"]) - - def test_build_contract_review_payload_source_content_is_truncated(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "a" * 200, "score": 1.0}], - "summary": ["a"], - "review_topics": ["付款"], - } - - payload = self.service.build_contract_review_payload("付款条款", summary) - - self.assertIn("...", payload["sources"][0]["content"]) - - def test_build_contract_review_payload_preserves_source_score(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": 0.9}], - "summary": ["a"], - "review_topics": ["付款"], - } - - payload = self.service.build_contract_review_payload("付款条款", summary) - - self.assertEqual(payload["sources"][0]["score"], 0.9) - - def test_generate_answer_with_context_contains_attention_section_when_hint_exists(self): - answer = self.service.generate_answer_with_context("付款条款", "[文档1] 付款节点需明确。") - - self.assertIn("建议重点关注", answer) - - def test_generate_answer_with_context_no_attention_section_when_no_hint(self): - answer = self.service.generate_answer_with_context("hello", "[文档1] 普通内容。") - - self.assertNotIn("建议重点关注", answer) - - def test_build_backup_sources_returns_topic_specific_content(self): - sources = self.service._build_backup_sources(["保密"]) - - self.assertIn("保密", sources[0]["title"]) - - def test_build_source_note_uses_focus_label(self): - note = self.service._build_source_note({"score": 1.0}, ["保密"]) - - self.assertIn("保密义务", note) - - def test_build_review_checklist_general_limit(self): - checklist = self.service._build_review_checklist(["general"]) - - self.assertLessEqual(len(checklist), 6) - - def test_build_contract_review_payload_general_sources_limit(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [], - "summary": ["a"], - "review_topics": ["general"], - } - - payload = self.service.build_contract_review_payload("random", summary) - - self.assertLessEqual(len(payload["sources"]), 2) - - def test_build_contract_review_payload_summary_contains_topic_points(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": 1.0}], - "summary": ["a"], - "review_topics": ["付款"], - } - - payload = self.service.build_contract_review_payload("付款条款", summary) - - self.assertIn("付款节点是否和验收挂钩", payload["chunks"][1]) - - def test_build_contract_review_payload_checklist_contains_topic_points(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": 1.0}], - "summary": ["a"], - "review_topics": ["付款"], - } - - payload = self.service.build_contract_review_payload("付款条款", summary) - - self.assertIn("逾期付款责任是否清晰", payload["chunks"][2]) - - def test_build_contract_review_fallback_payload_summary_contains_topic_points(self): - payload = self.service.build_contract_review_fallback_payload("付款条款") - - self.assertIn("付款节点是否和验收挂钩", payload["chunks"][2]) - - def test_build_contract_review_fallback_payload_checklist_contains_topic_points(self): - payload = self.service.build_contract_review_fallback_payload("付款条款") - - self.assertIn("逾期付款责任是否清晰", payload["chunks"][3]) - - def test_build_contract_review_payload_answer_contains_topic_findings(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": 1.0}], - "summary": ["a"], - "review_topics": ["付款"], - } - - payload = self.service.build_contract_review_payload("付款条款", summary) - - self.assertIn("付款与结算安排", payload["chunks"][0]) - - def test_build_contract_review_fallback_payload_answer_contains_general_focus(self): - payload = self.service.build_contract_review_fallback_payload("random") - - self.assertIn("审查结论", payload["chunks"][1]) - - def test_build_contract_review_payload_source_note_general(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": None}], - "summary": ["a"], - "review_topics": ["general"], - } - - payload = self.service.build_contract_review_payload("random", summary) - - self.assertIn("合同审查建议", payload["sources"][0]["content"]) - - def test_build_contract_review_payload_backup_sources_for_topic(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [], - "summary": ["a"], - "review_topics": ["违约"], - } - - payload = self.service.build_contract_review_payload("违约条款", summary) - - self.assertEqual(payload["sources"][0]["title"], "审查要点:违约责任") - - def test_build_contract_review_fallback_payload_general_sources(self): - payload = self.service.build_contract_review_fallback_payload("hello") - - self.assertEqual(payload["sources"][0]["title"], "平台提示") - - def test_build_contract_review_payload_summary_deduped(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": 1.0}], - "summary": ["付款节点是否和验收挂钩"], - "review_topics": ["付款"], - } - - payload = self.service.build_contract_review_payload("付款条款", summary) - bullet_lines = [line for line in payload["chunks"][1].splitlines() if line.startswith("-")] - - self.assertEqual(len(bullet_lines), len(set(bullet_lines))) - - def test_build_contract_review_payload_checklist_deduped(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "内容。", "score": 1.0}], - "summary": ["a"], - "review_topics": ["付款", "付款"], - } - - payload = self.service.build_contract_review_payload("付款条款", summary) - bullet_lines = [line for line in payload["chunks"][2].splitlines() if line.startswith("-")] - - self.assertEqual(len(bullet_lines), len(set(bullet_lines))) - - def test_build_contract_review_fallback_payload_general_summary_deduped(self): - payload = self.service.build_contract_review_fallback_payload("random") - bullet_lines = [line for line in payload["chunks"][2].splitlines() if line.startswith("-")] - - self.assertEqual(len(bullet_lines), len(set(bullet_lines))) - - def test_build_contract_review_fallback_payload_general_checklist_deduped(self): - payload = self.service.build_contract_review_fallback_payload("random") - bullet_lines = [line for line in payload["chunks"][3].splitlines() if line.startswith("-")] - - self.assertEqual(len(bullet_lines), len(set(bullet_lines))) - - def test_build_source_note_high_risk_for_high_score(self): - note = self.service._build_source_note({"score": 2.0}, ["违约"]) - - self.assertIn("高风险", note) - - def test_build_source_note_medium_risk_for_mid_score(self): - note = self.service._build_source_note({"score": 0.7}, ["违约"]) - - self.assertIn("中风险", note) - - def test_build_source_note_low_risk_for_low_score(self): - note = self.service._build_source_note({"score": 0.1}, ["违约"]) - - self.assertIn("低风险", note) - - def test_build_source_note_medium_risk_for_none_score(self): - note = self.service._build_source_note({"score": None}, ["违约"]) - - self.assertIn("中风险", note) - - def test_build_contract_review_payload_source_note_appended(self): - payload = self.service.build_chat_payload( - "违约条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "违约责任需明确。", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertIn("可参考来源", payload["sources"][0]["content"]) - - def test_build_contract_review_payload_for_general_query_with_legal_cue(self): - payload = self.service.build_chat_payload( - "hello", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "建议核查合同风险。", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertIn("审查结论", payload["chunks"][0]) - - def test_build_contract_review_payload_for_non_contract_query_without_legal_cue(self): - payload = self.service.build_chat_payload( - "hello", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "plain answer", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertEqual(len(payload["chunks"]), 2) - - def test_build_contract_review_fallback_payload_topics_from_query(self): - payload = self.service.build_contract_review_fallback_payload("违约付款") - - self.assertIn("违约触发条件是否明确", payload["chunks"][2]) - self.assertIn("付款节点是否和验收挂钩", payload["chunks"][2]) - - def test_build_contract_review_payload_topics_from_summary(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "付款节点需明确。", "score": 1.0}], - "summary": ["a"], - "review_topics": ["付款", "违约"], - } - - payload = self.service.build_contract_review_payload("综合审查", summary) - - self.assertIn("付款与结算安排", payload["chunks"][0]) - self.assertIn("违约责任与违约金", payload["chunks"][0]) - - def test_build_contract_review_payload_sources_include_backup_for_each_topic(self): - summary = { - "answer": "根据知识库中的相关文档,我为您提供以下信息:\n\n[文档1] 内容。", - "sources": [{"title": "文档: 合同.docx", "content": "付款节点需明确。", "score": 1.0}], - "summary": ["a"], - "review_topics": ["付款", "违约"], - } - - payload = self.service.build_contract_review_payload("综合审查", summary) - - self.assertLessEqual(len(payload["sources"]), 5) - - def test_build_contract_review_fallback_payload_source_titles(self): - payload = self.service.build_contract_review_fallback_payload("保密条款") - - self.assertTrue(all("title" in item for item in payload["sources"])) - - def test_build_contract_review_payload_source_titles(self): - payload = self.service.build_chat_payload( - "保密条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "保密义务需明确。", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertTrue(all("title" in item for item in payload["sources"])) - - def test_build_contract_review_payload_source_scores_preserved(self): - payload = self.service.build_chat_payload( - "付款条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点需明确。", "score": 0.95, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertEqual(payload["sources"][0]["score"], 0.95) - - def test_build_contract_review_payload_source_backup_score_none(self): - payload = self.service.build_chat_payload( - "付款条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点需明确。", "score": 0.95, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertIsNone(payload["sources"][-1]["score"]) - - def test_build_contract_review_payload_chunks_are_strings(self): - payload = self.service.build_chat_payload( - "付款条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点需明确。", "score": 0.95, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertTrue(all(isinstance(item, str) for item in payload["chunks"])) - - def test_build_contract_review_payload_sources_are_dicts(self): - payload = self.service.build_chat_payload( - "付款条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点需明确。", "score": 0.95, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertTrue(all(isinstance(item, dict) for item in payload["sources"])) - - def test_build_contract_review_payload_summary_headline(self): - payload = self.service.build_chat_payload( - "付款条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点需明确。", "score": 0.95, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertTrue(payload["chunks"][1].startswith("重点摘要")) - - def test_build_contract_review_payload_checklist_headline(self): - payload = self.service.build_chat_payload( - "付款条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点需明确。", "score": 0.95, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertTrue(payload["chunks"][2].startswith("建议核查点")) - - def test_build_contract_review_fallback_payload_summary_headline(self): - payload = self.service.build_contract_review_fallback_payload("付款条款") - - self.assertTrue(payload["chunks"][2].startswith("重点摘要")) - - def test_build_contract_review_fallback_payload_checklist_headline(self): - payload = self.service.build_contract_review_fallback_payload("付款条款") - - self.assertTrue(payload["chunks"][3].startswith("建议核查点")) - - def test_build_contract_review_payload_analysis_headline(self): - payload = self.service.build_chat_payload( - "付款条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点需明确。", "score": 0.95, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertTrue(payload["chunks"][0].startswith("审查结论")) - - def test_build_contract_review_fallback_payload_risk_headline(self): - payload = self.service.build_contract_review_fallback_payload("付款条款") - - self.assertTrue(payload["chunks"][0].startswith("风险提示")) - - def test_build_contract_review_fallback_payload_analysis_headline(self): - payload = self.service.build_contract_review_fallback_payload("付款条款") - - self.assertTrue(payload["chunks"][1].startswith("审查结论")) - - def test_build_contract_review_payload_source_note_contains_source_headline(self): - payload = self.service.build_chat_payload( - "付款条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点需明确。", "score": 0.95, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertIn("可参考来源", payload["sources"][0]["content"]) - - def test_build_contract_review_payload_appends_contract_disclaimer(self): - payload = self.service.build_chat_payload( - "付款条款怎么审", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点需明确。", "score": 0.95, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertEqual(payload["chunks"][-1], "以下意见仅作为合同审查辅助建议,不替代正式法律意见。") - - def test_build_contract_review_fallback_payload_appends_contract_disclaimer(self): - payload = self.service.build_contract_review_fallback_payload("付款条款") - - self.assertEqual(payload["chunks"][-1], "以下意见仅作为合同审查辅助建议,不替代正式法律意见。") - - def test_extract_keywords_includes_cjk_terms(self): - keywords = self.service._extract_keywords("自动续约条款与付款发票") - - self.assertIn("自动续约", keywords) - self.assertIn("付款", keywords) - self.assertIn("发票", keywords) - - def test_build_summary_points_dedupes_hints(self): - search_results = [ - { - "id": "doc-1", - "knowledge_base_id": "kb-1", - "file_name": "contract.docx", - "content": "违约责任应明确违约金和触发条件。", - "score": 1.0, - "created_at": "2026-04-16T10:00:00+00:00", - } - ] - - points = self.service._build_summary_points("违约条款怎么审", search_results) - - self.assertGreaterEqual(len(points), 1) - self.assertEqual(len(points), len(set(points))) - - def test_dedupe_results_keeps_top_order(self): - results = [ - {"file_name": "a.docx", "content": "x", "score": 3}, - {"file_name": "a.docx", "content": "x", "score": 2}, - {"file_name": "b.docx", "content": "y", "score": 1}, - ] - - deduped = self.service._dedupe_results(results, 5) - - self.assertEqual(len(deduped), 2) - self.assertEqual(deduped[0]["score"], 3) - self.assertEqual(deduped[1]["file_name"], "b.docx") - - def test_calculate_keyword_coverage(self): - coverage = self.service._calculate_keyword_coverage(["违约", "条款"], ["违约", "责任", "条款"]) - - self.assertGreater(coverage, 0.9) - - def test_calculate_domain_bonus(self): - bonus = self.service._calculate_domain_bonus("违约条款", "本合同的违约责任需要重点核查") - - self.assertGreater(bonus, 0) - - def test_build_context_limits_result_count(self): - search_results = [ - {"id": f"doc-{index}", "knowledge_base_id": "kb-1", "file_name": f"doc-{index}.txt", "content": f"内容{index}", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"} - for index in range(5) - ] - - context, sources = self.service.build_context_from_results(search_results) - - self.assertEqual(len(sources), 3) - self.assertIn("[文档3]", context) - self.assertNotIn("[文档4]", context) - - def test_summarize_results_returns_answer_sources_and_summary(self): - search_results = [ - {"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点应明确。", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"} - ] - - summary = self.service.summarize_results("付款条款", search_results) - - self.assertIn("answer", summary) - self.assertIn("sources", summary) - self.assertIn("summary", summary) - self.assertTrue(summary["summary"]) - - def test_search_relevant_documents_respects_limit(self): - docs = [ - TestKnowledgeBaseDocument( - id=f"doc-{index}", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name=f"doc-{index}.txt", - content_excerpt="违约条款需要重点核查。付款节点也要明确。", - ) - for index in range(10) - ] - self.service.document_repository.list_all_for_tenant = Mock(return_value=docs) - - results = self.service.search_relevant_documents("tenant-1", "违约条款", limit=2) - - self.assertLessEqual(len(results), 2) - - def test_build_chat_payload_preserves_source_score(self): - payload = self.service.build_chat_payload( - "违约条款", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "违约责任需明确。", "score": 2.5, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertEqual(payload["sources"][0]["score"], 2.5) - - def test_format_default_chunks_without_topic_hint(self): - chunks = self.service.format_default_chunks("随机问题") - - self.assertEqual(len(chunks), 2) - - def test_generate_answer_without_context(self): - answer = self.service.generate_answer_with_context("付款条款", "") - - self.assertIn("暂时无法回答", answer) - - def test_build_context_truncates_long_content(self): - search_results = [ - {"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "a" * 600, "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"} - ] - - context, sources = self.service.build_context_from_results(search_results) - - self.assertTrue(sources[0]["content"].endswith("...")) - self.assertIn("[文档1]", context) - - def test_search_relevant_documents_returns_empty_for_blank_keywords(self): - self.service.document_repository.list_all_for_tenant = Mock(return_value=[]) - - results = self.service.search_relevant_documents("tenant-1", " ") - - self.assertEqual(results, []) - - def test_format_answer_chunks_without_summary(self): - chunks = self.service.format_answer_chunks("回答正文", []) - - self.assertEqual(chunks, ["回答正文"]) - - def test_calculate_domain_bonus_without_term_match(self): - bonus = self.service._calculate_domain_bonus("天气如何", "今天阳光很好") - - self.assertEqual(bonus, 0.0) - - def test_keyword_coverage_without_overlap(self): - coverage = self.service._calculate_keyword_coverage(["违约"], ["付款"]) - - self.assertEqual(coverage, 0.0) - - def test_search_relevant_documents_dedupes_same_chunk(self): - mock_doc = TestKnowledgeBaseDocument( - id="doc-1", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name="dup.docx", - content_excerpt="违约条款。违约条款。违约条款。", - ) - self.service.document_repository.list_all_for_tenant = Mock(return_value=[mock_doc]) - - results = self.service.search_relevant_documents("tenant-1", "违约条款", limit=5) - - unique_pairs = {(item["file_name"], item["content"]) for item in results} - self.assertEqual(len(unique_pairs), len(results)) - - def test_format_default_sources_message(self): - sources = self.service.format_default_sources() - - self.assertIn("未检索到", sources[0]["content"]) - - def test_build_summary_points_respects_max_count(self): - search_results = [ - {"id": f"doc-{index}", "knowledge_base_id": "kb-1", "file_name": f"doc-{index}.txt", "content": f"付款条款内容{index}", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"} - for index in range(6) - ] - - points = self.service._build_summary_points("付款条款", search_results) - - self.assertLessEqual(len(points), 4) - - def test_generate_answer_with_multiple_hints(self): - answer = self.service.generate_answer_with_context("违约和付款条款", "[文档1] 内容") - - self.assertIn("建议重点关注", answer) - self.assertIn("付款节点", answer) - self.assertIn("违约责任", answer) - - def test_extract_keywords_handles_ascii_tokens(self): - keywords = self.service._extract_keywords("invoice payment clause") - - self.assertIn("invoice", keywords) - self.assertIn("payment", keywords) - self.assertIn("clause", keywords) - - def test_search_relevant_documents_with_ascii_query(self): - mock_doc = TestKnowledgeBaseDocument( - id="doc-1", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name="invoice.txt", - content_excerpt="invoice payment clause for delivery", - ) - self.service.document_repository.list_all_for_tenant = Mock(return_value=[mock_doc]) - - results = self.service.search_relevant_documents("tenant-1", "payment clause", limit=3) - - self.assertEqual(results[0]["id"], "doc-1") - self.assertGreater(results[0]["score"], 0) - - def test_build_chat_payload_without_results_uses_platform_source(self): - payload = self.service.build_chat_payload("免责条款", []) - - self.assertEqual(payload["sources"][0]["title"], "审查要点:免责条款") - self.assertIn("免责条款是否覆盖核心义务", "\n".join(payload["chunks"])) - - def test_generate_answer_with_context_preserves_query(self): - answer = self.service.generate_answer_with_context("付款条款有哪些风险", "[文档1] 付款节点应明确") - - self.assertIn("付款条款有哪些风险", answer) - - def test_build_context_returns_empty_for_no_results(self): - context, sources = self.service.build_context_from_results([]) - - self.assertEqual(context, "") - self.assertEqual(sources, []) - - def test_summarize_results_summary_uses_excerpt(self): - summary = self.service.summarize_results( - "付款条款", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点应明确,发票要求需写清。", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertTrue(any("付款节点" in item for item in summary["summary"])) - - def test_split_long_content_respects_chunk_overlap_behavior(self): - text = "。".join(["违约条款需要重点核查" for _ in range(50)]) - chunks = self.service.search_relevant_documents - self.assertTrue(callable(chunks)) - - def test_dedupe_results_respects_limit(self): - results = [ - {"file_name": f"doc-{index}.txt", "content": str(index), "score": index} - for index in range(10, 0, -1) - ] - - deduped = self.service._dedupe_results(results, 3) - - self.assertEqual(len(deduped), 3) - - def test_default_chunks_without_hints_have_two_sections(self): - chunks = self.service.format_default_chunks("hello world") - - self.assertEqual(len(chunks), 2) - - def test_context_sources_include_score(self): - context, sources = self.service.build_context_from_results([ - {"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "合同条款", "score": 2.3, "created_at": "2026-04-16T10:00:00+00:00"} - ]) - - self.assertIn("[文档1]", context) - self.assertEqual(sources[0]["score"], 2.3) - - def test_answer_chunks_support_summary_points(self): - chunks = self.service.format_answer_chunks("正文", ["A", "B", "C"]) - - self.assertEqual(len(chunks), 2) - self.assertIn("A", chunks[1]) - - def test_keyword_extraction_removes_stop_words(self): - keywords = self.service._extract_keywords("这是一个付款条款") - - self.assertNotIn("这", keywords) - self.assertIn("付款", keywords) - - def test_search_relevant_documents_returns_empty_when_no_chunk_matches(self): - mock_doc = TestKnowledgeBaseDocument( - id="doc-1", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name="doc.txt", - content_excerpt="天气晴朗适合出门。", - ) - self.service.document_repository.list_all_for_tenant = Mock(return_value=[mock_doc]) - - results = self.service.search_relevant_documents("tenant-1", "违约责任", limit=5) - - self.assertEqual(results, []) - - def test_domain_bonus_accumulates_terms(self): - bonus = self.service._calculate_domain_bonus("违约付款条款", "合同中违约和付款条款均需关注") - - self.assertGreaterEqual(bonus, 0.3) - - def test_summary_points_prioritize_topic_hints(self): - points = self.service._build_summary_points( - "违约条款", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "违约责任应明确。", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertIn("违约责任", points[0]) - - def test_search_relevant_documents_can_match_cjk_bigram(self): - mock_doc = TestKnowledgeBaseDocument( - id="doc-1", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name="doc.txt", - content_excerpt="自动续约需要设置通知期。", - ) - self.service.document_repository.list_all_for_tenant = Mock(return_value=[mock_doc]) - - results = self.service.search_relevant_documents("tenant-1", "续约通知", limit=5) - - self.assertGreaterEqual(len(results), 1) - - def test_generate_answer_with_context_contains_context(self): - answer = self.service.generate_answer_with_context("合同条款", "[文档1] 合同应明确付款节点") - - self.assertIn("[文档1]", answer) - - def test_default_sources_shape(self): - source = self.service.format_default_sources()[0] - - self.assertIn("title", source) - self.assertIn("content", source) - self.assertIn("score", source) - - def test_build_chat_payload_result_chunk_order(self): - payload = self.service.build_chat_payload( - "付款条款", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点应明确", "score": 2.0, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertTrue(payload["chunks"][0].startswith("审查结论")) - self.assertTrue(payload["chunks"][1].startswith("重点摘要")) - - def test_search_relevant_documents_orders_by_score_desc(self): - docs = [ - TestKnowledgeBaseDocument("doc-1", "kb-1", "tenant-1", "a.txt", "违约责任和付款节点都要关注。"), - TestKnowledgeBaseDocument("doc-2", "kb-1", "tenant-1", "b.txt", "付款节点。"), - ] - self.service.document_repository.list_all_for_tenant = Mock(return_value=docs) - - results = self.service.search_relevant_documents("tenant-1", "违约付款", limit=5) - - self.assertGreaterEqual(results[0]["score"], results[-1]["score"]) - - def test_extract_topic_hints_applies_known_terms(self): - payload = self.service.build_chat_payload("保密条款", []) - - self.assertIn("保密信息范围是否明确", "\n".join(payload["chunks"])) - - def test_build_context_source_titles_use_filename(self): - _context, sources = self.service.build_context_from_results([ - {"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "rules.pdf", "content": "付款规则", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"} - ]) - - self.assertEqual(sources[0]["title"], "文档: rules.pdf") - - def test_answer_with_context_has_final_guidance(self): - answer = self.service.generate_answer_with_context("续约条款", "[文档1] 应明确通知窗口") - - self.assertIn("优先核对上述文档原文", answer) - - def test_default_chunks_keep_query_text(self): - query = "这个付款条款怎么审" - chunks = self.service.format_default_chunks(query) - - self.assertIn(query, chunks[0]) - - def test_keyword_coverage_caps_by_unique_query_tokens(self): - coverage = self.service._calculate_keyword_coverage(["违约", "违约"], ["违约"]) - - self.assertEqual(coverage, 1.0) - - def test_search_relevant_documents_handles_many_documents(self): - docs = [ - TestKnowledgeBaseDocument( - id=f"doc-{index}", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name=f"doc-{index}.txt", - content_excerpt="违约责任和付款节点需要核查。", - ) - for index in range(20) - ] - self.service.document_repository.list_all_for_tenant = Mock(return_value=docs) - - results = self.service.search_relevant_documents("tenant-1", "违约付款", limit=5) - - self.assertLessEqual(len(results), 5) - - def test_build_summary_points_uses_excerpt_limit(self): - long_excerpt = "付款节点" * 50 - points = self.service._build_summary_points( - "付款条款", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": long_excerpt, "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertTrue(any(item.endswith("...") for item in points if "付款节点" in item)) - - def test_search_results_include_created_at(self): - mock_doc = TestKnowledgeBaseDocument( - id="doc-1", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name="doc.txt", - content_excerpt="违约责任应明确。", - ) - self.service.document_repository.list_all_for_tenant = Mock(return_value=[mock_doc]) - - results = self.service.search_relevant_documents("tenant-1", "违约责任", limit=5) - - self.assertIn("created_at", results[0]) - - def test_build_chat_payload_summary_has_at_most_four_points(self): - payload = self.service.build_chat_payload( - "违约付款保密续约", - [ - {"id": f"doc-{index}", "knowledge_base_id": "kb-1", "file_name": f"doc-{index}.txt", "content": f"内容{index}", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"} - for index in range(6) - ], - ) - - summary_lines = payload["chunks"][1].splitlines() - bullet_lines = [line for line in summary_lines if line.startswith("-")] - self.assertLessEqual(len(bullet_lines), 4) - - def test_default_sources_have_none_score(self): - self.assertIsNone(self.service.format_default_sources()[0]["score"]) - - def test_extract_keywords_keeps_ascii_and_cjk(self): - keywords = self.service._extract_keywords("payment 付款") - - self.assertIn("payment", keywords) - self.assertIn("付款", keywords) - - def test_domain_bonus_only_applies_for_shared_terms(self): - bonus = self.service._calculate_domain_bonus("付款条款", "这个文本没有对应词") - - self.assertEqual(bonus, 0.0) - - def test_dedupe_results_uses_file_and_content_pair(self): - results = [ - {"file_name": "same.txt", "content": "A", "score": 3}, - {"file_name": "same.txt", "content": "B", "score": 2}, - ] - - deduped = self.service._dedupe_results(results, 5) - - self.assertEqual(len(deduped), 2) - - def test_build_context_trims_to_three_sources(self): - context, sources = self.service.build_context_from_results([ - {"id": f"doc-{index}", "knowledge_base_id": "kb-1", "file_name": f"doc-{index}.txt", "content": f"内容{index}", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"} - for index in range(4) - ]) - - self.assertEqual(len(sources), 3) - self.assertNotIn("[文档4]", context) - - def test_generate_answer_with_context_without_hints(self): - answer = self.service.generate_answer_with_context("随机问题", "[文档1] 一般性内容") - - self.assertIn("根据知识库中的相关文档", answer) - self.assertNotIn("建议重点关注:\n-", answer) - - def test_format_default_chunks_with_multiple_hints(self): - chunks = self.service.format_default_chunks("违约付款") - - self.assertIn("违约责任", chunks[-1]) - self.assertIn("付款节点", chunks[-1]) - - def test_build_summary_points_without_results_still_uses_hints(self): - points = self.service._build_summary_points("续约条款", []) - - self.assertTrue(points) - - def test_search_relevant_documents_handles_empty_doc_content(self): - mock_doc = TestKnowledgeBaseDocument( - id="doc-1", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name="empty.txt", - content_excerpt="", - ) - self.service.document_repository.list_all_for_tenant = Mock(return_value=[mock_doc]) - - results = self.service.search_relevant_documents("tenant-1", "续约条款", limit=5) - - self.assertEqual(results, []) - - def test_answer_chunks_are_list_of_strings(self): - chunks = self.service.format_answer_chunks("正文", ["摘要"]) - - self.assertTrue(all(isinstance(item, str) for item in chunks)) - - def test_default_chunks_are_list_of_strings(self): - chunks = self.service.format_default_chunks("保密条款") - - self.assertTrue(all(isinstance(item, str) for item in chunks)) - - def test_summary_points_use_excerpt_after_hints(self): - points = self.service._build_summary_points( - "付款条款", - [{"id": "doc-1", "knowledge_base_id": "kb-1", "file_name": "doc.txt", "content": "付款节点需明确且发票前置要求应写清。", "score": 1.0, "created_at": "2026-04-16T10:00:00+00:00"}], - ) - - self.assertGreaterEqual(len(points), 2) - - def test_default_sources_content_is_user_friendly(self): - sources = self.service.format_default_sources() - - self.assertIn("补充资料", sources[0]["content"]) - - def test_search_relevant_documents_returns_ranked_chunks(self): - mock_doc = TestKnowledgeBaseDocument( - id="doc-1", - knowledge_base_id="kb-1", - tenant_id="tenant-1", - file_name="ranked.txt", - content_excerpt="第一段是普通内容。第二段写明违约责任、付款节点和保密义务。第三段是其他内容。", - ) - self.service.document_repository.list_all_for_tenant = Mock(return_value=[mock_doc]) - - results = self.service.search_relevant_documents("tenant-1", "违约付款保密", limit=5) - - self.assertGreaterEqual(len(results), 1) - self.assertIn("违约", results[0]["content"]) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/backend/tests/test_review_service.py b/backend/tests/test_review_service.py index 3f15862e..503a24e7 100644 --- a/backend/tests/test_review_service.py +++ b/backend/tests/test_review_service.py @@ -94,6 +94,104 @@ class StubFileService: return self.items[file_id] +class StubLegalReviewGateway: + def run_review(self, review): + return { + "basic_info": { + "contract_type": review.contract_type, + "contract_title": review.title, + "party_a": "甲方", + "party_b": "乙方", + "amount": "待识别", + "term": "待识别", + }, + "risk_summary": {"high": 2, "medium": 2, "low": 1, "overall": "high"}, + "clauses": [ + { + "title": "条款1", + "content": review.input_text or "", + "clause_type": "general", + "clause_label": "一般条款", + "summary": "条款摘要", + } + ], + "risk_items": [ + { + "title": "存在自动续约安排", + "level": "high", + "description": "检测到自动续约。", + "suggestion": "建议人工复核后再决定。", + "certainty": "confirmed", + "evidence": {"quote": "自动续约", "clause_title": "条款1"}, + }, + { + "title": "免责或责任限制条款需要限制范围", + "level": "high", + "description": "检测到免责。", + "suggestion": "建议明确责任边界。", + "certainty": "confirmed", + "evidence": {"quote": "免责", "clause_title": "条款1"}, + }, + { + "title": "付款条件建议明确", + "level": "medium", + "description": "检测到付款。", + "suggestion": "建议明确付款节点。", + "certainty": "confirmed", + "evidence": {"quote": "付款", "clause_title": "条款1"}, + }, + { + "title": "违约责任条款需重点核查", + "level": "medium", + "description": "检测到违约金。", + "suggestion": "建议核对违约责任。", + "certainty": "confirmed", + "evidence": {"quote": "违约金", "clause_title": "条款1"}, + }, + { + "title": "保密义务建议细化", + "level": "low", + "description": "检测到保密。", + "suggestion": "建议明确保密期限。", + "certainty": "confirmed", + "evidence": {"quote": "保密", "clause_title": "条款1"}, + }, + ], + "review_opinion": "检测到高风险条款,建议暂停签署并由法务重点复核。", + "recommendation": "manual_review", + "confidence_summary": { + "provider": "shared-ai-platform", + "model": "gpt-5.4", + "prompt_version": "legal-review.v1", + "workflow_run_id": "workflow-run-001", + "task_run_id": "task-run-001", + "conversation_id": "conversation-run-001", + "knowledge_source": "contract-review-kb", + "analysis_mode": "legal_review", + "provider_mode": "fallback", + "router_status": "matched", + "structured_analysis": {"clause_count": 1}, + "template_source": "local_default", + }, + "contract_structure": { + "title": review.title, + "detected_contract_type": review.contract_type, + "detected_contract_type_label": review.contract_type, + "party_a": "甲方", + "party_b": "乙方", + "amount": "待识别", + "term": "待识别", + "clause_count": 1, + "clause_type_counts": {"general": 1}, + "missing_sections": ["争议解决条款"], + "payment_milestones": [], + "clause_relationships": [], + "summary": "暂无结构化摘要", + }, + "rendered_report": f"审查任务:{review.title}\n结构化摘要:\n条款结构:\n高风险:2\n总体等级:high", + } + + class ReviewServiceTestCase(unittest.TestCase): def setUp(self): self.service = ReviewService(db=None) @@ -101,6 +199,7 @@ class ReviewServiceTestCase(unittest.TestCase): self.service.review_rule_repository = StubRuleRepository() self.service.review_result_repository = StubReviewResultRepository() self.service.file_service = StubFileService() + self.service._get_legal_review_execution_gateway = lambda: StubLegalReviewGateway() self.current_user = {"id": "user-1", "tenant_id": "tenant-demo-001", "role": "admin"} self.same_tenant_other_user = {"id": "user-2", "tenant_id": "tenant-demo-001", "role": "admin"} self.other_user = {"id": "user-3", "tenant_id": "tenant-other", "role": "admin"} @@ -134,6 +233,10 @@ class ReviewServiceTestCase(unittest.TestCase): self.assertEqual(detail["report_status"], "ready") self.assertIsNone(detail["failure_reason"]) self.assertIn("查看风险详情", detail["next_action"]) + self.assertEqual(detail["workflow_state"]["review_id"], created["id"]) + self.assertEqual(detail["workflow_state"]["current_stage"], "report") + self.assertEqual(detail["ai_trace"]["review_id"], created["id"]) + self.assertEqual(detail["source_document"]["text_length"], len("本合同包含自动续约、免责、付款、保密、违约金条款。")) self.assertEqual(result["risk_summary"]["overall"], "high") self.assertEqual(result["risk_summary"]["high"], 2) self.assertEqual(result["risk_summary"]["medium"], 2) @@ -144,6 +247,10 @@ class ReviewServiceTestCase(unittest.TestCase): self.assertEqual(result["contract_structure"]["detected_contract_type"], "service") self.assertGreaterEqual(result["contract_structure"]["clause_count"], 1) self.assertIn("争议解决条款", result["contract_structure"]["missing_sections"]) + self.assertEqual(result["workflow_state"]["review_id"], created["id"]) + self.assertEqual(result["workflow_state"]["status"], "completed") + self.assertEqual(result["ai_trace"]["review_id"], created["id"]) + self.assertEqual(result["source_document"]["text_length"], len("本合同包含自动续约、免责、付款、保密、违约金条款。")) self.assertEqual(len(result["clauses"]), 1) self.assertEqual(result["clauses"][0]["title"], "条款1") self.assertIn("clause_type", result["clauses"][0]) @@ -180,6 +287,11 @@ class ReviewServiceTestCase(unittest.TestCase): self.assertIn("条款结构:", content) self.assertIn("高风险:2", content) self.assertIn("总体等级:high", content) + result = self.service.get_review_result(self.current_user, created["id"]) + self.assertIn("rendered_report", result) + self.assertEqual(content, result["rendered_report"]) + self.assertEqual(result["confidence_summary"]["template_source"], "local_default") + self.assertEqual(result["confidence_summary"]["rendered_report"], content) pdf_content = self.service.build_report_pdf(self.current_user, created["id"]) self.assertIsNotNone(pdf_content) @@ -562,6 +674,20 @@ class ReviewServiceTestCase(unittest.TestCase): assert result is not None self.assertEqual(result["confidence_summary"]["document_analysis"]["document_kind"], "scanned_pdf") self.assertEqual(result["confidence_summary"]["document_analysis"]["ocr_gate_status"], "WARN") + self.assertEqual(result["confidence_summary"]["provider"], "shared-ai-platform") + self.assertEqual(result["confidence_summary"]["knowledge_source"], "contract-review-kb") + self.assertEqual(result["source_document"]["source_kind"], "scanned_pdf") + self.assertEqual(result["source_document"]["parse_status"], "completed") + self.assertEqual(result["ai_trace"]["provider"], "shared-ai-platform") + self.assertEqual(result["ai_trace"]["model"], "gpt-5.4") + self.assertEqual(result["ai_trace"]["prompt_version"], "legal-review.v1") + self.assertEqual(result["ai_trace"]["knowledge_source"], "contract-review-kb") + self.assertEqual(result["ai_trace"]["workflow_run_id"], "workflow-run-001") + self.assertEqual(result["ai_trace"]["task_run_id"], "task-run-001") + self.assertEqual(result["ai_trace"]["conversation_id"], "conversation-run-001") + self.assertEqual(result["workflow_state"]["ai_trace"]["knowledge_source"], "contract-review-kb") + self.assertEqual(result["workflow_state"]["source_document"]["source_kind"], "scanned_pdf") + self.assertEqual(result["workflow_state"]["source_text_length"], result["source_document"]["text_length"]) def test_merge_review_text_prefers_both_sources(self): merged = self.service.merge_review_text("手工补充", "自动续约") @@ -593,6 +719,8 @@ class ReviewServiceTestCase(unittest.TestCase): self.assertEqual(detail["report_status"], "blocked") self.assertEqual(detail["failure_reason"], "无法提取合同正文") self.assertIn("检查文件格式", detail["next_action"]) + self.assertEqual(detail["source_document"]["parse_status"], "failed") + self.assertEqual(detail["source_document"]["failure_reason"], "无法提取合同正文") def test_partial_review_lifecycle_marks_report_as_partial(self): created = self.service.create_review_task( @@ -743,6 +871,67 @@ class ReviewServiceTestCase(unittest.TestCase): self.assertIsNone(self.service.build_report_content(self.other_user, created["id"])) self.assertIsNone(self.service.build_report_pdf(self.other_user, created["id"])) + def test_finalize_review_task_tolerates_partial_remote_contract_structure(self): + class PartialStructureGateway: + def run_review(self_inner, review): + return { + "basic_info": { + "contract_type": review.contract_type, + "contract_title": review.title, + }, + "risk_summary": {"high": 0, "medium": 1, "low": 0, "overall": "medium"}, + "clauses": [ + { + "title": "付款条款", + "content": review.input_text or "", + "clause_type": "payment", + "clause_label": "付款条款", + "summary": "付款安排待补充验收条件", + } + ], + "risk_items": [ + { + "title": "付款条件建议明确", + "level": "medium", + "description": "建议补充验收和开票条件。", + "suggestion": "补充验收标准和付款期限。", + "certainty": "confirmed", + "evidence": {"quote": "付款", "clause_title": "付款条款"}, + } + ], + "review_opinion": "建议人工复核付款触发条件。", + "recommendation": "manual_review", + "confidence_summary": {"provider": "shared-ai-platform"}, + "contract_structure": { + "title": review.title, + "detected_contract_type": review.contract_type, + "clause_count": 1, + "clause_type_counts": {"payment": 1}, + "missing_sections": [], + "payment_milestones": [], + "clause_relationships": [], + }, + } + + self.service._get_legal_review_execution_gateway = lambda: PartialStructureGateway() + + created = self.service.create_review_task( + tenant_id=self.current_user["tenant_id"], + user_id=self.current_user["id"], + review_as="party_a", + contract_type="service", + text="甲方应在验收完成后十个工作日内支付合同款项。", + file_name="partial-structure.txt", + ) + + result = self.service.get_review_result(self.current_user, created["id"]) + assert result is not None + self.assertEqual(created["status"], "completed") + self.assertEqual(result["basic_info"]["party_a"], "甲方") + self.assertEqual(result["basic_info"]["party_b"], "乙方") + self.assertEqual(result["contract_structure"]["clause_type_counts"]["payment"], 1) + self.assertEqual(result["risk_summary"]["overall"], "medium") + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_shared_ai_platform_client.py b/backend/tests/test_shared_ai_platform_client.py index 3c2669f1..97108b0f 100644 --- a/backend/tests/test_shared_ai_platform_client.py +++ b/backend/tests/test_shared_ai_platform_client.py @@ -1,141 +1,12 @@ import unittest import httpx -from fastapi import HTTPException -from app.clients.shared_ai_platform import HybridSharedAIPlatformClient, RemoteSharedAIPlatformClient +from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient from shared_ai_platform_sdk.client import SharedAIPlatformClient as VendoredSharedAiPlatformClient from shared_ai_platform_sdk.types import RequestContext, SSEEvent -class StubRemoteClient: - def __init__(self): - self.calls = [] - self.raise_for = {} - - def _run(self, method_name, *args): - self.calls.append((method_name, args)) - maybe_error = self.raise_for.get(method_name) - if maybe_error is not None: - raise maybe_error - return {"source": "remote", "method": method_name} - - def stream_answer(self, current_user, conversation_id, message, review_context=None): - self.calls.append(("stream_answer", (current_user, conversation_id, message, review_context))) - maybe_error = self.raise_for.get("stream_answer") - if maybe_error is not None: - raise maybe_error - yield 'data: {"type":"text","content":"remote"}\n\n' - - def list_knowledge_bases(self, current_user, page, page_size): - return self._run("list_knowledge_bases", current_user, page, page_size) - - def list_conversations(self, current_user, page, page_size): - return self._run("list_conversations", current_user, page, page_size) - - def get_conversation_detail(self, current_user, conversation_id): - return self._run("get_conversation_detail", current_user, conversation_id) - - def run_legal_review(self, current_user, payload): - return self._run("run_legal_review", current_user, payload) - - def create_knowledge_base(self, current_user, name, description): - return self._run("create_knowledge_base", current_user, name, description) - - def list_documents(self, current_user, knowledge_base_id, page, page_size): - return self._run("list_documents", current_user, knowledge_base_id, page, page_size) - - def upload_document(self, current_user, knowledge_base_id, file_name, mime_type, content): - return self._run("upload_document", current_user, knowledge_base_id, file_name, mime_type, content) - - def retry_document(self, current_user, knowledge_base_id, document_id, file_name=None, mime_type=None, content=None): - return self._run("retry_document", current_user, knowledge_base_id, document_id, file_name, mime_type, content) - - -class StubLocalClient: - def __init__(self): - self.calls = [] - - def _run(self, method_name, *args): - self.calls.append((method_name, args)) - return {"source": "local", "method": method_name} - - def stream_answer(self, current_user, conversation_id, message, review_context=None): - self.calls.append(("stream_answer", (current_user, conversation_id, message, review_context))) - yield 'data: {"type":"text","content":"local"}\n\n' - - def list_knowledge_bases(self, current_user, page, page_size): - return self._run("list_knowledge_bases", current_user, page, page_size) - - def list_conversations(self, current_user, page, page_size): - return self._run("list_conversations", current_user, page, page_size) - - def get_conversation_detail(self, current_user, conversation_id): - return self._run("get_conversation_detail", current_user, conversation_id) - - def run_legal_review(self, current_user, payload): - return self._run("run_legal_review", current_user, payload) - - def create_knowledge_base(self, current_user, name, description): - return self._run("create_knowledge_base", current_user, name, description) - - def list_documents(self, current_user, knowledge_base_id, page, page_size): - return self._run("list_documents", current_user, knowledge_base_id, page, page_size) - - def upload_document(self, current_user, knowledge_base_id, file_name, mime_type, content): - return self._run("upload_document", current_user, knowledge_base_id, file_name, mime_type, content) - - def retry_document(self, current_user, knowledge_base_id, document_id, file_name=None, mime_type=None, content=None): - return self._run("retry_document", current_user, knowledge_base_id, document_id, file_name, mime_type, content) - - -class HybridSharedAIPlatformClientTestCase(unittest.TestCase): - def setUp(self): - self.remote = StubRemoteClient() - self.local = StubLocalClient() - self.client = HybridSharedAIPlatformClient(self.remote, self.local) - self.current_user = {"id": "user-1", "tenant_id": "tenant-1", "role": "admin"} - - def test_read_calls_use_remote_when_remote_is_healthy(self): - payload = self.client.list_conversations(self.current_user, 1, 20) - - self.assertEqual(payload["source"], "remote") - self.assertEqual(self.local.calls, []) - - def test_read_calls_fallback_to_local_on_remote_500(self): - self.remote.raise_for["list_knowledge_bases"] = HTTPException(status_code=500, detail="remote failure") - - payload = self.client.list_knowledge_bases(self.current_user, 1, 20) - - self.assertEqual(payload["source"], "local") - self.assertEqual(self.local.calls[0][0], "list_knowledge_bases") - - def test_read_calls_do_not_mask_remote_403(self): - self.remote.raise_for["get_conversation_detail"] = HTTPException(status_code=403, detail="forbidden") - - with self.assertRaises(HTTPException) as context: - self.client.get_conversation_detail(self.current_user, "conv-1") - - self.assertEqual(context.exception.status_code, 403) - self.assertEqual(self.local.calls, []) - - def test_stream_answer_falls_back_to_local_when_remote_stream_crashes(self): - self.remote.raise_for["stream_answer"] = RuntimeError("stream offline") - - chunks = list(self.client.stream_answer(self.current_user, None, "hello", None)) - - self.assertEqual(chunks, ['data: {"type":"text","content":"local"}\n\n']) - self.assertEqual(self.local.calls[0][0], "stream_answer") - - def test_write_calls_fallback_to_local_on_remote_500(self): - self.remote.raise_for["create_knowledge_base"] = HTTPException(status_code=502, detail="bad gateway") - - payload = self.client.create_knowledge_base(self.current_user, "测试知识库", "desc") - - self.assertEqual(payload["source"], "local") - self.assertEqual(self.local.calls[0][0], "create_knowledge_base") - - class VendoredSharedAiPlatformSdkTestCase(unittest.TestCase): def test_stream_conversation_supports_split_data_and_json_lines(self): sse_payload = ( @@ -186,6 +57,25 @@ class RemoteSharedAiPlatformClientStreamTestCase(unittest.TestCase): yield SSEEvent(event_type="sources", payload={"type": "sources", "sources": [{"title": "source", "content": "body"}]}) yield SSEEvent(event_type="end", payload={"type": "end", "conversation_id": "conv-1"}) + def run_legal_review(self, context, *, title, text, contract_type=None, review_as="party_a", provider=None): + return { + "basic_info": { + "contract_type": contract_type or "采购合同", + "contract_title": title, + "party_a": "甲方", + "party_b": "乙方", + "amount": "100万元", + "term": "自2026-01-01至2026-12-31", + }, + "risk_summary": {"high": 1, "medium": 0, "low": 0, "overall": "high"}, + "clauses": [{"title": "付款条款", "content": "签署后5日内支付50%", "clause_type": "payment", "clause_label": "付款条款", "summary": "签署后5日内支付50%"}], + "risk_items": [{"title": "存在自动续约安排", "level": "high", "description": "需核查自动续约", "suggestion": "补充退出机制"}], + "review_opinion": "建议人工复核", + "recommendation": "manual_review", + "confidence_summary": {"provider_mode": "provider-enhanced"}, + "contract_structure": {"title": title, "payment_milestones": [], "clause_relationships": [], "missing_sections": [], "clause_type_counts": {"payment": 1}}, + } + client = RemoteSharedAIPlatformClient( base_url="http://testserver", app_id="test-app", diff --git a/backend/tests/test_system_api.py b/backend/tests/test_system_api.py index 9193b9d0..9a548dd6 100644 --- a/backend/tests/test_system_api.py +++ b/backend/tests/test_system_api.py @@ -44,7 +44,18 @@ class StubBrokenDb: class StubSharedAIPlatformClient: + def __init__(self): + self.knowledge_bases = [] + self.documents_by_kb = {} + def list_knowledge_bases(self, current_user, page, page_size): + if self.knowledge_bases: + return { + "items": self.knowledge_bases, + "total": len(self.knowledge_bases), + "page": page, + "page_size": page_size, + } return { "items": [ { @@ -61,8 +72,59 @@ class StubSharedAIPlatformClient: "page_size": page_size, } + def create_knowledge_base(self, current_user, name, description): + item = { + "id": "kb-demo-001", + "name": name, + "description": description, + "status": "active", + "document_count": 0, + "retrieval_mode": "hybrid", + "updated_at": "2026-05-12T09:00:00+00:00", + } + self.knowledge_bases.append(item) + self.documents_by_kb[item["id"]] = [] + return item + + def list_documents(self, current_user, knowledge_base_id, page, page_size): + items = self.documents_by_kb.get(knowledge_base_id, []) + return { + "items": items, + "total": len(items), + "page": page, + "page_size": page_size, + } + + def upload_document(self, current_user, knowledge_base_id, file_name, mime_type, content): + item = { + "id": "kbdoc-demo-001", + "knowledge_base_id": knowledge_base_id, + "file_name": file_name, + "mime_type": mime_type, + "file_size": len(content), + "status": "indexed", + "current_stage": "indexed", + "parse_status": "parsed", + "index_status": "indexed", + "updated_at": "2026-05-12T09:01:00+00:00", + } + self.documents_by_kb.setdefault(knowledge_base_id, []).append(item) + for knowledge_base in self.knowledge_bases: + if knowledge_base["id"] == knowledge_base_id: + knowledge_base["document_count"] = len(self.documents_by_kb[knowledge_base_id]) + return item + class StubTemplateService: + def seed_default_templates(self, current_user): + return { + "created": [{"id": "tpl-demo-001", "name": "Procurement Template"}], + "existing": [], + "created_count": 1, + "existing_count": 0, + "total": 3, + } + def list_templates(self, current_user, page, page_size, contract_type): return { "items": [ @@ -88,6 +150,12 @@ class StubTemplateService: class StubAuditLogService: + def __init__(self): + self.events = [] + + def record_event(self, **kwargs): + self.events.append(kwargs) + def list_logs(self, current_user, page, page_size, action): return { "items": [ @@ -144,6 +212,7 @@ class StubGovernanceService: class SystemApiTestCase(unittest.TestCase): def setUp(self): self.original_shared_ai_platform_base_url = settings.shared_ai_platform_base_url + self.original_shared_ai_platform_api_key = settings.shared_ai_platform_api_key self.original_shared_ai_platform_api_prefix = settings.shared_ai_platform_api_prefix self.original_shared_ai_platform_app_id = settings.shared_ai_platform_app_id self.original_shared_ai_platform_timeout_seconds = settings.shared_ai_platform_timeout_seconds @@ -152,6 +221,7 @@ class SystemApiTestCase(unittest.TestCase): def tearDown(self): settings.shared_ai_platform_base_url = self.original_shared_ai_platform_base_url + settings.shared_ai_platform_api_key = self.original_shared_ai_platform_api_key settings.shared_ai_platform_api_prefix = self.original_shared_ai_platform_api_prefix settings.shared_ai_platform_app_id = self.original_shared_ai_platform_app_id settings.shared_ai_platform_timeout_seconds = self.original_shared_ai_platform_timeout_seconds @@ -171,9 +241,132 @@ class SystemApiTestCase(unittest.TestCase): self.assertEqual(payload["pilot_assets"]["default_admin"]["username"], "admin@example.com") self.assertTrue(payload["pilot_assets"]["default_admin"]["password_rotated"]) self.assertGreaterEqual(len(payload["pilot_assets"]["demo_flow"]), 4) + self.assertEqual(payload["pilot_scope"]["status"], "frozen") + self.assertTrue(any(item["key"] == "review_chat_followup" for item in payload["pilot_scope"]["included"])) + self.assertTrue(any(item["key"] == "complex_approval" for item in payload["pilot_scope"]["excluded"])) + self.assertEqual(payload["pilot_materials"]["status"], "ready") + self.assertTrue(any(item["key"] == "feedback_template" for item in payload["pilot_materials"]["materials"])) + self.assertEqual(payload["pilot_error_feedback"]["status"], "ready") + self.assertTrue(any(item["key"] == "chat_response_failed" for item in payload["pilot_error_feedback"]["items"])) + self.assertEqual(payload["pilot_launch_pack"]["status"], "ready") + self.assertTrue(any(item["key"] == "review_chat_followup" for item in payload["pilot_launch_pack"]["script_steps"])) + self.assertEqual(payload["p0_mainline"]["status"], "aligned") + self.assertEqual(len(payload["p0_mainline"]["capabilities"]), 5) + self.assertTrue(all(item["source_project"] for item in payload["p0_mainline"]["capabilities"])) + self.assertTrue(all(item["target_layer"] for item in payload["p0_mainline"]["capabilities"])) self.assertTrue(any(item["key"] == "database" and item["status"] == "ok" for item in payload["checks"])) self.assertTrue(any(item["key"] == "default_admin_password" and item["status"] == "ok" for item in payload["checks"])) + def test_p0_mainline_returns_source_ownership_and_acceptance(self): + client = TestClient(app) + + response = client.get("/api/v1/system/p0-mainline") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["version"], "p0-mainline-alignment-2026-05-12") + self.assertEqual(payload["status"], "aligned") + capability_keys = {item["key"] for item in payload["capabilities"]} + self.assertSetEqual( + capability_keys, + { + "p0_a_document_parsing", + "p0_b_shared_ai_orchestration", + "p0_c_chat_experience", + "p0_d_product_shell", + "p0_e_auto_review_loop", + }, + ) + self.assertTrue(all(item["source_project"] for item in payload["capabilities"])) + self.assertTrue(all(item["target_layer"] for item in payload["capabilities"])) + self.assertTrue(all(item["contract_platform_responsibility"] for item in payload["capabilities"])) + self.assertTrue(all(item["acceptance"] for item in payload["capabilities"])) + self.assertTrue(any("Dify" in item["label"] for item in payload["guardrails"])) + self.assertIn("Slice 1-8", payload["phase_boundary"]["mainline_scope"]) + + def test_pilot_scope_returns_frozen_boundary(self): + client = TestClient(app) + + response = client.get("/api/v1/system/pilot-scope") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["version"], "pilot-scope-2026-05-12") + self.assertEqual(payload["status"], "frozen") + self.assertGreaterEqual(len(payload["included"]), 7) + self.assertGreaterEqual(len(payload["excluded"]), 4) + self.assertGreaterEqual(len(payload["defect_policy"]), 3) + self.assertTrue(any(item["key"] == "knowledge_enhancement" for item in payload["included"])) + self.assertTrue(any(item["level"] == "must_fix" for item in payload["defect_policy"])) + + def test_pilot_materials_returns_required_pack(self): + client = TestClient(app) + + response = client.get("/api/v1/system/pilot-materials") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["version"], "pilot-materials-2026-05-12") + self.assertEqual(payload["status"], "ready") + material_keys = {item["key"] for item in payload["materials"]} + self.assertSetEqual( + material_keys, + {"pilot_brief", "user_guide", "admin_guide", "feedback_template", "faq"}, + ) + self.assertGreaterEqual(len(payload["feedback_template"]), 5) + self.assertTrue(any(item["key"] == "result_rating" for item in payload["feedback_template"])) + self.assertGreaterEqual(len(payload["acceptance"]), 3) + + def test_pilot_error_feedback_returns_required_guide(self): + client = TestClient(app) + + response = client.get("/api/v1/system/pilot-error-feedback") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["version"], "pilot-error-feedback-2026-05-12") + self.assertEqual(payload["status"], "ready") + feedback_keys = {item["key"] for item in payload["items"]} + self.assertSetEqual( + feedback_keys, + { + "ocr_pdf_parse_failed", + "pdf_quality_insufficient", + "auto_review_failed", + "chat_response_failed", + }, + ) + self.assertTrue(all(item["user_message"] for item in payload["items"])) + self.assertTrue(all(item["next_actions"] for item in payload["items"])) + self.assertTrue(any(item["qa_selector"] == "chat-exception-feedback" for item in payload["items"])) + self.assertGreaterEqual(len(payload["acceptance"]), 4) + + def test_pilot_launch_pack_returns_demo_script_and_gates(self): + client = TestClient(app) + + response = client.get("/api/v1/system/pilot-launch-pack") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["version"], "pilot-launch-pack-2026-05-12") + self.assertEqual(payload["status"], "ready") + step_keys = {item["key"] for item in payload["script_steps"]} + self.assertSetEqual( + step_keys, + { + "kickoff_alignment", + "business_contract_generation", + "legal_auto_review", + "review_chat_followup", + "report_feedback_handoff", + }, + ) + gate_keys = {item["key"] for item in payload["readiness_gates"]} + self.assertTrue({"system_readiness", "pilot_scope_frozen", "pilot_materials_ready"}.issubset(gate_keys)) + self.assertGreaterEqual(len(payload["roles"]), 5) + self.assertTrue(any(item["key"] == "readiness_failed" for item in payload["blockers"])) + self.assertGreaterEqual(len(payload["acceptance"]), 4) + def test_readiness_warns_when_default_admin_password_is_active(self): app.dependency_overrides[get_db_session] = lambda: StubReadyDb(password="123456") client = TestClient(app) @@ -213,14 +406,17 @@ class SystemApiTestCase(unittest.TestCase): self.assertEqual(response.status_code, 200) payload = response.json()["data"] - self.assertEqual(payload["mode"], "local-only") + self.assertEqual(payload["mode"], "configuration-required") self.assertFalse(payload["remote_configured"]) - self.assertEqual(payload["knowledge_defaults"]["retrieval_mode"], "local-bm25") - self.assertTrue(payload["capabilities"]["chat_stream"]) + self.assertEqual(payload["transport"]["fallback"], "disabled") + self.assertEqual(payload["knowledge_defaults"]["retrieval_mode"], "unknown") + self.assertFalse(payload["capabilities"]["chat_stream"]) self.assertFalse(payload["capabilities"]["legal_review_domain_pack"]) + self.assertFalse(payload["capabilities"]["local_legal_review_fallback"]) def test_shared_ai_status_reports_remote_preferred_mode(self): settings.shared_ai_platform_base_url = "http://127.0.0.1:8011" + settings.shared_ai_platform_api_key = "test-key" settings.shared_ai_platform_api_prefix = "/api/v0.1" settings.shared_ai_platform_app_id = "ai-contract-platform" settings.shared_ai_platform_timeout_seconds = 45.0 @@ -240,12 +436,36 @@ class SystemApiTestCase(unittest.TestCase): self.assertEqual(payload["mode"], "remote-preferred") self.assertTrue(payload["remote_configured"]) self.assertEqual(payload["base_url"], "http://127.0.0.1:8011") - self.assertEqual(payload["transport"]["sdk"], "vendored-python-sdk") - self.assertEqual(payload["transport"]["fallback"], "local-fallback") + self.assertEqual(payload["transport"]["sdk"], "local-shared-ai-sdk") + self.assertEqual(payload["transport"]["fallback"], "disabled") self.assertEqual(payload["knowledge_defaults"]["retrieval_mode"], "hybrid") self.assertTrue(payload["capabilities"]["legal_review_domain_pack"]) self.assertTrue(payload["capabilities"]["local_legal_review_fallback"]) + def test_shared_ai_status_requires_api_key_for_remote_mode(self): + settings.shared_ai_platform_base_url = "http://127.0.0.1:8011" + settings.shared_ai_platform_api_key = "" + settings.shared_ai_platform_legal_review_enabled = True + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-member", + "tenant_id": "tenant-demo-001", + "role": "member", + } + client = TestClient(app) + + response = client.get("/api/v1/system/shared-ai") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["mode"], "configuration-required") + self.assertFalse(payload["remote_configured"]) + self.assertFalse(payload["capabilities"]["chat_stream"]) + self.assertFalse(payload["capabilities"]["legal_review_domain_pack"]) + self.assertFalse(payload["capabilities"]["local_legal_review_fallback"]) + self.assertEqual(payload["transport"]["sdk"], "disabled") + self.assertEqual(payload["knowledge_defaults"]["retrieval_mode"], "unknown") + self.assertEqual(payload["base_url"], "http://127.0.0.1:8011") + def test_admin_snapshot_aggregates_management_views(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", @@ -268,6 +488,46 @@ class SystemApiTestCase(unittest.TestCase): self.assertEqual(payload["governance"]["pending_assignments"], 2) self.assertEqual(payload["governance"]["pending_assignment_items"][0]["asset_name"], "Procurement Template") + def test_admin_can_seed_pilot_demo_assets(self): + audit_log_service = StubAuditLogService() + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + app.dependency_overrides[get_db_session] = lambda: StubReadyDb() + app.dependency_overrides[get_shared_ai_platform_client] = lambda: StubSharedAIPlatformClient() + app.dependency_overrides[get_template_service] = lambda: StubTemplateService() + app.dependency_overrides[get_audit_log_service] = lambda: audit_log_service + client = TestClient(app) + + response = client.post("/api/v1/system/pilot-demo-assets/seed") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["templates"]["total"], 3) + self.assertEqual(payload["knowledge"]["status"], "ready") + self.assertEqual(payload["knowledge"]["knowledge_base"]["name"], "AI合同试点演示知识库") + self.assertEqual(payload["knowledge"]["document"]["status"], "indexed") + self.assertIn("legal", payload["demo_accounts"]["roles"]) + self.assertEqual(audit_log_service.events[0]["action"], "seed_pilot_demo_assets") + + def test_non_admin_cannot_seed_pilot_demo_assets(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-legal", + "tenant_id": "tenant-demo-001", + "role": "legal", + } + app.dependency_overrides[get_db_session] = lambda: StubReadyDb() + app.dependency_overrides[get_shared_ai_platform_client] = lambda: StubSharedAIPlatformClient() + app.dependency_overrides[get_template_service] = lambda: StubTemplateService() + app.dependency_overrides[get_audit_log_service] = lambda: StubAuditLogService() + client = TestClient(app) + + response = client.post("/api/v1/system/pilot-demo-assets/seed") + + self.assertEqual(response.status_code, 403) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_template_service.py b/backend/tests/test_template_service.py index 71fedbeb..3b6cff20 100644 --- a/backend/tests/test_template_service.py +++ b/backend/tests/test_template_service.py @@ -29,44 +29,89 @@ class StubRepository: def get_by_id(self, template_id): return self.items.get(template_id) - def list_paginated(self, tenant_id, page, page_size, contract_type=None): + def get_by_name(self, tenant_id, name): + normalized_name = name.strip().lower() + for item in self.items.values(): + if item.tenant_id == tenant_id and item.name.strip().lower() == normalized_name: + return item + return None + + def list_paginated(self, tenant_id, page, page_size, contract_type=None, template_category=None, industry_category=None): items = [item for item in self.items.values() if item.tenant_id == tenant_id] if contract_type: items = [item for item in items if item.contract_type == contract_type] + if template_category: + items = [item for item in items if item.template_category == template_category] + if industry_category: + items = [item for item in items if item.industry_category == industry_category] items.sort(key=lambda item: (item.updated_at, item.created_at), reverse=True) start = (page - 1) * page_size return items[start : start + page_size] - def count(self, tenant_id, contract_type=None): - return len(self.list_paginated(tenant_id, 1, 10000, contract_type)) + def count(self, tenant_id, contract_type=None, template_category=None, industry_category=None): + return len(self.list_paginated(tenant_id, 1, 10000, contract_type, template_category, industry_category)) + + +class StubVersionRepository: + def __init__(self): + self.items = [] + + def create(self, version): + self.items.append(version) + return version + + def list_by_template_id(self, tenant_id, template_id): + versions = [ + item + for item in self.items + if item.tenant_id == tenant_id and item.template_id == template_id + ] + versions.sort(key=lambda item: item.created_at, reverse=True) + return versions + + def clear_current(self, tenant_id, template_id): + for item in self.items: + if item.tenant_id == tenant_id and item.template_id == template_id: + item.is_current = False class TemplateServiceTestCase(unittest.TestCase): def setUp(self): self.service = TemplateService(db=None) self.service.repository = StubRepository() - self.current_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"} + self.service.version_repository = StubVersionRepository() + self.current_user = { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + "display_name": "Admin", + } self.other_user = {"id": "user-other", "tenant_id": "tenant-other", "role": "admin"} self.member_user = {"id": "user-member", "tenant_id": "tenant-demo-001", "role": "member"} def test_admin_can_create_and_list_templates(self): created = self.service.create_template( self.current_user, - name=" 劳动合同模板 ", + name=" Employment Template ", contract_type=" labor ", - description=" 标准劳动合同 ", - content=" 第一条 工作内容 ", - variables_json='[{"name": "employee_name", "label": "员工姓名"}]', + description=" Standard employment template ", + content=" Clause one ", + variables_json='[{"name": "employee_name", "label": "Employee Name"}]', enabled=True, + change_summary="Initial import", ) listed = self.service.list_templates(self.current_user, page=0, page_size=200, contract_type=" labor ") - self.assertEqual(created["name"], "劳动合同模板") + self.assertEqual(created["name"], "Employment Template") self.assertEqual(created["contract_type"], "labor") - self.assertEqual(created["description"], "标准劳动合同") - self.assertEqual(created["content"], "第一条 工作内容") + self.assertEqual(created["description"], "Standard employment template") + self.assertEqual(created["content"], "Clause one") self.assertEqual(created["variables"][0]["name"], "employee_name") + self.assertEqual(created["template_category"], "employment-hr") + self.assertEqual(created["industry_category"], "general-enterprise") + self.assertEqual(created["current_version_label"], "v1.0") + self.assertEqual(created["version_count"], 1) self.assertEqual(listed["page"], 1) self.assertEqual(listed["page_size"], 100) self.assertEqual(listed["total"], 1) @@ -75,10 +120,10 @@ class TemplateServiceTestCase(unittest.TestCase): def test_update_template_requires_same_tenant(self): created = self.service.create_template( self.current_user, - name="采购合同模板", + name="Procurement Template", contract_type="procurement", description=None, - content="付款条款", + content="Payment clause", variables_json=None, enabled=True, ) @@ -87,10 +132,10 @@ class TemplateServiceTestCase(unittest.TestCase): self.service.update_template( self.other_user, created["id"], - name="采购合同模板-更新", + name="Procurement Template Updated", contract_type="procurement", - description="跨租户", - content="付款条款更新", + description="Cross-tenant", + content="Updated payment clause", variables_json=None, enabled=False, ) @@ -101,10 +146,10 @@ class TemplateServiceTestCase(unittest.TestCase): def test_delete_template_removes_item(self): created = self.service.create_template( self.current_user, - name="服务合同模板", + name="Service Template", contract_type="service", description=None, - content="服务范围", + content="Service scope", variables_json=None, enabled=True, ) @@ -122,46 +167,121 @@ class TemplateServiceTestCase(unittest.TestCase): self.assertEqual(context.exception.status_code, 403) self.assertEqual(context.exception.detail, "forbidden") - def test_admin_can_update_template_with_multiple_variables(self): + def test_admin_can_update_template_with_multiple_variables_and_version_bump(self): created = self.service.create_template( self.current_user, - name="采购合同模板", + name="Procurement Template", contract_type="procurement", - description="原始说明", - content="原始内容", - variables_json='[{"name": "supplier_name", "label": "供应商名称"}]', + description="Original description", + content="Original content", + variables_json='[{"name": "supplier_name", "label": "Supplier Name"}]', enabled=True, ) updated = self.service.update_template( self.current_user, created["id"], - name="采购合同模板-更新", + name="Procurement Template Updated", contract_type="procurement", - description="更新说明", - content="更新后的内容", - variables_json='[{"name": "supplier_name", "label": "供应商名称"}, {"name": "contract_amount", "label": "合同金额"}]', + description="Updated description", + content="Updated content", + variables_json='[{"name": "supplier_name", "label": "Supplier Name"}, {"name": "contract_amount", "label": "Contract Amount"}]', enabled=False, + change_summary="Add amount variable", ) - self.assertEqual(updated["name"], "采购合同模板-更新") + self.assertEqual(updated["name"], "Procurement Template Updated") self.assertFalse(updated["enabled"]) self.assertEqual( updated["variables"], [ - {"name": "supplier_name", "label": "供应商名称"}, - {"name": "contract_amount", "label": "合同金额"}, + {"name": "supplier_name", "label": "Supplier Name"}, + {"name": "contract_amount", "label": "Contract Amount"}, ], ) + self.assertEqual(updated["current_version_label"], "v1.1") + self.assertEqual(updated["latest_change_summary"], "Add amount variable") + self.assertEqual(updated["version_count"], 2) + self.assertEqual(updated["template_category"], "procurement-supply") + self.assertEqual(updated["industry_category"], "general-enterprise") + + def test_get_template_versions_returns_current_and_history(self): + created = self.service.create_template( + self.current_user, + name="Procurement Template", + contract_type="procurement", + description="Original description", + content="Original content", + variables_json='[{"name": "supplier_name", "label": "Supplier Name"}]', + enabled=True, + change_summary="Initial version", + ) + + self.service.update_template( + self.current_user, + created["id"], + name="Procurement Template Updated", + contract_type="procurement", + description="Updated description", + content="Updated content", + variables_json='[{"name": "supplier_name", "label": "Supplier Name"}]', + enabled=True, + change_summary="Add payment clause", + ) + + versions = self.service.get_template_versions(self.current_user, created["id"]) + + self.assertEqual(versions["current_version_label"], "v1.1") + self.assertEqual(versions["version_count"], 2) + self.assertEqual(len(versions["items"]), 2) + self.assertEqual(versions["items"][0]["version_label"], "v1.1") + self.assertEqual(versions["items"][0]["change_summary"], "Add payment clause") + self.assertEqual(versions["items"][0]["template_category"], "procurement-supply") + self.assertTrue(versions["items"][0]["is_current"]) + self.assertEqual(versions["items"][1]["version_label"], "v1.0") + + def test_list_templates_supports_template_and_industry_filters(self): + self.service.create_template( + self.current_user, + name="Employment Template", + contract_type="labor", + description=None, + content="Employment clause", + variables_json=None, + enabled=True, + ) + self.service.create_template( + self.current_user, + name="Service Template", + contract_type="service", + description=None, + content="Service clause", + variables_json=None, + enabled=True, + template_category="service-delivery", + industry_category="professional-services", + ) + + filtered = self.service.list_templates( + self.current_user, + contract_type=None, + page=1, + page_size=20, + template_category="service-delivery", + industry_category="professional-services", + ) + + self.assertEqual(filtered["total"], 1) + self.assertEqual(filtered["items"][0]["name"], "Service Template") def test_variables_json_must_be_valid_object_array(self): with self.assertRaises(HTTPException) as invalid_json_context: self.service.create_template( self.current_user, - name="模板", + name="Template", contract_type=None, description=None, - content="内容", + content="Content", variables_json="not-json", enabled=True, ) @@ -171,10 +291,10 @@ class TemplateServiceTestCase(unittest.TestCase): with self.assertRaises(HTTPException) as invalid_type_context: self.service.create_template( self.current_user, - name="模板", + name="Template", contract_type=None, description=None, - content="内容", + content="Content", variables_json='{"name": "employee_name"}', enabled=True, ) @@ -184,10 +304,10 @@ class TemplateServiceTestCase(unittest.TestCase): with self.assertRaises(HTTPException) as invalid_item_context: self.service.create_template( self.current_user, - name="模板", + name="Template", contract_type=None, description=None, - content="内容", + content="Content", variables_json='["employee_name"]', enabled=True, ) @@ -197,10 +317,10 @@ class TemplateServiceTestCase(unittest.TestCase): with self.assertRaises(HTTPException) as invalid_nested_item_context: self.service.create_template( self.current_user, - name="模板", + name="Template", contract_type=None, description=None, - content="内容", + content="Content", variables_json='[{"name": "employee_name"}, ["invalid"]]', enabled=True, ) diff --git a/backend/tests/test_templates_api.py b/backend/tests/test_templates_api.py index 35b2a8c4..6700c2d4 100644 --- a/backend/tests/test_templates_api.py +++ b/backend/tests/test_templates_api.py @@ -22,19 +22,25 @@ class StubTemplateService: def _parse_variables(variables_json): return json.loads(variables_json) if variables_json else [] - def list_templates(self, current_user, page, page_size, contract_type): + def list_templates(self, current_user, page, page_size, contract_type, template_category=None, industry_category=None): if current_user["role"] != "admin": raise HTTPException(status_code=403, detail="forbidden") return { "items": [ { "id": "tpl-123", - "name": "劳动合同模板", + "name": "Employment Template", "contract_type": contract_type or "labor", - "description": "标准劳动合同", - "content": "第一条 工作内容", - "variables": [{"name": "employee_name", "label": "员工姓名"}], + "template_category": template_category or "employment-hr", + "industry_category": industry_category or "general-enterprise", + "description": "Standard employment template", + "content": "Clause one", + "variables": [{"name": "employee_name", "label": "Employee Name"}], "enabled": True, + "current_version_label": "v1.2", + "latest_change_summary": "Add probation clause", + "latest_version_created_at": "2026-04-17T00:00:00+00:00", + "version_count": 3, "created_by": "user-admin", "updated_by": "user-admin", "created_at": "2026-04-16T00:00:00+00:00", @@ -46,34 +52,74 @@ class StubTemplateService: "total": 1, } - def create_template(self, current_user, name, contract_type, description, content, variables_json, enabled): + def get_template_versions(self, current_user, template_id): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "template_id": template_id, + "current_version_label": "v1.2", + "version_count": 3, + "items": [ + { + "id": "tplver-003", + "template_id": template_id, + "version_label": "v1.2", + "change_summary": "Add probation clause", + "name": "Employment Template", + "contract_type": "labor", + "template_category": "employment-hr", + "industry_category": "general-enterprise", + "description": "Standard employment template", + "content": "Clause one", + "variables": [{"name": "employee_name", "label": "Employee Name"}], + "is_current": True, + "created_by": "user-admin", + "created_by_name": "Admin", + "created_at": "2026-04-17T00:00:00+00:00", + } + ], + } + + def create_template(self, current_user, name, contract_type, description, content, variables_json, enabled, change_summary, template_category=None, industry_category=None): if current_user["role"] != "admin": raise HTTPException(status_code=403, detail="forbidden") return { "id": "tpl-123", "name": name, "contract_type": contract_type, + "template_category": template_category or "general-business", + "industry_category": industry_category or "general-enterprise", "description": description, "content": content, "variables": self._parse_variables(variables_json), "enabled": enabled, + "current_version_label": "v1.0", + "latest_change_summary": change_summary or "Initial template version", + "latest_version_created_at": "2026-04-16T00:00:00+00:00", + "version_count": 1, "created_by": current_user["id"], "updated_by": current_user["id"], "created_at": "2026-04-16T00:00:00+00:00", "updated_at": "2026-04-16T00:00:00+00:00", } - def update_template(self, current_user, template_id, name, contract_type, description, content, variables_json, enabled): + def update_template(self, current_user, template_id, name, contract_type, description, content, variables_json, enabled, change_summary, template_category=None, industry_category=None): if current_user["role"] != "admin": raise HTTPException(status_code=403, detail="forbidden") return { "id": template_id, "name": name, "contract_type": contract_type, + "template_category": template_category or "general-business", + "industry_category": industry_category or "general-enterprise", "description": description, "content": content, "variables": self._parse_variables(variables_json), "enabled": enabled, + "current_version_label": "v1.1", + "latest_change_summary": change_summary or "v1.1 template update", + "latest_version_created_at": "2026-04-16T00:00:00+00:00", + "version_count": 2, "created_by": current_user["id"], "updated_by": current_user["id"], "created_at": "2026-04-16T00:00:00+00:00", @@ -97,9 +143,10 @@ class StubTemplateService: raise HTTPException(status_code=403, detail="forbidden") return { "id": template_id, - "name": "劳动合同模板", + "name": "Employment Template", "contract_type": "labor", "enabled": True, + "variables": [], } @@ -123,8 +170,40 @@ class TemplatesApiTestCase(unittest.TestCase): response = self.client.get("/api/v1/templates", params={"contract_type": "labor"}) self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["data"]["items"][0]["name"], "劳动合同模板") - self.assertEqual(response.json()["data"]["items"][0]["contract_type"], "labor") + payload = response.json()["data"]["items"][0] + self.assertEqual(payload["name"], "Employment Template") + self.assertEqual(payload["contract_type"], "labor") + self.assertEqual(payload["template_category"], "employment-hr") + self.assertEqual(payload["industry_category"], "general-enterprise") + self.assertEqual(payload["current_version_label"], "v1.2") + + def test_admin_can_filter_templates_by_template_category(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + response = self.client.get("/api/v1/templates", params={"template_category": "employment-hr"}) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"]["items"][0] + self.assertEqual(payload["template_category"], "employment-hr") + + def test_admin_can_list_template_versions(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + response = self.client.get("/api/v1/templates/tpl-123/versions") + + self.assertEqual(response.status_code, 200) + body = response.json()["data"] + self.assertEqual(body["current_version_label"], "v1.2") + self.assertEqual(body["version_count"], 3) + self.assertEqual(body["items"][0]["version_label"], "v1.2") def test_admin_can_create_template(self): app.dependency_overrides[require_current_user] = lambda: { @@ -136,26 +215,36 @@ class TemplatesApiTestCase(unittest.TestCase): response = self.client.post( "/api/v1/templates", data={ - "name": "采购合同模板", + "name": "Procurement Template", "contract_type": "procurement", - "description": "采购标准模板", - "content": "第一条 采购范围", + "template_category": "procurement-supply", + "industry_category": "general-enterprise", + "description": "Standard procurement template", + "content": "Procurement scope", "variables_json": '[{"name":"supplier_name"}]', "enabled": "true", + "change_summary": "Initial version", }, ) self.assertEqual(response.status_code, 200) - body = response.json() - self.assertEqual(body["data"]["name"], "采购合同模板") + body = response.json()["data"] + self.assertEqual(body["name"], "Procurement Template") + self.assertEqual(body["template_category"], "procurement-supply") + self.assertEqual(body["industry_category"], "general-enterprise") + self.assertEqual(body["current_version_label"], "v1.0") self.assertEqual(self.audit_log_service.events[0]["action"], "create_template") self.assertEqual( self.audit_log_service.events[0]["detail_json"], { - "name": "采购合同模板", + "name": "Procurement Template", "contract_type": "procurement", + "template_category": "procurement-supply", + "industry_category": "general-enterprise", "enabled": True, "variable_count": 1, + "current_version_label": "v1.0", + "latest_change_summary": "Initial version", }, ) @@ -169,27 +258,37 @@ class TemplatesApiTestCase(unittest.TestCase): response = self.client.put( "/api/v1/templates/tpl-123", data={ - "name": "采购合同模板-更新", + "name": "Procurement Template Updated", "contract_type": "procurement", - "description": "更新说明", - "content": "第二条 付款安排", + "template_category": "procurement-supply", + "industry_category": "general-enterprise", + "description": "Updated description", + "content": "Payment arrangement", "variables_json": '[{"name":"supplier_name"}]', "enabled": "false", + "change_summary": "Add payment clause", }, ) self.assertEqual(response.status_code, 200) - body = response.json() - self.assertEqual(body["data"]["id"], "tpl-123") - self.assertFalse(body["data"]["enabled"]) + body = response.json()["data"] + self.assertEqual(body["id"], "tpl-123") + self.assertFalse(body["enabled"]) + self.assertEqual(body["template_category"], "procurement-supply") + self.assertEqual(body["industry_category"], "general-enterprise") + self.assertEqual(body["current_version_label"], "v1.1") self.assertEqual(self.audit_log_service.events[0]["action"], "update_template") self.assertEqual( self.audit_log_service.events[0]["detail_json"], { - "name": "采购合同模板-更新", + "name": "Procurement Template Updated", "contract_type": "procurement", + "template_category": "procurement-supply", + "industry_category": "general-enterprise", "enabled": False, "variable_count": 1, + "current_version_label": "v1.1", + "latest_change_summary": "Add payment clause", }, ) @@ -203,25 +302,21 @@ class TemplatesApiTestCase(unittest.TestCase): response = self.client.put( "/api/v1/templates/tpl-123", data={ - "name": "采购合同模板-多变量", + "name": "Procurement Template Updated", "contract_type": "procurement", - "description": "更新说明", - "content": "第二条 付款安排", - "variables_json": '[{"name":"supplier_name","label":"供应商名称"},{"name":"contract_amount","label":"合同金额"}]', + "template_category": "procurement-supply", + "industry_category": "general-enterprise", + "description": "Updated description", + "content": "Payment arrangement", + "variables_json": '[{"name":"supplier_name","label":"Supplier Name"},{"name":"contract_amount","label":"Contract Amount"}]', "enabled": "true", + "change_summary": "Add amount variable", }, ) self.assertEqual(response.status_code, 200) - body = response.json() - self.assertEqual(body["data"]["name"], "采购合同模板-多变量") - self.assertEqual( - body["data"]["variables"], - [ - {"name": "supplier_name", "label": "供应商名称"}, - {"name": "contract_amount", "label": "合同金额"}, - ], - ) + body = response.json()["data"] + self.assertEqual(body["variables"][1]["name"], "contract_amount") self.assertEqual(self.audit_log_service.events[0]["action"], "update_template") def test_admin_can_seed_default_templates(self): @@ -234,8 +329,8 @@ class TemplatesApiTestCase(unittest.TestCase): response = self.client.post("/api/v1/templates/seed-defaults") self.assertEqual(response.status_code, 200) - body = response.json() - self.assertEqual(body["data"]["total"], 3) + body = response.json()["data"] + self.assertEqual(body["total"], 3) self.assertEqual(self.audit_log_service.events[0]["action"], "seed_default_templates") self.assertEqual( self.audit_log_service.events[0]["detail_json"], @@ -261,7 +356,7 @@ class TemplatesApiTestCase(unittest.TestCase): self.assertEqual( self.audit_log_service.events[0]["detail_json"], { - "name": "劳动合同模板", + "name": "Employment Template", "contract_type": "labor", "enabled": True, "variable_count": 0, diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py new file mode 100644 index 00000000..4abd5f71 --- /dev/null +++ b/backend/tests/test_user_service.py @@ -0,0 +1,95 @@ +import unittest + +from fastapi import HTTPException, status +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.db import Base +from app.models.user import User +from app.services.user_service import UserService + + +class UserServiceRoleTestCase(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=self.engine) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) + self.db = self.SessionLocal() + self.service = UserService(self.db) + self.admin_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"} + self.db.add( + User( + id="user-admin", + tenant_id="tenant-demo-001", + username="admin@example.com", + display_name="管理员", + password_hash="hash", + role="admin", + status="active", + ) + ) + self.db.commit() + + def tearDown(self): + self.db.close() + self.engine.dispose() + + def test_create_user_accepts_legal_and_business_roles(self): + legal_user = self.service.create_user( + self.admin_user, + "legal@example.com", + "法务用户", + "123456", + "legal", + "active", + ) + business_user = self.service.create_user( + self.admin_user, + "business@example.com", + "业务用户", + "123456", + "business", + "active", + ) + + self.assertEqual(legal_user["role"], "legal") + self.assertEqual(business_user["role"], "business") + + def test_update_user_accepts_legacy_member_role(self): + created = self.service.create_user( + self.admin_user, + "legacy@example.com", + "历史成员", + "123456", + "business", + "active", + ) + + updated = self.service.update_user( + self.admin_user, + created["id"], + "legacy@example.com", + "历史成员", + None, + "member", + "active", + ) + + self.assertEqual(updated["role"], "member") + + def test_invalid_role_rejected(self): + with self.assertRaises(HTTPException) as ctx: + self.service.create_user( + self.admin_user, + "invalid@example.com", + "非法角色", + "123456", + "viewer", + "active", + ) + + self.assertEqual(ctx.exception.status_code, status.HTTP_400_BAD_REQUEST) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_users_api.py b/backend/tests/test_users_api.py index 3fc3b368..745dbcfc 100644 --- a/backend/tests/test_users_api.py +++ b/backend/tests/test_users_api.py @@ -27,7 +27,7 @@ class StubUserService: "tenant_id": current_user["tenant_id"], "username": "member@example.com", "display_name": "法务同事", - "role": "member", + "role": "legal", "status": "active", "created_at": "2026-04-16T00:00:00+00:00", } @@ -99,7 +99,7 @@ class UsersApiTestCase(unittest.TestCase): "username": "new.user@example.com", "display_name": "新同事", "password": "123456", - "role": "member", + "role": "legal", "status": "active", }, ) @@ -112,7 +112,7 @@ class UsersApiTestCase(unittest.TestCase): { "username": "new.user@example.com", "display_name": "新同事", - "role": "member", + "role": "legal", "status": "active", }, ) @@ -131,14 +131,14 @@ class UsersApiTestCase(unittest.TestCase): "username": "member@example.com", "display_name": "法务负责人", "password": "", - "role": "admin", + "role": "business", "status": "disabled", }, ) self.assertEqual(response.status_code, 200) body = response.json() - self.assertEqual(body["data"]["role"], "admin") + self.assertEqual(body["data"]["role"], "business") self.assertEqual(body["data"]["status"], "disabled") self.assertEqual(self.audit_log_service.events[0]["action"], "update_user") self.assertEqual( @@ -146,7 +146,7 @@ class UsersApiTestCase(unittest.TestCase): { "username": "member@example.com", "display_name": "法务负责人", - "role": "admin", + "role": "business", "status": "disabled", "password_changed": False, }, diff --git a/backend/tools/legal_review_dual_run.py b/backend/tools/legal_review_dual_run.py new file mode 100644 index 00000000..ee0cfc82 --- /dev/null +++ b/backend/tools/legal_review_dual_run.py @@ -0,0 +1,244 @@ +import json +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient +from app.models.review_task import ReviewTask +from app.services.review_service import ReviewService + + +RUNTIME_DIR = Path("D:/shared-ai-platform-java/_runtime/legal-review-dual-run") +CURRENT_USER = { + "id": "dual-run-admin-001", + "tenant_id": "dual-run-tenant-001", + "role": "admin", +} +SAMPLES = [ + { + "sample_id": "sample-001-procurement", + "title": "采购合同审查", + "contract_type": "procurement", + "review_as": "party_a", + "text": "本合同包含自动续约条款,签署后5日内支付50%预付款,验收合格后支付尾款。", + }, + { + "sample_id": "sample-002-service", + "title": "服务合同审查", + "contract_type": "service", + "review_as": "party_a", + "text": "甲方:云平台公司\n乙方:实施服务商\n合同金额:人民币100万元\n服务期限:自2026年1月1日至2026年12月31日\n乙方应对客户资料承担保密义务,违约金按合同金额10%计算。", + }, + { + "sample_id": "sample-003-nda", + "title": "保密协议审查", + "contract_type": "nda", + "review_as": "party_a", + "text": "双方应对商业秘密承担保密义务,未经许可不得披露。责任限制不适用于保密义务违约。", + }, +] + + +class StubRepository: + def __init__(self): + self.items = {} + + def get_by_id(self, review_id, tenant_id=None, user_id=None): + item = self.items.get(review_id) + if item is None: + return None + if tenant_id is not None and item.tenant_id != tenant_id: + return None + if user_id is not None and item.user_id != user_id: + return None + return item + + +class StubRuleRepository: + def list_active_for_contract_type(self, tenant_id, contract_type): + return [] + + +class StubReviewResultRepository: + def get_by_review_task_id(self, review_task_id): + return None + + +class StubFileService: + def create_file_record(self, *args, **kwargs): + raise NotImplementedError + + def get_download_payload(self, *args, **kwargs): + raise NotImplementedError + + +@dataclass +class DualRunResult: + sample_id: str + java_result: dict[str, Any] + python_fallback_result: dict[str, Any] + diff_summary: dict[str, Any] + + +def build_remote_client() -> RemoteSharedAIPlatformClient: + return RemoteSharedAIPlatformClient( + base_url=os.getenv("SHARED_AI_PLATFORM_BASE_URL", "http://127.0.0.1:18011"), + app_id=os.getenv("SHARED_AI_PLATFORM_APP_ID", "ai-contract-platform"), + api_key=os.getenv("SHARED_AI_PLATFORM_API_KEY", "shared-ai-platform-admin-dev-token"), + timeout_seconds=float(os.getenv("SHARED_AI_PLATFORM_TIMEOUT_SECONDS", "30")), + ) + + +def build_review_service() -> ReviewService: + service = ReviewService(db=None) + service.repository = StubRepository() + service.review_rule_repository = StubRuleRepository() + service.review_result_repository = StubReviewResultRepository() + service.file_service = StubFileService() + return service + + +def run_java_review(client: RemoteSharedAIPlatformClient, sample: dict[str, Any]) -> dict[str, Any]: + return client.run_legal_review(CURRENT_USER, sample) + + +def run_python_fallback(service: ReviewService, sample: dict[str, Any]) -> dict[str, Any]: + review = ReviewTask( + id=f"fallback-{sample['sample_id']}", + tenant_id=CURRENT_USER["tenant_id"], + user_id=CURRENT_USER["id"], + title=sample["title"], + contract_type=sample["contract_type"], + review_as=sample["review_as"], + status="pending", + source_type="text", + input_text=sample["text"], + extracted_text=None, + ) + raw_risk_items = service._build_risk_items(review) + raw_risk_summary = service._build_risk_summary(raw_risk_items) + return service._build_report_payload( + review, + { + "basic_info": { + "contract_type": review.contract_type, + "party_a": "甲方", + "party_b": "乙方", + "amount": "待识别", + "term": "待识别", + }, + "risk_summary": raw_risk_summary, + "clauses": service._build_clauses(review), + "risk_items": raw_risk_items, + "review_opinion": service._build_review_opinion(raw_risk_summary), + "recommendation": "manual_review", + }, + ) + + +def compare_results(sample_id: str, java_result: dict[str, Any], python_result: dict[str, Any]) -> dict[str, Any]: + field_differences = [] + missing_fields = [] + + def compare_path(label: str, java_value: Any, python_value: Any): + if java_value is None and python_value is None: + return + if java_value is None or python_value is None: + missing_fields.append({"field": label, "java": java_value is not None, "python": python_value is not None}) + return + if java_value != python_value: + field_differences.append({"field": label, "java": java_value, "python": python_value}) + + compare_path("basic_info.contract_type", java_result.get("basic_info", {}).get("contract_type"), python_result.get("basic_info", {}).get("contract_type")) + compare_path("contract_structure.detected_contract_type", java_result.get("contract_structure", {}).get("detected_contract_type"), python_result.get("contract_structure", {}).get("detected_contract_type")) + compare_path("contract_structure.clause_count", java_result.get("contract_structure", {}).get("clause_count"), python_result.get("contract_structure", {}).get("clause_count")) + compare_path("risk_summary.overall", java_result.get("risk_summary", {}).get("overall"), python_result.get("risk_summary", {}).get("overall")) + compare_path("recommendation", java_result.get("recommendation"), python_result.get("recommendation")) + compare_path( + "confidence_summary.structured_analysis", + java_result.get("confidence_summary", {}).get("structured_analysis"), + python_result.get("confidence_summary", {}).get("structured_analysis"), + ) + + java_risk_titles = [item.get("title") for item in java_result.get("risk_items", [])] + python_risk_titles = [item.get("title") for item in python_result.get("risk_items", [])] + compare_path("risk_items.titles", java_risk_titles, python_risk_titles) + + java_milestones = java_result.get("contract_structure", {}).get("payment_milestones", []) + python_milestones = python_result.get("contract_structure", {}).get("payment_milestones", []) + compare_path("contract_structure.payment_milestones", java_milestones, python_milestones) + + java_relationships = java_result.get("contract_structure", {}).get("clause_relationships", []) + python_relationships = python_result.get("contract_structure", {}).get("clause_relationships", []) + compare_path("contract_structure.clause_relationships", java_relationships, python_relationships) + + if missing_fields: + match_status = "investigate" + elif not field_differences: + match_status = "match" + elif len(field_differences) <= 5: + match_status = "acceptable_diff" + else: + match_status = "investigate" + + return { + "sample_id": sample_id, + "generated_at": datetime.now(timezone.utc).isoformat(), + "match_status": match_status, + "missing_fields": missing_fields, + "field_differences": field_differences, + "notes": [ + "允许文案细微差异,但不允许结构性缺项。", + "当前第一版重点关注 contract_structure、risk_items、structured_analysis。", + ], + } + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def run_dual_run(samples: list[dict[str, Any]] | None = None) -> list[DualRunResult]: + client = build_remote_client() + service = build_review_service() + results = [] + sample_list = samples or SAMPLES + for sample in sample_list: + java_result = run_java_review(client, sample) + python_result = run_python_fallback(service, sample) + diff_summary = compare_results(sample["sample_id"], java_result, python_result) + + sample_dir = RUNTIME_DIR / sample["sample_id"] + sample_dir.mkdir(parents=True, exist_ok=True) + write_json(sample_dir / "java_result.json", java_result) + write_json(sample_dir / "python_fallback_result.json", python_result) + write_json(sample_dir / "diff_summary.json", diff_summary) + + results.append(DualRunResult(sample["sample_id"], java_result, python_result, diff_summary)) + return results + + +def main() -> None: + results = run_dual_run() + print(json.dumps( + { + "output_dir": str(RUNTIME_DIR), + "samples": [ + { + "sample_id": result.sample_id, + "match_status": result.diff_summary["match_status"], + "difference_count": len(result.diff_summary["field_differences"]), + "missing_count": len(result.diff_summary["missing_fields"]), + } + for result in results + ], + }, + ensure_ascii=False, + indent=2, + )) + + +if __name__ == "__main__": + main() diff --git a/backend/tools/pdf_acceptance_gate.py b/backend/tools/pdf_acceptance_gate.py index 132b8911..8cf4f683 100644 --- a/backend/tools/pdf_acceptance_gate.py +++ b/backend/tools/pdf_acceptance_gate.py @@ -496,16 +496,57 @@ def register_and_login(client: httpx.Client, api_base: str, password: str) -> Di return {"username": username, "token": login_data["access_token"]} +def request_with_retry( + client: httpx.Client, + method: str, + url: str, + *, + headers: Optional[Dict[str, str]] = None, + max_attempts: int = 4, + retry_delay_seconds: float = 1.0, +) -> httpx.Response: + last_error: Optional[Exception] = None + for attempt in range(1, max_attempts + 1): + try: + response = client.request(method, url, headers=headers) + response.raise_for_status() + return response + except (httpx.TimeoutException, httpx.ReadError, httpx.ConnectError) as exc: + last_error = exc + if attempt == max_attempts: + raise + time.sleep(retry_delay_seconds * attempt) + assert last_error is not None + raise last_error + + +def wait_for_review_completion( + client: httpx.Client, + api_base: str, + review_id: str, + headers: Dict[str, str], + *, + max_polls: int = 6, + poll_delay_seconds: float = 1.5, +) -> Dict: + last_detail_json: Optional[Dict] = None + for poll in range(1, max_polls + 1): + detail = request_with_retry(client, "GET", f"{api_base}/reviews/{review_id}", headers=headers) + detail_json = detail.json()["data"] + last_detail_json = detail_json + if detail_json.get("status") == "completed": + return detail_json + time.sleep(poll_delay_seconds) + assert last_detail_json is not None + return last_detail_json + + def validate_success_case(client: httpx.Client, api_base: str, headers: Dict[str, str], case: PdfCase, submit_json: Dict) -> Dict: review_id = submit_json["data"]["id"] - detail = client.get(f"{api_base}/reviews/{review_id}", headers=headers) - detail.raise_for_status() - detail_json = detail.json()["data"] - result = client.get(f"{api_base}/reviews/{review_id}/result", headers=headers) - result.raise_for_status() + detail_json = wait_for_review_completion(client, api_base, review_id, headers) + result = request_with_retry(client, "GET", f"{api_base}/reviews/{review_id}/result", headers=headers) result_json = result.json()["data"] - download = client.get(f"{api_base}/reviews/{review_id}/report/download", headers=headers) - download.raise_for_status() + download = request_with_retry(client, "GET", f"{api_base}/reviews/{review_id}/report/download", headers=headers) preview_text = detail_json.get("source_text_preview") or "" clause_text = "\n".join(item.get("content", "") for item in result_json.get("clauses", [])) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0c6204bd..723f5a34 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,8 +11,8 @@ "@ant-design/icons": "^6.1.1", "@tanstack/react-query": "^5.99.0", "antd": "^6.3.5", - "axios": "^1.15.0", - "next": "16.2.4", + "axios": "^1.16.0", + "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", "zustand": "^5.0.12" @@ -22,7 +22,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.4", + "eslint-config-next": "16.2.6", "playwright": "1.59.1", "typescript": "^5" } @@ -1140,15 +1140,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", - "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz", - "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.6.tgz", + "integrity": "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==", "dev": true, "license": "MIT", "dependencies": { @@ -1156,9 +1156,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", - "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", "cpu": [ "arm64" ], @@ -1172,9 +1172,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", - "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", "cpu": [ "x64" ], @@ -1188,9 +1188,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", - "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", "cpu": [ "arm64" ], @@ -1204,9 +1204,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", - "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", "cpu": [ "arm64" ], @@ -1220,9 +1220,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", - "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", "cpu": [ "x64" ], @@ -1236,9 +1236,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", - "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", "cpu": [ "x64" ], @@ -1252,9 +1252,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", - "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", "cpu": [ "arm64" ], @@ -1268,9 +1268,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", - "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", "cpu": [ "x64" ], @@ -3065,12 +3065,12 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -3791,13 +3791,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz", - "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.6.tgz", + "integrity": "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.4", + "@next/eslint-plugin-next": "16.2.6", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -5432,12 +5432,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", - "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", "license": "MIT", "dependencies": { - "@next/env": "16.2.4", + "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -5451,14 +5451,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.4", - "@next/swc-darwin-x64": "16.2.4", - "@next/swc-linux-arm64-gnu": "16.2.4", - "@next/swc-linux-arm64-musl": "16.2.4", - "@next/swc-linux-x64-gnu": "16.2.4", - "@next/swc-linux-x64-musl": "16.2.4", - "@next/swc-win32-arm64-msvc": "16.2.4", - "@next/swc-win32-x64-msvc": "16.2.4", + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { diff --git a/frontend/package.json b/frontend/package.json index b311e5fb..4326d675 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,14 +13,25 @@ "qa:phase1": "node scripts/phase1-closed-loop-e2e.mjs", "qa:phase2": "node scripts/phase2-closed-loop-e2e.mjs", "qa:phase3": "node scripts/phase3-closed-loop-e2e.mjs", + "qa:slice4": "node scripts/slice4-auto-review-closed-loop-e2e.mjs", + "qa:slice4:shared-ai-stub": "node scripts/slice4-shared-ai-stub.mjs", + "qa:slice5": "node scripts/slice5-pilot-shell-closed-loop-e2e.mjs", + "qa:slice6": "node scripts/slice6-role-pilot-user-closed-loop-e2e.mjs", + "qa:slice7": "node scripts/slice7-demo-assets-closed-loop-e2e.mjs", + "qa:slice8": "node scripts/slice8-pilot-scope-closed-loop-e2e.mjs", + "qa:slice9": "node scripts/slice9-readability-closed-loop-e2e.mjs", + "qa:p0-mainline": "node scripts/p0-mainline-alignment-closed-loop-e2e.mjs", + "qa:slice10": "node scripts/slice10-pilot-materials-closed-loop-e2e.mjs", + "qa:slice11": "node scripts/slice11-error-feedback-closed-loop-e2e.mjs", + "qa:slice12": "node scripts/slice12-pilot-launch-pack-closed-loop-e2e.mjs", "qa:shared-ai-remote": "node scripts/shared-ai-remote-browser-e2e.mjs" }, "dependencies": { "@ant-design/icons": "^6.1.1", "@tanstack/react-query": "^5.99.0", "antd": "^6.3.5", - "axios": "^1.15.0", - "next": "16.2.4", + "axios": "^1.16.0", + "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", "zustand": "^5.0.12" @@ -33,7 +44,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.4", + "eslint-config-next": "16.2.6", "playwright": "1.59.1", "typescript": "^5" } diff --git a/frontend/scripts/p0-mainline-alignment-closed-loop-e2e.mjs b/frontend/scripts/p0-mainline-alignment-closed-loop-e2e.mjs new file mode 100644 index 00000000..ae42b58d --- /dev/null +++ b/frontend/scripts/p0-mainline-alignment-closed-loop-e2e.mjs @@ -0,0 +1,249 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `p0-mainline-alignment-${Date.now()}`; +const webBase = process.env.P0_MAINLINE_WEB_URL || "http://localhost:3000"; +const apiBase = process.env.P0_MAINLINE_API_URL || "http://127.0.0.1:8010/api/v1"; +const healthUrl = process.env.P0_MAINLINE_HEALTH_URL || "http://127.0.0.1:8010/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.P0_MAINLINE_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "p0-mainline-alignment-closed-loop", stamp); +const p0PlanDocPath = path.resolve( + __dirname, + "..", + "..", + "project-docs", + "AI合同起草与审查助手-P0实现主线拆分方案-2026-05-11.md", +); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const state = { + api: null, + browser: null, + contexts: [], + admin: null, +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { id, title, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await state.api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +function assertP0Mainline(payload, label) { + assert(payload.version === "p0-mainline-alignment-2026-05-12", `${label} version mismatch`, payload); + assert(payload.status === "aligned", `${label} status should be aligned`, payload); + assert(payload.phase_boundary?.mainline_scope?.includes("Slice 1-8"), `${label} missing mainline scope`, payload.phase_boundary); + assert(payload.phase_boundary?.next_stage_scope?.includes("Slice 9+"), `${label} missing next stage scope`, payload.phase_boundary); + assert(Array.isArray(payload.capabilities) && payload.capabilities.length === 5, `${label} capability count mismatch`, payload.capabilities); + assert(Array.isArray(payload.guardrails) && payload.guardrails.length >= 5, `${label} guardrail count mismatch`, payload.guardrails); + assert(Array.isArray(payload.acceptance) && payload.acceptance.length >= 4, `${label} acceptance count mismatch`, payload.acceptance); + for (const capability of payload.capabilities) { + assert(capability.source_project, `${label} capability missing source project`, capability); + assert(capability.target_layer, `${label} capability missing target layer`, capability); + assert(capability.contract_platform_responsibility, `${label} capability missing contract responsibility`, capability); + assert(capability.acceptance, `${label} capability missing acceptance`, capability); + assert(Array.isArray(capability.evidence) && capability.evidence.length > 0, `${label} capability missing evidence`, capability); + } +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +async function attachApiProxy(context) { + await context.route("**/api/v1/**", async (route) => { + const browserRequest = route.request(); + const browserUrl = new URL(browserRequest.url()); + const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); + const headers = { ...browserRequest.headers() }; + delete headers.host; + + try { + const response = await state.api.fetch(targetUrl, { + method: browserRequest.method(), + headers, + data: browserRequest.postDataBuffer() ?? undefined, + }); + await route.fulfill({ response }); + } catch { + await route.abort("failed").catch(() => {}); + } + }); +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ viewport: { width: 1440, height: 1400 } }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: userSession.access_token, user: userSession.user }, + ); + await attachApiProxy(context); + state.contexts.push(context); + return context; +} + +async function run() { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "frontend and backend health", async () => { + const [frontend, health] = await Promise.all([state.api.get(`${webBase}/`), state.api.get(healthUrl)]); + assert(frontend.status() === 200, `frontend / status ${frontend.status()}`); + assert(health.status() === 200, `backend /health status ${health.status()}`); + return { webBase, apiBase, healthUrl, artifactDir, runId }; + }); + + await step("DOC-001", "P0 mainline plan document exists", async () => { + assert(fs.existsSync(p0PlanDocPath), "missing P0 mainline plan doc", { p0PlanDocPath }); + const content = fs.readFileSync(p0PlanDocPath, "utf8"); + assert(content.includes("P0-A 文档解析"), "P0 plan missing document parsing lane", { p0PlanDocPath }); + assert(content.includes("P0-E 自动审查闭环"), "P0 plan missing auto review lane", { p0PlanDocPath }); + return { p0PlanDocPath }; + }); + + await step("API-001", "p0 mainline API exposes source and ownership matrix", async () => { + const payload = await apiRequest("get", "/system/p0-mainline"); + expectOk(payload, "p0 mainline"); + assertP0Mainline(payload.body.data, "p0 mainline API"); + return { + version: payload.body.data.version, + capability_count: payload.body.data.capabilities.length, + }; + }); + + await step("READINESS-001", "readiness embeds p0 mainline", async () => { + const payload = await apiRequest("get", "/system/readiness"); + expectOk(payload, "readiness"); + assertP0Mainline(payload.body.data.p0_mainline, "readiness p0 mainline"); + return { + readiness_status: payload.body.data.status, + mainline_version: payload.body.data.p0_mainline.version, + }; + }); + + await step("AUTH-001", "default admin seed and login", async () => { + const seed = await apiRequest("post", "/auth/seed-admin"); + expectOk(seed, "seed default admin"); + state.admin = await login("admin@example.com", adminPassword); + assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); + return { user: state.admin.user.username, role: state.admin.user.role }; + }); + + await step("BROWSER-001", "pilot readiness page exposes mainline alignment", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/pilot-readiness`, { waitUntil: "domcontentloaded" }); + const panel = page.getByTestId("p0-mainline-alignment"); + await panel.waitFor({ state: "visible", timeout: 12000 }); + const capabilityCount = await panel.getByTestId("p0-mainline-capability").count(); + assert(capabilityCount === 5, "mainline alignment should expose five capabilities", { capabilityCount }); + await page.getByTestId("p0-mainline-boundary").waitFor({ state: "visible", timeout: 12000 }); + await page.getByTestId("p0-mainline-guardrails").waitFor({ state: "visible", timeout: 12000 }); + await page.getByTestId("p0-mainline-acceptance").waitFor({ state: "visible", timeout: 12000 }); + await page.screenshot({ path: screenshotPath("p0-mainline-alignment"), fullPage: true }); + return { screenshot: "p0-mainline-alignment.png", capability_count: capabilityCount }; + }); +} + +try { + await run(); +} finally { + for (const context of state.contexts) { + await context.close().catch(() => {}); + } + if (state.browser) { + await state.browser.close().catch(() => {}); + } + if (state.api) { + await state.api.dispose().catch(() => {}); + } + const summary = { + run_id: runId, + artifact_dir: artifactDir, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); + if (summary.failed > 0) { + process.exitCode = 1; + } +} diff --git a/frontend/scripts/phase1-closed-loop-e2e.mjs b/frontend/scripts/phase1-closed-loop-e2e.mjs index 70bdb4ba..14baac71 100644 --- a/frontend/scripts/phase1-closed-loop-e2e.mjs +++ b/frontend/scripts/phase1-closed-loop-e2e.mjs @@ -44,6 +44,23 @@ function assert(condition, message, details = undefined) { } } +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isTransientRequestError(error) { + return /Timeout|ECONNRESET|ECONNREFUSED|ETIMEDOUT|socket hang up/i.test(error?.message || ""); +} + +async function resetApiContext() { + try { + await state.api?.dispose(); + } catch { + // Ignore cleanup errors before creating a fresh API request context. + } + state.api = await request.newContext(); +} + async function step(id, title, fn) { const startedAt = new Date().toISOString(); const started = Date.now(); @@ -77,21 +94,37 @@ async function step(id, title, fn) { } async function apiRequest(method, route, options = {}) { + const { retries = 2, authToken, headers: optionHeaders = {}, ...requestOptions } = options; const headers = { - ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), - ...(options.headers || {}), + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + ...optionHeaders, }; - const response = await state.api[method](apiUrl(route), { ...options, headers }); - const text = await response.text(); - let body = null; - if (text) { + + let lastError = null; + for (let attempt = 0; attempt <= retries; attempt += 1) { try { - body = JSON.parse(text); - } catch { - body = text; + const response = await state.api[method](apiUrl(route), { ...requestOptions, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; + } catch (error) { + lastError = error; + if (attempt >= retries || !isTransientRequestError(error)) { + throw error; + } + await resetApiContext(); + await delay(500 * (attempt + 1)); } } - return { response, body, text }; + + throw lastError; } function expectOk(payload, label) { @@ -208,6 +241,7 @@ async function run() { ].join("\n"); const create = await apiRequest("post", "/reviews", { authToken: state.admin.access_token, + timeout: 90000, multipart: { text, review_as: "party_a", contract_type: "procurement" }, }); expectOk(create, "create text review"); @@ -226,6 +260,7 @@ async function run() { const content = Buffer.from(`${runId}\nThis procurement contract includes payment and confidentiality clauses.`, "utf8"); const create = await apiRequest("post", "/reviews", { authToken: state.admin.access_token, + timeout: 90000, multipart: { file: { name: `${runId}-review.txt`, mimeType: "text/plain", buffer: content }, review_as: "party_b", diff --git a/frontend/scripts/phase2-closed-loop-e2e.mjs b/frontend/scripts/phase2-closed-loop-e2e.mjs index 5417f8b9..4450d412 100644 --- a/frontend/scripts/phase2-closed-loop-e2e.mjs +++ b/frontend/scripts/phase2-closed-loop-e2e.mjs @@ -208,7 +208,14 @@ async function run() { }); expectOk(retryWithoutFile, "retry without file"); assert(retryWithoutFile.body.data.status === "failed", "retry without source should remain failed", retryWithoutFile.body.data); - assert(retryWithoutFile.body.data.error_message?.includes("重新上传"), "retry should explain reupload", retryWithoutFile.body.data); + const retryGuidance = [ + retryWithoutFile.body.data.status_detail, + retryWithoutFile.body.data.next_action, + retryWithoutFile.body.data.error_message, + ] + .filter(Boolean) + .join(" "); + assert(retryGuidance.includes("重新上传"), "retry should explain reupload", retryWithoutFile.body.data); const replacement = await apiRequest("post", `/knowledge-bases/${state.knowledgeBaseId}/documents/${state.failedDocumentId}/retry`, { authToken: state.admin.access_token, diff --git a/frontend/scripts/phase3-closed-loop-e2e.mjs b/frontend/scripts/phase3-closed-loop-e2e.mjs index 36064f3b..0008b217 100644 --- a/frontend/scripts/phase3-closed-loop-e2e.mjs +++ b/frontend/scripts/phase3-closed-loop-e2e.mjs @@ -13,6 +13,7 @@ const apiBase = process.env.PHASE3_API_URL || "http://127.0.0.1:8000/api/v1"; const healthUrl = process.env.PHASE3_HEALTH_URL || "http://127.0.0.1:8000/health"; const readinessUrl = process.env.PHASE3_READINESS_URL || "http://127.0.0.1:8000/api/v1/system/readiness"; const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const shouldSeedAdmin = process.env.PHASE3_SEED_ADMIN !== "false"; const artifactDir = process.env.PHASE3_ARTIFACT_DIR || path.resolve(__dirname, "..", "..", "..", "project-docs", "qa-artifacts", "phase3-closed-loop", stamp); @@ -169,13 +170,17 @@ async function run() { }; }); - await step("AUTH-001", "default admin seed and login", async () => { - const seed = await apiRequest("post", "/auth/seed-admin"); - expectOk(seed, "seed default admin"); + await step("AUTH-001", "admin login", async () => { + let seed = null; + if (shouldSeedAdmin) { + seed = await apiRequest("post", "/auth/seed-admin"); + expectOk(seed, "seed default admin"); + } state.admin = await login("admin@example.com", adminPassword); assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); return { - seed_created: seed.body.data.created, + seed_admin: shouldSeedAdmin, + seed_created: seed?.body?.data?.created ?? null, user: state.admin.user.username, role: state.admin.user.role, }; diff --git a/frontend/scripts/slice10-pilot-materials-closed-loop-e2e.mjs b/frontend/scripts/slice10-pilot-materials-closed-loop-e2e.mjs new file mode 100644 index 00000000..2ee56147 --- /dev/null +++ b/frontend/scripts/slice10-pilot-materials-closed-loop-e2e.mjs @@ -0,0 +1,242 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `slice10-materials-${Date.now()}`; +const webBase = process.env.SLICE10_WEB_URL || "http://localhost:3000"; +const apiBase = process.env.SLICE10_API_URL || "http://127.0.0.1:8010/api/v1"; +const healthUrl = process.env.SLICE10_HEALTH_URL || "http://127.0.0.1:8010/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.SLICE10_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice10-pilot-materials-closed-loop", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const materialDocDir = path.resolve(__dirname, "..", "..", "project-docs", "pilot-materials"); +const materialDocFiles = [ + "AI合同起草与审查助手-试点说明.md", + "AI合同起草与审查助手-用户使用手册.md", + "AI合同起草与审查助手-管理员手册.md", + "AI合同起草与审查助手-试点反馈模板.md", + "AI合同起草与审查助手-FAQ.md", +]; +const state = { + api: null, + browser: null, + contexts: [], + admin: null, +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { id, title, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await state.api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +async function attachApiProxy(context) { + await context.route("**/api/v1/**", async (route) => { + const browserRequest = route.request(); + const browserUrl = new URL(browserRequest.url()); + const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); + const headers = { ...browserRequest.headers() }; + delete headers.host; + + const response = await state.api.fetch(targetUrl, { + method: browserRequest.method(), + headers, + data: browserRequest.postDataBuffer() ?? undefined, + }); + await route.fulfill({ response }); + }); +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ viewport: { width: 1440, height: 1200 } }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: userSession.access_token, user: userSession.user }, + ); + await attachApiProxy(context); + state.contexts.push(context); + return context; +} + +function assertPilotMaterials(materials, label) { + assert(materials.version === "pilot-materials-2026-05-12", `${label} version mismatch`, materials); + assert(materials.status === "ready", `${label} status should be ready`, materials); + const materialKeys = new Set((materials.materials || []).map((item) => item.key)); + for (const key of ["pilot_brief", "user_guide", "admin_guide", "feedback_template", "faq"]) { + assert(materialKeys.has(key), `${label} missing material ${key}`, materials.materials); + } + assert((materials.feedback_template || []).some((item) => item.key === "result_rating"), `${label} missing result rating field`, materials.feedback_template); + assert((materials.acceptance || []).length >= 3, `${label} acceptance too small`, materials.acceptance); +} + +async function run() { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "frontend and backend health", async () => { + const [frontend, health] = await Promise.all([state.api.get(`${webBase}/`), state.api.get(healthUrl)]); + assert(frontend.status() === 200, `frontend / status ${frontend.status()}`); + assert(health.status() === 200, `backend /health status ${health.status()}`); + return { webBase, apiBase, healthUrl, artifactDir, runId }; + }); + + await step("DOC-001", "pilot material markdown files exist", async () => { + const files = materialDocFiles.map((fileName) => { + const filePath = path.join(materialDocDir, fileName); + assert(fs.existsSync(filePath), `missing material doc ${fileName}`); + const stat = fs.statSync(filePath); + assert(stat.size > 120, `material doc too small ${fileName}`, { size: stat.size }); + return { fileName, size: stat.size }; + }); + return { materialDocDir, files }; + }); + + await step("MATERIALS-001", "pilot materials API returns fixed pack", async () => { + const payload = await apiRequest("get", "/system/pilot-materials"); + expectOk(payload, "pilot materials"); + assertPilotMaterials(payload.body.data, "pilot materials API"); + return { + version: payload.body.data.version, + materials: payload.body.data.materials.length, + feedback_fields: payload.body.data.feedback_template.length, + }; + }); + + await step("READINESS-001", "readiness embeds same pilot materials", async () => { + const payload = await apiRequest("get", "/system/readiness"); + expectOk(payload, "readiness"); + assertPilotMaterials(payload.body.data.pilot_materials, "readiness pilot materials"); + return { + readiness_status: payload.body.data.status, + materials_version: payload.body.data.pilot_materials.version, + }; + }); + + await step("AUTH-001", "default admin seed and login", async () => { + const seed = await apiRequest("post", "/auth/seed-admin"); + expectOk(seed, "seed default admin"); + state.admin = await login("admin@example.com", adminPassword); + assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); + return { user: state.admin.user.username, role: state.admin.user.role }; + }); + + await step("BROWSER-001", "pilot readiness page exposes materials pack", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/pilot-readiness`, { waitUntil: "domcontentloaded" }); + const panel = page.getByTestId("pilot-materials-pack"); + await panel.waitFor({ state: "visible", timeout: 12000 }); + await page.getByTestId("pilot-feedback-template").waitFor({ state: "visible", timeout: 12000 }); + const materialCardCount = await panel.locator(".ant-card").count(); + assert(materialCardCount >= 7, "materials pack should expose material, feedback, and acceptance cards", { materialCardCount }); + await page.screenshot({ path: screenshotPath("pilot-materials-pack"), fullPage: true }); + return { screenshot: "pilot-materials-pack.png", material_card_count: materialCardCount }; + }); +} + +try { + await run(); +} finally { + for (const context of state.contexts) { + await context.close().catch(() => {}); + } + if (state.browser) { + await state.browser.close().catch(() => {}); + } + if (state.api) { + await state.api.dispose().catch(() => {}); + } + const summary = { + run_id: runId, + artifact_dir: artifactDir, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); + if (summary.failed > 0) { + process.exitCode = 1; + } +} diff --git a/frontend/scripts/slice11-error-feedback-closed-loop-e2e.mjs b/frontend/scripts/slice11-error-feedback-closed-loop-e2e.mjs new file mode 100644 index 00000000..b946c12d --- /dev/null +++ b/frontend/scripts/slice11-error-feedback-closed-loop-e2e.mjs @@ -0,0 +1,250 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `slice11-error-feedback-${Date.now()}`; +const webBase = process.env.SLICE11_WEB_URL || "http://localhost:3000"; +const apiBase = process.env.SLICE11_API_URL || "http://127.0.0.1:8010/api/v1"; +const healthUrl = process.env.SLICE11_HEALTH_URL || "http://127.0.0.1:8010/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.SLICE11_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice11-error-feedback-closed-loop", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const state = { + api: null, + browser: null, + contexts: [], + admin: null, +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { id, title, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await state.api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +function assertPilotErrorFeedback(feedback, label) { + assert(feedback.version === "pilot-error-feedback-2026-05-12", `${label} version mismatch`, feedback); + assert(feedback.status === "ready", `${label} status should be ready`, feedback); + const keys = new Set((feedback.items || []).map((item) => item.key)); + for (const key of ["ocr_pdf_parse_failed", "pdf_quality_insufficient", "auto_review_failed", "chat_response_failed"]) { + assert(keys.has(key), `${label} missing feedback key ${key}`, feedback.items); + } + for (const item of feedback.items || []) { + assert(item.user_message, `${label} item ${item.key} missing user message`, item); + assert(Array.isArray(item.next_actions) && item.next_actions.length > 0, `${label} item ${item.key} missing next actions`, item); + assert(item.qa_selector, `${label} item ${item.key} missing QA selector`, item); + } + assert((feedback.acceptance || []).length >= 4, `${label} acceptance too small`, feedback.acceptance); +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +async function attachApiProxy(context) { + await context.route("**/api/v1/**", async (route) => { + const browserRequest = route.request(); + const browserUrl = new URL(browserRequest.url()); + const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); + const headers = { ...browserRequest.headers() }; + delete headers.host; + + try { + const response = await state.api.fetch(targetUrl, { + method: browserRequest.method(), + headers, + data: browserRequest.postDataBuffer() ?? undefined, + }); + await route.fulfill({ response }); + } catch { + await route.abort("failed").catch(() => {}); + } + }); +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ viewport: { width: 1440, height: 1200 } }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: userSession.access_token, user: userSession.user }, + ); + await attachApiProxy(context); + state.contexts.push(context); + return context; +} + +async function run() { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "frontend and backend health", async () => { + const [frontend, health] = await Promise.all([state.api.get(`${webBase}/`), state.api.get(healthUrl)]); + assert(frontend.status() === 200, `frontend / status ${frontend.status()}`); + assert(health.status() === 200, `backend /health status ${health.status()}`); + return { webBase, apiBase, healthUrl, artifactDir, runId }; + }); + + await step("ERROR-API-001", "pilot error feedback API exposes customer-readable guide", async () => { + const payload = await apiRequest("get", "/system/pilot-error-feedback"); + expectOk(payload, "pilot error feedback"); + assertPilotErrorFeedback(payload.body.data, "pilot error feedback API"); + return { + version: payload.body.data.version, + items: payload.body.data.items.length, + selectors: payload.body.data.items.map((item) => item.qa_selector), + }; + }); + + await step("READINESS-001", "readiness embeds pilot error feedback", async () => { + const payload = await apiRequest("get", "/system/readiness"); + expectOk(payload, "readiness"); + assertPilotErrorFeedback(payload.body.data.pilot_error_feedback, "readiness pilot error feedback"); + return { + readiness_status: payload.body.data.status, + error_feedback_version: payload.body.data.pilot_error_feedback.version, + }; + }); + + await step("AUTH-001", "default admin seed and login", async () => { + const seed = await apiRequest("post", "/auth/seed-admin"); + expectOk(seed, "seed default admin"); + state.admin = await login("admin@example.com", adminPassword); + assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); + return { user: state.admin.user.username, role: state.admin.user.role }; + }); + + await step("BROWSER-001", "pilot readiness page exposes error feedback guide", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/pilot-readiness`, { waitUntil: "domcontentloaded" }); + const panel = page.getByTestId("pilot-error-feedback-guide"); + await panel.waitFor({ state: "visible", timeout: 12000 }); + const itemCount = await panel.getByTestId("pilot-error-feedback-item").count(); + assert(itemCount >= 4, "error feedback guide should expose four feedback cards", { itemCount }); + await page.getByTestId("pilot-error-feedback-acceptance").waitFor({ state: "visible", timeout: 12000 }); + await page.screenshot({ path: screenshotPath("pilot-error-feedback-guide"), fullPage: true }); + return { screenshot: "pilot-error-feedback-guide.png", item_count: itemCount }; + }); + + await step("BROWSER-002", "chat stream failure renders retryable customer feedback", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.route("**/api/v1/chat/stream", async (route) => { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ code: 1, message: "Chat provider unavailable in Slice 11 QA" }), + }); + }); + await page.goto(`${webBase}/chat`, { waitUntil: "domcontentloaded" }); + await page.locator("textarea").first().fill("请说明这份合同的主要风险"); + await page.locator("button.ant-btn-primary").first().click(); + await page.getByTestId("chat-exception-feedback").waitFor({ state: "visible", timeout: 12000 }); + await page.waitForTimeout(300); + await page.screenshot({ path: screenshotPath("chat-exception-feedback"), fullPage: true }); + return { screenshot: "chat-exception-feedback.png" }; + }); +} + +try { + await run(); +} finally { + for (const context of state.contexts) { + await context.close().catch(() => {}); + } + if (state.browser) { + await state.browser.close().catch(() => {}); + } + if (state.api) { + await state.api.dispose().catch(() => {}); + } + const summary = { + run_id: runId, + artifact_dir: artifactDir, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); + if (summary.failed > 0) { + process.exitCode = 1; + } +} diff --git a/frontend/scripts/slice12-pilot-launch-pack-closed-loop-e2e.mjs b/frontend/scripts/slice12-pilot-launch-pack-closed-loop-e2e.mjs new file mode 100644 index 00000000..60659ef8 --- /dev/null +++ b/frontend/scripts/slice12-pilot-launch-pack-closed-loop-e2e.mjs @@ -0,0 +1,255 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `slice12-pilot-launch-pack-${Date.now()}`; +const webBase = process.env.SLICE12_WEB_URL || "http://localhost:3000"; +const apiBase = process.env.SLICE12_API_URL || "http://127.0.0.1:8010/api/v1"; +const healthUrl = process.env.SLICE12_HEALTH_URL || "http://127.0.0.1:8010/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.SLICE12_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice12-pilot-launch-pack-closed-loop", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const launchScriptDocPath = path.resolve( + __dirname, + "..", + "..", + "project-docs", + "pilot-materials", + "AI合同起草与审查助手-销售客户演示脚本.md", +); +const state = { + api: null, + browser: null, + contexts: [], + admin: null, +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { id, title, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await state.api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +function assertPilotLaunchPack(pack, label) { + assert(pack.version === "pilot-launch-pack-2026-05-12", `${label} version mismatch`, pack); + assert(pack.status === "ready", `${label} status should be ready`, pack); + const stepKeys = new Set((pack.script_steps || []).map((item) => item.key)); + for (const key of ["kickoff_alignment", "business_contract_generation", "legal_auto_review", "review_chat_followup", "report_feedback_handoff"]) { + assert(stepKeys.has(key), `${label} missing script step ${key}`, pack.script_steps); + } + for (const item of pack.script_steps || []) { + assert(item.route, `${label} script step ${item.key} missing route`, item); + assert(item.talk_track, `${label} script step ${item.key} missing talk track`, item); + assert(Array.isArray(item.evidence) && item.evidence.length > 0, `${label} script step ${item.key} missing evidence`, item); + } + const gateKeys = new Set((pack.readiness_gates || []).map((item) => item.key)); + for (const key of ["system_readiness", "pilot_scope_frozen", "pilot_materials_ready", "error_feedback_ready", "demo_assets_seedable"]) { + assert(gateKeys.has(key), `${label} missing readiness gate ${key}`, pack.readiness_gates); + } + assert((pack.roles || []).length >= 5, `${label} roles too small`, pack.roles); + assert((pack.blockers || []).length >= 4, `${label} blockers too small`, pack.blockers); + assert((pack.acceptance || []).length >= 4, `${label} acceptance too small`, pack.acceptance); +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +async function attachApiProxy(context) { + await context.route("**/api/v1/**", async (route) => { + const browserRequest = route.request(); + const browserUrl = new URL(browserRequest.url()); + const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); + const headers = { ...browserRequest.headers() }; + delete headers.host; + + try { + const response = await state.api.fetch(targetUrl, { + method: browserRequest.method(), + headers, + data: browserRequest.postDataBuffer() ?? undefined, + }); + await route.fulfill({ response }); + } catch { + await route.abort("failed").catch(() => {}); + } + }); +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ viewport: { width: 1440, height: 1400 } }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: userSession.access_token, user: userSession.user }, + ); + await attachApiProxy(context); + state.contexts.push(context); + return context; +} + +async function run() { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "frontend and backend health", async () => { + const [frontend, health] = await Promise.all([state.api.get(`${webBase}/`), state.api.get(healthUrl)]); + assert(frontend.status() === 200, `frontend / status ${frontend.status()}`); + assert(health.status() === 200, `backend /health status ${health.status()}`); + return { webBase, apiBase, healthUrl, artifactDir, runId }; + }); + + await step("DOC-001", "pilot customer demo script document exists", async () => { + assert(fs.existsSync(launchScriptDocPath), "missing customer demo script doc", { launchScriptDocPath }); + const stat = fs.statSync(launchScriptDocPath); + assert(stat.size > 600, "customer demo script doc too small", { size: stat.size }); + return { launchScriptDocPath, size: stat.size }; + }); + + await step("LAUNCH-API-001", "pilot launch pack API exposes script and gates", async () => { + const payload = await apiRequest("get", "/system/pilot-launch-pack"); + expectOk(payload, "pilot launch pack"); + assertPilotLaunchPack(payload.body.data, "pilot launch pack API"); + return { + version: payload.body.data.version, + script_steps: payload.body.data.script_steps.length, + readiness_gates: payload.body.data.readiness_gates.length, + }; + }); + + await step("READINESS-001", "readiness embeds pilot launch pack", async () => { + const payload = await apiRequest("get", "/system/readiness"); + expectOk(payload, "readiness"); + assertPilotLaunchPack(payload.body.data.pilot_launch_pack, "readiness pilot launch pack"); + return { + readiness_status: payload.body.data.status, + launch_pack_version: payload.body.data.pilot_launch_pack.version, + }; + }); + + await step("AUTH-001", "default admin seed and login", async () => { + const seed = await apiRequest("post", "/auth/seed-admin"); + expectOk(seed, "seed default admin"); + state.admin = await login("admin@example.com", adminPassword); + assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); + return { user: state.admin.user.username, role: state.admin.user.role }; + }); + + await step("BROWSER-001", "pilot readiness page exposes launch pack", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/pilot-readiness`, { waitUntil: "domcontentloaded" }); + const panel = page.getByTestId("pilot-launch-pack"); + await panel.waitFor({ state: "visible", timeout: 12000 }); + const stepCount = await panel.getByTestId("pilot-launch-step").count(); + assert(stepCount >= 5, "launch pack should expose five script steps", { stepCount }); + await page.getByTestId("pilot-launch-gates").waitFor({ state: "visible", timeout: 12000 }); + await page.getByTestId("pilot-launch-roles").waitFor({ state: "visible", timeout: 12000 }); + await page.getByTestId("pilot-launch-blockers").waitFor({ state: "visible", timeout: 12000 }); + await page.getByTestId("pilot-launch-acceptance").waitFor({ state: "visible", timeout: 12000 }); + await page.screenshot({ path: screenshotPath("pilot-launch-pack"), fullPage: true }); + return { screenshot: "pilot-launch-pack.png", step_count: stepCount }; + }); +} + +try { + await run(); +} finally { + for (const context of state.contexts) { + await context.close().catch(() => {}); + } + if (state.browser) { + await state.browser.close().catch(() => {}); + } + if (state.api) { + await state.api.dispose().catch(() => {}); + } + const summary = { + run_id: runId, + artifact_dir: artifactDir, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); + if (summary.failed > 0) { + process.exitCode = 1; + } +} diff --git a/frontend/scripts/slice4-auto-review-closed-loop-e2e.mjs b/frontend/scripts/slice4-auto-review-closed-loop-e2e.mjs new file mode 100644 index 00000000..3e83167c --- /dev/null +++ b/frontend/scripts/slice4-auto-review-closed-loop-e2e.mjs @@ -0,0 +1,309 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `slice4-auto-${Date.now()}`; +const webBase = process.env.SLICE4_WEB_URL || "http://localhost:3007"; +const apiBase = process.env.SLICE4_API_URL || "http://127.0.0.1:8000/api/v1"; +const healthUrl = process.env.SLICE4_HEALTH_URL || "http://127.0.0.1:8000/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.SLICE4_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice4-auto-review-closed-loop", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const state = { + admin: null, + api: null, + browser: null, + contexts: [], + contractId: null, + reviewId: null, + chatUrl: null, +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { + id, + title, + status: "PASS", + duration_ms: Date.now() - started, + started_at: startedAt, + details: details || {}, + }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await state.api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { + data: { account, password }, + }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +function buildVariableValues(template) { + return Object.fromEntries( + (template.variables || []).map((variable) => { + const label = variable.label || variable.name; + const value = variable.default_value || `${runId}-${label}`; + return [variable.name, value]; + }), + ); +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ + viewport: { width: 1440, height: 1100 }, + acceptDownloads: true, + }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: userSession.access_token, user: userSession.user }, + ); + await context.route("**/api/v1/**", async (route) => { + const browserRequest = route.request(); + const browserUrl = new URL(browserRequest.url()); + const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); + const headers = { ...browserRequest.headers() }; + delete headers.host; + + const response = await state.api.fetch(targetUrl, { + method: browserRequest.method(), + headers, + data: browserRequest.postDataBuffer() ?? undefined, + }); + await route.fulfill({ response }); + }); + state.contexts.push(context); + return context; +} + +function decodeReviewContext(chatUrl) { + const url = new URL(chatUrl, webBase); + const raw = url.searchParams.get("review"); + assert(raw, "chat URL missing review query", { chatUrl }); + return JSON.parse(raw); +} + +async function run() { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "frontend and backend health", async () => { + const frontend = await state.api.get(`${webBase}/login`); + const health = await state.api.get(healthUrl); + assert(frontend.status() === 200, `frontend /login status ${frontend.status()}`); + assert(health.status() === 200, `backend /health status ${health.status()}`); + return { webBase, apiBase, healthUrl, artifactDir }; + }); + + await step("AUTH-001", "default admin seed and login", async () => { + const seed = await apiRequest("post", "/auth/seed-admin"); + expectOk(seed, "seed default admin"); + state.admin = await login("admin@example.com", adminPassword); + assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); + return { user: state.admin.user.username, role: state.admin.user.role }; + }); + + await step("CONTRACT-001", "contract generation triggers auto review", async () => { + const templateSeed = await apiRequest("post", "/templates/seed-defaults", { + authToken: state.admin.access_token, + }); + expectOk(templateSeed, "seed default templates"); + + const templates = await apiRequest("get", "/contracts/templates", { authToken: state.admin.access_token }); + expectOk(templates, "contract templates"); + const template = templates.body.data[0]; + assert(template, "no contract template available"); + const variableValues = buildVariableValues(template); + + const generated = await apiRequest("post", "/contracts/generate", { + authToken: state.admin.access_token, + multipart: { + template_id: template.id, + variable_values_json: JSON.stringify(variableValues), + export_format: "txt", + options_json: JSON.stringify({ slice: "slice4", run_id: runId }), + }, + }); + expectOk(generated, "contract generate"); + + const contract = generated.body.data; + const autoReview = contract.auto_review; + assert(contract.id, "generated contract missing id", contract); + assert(contract.file_id, "generated contract missing file asset", contract); + assert(autoReview, "generated contract missing auto_review", contract); + assert(autoReview.review_id, "auto review missing review id", autoReview); + assert(autoReview.status === "completed", "auto review should complete", autoReview); + assert(autoReview.detail_url === `/reviews/${autoReview.review_id}`, "auto review detail URL mismatch", autoReview); + assert(autoReview.chat_url?.startsWith("/chat?review="), "auto review chat URL missing", autoReview); + assert(autoReview.report_download_url?.includes(`/reviews/${autoReview.review_id}/report/download`), "report download URL mismatch", autoReview); + + state.contractId = contract.id; + state.reviewId = autoReview.review_id; + state.chatUrl = autoReview.chat_url; + return { + contract_id: state.contractId, + review_id: state.reviewId, + auto_review_status: autoReview.status, + current_stage: autoReview.current_stage, + risk_level: autoReview.risk_level, + }; + }); + + await step("REVIEW-001", "auto review detail and result are reachable", async () => { + const detail = await apiRequest("get", `/reviews/${state.reviewId}`, { authToken: state.admin.access_token }); + const result = await apiRequest("get", `/reviews/${state.reviewId}/result`, { authToken: state.admin.access_token }); + expectOk(detail, "auto review detail"); + expectOk(result, "auto review result"); + assert(detail.body.data.current_stage === "report", "auto review should be in report stage", detail.body.data); + assert(detail.body.data.source_document?.document_id, "source document should keep generated file id", detail.body.data); + assert(result.body.data.workflow_state?.review_id === state.reviewId, "workflow state review id mismatch", result.body.data); + assert(result.body.data.ai_trace?.review_id === state.reviewId, "ai trace review id mismatch", result.body.data); + return { + review_status: detail.body.data.status, + source_document: detail.body.data.source_document?.document_id, + risk_level: result.body.data.risk_summary?.overall, + }; + }); + + await step("CHAT-001", "auto review chat context binds review state", async () => { + const context = decodeReviewContext(state.chatUrl); + assert(context.reviewId === state.reviewId, "chat context review id mismatch", context); + assert(context.metadata?.focusType === "generated_contract", "chat context focus type mismatch", context.metadata); + assert(context.workflowState?.review_id === state.reviewId, "chat context workflow state missing review id", context.workflowState); + assert(context.aiTrace?.review_id === state.reviewId, "chat context ai trace missing review id", context.aiTrace); + assert(context.sourceDocument?.document_id, "chat context source document missing generated file id", context.sourceDocument); + return { + review_id: context.reviewId, + focus_type: context.metadata?.focusType, + prompt_preview: context.prompt?.slice(0, 80), + }; + }); + + await step("REPORT-001", "auto review report is downloadable", async () => { + const report = await apiRequest("get", `/reviews/${state.reviewId}/report`, { authToken: state.admin.access_token }); + expectOk(report, "auto review report metadata"); + const download = await apiRequest("get", `/reviews/${state.reviewId}/report/download`, { authToken: state.admin.access_token }); + assert(download.response.status() === 200, `report download HTTP ${download.response.status()}`, { body: download.body }); + assert(download.text.length > 0, "report download returned empty body"); + return { + file_name: report.body.data.file_name, + download_bytes: download.text.length, + }; + }); + + await step("BROWSER-001", "contract detail exposes auto review actions", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/contracts/${state.contractId}`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1600); + await page.screenshot({ path: screenshotPath("contract-detail-auto-review"), fullPage: true }); + const body = await page.locator("body").innerText(); + assert(body.includes("自动审查"), "contract detail missing auto review section", body.slice(0, 1800)); + assert(body.includes("查看审查详情"), "contract detail missing review detail action", body.slice(0, 2200)); + assert(body.includes("继续审查问答"), "contract detail missing chat action", body.slice(0, 2200)); + assert(body.includes("下载审查报告"), "contract detail missing report action", body.slice(0, 2200)); + return { screenshot: "contract-detail-auto-review.png" }; + }); +} + +try { + await run(); +} finally { + for (const context of state.contexts) { + await context.close().catch(() => {}); + } + if (state.browser) { + await state.browser.close().catch(() => {}); + } + if (state.api) { + await state.api.dispose().catch(() => {}); + } + const summary = { + run_id: runId, + artifact_dir: artifactDir, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); + if (summary.failed > 0) { + process.exitCode = 1; + } +} diff --git a/frontend/scripts/slice4-shared-ai-stub.mjs b/frontend/scripts/slice4-shared-ai-stub.mjs new file mode 100644 index 00000000..d20692dd --- /dev/null +++ b/frontend/scripts/slice4-shared-ai-stub.mjs @@ -0,0 +1,234 @@ +import http from "node:http"; + +const port = Number(process.env.SLICE4_SHARED_AI_STUB_PORT || 18011); +const knowledgeBases = []; +const documentsByKnowledgeBase = new Map(); + +function sendJson(response, status, payload) { + response.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + response.end(`${JSON.stringify(payload)}\n`); +} + +async function readJson(request) { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + const text = Buffer.concat(chunks).toString("utf8"); + return text ? JSON.parse(text) : {}; +} + +async function readBuffer(request) { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +function nowIso() { + return new Date().toISOString(); +} + +function parseMultipartFile(buffer, contentType = "") { + const text = buffer.toString("latin1"); + const fileNameMatch = text.match(/filename="([^"]+)"/i); + const mimeTypeMatch = text.match(/Content-Type:\s*([^\r\n]+)/i); + return { + fileName: fileNameMatch?.[1] || "document.txt", + mimeType: mimeTypeMatch?.[1] || contentType || "application/octet-stream", + fileSize: buffer.length, + }; +} + +function buildReviewPayload(body) { + const title = body.title || "Local shared-ai stub review"; + const contractType = body.contract_type || "procurement"; + const text = body.text || ""; + const quote = text.slice(0, 160) || "Generated contract content"; + + return { + basic_info: { + contract_type: contractType, + contract_title: title, + party_a: "Party A", + party_b: "Party B", + amount: "Pending extraction", + term: "Pending extraction", + }, + contract_structure: { + title, + detected_contract_type: contractType, + detected_contract_type_label: contractType, + clause_count: 2, + payment_milestones: [], + clause_relationships: [], + missing_sections: ["acceptance clause", "breach clause", "dispute clause"], + clause_type_counts: { payment: 1, confidentiality: 1 }, + summary: "Stub review extracted payment and confidentiality clauses and marked the contract as high risk.", + }, + risk_summary: { high: 1, medium: 0, low: 1, overall: "high" }, + clauses: [ + { + title: "Payment Clause", + content: quote, + clause_type: "payment", + clause_label: "Payment Clause", + summary: "Payment arrangement detected; acceptance trigger and invoice dependency should be clarified.", + }, + { + title: "Confidentiality Clause", + content: quote, + clause_type: "confidentiality", + clause_label: "Confidentiality Clause", + summary: "Confidentiality obligation detected; duration and scope should be clarified.", + }, + ], + risk_items: [ + { + title: "Payment trigger needs clarification", + level: "high", + description: "The payment section still depends on vague acceptance and invoice conditions.", + suggestion: "Add explicit acceptance criteria, invoice dependency, and due date for payment.", + evidence: { quote, location: "generated_contract" }, + }, + { + title: "Confidentiality scope should be refined", + level: "low", + description: "The confidentiality clause needs a clearer scope and survival period.", + suggestion: "Specify confidential information scope, carve-outs, and survival term.", + evidence: { quote, location: "generated_contract" }, + }, + ], + suggestions: [ + "Add explicit acceptance criteria, invoice dependency, and due date for payment.", + "Specify confidential information scope, carve-outs, and survival term.", + ], + legal_basis: [], + review_opinion: "Stub review completed. Manual review is recommended before delivery.", + recommendation: "manual_review", + confidence_summary: { + provider: "slice4-shared-ai-stub", + knowledge_source: "qa-fixture", + structured_analysis: { stage: "slice4-auto-review-stub" }, + rendered_report: [ + `Review task: ${title}`, + "Structured summary: local shared-ai stub review", + "Risk summary: high risk 1, low risk 1", + "Opinion: manual review is recommended before delivery.", + ].join("\n"), + }, + rendered_report: [ + `Review task: ${title}`, + "Structured summary: local shared-ai stub review", + "Risk summary: high risk 1, low risk 1", + "Opinion: manual review is recommended before delivery.", + ].join("\n"), + }; +} + +const server = http.createServer(async (request, response) => { + try { + if (request.method === "GET" && request.url === "/health") { + sendJson(response, 200, { code: 0, data: { status: "ok" } }); + return; + } + + if (request.method === "POST" && request.url === "/api/v0.1/domain-packs/legal-review/reviews/run") { + const body = await readJson(request); + sendJson(response, 200, { code: 0, data: buildReviewPayload(body) }); + return; + } + + if (request.method === "GET" && request.url?.startsWith("/api/v0.2/knowledge-bases")) { + const match = request.url.match(/^\/api\/v0\.2\/knowledge-bases\/([^/?]+)\/documents/); + if (match) { + const knowledgeBaseId = decodeURIComponent(match[1]); + const items = documentsByKnowledgeBase.get(knowledgeBaseId) || []; + sendJson(response, 200, { + code: 0, + data: { items, total: items.length, page: 1, page_size: 20 }, + }); + return; + } + + sendJson(response, 200, { + code: 0, + data: { items: knowledgeBases, total: knowledgeBases.length, page: 1, page_size: 20 }, + }); + return; + } + + if (request.method === "POST" && request.url === "/api/v0.2/knowledge-bases") { + const body = await readJson(request); + const existing = knowledgeBases.find((item) => item.name === body.name); + if (existing) { + sendJson(response, 200, { code: 0, data: existing }); + return; + } + const knowledgeBase = { + id: `kb-${knowledgeBases.length + 1}`, + name: body.name || "Demo Knowledge Base", + description: body.description || null, + status: "active", + document_count: 0, + retrieval_mode: body.retrieval_mode || "hybrid", + created_at: nowIso(), + updated_at: nowIso(), + }; + knowledgeBases.push(knowledgeBase); + documentsByKnowledgeBase.set(knowledgeBase.id, []); + sendJson(response, 200, { code: 0, data: knowledgeBase }); + return; + } + + if (request.method === "POST") { + const uploadMatch = request.url?.match(/^\/api\/v0\.2\/knowledge-bases\/([^/?]+)\/documents$/); + if (uploadMatch) { + const knowledgeBaseId = decodeURIComponent(uploadMatch[1]); + const buffer = await readBuffer(request); + const file = parseMultipartFile(buffer, request.headers["content-type"]); + const items = documentsByKnowledgeBase.get(knowledgeBaseId) || []; + const document = { + id: `kbdoc-${knowledgeBaseId}-${items.length + 1}`, + knowledge_base_id: knowledgeBaseId, + file_name: file.fileName, + mime_type: file.mimeType, + file_size: file.fileSize, + status: "indexed", + content_excerpt: "Pilot demo knowledge document indexed by local shared-ai stub.", + error_message: null, + extracted_text_length: file.fileSize, + preview_truncated: false, + current_stage: "indexed", + current_stage_label: "Indexed", + status_detail: "Document indexed", + parse_status: "parsed", + index_status: "indexed", + failure_reason: null, + next_action: "ready", + retryable: false, + created_at: nowIso(), + updated_at: nowIso(), + }; + items.push(document); + documentsByKnowledgeBase.set(knowledgeBaseId, items); + const knowledgeBase = knowledgeBases.find((item) => item.id === knowledgeBaseId); + if (knowledgeBase) { + knowledgeBase.document_count = items.length; + knowledgeBase.updated_at = nowIso(); + } + sendJson(response, 200, { code: 0, data: document }); + return; + } + } + + sendJson(response, 404, { code: 404, message: "not found" }); + } catch (error) { + sendJson(response, 500, { code: 500, message: error.message }); + } +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`slice4 shared-ai stub listening on http://127.0.0.1:${port}`); +}); diff --git a/frontend/scripts/slice5-pilot-shell-closed-loop-e2e.mjs b/frontend/scripts/slice5-pilot-shell-closed-loop-e2e.mjs new file mode 100644 index 00000000..7d005f9c --- /dev/null +++ b/frontend/scripts/slice5-pilot-shell-closed-loop-e2e.mjs @@ -0,0 +1,255 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `slice5-pilot-${Date.now()}`; +const webBase = process.env.SLICE5_WEB_URL || "http://localhost:3000"; +const apiBase = process.env.SLICE5_API_URL || "http://127.0.0.1:8010/api/v1"; +const healthUrl = process.env.SLICE5_HEALTH_URL || "http://127.0.0.1:8010/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.SLICE5_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice5-pilot-shell-closed-loop", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const state = { + api: null, + browser: null, + contexts: [], + admin: null, + requestId: null, + companyName: `切片五试点企业-${Date.now()}`, + contactName: "切片五联系人", + email: `slice5-${Date.now()}@example.com`, +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { id, title, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await state.api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +async function attachApiProxy(context) { + await context.route("**/api/v1/**", async (route) => { + const browserRequest = route.request(); + const browserUrl = new URL(browserRequest.url()); + const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); + const headers = { ...browserRequest.headers() }; + delete headers.host; + + const response = await state.api.fetch(targetUrl, { + method: browserRequest.method(), + headers, + data: browserRequest.postDataBuffer() ?? undefined, + }); + await route.fulfill({ response }); + }); +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ viewport: { width: 1440, height: 1100 } }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: userSession.access_token, user: userSession.user }, + ); + await attachApiProxy(context); + state.contexts.push(context); + return context; +} + +async function createPublicContext() { + const context = await state.browser.newContext({ viewport: { width: 1440, height: 1100 } }); + await attachApiProxy(context); + state.contexts.push(context); + return context; +} + +try { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "frontend and backend health", async () => { + const [frontend, health] = await Promise.all([state.api.get(`${webBase}/`), state.api.get(healthUrl)]); + assert(frontend.status() === 200, `frontend / status ${frontend.status()}`); + assert(health.status() === 200, `backend /health status ${health.status()}`); + return { webBase, apiBase, healthUrl, artifactDir }; + }); + + await step("PUBLIC-001", "public demo request page submits pilot request", async () => { + const context = await createPublicContext(); + const page = await context.newPage(); + await page.goto(`${webBase}/demo-request`, { waitUntil: "domcontentloaded" }); + await page.getByPlaceholder("例如:张三").fill(state.contactName); + await page.getByPlaceholder("例如:示例科技有限公司").fill(state.companyName); + await page.getByPlaceholder("例如:法务负责人 / 业务负责人 / 信息化负责人").fill("法务负责人"); + await page.getByPlaceholder("name@company.com").fill(state.email); + await page.getByPlaceholder("选填,用于演示沟通").fill("13800000000"); + await page.getByPlaceholder("例如:每月 100 份 / 每季度 500 份").fill("每月 200 份"); + await page.getByLabel("最想先验证的目标").click(); + await page.getByText("合同审查试点", { exact: true }).click(); + await page.getByLabel("部署偏好").click(); + await page.getByText("SaaS", { exact: true }).click(); + await page.getByPlaceholder("例如:希望先验证采购合同的自动审查、风险提示和报告导出,后续再接入知识库问答。").fill( + "希望先验证采购合同自动审查、风险提示和报告导出的试点能力。", + ); + await page.getByRole("button", { name: "提交演示申请" }).click(); + await page.waitForTimeout(1200); + await page.screenshot({ path: screenshotPath("demo-request-success"), fullPage: true }); + const body = await page.locator("body").innerText(); + const match = body.match(/pilot-[a-z0-9]+/i); + assert(body.includes("申请已提交"), "demo request success message missing", body.slice(0, 2000)); + assert(match, "pilot request id missing in success body", body.slice(0, 2000)); + state.requestId = match[0]; + return { request_id: state.requestId, company_name: state.companyName }; + }); + + await step("AUTH-001", "admin seed and login", async () => { + const seed = await apiRequest("post", "/auth/seed-admin"); + expectOk(seed, "seed default admin"); + state.admin = await login("admin@example.com", adminPassword); + assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); + return { user: state.admin.user.username, role: state.admin.user.role }; + }); + + await step("ADMIN-001", "admin APIs can see submitted pilot request", async () => { + const [list, summary] = await Promise.all([ + apiRequest("get", "/pilot-requests", { authToken: state.admin.access_token }), + apiRequest("get", "/pilot-requests/summary", { authToken: state.admin.access_token }), + ]); + expectOk(list, "pilot request list"); + expectOk(summary, "pilot request summary"); + const matched = list.body.data.items.find((item) => item.id === state.requestId); + assert(matched, "submitted pilot request missing from admin list", list.body.data.items); + assert(summary.body.data.total >= 1, "pilot summary total should be >= 1", summary.body.data); + return { total: summary.body.data.total, request_id: state.requestId }; + }); + + await step("ADMIN-002", "admin pilot request page updates request status", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/pilot-requests`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1800); + const body = await page.locator("body").innerText(); + assert(body.includes(state.companyName), "pilot request page missing submitted company", body.slice(0, 2600)); + await page.getByRole("button", { name: "跟进状态" }).first().click(); + await page.getByLabel("跟进状态").click(); + await page.locator(".ant-select-dropdown .ant-select-item-option").filter({ hasText: "已排演示" }).first().click(); + await page.getByLabel("管理员备注").fill("已安排本周四试点演示。"); + await page.getByRole("button", { name: "保存状态" }).click(); + await page.waitForTimeout(1600); + await page.screenshot({ path: screenshotPath("pilot-requests-updated"), fullPage: true }); + const updatedBody = await page.locator("body").innerText(); + assert(updatedBody.includes("已排演示"), "scheduled status missing after update", updatedBody.slice(0, 2600)); + return { request_id: state.requestId, updated_status: "scheduled" }; + }); + + await step("BROWSER-001", "admin workspace exposes pilot request entry", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/admin`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1200); + const body = await page.locator("body").innerText(); + assert(body.includes("试点申请"), "admin workspace missing pilot request entry", body.slice(0, 2000)); + return { href: "/pilot-requests" }; + }); +} finally { + for (const context of state.contexts) { + await context.close().catch(() => {}); + } + if (state.browser) { + await state.browser.close().catch(() => {}); + } + if (state.api) { + await state.api.dispose().catch(() => {}); + } + const summary = { + run_id: runId, + artifact_dir: artifactDir, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); + if (summary.failed > 0) { + process.exitCode = 1; + } +} diff --git a/frontend/scripts/slice6-role-pilot-user-closed-loop-e2e.mjs b/frontend/scripts/slice6-role-pilot-user-closed-loop-e2e.mjs new file mode 100644 index 00000000..378512f6 --- /dev/null +++ b/frontend/scripts/slice6-role-pilot-user-closed-loop-e2e.mjs @@ -0,0 +1,286 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `slice6-role-${Date.now()}`; +const webBase = process.env.SLICE6_WEB_URL || "http://localhost:3000"; +const apiBase = process.env.SLICE6_API_URL || "http://127.0.0.1:8010/api/v1"; +const healthUrl = process.env.SLICE6_HEALTH_URL || "http://127.0.0.1:8010/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.SLICE6_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice6-role-pilot-user-closed-loop", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const state = { + api: null, + browser: null, + contexts: [], + admin: null, + legal: null, + business: null, + legalEmail: `slice6-legal-${Date.now()}@example.com`, + businessEmail: `slice6-business-${Date.now()}@example.com`, + registeredEmail: `slice6-register-${Date.now()}@example.com`, + password: "slice6Pass123", +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { id, title, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await state.api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +async function createPilotUser(username, role, displayName) { + const payload = await apiRequest("post", "/users", { + authToken: state.admin.access_token, + multipart: { + username, + display_name: displayName, + password: state.password, + role, + status: "active", + }, + }); + expectOk(payload, `create ${role} user`); + assert(payload.body.data.role === role, `${role} role mismatch`, payload.body.data); + return payload.body.data; +} + +async function attachApiProxy(context) { + await context.route("**/api/v1/**", async (route) => { + const browserRequest = route.request(); + const browserUrl = new URL(browserRequest.url()); + const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); + const headers = { ...browserRequest.headers() }; + delete headers.host; + + const response = await state.api.fetch(targetUrl, { + method: browserRequest.method(), + headers, + data: browserRequest.postDataBuffer() ?? undefined, + }); + await route.fulfill({ response }); + }); +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ viewport: { width: 1440, height: 1100 } }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: userSession.access_token, user: userSession.user }, + ); + await attachApiProxy(context); + state.contexts.push(context); + return context; +} + +async function navLinkCount(page, href) { + return page.locator(`.app-nav a[href="${href}"]`).count(); +} + +async function run() { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "frontend and backend health", async () => { + const [frontend, health] = await Promise.all([state.api.get(`${webBase}/`), state.api.get(healthUrl)]); + assert(frontend.status() === 200, `frontend / status ${frontend.status()}`); + assert(health.status() === 200, `backend /health status ${health.status()}`); + return { webBase, apiBase, healthUrl, artifactDir }; + }); + + await step("AUTH-001", "default admin seed and login", async () => { + const seed = await apiRequest("post", "/auth/seed-admin"); + expectOk(seed, "seed default admin"); + state.admin = await login("admin@example.com", adminPassword); + assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); + return { user: state.admin.user.username, role: state.admin.user.role }; + }); + + await step("ADMIN-001", "admin creates legal and business pilot users", async () => { + const legal = await createPilotUser(state.legalEmail, "legal", "Slice 6 Legal User"); + const business = await createPilotUser(state.businessEmail, "business", "Slice 6 Business User"); + const list = await apiRequest("get", "/users?page=1&page_size=20", { authToken: state.admin.access_token }); + expectOk(list, "list users"); + const users = list.body.data.items; + assert(users.some((item) => item.username === state.legalEmail && item.role === "legal"), "legal pilot user missing", users); + assert(users.some((item) => item.username === state.businessEmail && item.role === "business"), "business pilot user missing", users); + return { legal_user_id: legal.id, business_user_id: business.id }; + }); + + await step("ADMIN-002", "admin users page shows created pilot accounts", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/users`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction( + ({ legalEmail, businessEmail }) => + document.body.innerText.includes(legalEmail) && document.body.innerText.includes(businessEmail), + { legalEmail: state.legalEmail, businessEmail: state.businessEmail }, + { timeout: 8000 }, + ); + await page.screenshot({ path: screenshotPath("admin-users-pilot-roles"), fullPage: true }); + return { screenshot: "admin-users-pilot-roles.png" }; + }); + + await step("LEGAL-001", "legal user gets review-first navigation without admin menu", async () => { + state.legal = await login(state.legalEmail, state.password); + assert(state.legal.user.role === "legal", "legal login role mismatch", state.legal.user); + const context = await createAuthenticatedContext(state.legal); + const page = await context.newPage(); + await page.goto(`${webBase}/`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1200); + assert((await navLinkCount(page, "/review")) >= 1, "legal nav missing review entry"); + assert((await navLinkCount(page, "/contracts")) === 0, "legal nav should not expose contract generation entry"); + assert((await page.locator(".app-nav-admin-trigger").count()) === 0, "legal user should not see admin menu"); + await page.screenshot({ path: screenshotPath("legal-review-navigation"), fullPage: true }); + return { role: state.legal.user.role }; + }); + + await step("BUSINESS-001", "business user gets contract-first navigation without admin menu", async () => { + state.business = await login(state.businessEmail, state.password); + assert(state.business.user.role === "business", "business login role mismatch", state.business.user); + const context = await createAuthenticatedContext(state.business); + const page = await context.newPage(); + await page.goto(`${webBase}/`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(1200); + assert((await navLinkCount(page, "/contracts")) >= 1, "business nav missing contract generation entry"); + assert((await navLinkCount(page, "/review")) === 0, "business nav should not expose direct review entry"); + assert((await page.locator(".app-nav-admin-trigger").count()) === 0, "business user should not see admin menu"); + await page.screenshot({ path: screenshotPath("business-contract-navigation"), fullPage: true }); + return { role: state.business.user.role }; + }); + + await step("PERM-001", "non-admin roles cannot call user management API", async () => { + const [legalUsers, businessUsers] = await Promise.all([ + apiRequest("get", "/users", { authToken: state.legal.access_token }), + apiRequest("get", "/users", { authToken: state.business.access_token }), + ]); + assert(legalUsers.response.status() === 403, `legal /users should be 403, got ${legalUsers.response.status()}`, legalUsers.body); + assert( + businessUsers.response.status() === 403, + `business /users should be 403, got ${businessUsers.response.status()}`, + businessUsers.body, + ); + return { legal_status: legalUsers.response.status(), business_status: businessUsers.response.status() }; + }); + + await step("SELF-REG-001", "self registration defaults to business role", async () => { + const registered = await apiRequest("post", "/auth/register", { + data: { + username: state.registeredEmail, + password: state.password, + display_name: "Slice 6 Registered User", + }, + }); + expectOk(registered, "register public user"); + assert(registered.body.data.role === "business", "registered user should default to business", registered.body.data); + const session = await login(state.registeredEmail, state.password); + assert(session.user.role === "business", "registered login role mismatch", session.user); + return { username: state.registeredEmail, role: session.user.role }; + }); +} + +try { + await run(); +} finally { + for (const context of state.contexts) { + await context.close().catch(() => {}); + } + if (state.browser) { + await state.browser.close().catch(() => {}); + } + if (state.api) { + await state.api.dispose().catch(() => {}); + } + const summary = { + run_id: runId, + artifact_dir: artifactDir, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); + if (summary.failed > 0) { + process.exitCode = 1; + } +} diff --git a/frontend/scripts/slice7-demo-assets-closed-loop-e2e.mjs b/frontend/scripts/slice7-demo-assets-closed-loop-e2e.mjs new file mode 100644 index 00000000..219bfeb8 --- /dev/null +++ b/frontend/scripts/slice7-demo-assets-closed-loop-e2e.mjs @@ -0,0 +1,227 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `slice7-demo-${Date.now()}`; +const webBase = process.env.SLICE7_WEB_URL || "http://localhost:3000"; +const apiBase = process.env.SLICE7_API_URL || "http://127.0.0.1:8010/api/v1"; +const healthUrl = process.env.SLICE7_HEALTH_URL || "http://127.0.0.1:8010/health"; +const sharedAiHealthUrl = process.env.SLICE7_SHARED_AI_HEALTH_URL || "http://127.0.0.1:18011/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.SLICE7_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice7-demo-assets-closed-loop", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const state = { + api: null, + browser: null, + contexts: [], + admin: null, +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { id, title, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await state.api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +async function attachApiProxy(context) { + await context.route("**/api/v1/**", async (route) => { + const browserRequest = route.request(); + const browserUrl = new URL(browserRequest.url()); + const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); + const headers = { ...browserRequest.headers() }; + delete headers.host; + + const response = await state.api.fetch(targetUrl, { + method: browserRequest.method(), + headers, + data: browserRequest.postDataBuffer() ?? undefined, + }); + await route.fulfill({ response }); + }); +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ viewport: { width: 1440, height: 1100 } }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: userSession.access_token, user: userSession.user }, + ); + await attachApiProxy(context); + state.contexts.push(context); + return context; +} + +async function run() { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "frontend, backend, and shared-ai health", async () => { + const [frontend, backendHealth, sharedAiHealth] = await Promise.all([ + state.api.get(`${webBase}/`), + state.api.get(healthUrl), + state.api.get(sharedAiHealthUrl), + ]); + assert(frontend.status() === 200, `frontend / status ${frontend.status()}`); + assert(backendHealth.status() === 200, `backend /health status ${backendHealth.status()}`); + assert(sharedAiHealth.status() === 200, `shared-ai stub /health status ${sharedAiHealth.status()}`); + return { webBase, apiBase, healthUrl, sharedAiHealthUrl, artifactDir }; + }); + + await step("AUTH-001", "default admin seed and login", async () => { + const seed = await apiRequest("post", "/auth/seed-admin"); + expectOk(seed, "seed default admin"); + state.admin = await login("admin@example.com", adminPassword); + assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); + return { user: state.admin.user.username, role: state.admin.user.role }; + }); + + await step("SEED-001", "admin seeds fixed demo assets", async () => { + const payload = await apiRequest("post", "/system/pilot-demo-assets/seed", { authToken: state.admin.access_token }); + expectOk(payload, "seed pilot demo assets"); + const data = payload.body.data; + assert(data.templates.total >= 3, "default template count should be at least 3", data.templates); + assert(data.knowledge.status === "ready", "demo knowledge should be ready", data.knowledge); + assert(data.knowledge.knowledge_base.name === "AI合同试点演示知识库", "demo knowledge base name mismatch", data.knowledge); + assert(data.knowledge.document.status === "indexed", "demo knowledge document should be indexed", data.knowledge.document); + assert(data.demo_accounts.roles.includes("legal") && data.demo_accounts.roles.includes("business"), "demo roles missing", data.demo_accounts); + return { + templates_total: data.templates.total, + knowledge_base_id: data.knowledge.knowledge_base.id, + document_id: data.knowledge.document.id, + }; + }); + + await step("ASSET-001", "templates and knowledge base are visible through management APIs", async () => { + const [templates, knowledgeBases] = await Promise.all([ + apiRequest("get", "/templates?page=1&page_size=20", { authToken: state.admin.access_token }), + apiRequest("get", "/knowledge-bases?page=1&page_size=20", { authToken: state.admin.access_token }), + ]); + expectOk(templates, "template list"); + expectOk(knowledgeBases, "knowledge base list"); + assert(templates.body.data.total >= 3, "seeded templates missing from template list", templates.body.data); + const demoKb = knowledgeBases.body.data.items.find((item) => item.name === "AI合同试点演示知识库"); + assert(demoKb, "seeded demo knowledge base missing", knowledgeBases.body.data); + assert(demoKb.document_count >= 1, "demo knowledge base should include a document", demoKb); + return { templates_total: templates.body.data.total, demo_knowledge_base_id: demoKb.id }; + }); + + await step("BROWSER-001", "pilot readiness page can seed demo assets from UI", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/pilot-readiness`, { waitUntil: "domcontentloaded" }); + await page.getByTestId("seed-pilot-demo-assets").click(); + await page.getByTestId("pilot-demo-assets-result").waitFor({ state: "visible", timeout: 12000 }); + await page.screenshot({ path: screenshotPath("pilot-readiness-demo-assets"), fullPage: true }); + const resultText = await page.getByTestId("pilot-demo-assets-result").innerText(); + assert(resultText.includes("AI合同试点演示知识库"), "UI seed result missing demo knowledge base", resultText); + assert(resultText.includes("pilot-contract-review-playbook.txt"), "UI seed result missing demo document", resultText); + return { screenshot: "pilot-readiness-demo-assets.png" }; + }); +} + +try { + await run(); +} finally { + for (const context of state.contexts) { + await context.close().catch(() => {}); + } + if (state.browser) { + await state.browser.close().catch(() => {}); + } + if (state.api) { + await state.api.dispose().catch(() => {}); + } + const summary = { + run_id: runId, + artifact_dir: artifactDir, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); + if (summary.failed > 0) { + process.exitCode = 1; + } +} diff --git a/frontend/scripts/slice8-pilot-scope-closed-loop-e2e.mjs b/frontend/scripts/slice8-pilot-scope-closed-loop-e2e.mjs new file mode 100644 index 00000000..d3c95008 --- /dev/null +++ b/frontend/scripts/slice8-pilot-scope-closed-loop-e2e.mjs @@ -0,0 +1,224 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `slice8-scope-${Date.now()}`; +const webBase = process.env.SLICE8_WEB_URL || "http://localhost:3000"; +const apiBase = process.env.SLICE8_API_URL || "http://127.0.0.1:8010/api/v1"; +const healthUrl = process.env.SLICE8_HEALTH_URL || "http://127.0.0.1:8010/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.SLICE8_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice8-pilot-scope-closed-loop", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const state = { + api: null, + browser: null, + contexts: [], + admin: null, +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { id, title, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await state.api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +async function attachApiProxy(context) { + await context.route("**/api/v1/**", async (route) => { + const browserRequest = route.request(); + const browserUrl = new URL(browserRequest.url()); + const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); + const headers = { ...browserRequest.headers() }; + delete headers.host; + + const response = await state.api.fetch(targetUrl, { + method: browserRequest.method(), + headers, + data: browserRequest.postDataBuffer() ?? undefined, + }); + await route.fulfill({ response }); + }); +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ viewport: { width: 1440, height: 1100 } }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: userSession.access_token, user: userSession.user }, + ); + await attachApiProxy(context); + state.contexts.push(context); + return context; +} + +function assertPilotScope(scope, label) { + assert(scope.version === "pilot-scope-2026-05-12", `${label} version mismatch`, scope); + assert(scope.status === "frozen", `${label} should be frozen`, scope); + assert(scope.included.some((item) => item.key === "template_generation"), `${label} missing template generation`, scope.included); + assert(scope.included.some((item) => item.key === "review_chat_followup"), `${label} missing chat followup`, scope.included); + assert(scope.included.some((item) => item.key === "knowledge_enhancement"), `${label} missing knowledge enhancement`, scope.included); + assert(scope.excluded.some((item) => item.key === "complex_approval"), `${label} missing complex approval exclusion`, scope.excluded); + assert(scope.excluded.some((item) => item.key === "advanced_reporting"), `${label} missing reporting exclusion`, scope.excluded); + assert(scope.defect_policy.some((item) => item.level === "must_fix"), `${label} missing must-fix policy`, scope.defect_policy); +} + +async function run() { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "frontend and backend health", async () => { + const [frontend, health] = await Promise.all([state.api.get(`${webBase}/`), state.api.get(healthUrl)]); + assert(frontend.status() === 200, `frontend / status ${frontend.status()}`); + assert(health.status() === 200, `backend /health status ${health.status()}`); + return { webBase, apiBase, healthUrl, artifactDir }; + }); + + await step("SCOPE-001", "pilot scope API returns frozen boundary", async () => { + const payload = await apiRequest("get", "/system/pilot-scope"); + expectOk(payload, "pilot scope"); + assertPilotScope(payload.body.data, "pilot scope API"); + return { + version: payload.body.data.version, + included: payload.body.data.included.length, + excluded: payload.body.data.excluded.length, + }; + }); + + await step("READINESS-001", "readiness embeds same pilot scope", async () => { + const payload = await apiRequest("get", "/system/readiness"); + expectOk(payload, "readiness"); + assertPilotScope(payload.body.data.pilot_scope, "readiness pilot scope"); + return { + readiness_status: payload.body.data.status, + scope_version: payload.body.data.pilot_scope.version, + }; + }); + + await step("AUTH-001", "default admin seed and login", async () => { + const seed = await apiRequest("post", "/auth/seed-admin"); + expectOk(seed, "seed default admin"); + state.admin = await login("admin@example.com", adminPassword); + assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); + return { user: state.admin.user.username, role: state.admin.user.role }; + }); + + await step("BROWSER-001", "pilot readiness page exposes frozen scope", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/pilot-readiness`, { waitUntil: "domcontentloaded" }); + await page.getByTestId("pilot-scope-freeze").waitFor({ state: "visible", timeout: 12000 }); + await page.screenshot({ path: screenshotPath("pilot-scope-freeze"), fullPage: true }); + const resultText = await page.getByTestId("pilot-scope-freeze").innerText(); + assert(resultText.includes("试点版功能边界"), "page missing pilot scope title", resultText); + assert(resultText.includes("模板生成"), "page missing included scope", resultText); + assert(resultText.includes("复杂审批流"), "page missing excluded scope", resultText); + assert(resultText.includes("必须修复"), "page missing defect policy", resultText); + return { screenshot: "pilot-scope-freeze.png" }; + }); +} + +try { + await run(); +} finally { + for (const context of state.contexts) { + await context.close().catch(() => {}); + } + if (state.browser) { + await state.browser.close().catch(() => {}); + } + if (state.api) { + await state.api.dispose().catch(() => {}); + } + const summary = { + run_id: runId, + artifact_dir: artifactDir, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); + if (summary.failed > 0) { + process.exitCode = 1; + } +} diff --git a/frontend/scripts/slice9-readability-closed-loop-e2e.mjs b/frontend/scripts/slice9-readability-closed-loop-e2e.mjs new file mode 100644 index 00000000..889b5139 --- /dev/null +++ b/frontend/scripts/slice9-readability-closed-loop-e2e.mjs @@ -0,0 +1,276 @@ +import { chromium, request } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const runId = `slice9-readability-${Date.now()}`; +const webBase = process.env.SLICE9_WEB_URL || "http://localhost:3000"; +const apiBase = process.env.SLICE9_API_URL || "http://127.0.0.1:8010/api/v1"; +const healthUrl = process.env.SLICE9_HEALTH_URL || "http://127.0.0.1:8010/health"; +const sharedAiHealthUrl = process.env.SLICE9_SHARED_AI_HEALTH_URL || "http://127.0.0.1:18011/health"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD || "123456"; +const artifactDir = + process.env.SLICE9_ARTIFACT_DIR || + path.resolve(__dirname, "..", "..", "project-docs", "qa-artifacts", "slice9-readability-closed-loop", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const results = []; +const state = { + admin: null, + api: null, + browser: null, + contexts: [], + contractId: null, + reviewId: null, +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function screenshotPath(name) { + return path.join(artifactDir, `${name}.png`); +} + +function assert(condition, message, details = undefined) { + if (!condition) { + const error = new Error(message); + error.details = details; + throw error; + } +} + +async function step(id, title, fn) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + try { + const details = await fn(); + const item = { id, title, status: "PASS", duration_ms: Date.now() - started, started_at: startedAt, details: details || {} }; + results.push(item); + console.log(`PASS ${id} ${title}`); + return item; + } catch (error) { + const item = { + id, + title, + status: "FAIL", + duration_ms: Date.now() - started, + started_at: startedAt, + error: error.message, + details: error.details || {}, + }; + results.push(item); + console.error(`FAIL ${id} ${title}: ${error.message}`); + return item; + } +} + +async function apiRequest(method, route, options = {}) { + const headers = { + ...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}), + ...(options.headers || {}), + }; + const response = await state.api[method](apiUrl(route), { ...options, headers }); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +function expectOk(payload, label) { + assert(payload.response.status() >= 200 && payload.response.status() < 300, `${label} HTTP ${payload.response.status()}`, { + body: payload.body, + }); + if (payload.body && typeof payload.body === "object" && "code" in payload.body) { + assert(payload.body.code === 0, `${label} API code ${payload.body.code}`, { body: payload.body }); + } +} + +async function login(account, password) { + const payload = await apiRequest("post", "/auth/login", { data: { account, password } }); + expectOk(payload, `login ${account}`); + return payload.body.data; +} + +function buildVariableValues(template) { + return Object.fromEntries( + (template.variables || []).map((variable) => [ + variable.name, + variable.default_value || `${runId}-${variable.label || variable.name}`, + ]), + ); +} + +async function attachApiProxy(context) { + await context.route("**/api/v1/**", async (route) => { + const browserRequest = route.request(); + const browserUrl = new URL(browserRequest.url()); + const targetUrl = apiUrl(browserUrl.pathname.replace(/^\/api\/v1/, "") + browserUrl.search); + const headers = { ...browserRequest.headers() }; + delete headers.host; + + const response = await state.api.fetch(targetUrl, { + method: browserRequest.method(), + headers, + data: browserRequest.postDataBuffer() ?? undefined, + }); + await route.fulfill({ response }); + }); +} + +async function createAuthenticatedContext(userSession) { + const context = await state.browser.newContext({ viewport: { width: 1440, height: 1100 } }); + await context.addInitScript( + ({ token, user }) => { + window.localStorage.setItem("access_token", token); + window.localStorage.setItem("auth-store", JSON.stringify({ state: { accessToken: token, currentUser: user }, version: 0 })); + }, + { token: userSession.access_token, user: userSession.user }, + ); + await attachApiProxy(context); + state.contexts.push(context); + return context; +} + +async function run() { + state.api = await request.newContext(); + state.browser = await chromium.launch({ headless: true }); + + await step("ENV-001", "frontend, backend, and shared-ai health", async () => { + const [frontend, backendHealth, sharedAiHealth] = await Promise.all([ + state.api.get(`${webBase}/`), + state.api.get(healthUrl), + state.api.get(sharedAiHealthUrl), + ]); + assert(frontend.status() === 200, `frontend / status ${frontend.status()}`); + assert(backendHealth.status() === 200, `backend /health status ${backendHealth.status()}`); + assert(sharedAiHealth.status() === 200, `shared-ai stub /health status ${sharedAiHealth.status()}`); + return { webBase, apiBase, healthUrl, sharedAiHealthUrl, artifactDir }; + }); + + await step("AUTH-001", "default admin seed and login", async () => { + const seed = await apiRequest("post", "/auth/seed-admin"); + expectOk(seed, "seed default admin"); + state.admin = await login("admin@example.com", adminPassword); + assert(state.admin.user.role === "admin", "admin role mismatch", state.admin.user); + return { user: state.admin.user.username, role: state.admin.user.role }; + }); + + await step("CONTRACT-001", "generate contract with auto review evidence", async () => { + const templateSeed = await apiRequest("post", "/templates/seed-defaults", { authToken: state.admin.access_token }); + expectOk(templateSeed, "seed default templates"); + + const templates = await apiRequest("get", "/contracts/templates", { authToken: state.admin.access_token }); + expectOk(templates, "contract templates"); + const template = templates.body.data[0]; + assert(template, "no contract template available"); + + const generated = await apiRequest("post", "/contracts/generate", { + authToken: state.admin.access_token, + multipart: { + template_id: template.id, + variable_values_json: JSON.stringify(buildVariableValues(template)), + export_format: "txt", + options_json: JSON.stringify({ slice: "slice9", run_id: runId }), + }, + }); + expectOk(generated, "contract generate"); + + const contract = generated.body.data; + const autoReview = contract.auto_review; + assert(contract.id, "generated contract missing id", contract); + assert(autoReview?.review_id, "generated contract missing auto review id", contract); + assert(autoReview.status === "completed", "auto review should complete for readability smoke", autoReview); + + state.contractId = contract.id; + state.reviewId = autoReview.review_id; + return { + contract_id: state.contractId, + review_id: state.reviewId, + auto_review_status: autoReview.status, + risk_level: autoReview.risk_level, + }; + }); + + await step("REVIEW-001", "review detail API includes result and risks", async () => { + const [detail, result] = await Promise.all([ + apiRequest("get", `/reviews/${state.reviewId}`, { authToken: state.admin.access_token }), + apiRequest("get", `/reviews/${state.reviewId}/result`, { authToken: state.admin.access_token }), + ]); + expectOk(detail, "review detail"); + expectOk(result, "review result"); + assert(result.body.data.workflow_state?.review_id === state.reviewId, "workflow state review id mismatch", result.body.data); + assert(Array.isArray(result.body.data.risk_items), "risk items should be an array", result.body.data); + assert(result.body.data.risk_items.length > 0, "readability slice needs at least one risk item", result.body.data); + return { + status: detail.body.data.status, + risk_count: result.body.data.risk_items.length, + overall_risk: result.body.data.risk_summary?.overall, + }; + }); + + await step("BROWSER-001", "contract detail exposes readable generated result summary", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/contracts/${state.contractId}`, { waitUntil: "domcontentloaded" }); + const panel = page.getByTestId("contract-detail-readability"); + await panel.waitFor({ state: "visible", timeout: 12000 }); + await page.getByTestId("contract-auto-review-readability").waitFor({ state: "visible", timeout: 12000 }); + const itemCount = await panel.locator(".review-detail-readability-item").count(); + assert(itemCount >= 6, "contract readability panel should expose six summary items", { itemCount }); + await page.screenshot({ path: screenshotPath("contract-detail-readability"), fullPage: true }); + return { screenshot: "contract-detail-readability.png", item_count: itemCount }; + }); + + await step("BROWSER-002", "review detail exposes readable risk and evidence summary", async () => { + const context = await createAuthenticatedContext(state.admin); + const page = await context.newPage(); + await page.goto(`${webBase}/reviews/${state.reviewId}`, { waitUntil: "domcontentloaded" }); + const panel = page.getByTestId("review-detail-readability"); + await panel.waitFor({ state: "visible", timeout: 12000 }); + await page.getByTestId("review-risk-overview-readability").waitFor({ state: "visible", timeout: 12000 }); + const itemCount = await panel.locator(".review-detail-readability-item").count(); + const riskItemCount = await page.getByTestId("review-risk-item-readability").count(); + assert(itemCount >= 6, "review readability panel should expose six summary items", { itemCount }); + assert(riskItemCount > 0, "review risk item cards should be visible", { riskItemCount }); + await page.screenshot({ path: screenshotPath("review-detail-readability"), fullPage: true }); + return { screenshot: "review-detail-readability.png", item_count: itemCount, risk_item_count: riskItemCount }; + }); +} + +try { + await run(); +} finally { + for (const context of state.contexts) { + await context.close().catch(() => {}); + } + if (state.browser) { + await state.browser.close().catch(() => {}); + } + if (state.api) { + await state.api.dispose().catch(() => {}); + } + const summary = { + run_id: runId, + artifact_dir: artifactDir, + passed: results.filter((item) => item.status === "PASS").length, + failed: results.filter((item) => item.status === "FAIL").length, + results, + }; + fs.writeFileSync(path.join(artifactDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(`Wrote ${path.join(artifactDir, "summary.json")}`); + if (summary.failed > 0) { + process.exitCode = 1; + } +} diff --git a/frontend/src/app/admin/[slug]/page.tsx b/frontend/src/app/admin/[slug]/page.tsx index 3b25c670..2f905661 100644 --- a/frontend/src/app/admin/[slug]/page.tsx +++ b/frontend/src/app/admin/[slug]/page.tsx @@ -9,6 +9,8 @@ const adminRouteMap: Record = { rules: "/rules", templates: "/templates", "audit-logs": "/audit-logs", + "pilot-requests": "/pilot-requests", + requests: "/pilot-requests", "pilot-readiness": "/pilot-readiness", pilot: "/pilot-readiness", "tenant-settings": "/tenant-settings", diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index e3d7ecd5..684ec830 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,6 +1,12 @@ import Link from "next/link"; const adminSections = [ + { + href: "/pilot-requests", + eyebrow: "Requests", + label: "试点申请", + description: "查看公开演示入口收到的线索,推进联系、排期和试点判断。", + }, { href: "/governance", eyebrow: "Governance", @@ -57,11 +63,11 @@ export default function AdminPage() {
Admin Workspace -

统一进入后台治理与管理模块。

-

这里收拢治理中心、知识库、规则、模板、审计和试点交付入口,避免“看起来有后台,深链却是 404”的割裂体验。

+

统一进入后台治理、试点申请和交付准备工作台。

+

这里收拢治理中心、知识库、规则、模板、试点申请和试点交付入口,避免“看起来有后台,深链却是 404”的割裂体验。

- - 进入治理中心 + + 打开试点申请看板 打开试点交付准备 diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 01a6db3d..cdfc2d72 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -7,7 +7,7 @@ import { Alert, Button, Card, Empty, Input, List, Space, Spin, Tag, Typography, import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getConversationDetail, listConversations, streamChat } from "@/services/chat"; -import type { ChatMessage, ChatSourceItem, ChatStreamEvent, ConversationListItem, ConversationMessage } from "@/types/api"; +import type { ChatMessage, ChatSourceItem, ChatStreamEvent, ConversationListItem, ConversationMessage, ReviewChatContext } from "@/types/api"; const { TextArea } = Input; const { Title, Text } = Typography; @@ -19,21 +19,6 @@ const defaultChatTips = [ "如果中途中断,可直接点击重试继续发问。", ]; -type ReviewChatContext = { - reviewId?: string | null; - prompt?: string | null; - metadata?: { - reviewTitle?: string | null; - reviewStatus?: string | null; - contractType?: string | null; - overallRisk?: string | null; - reviewOpinion?: string | null; - focusLabel?: string | null; - actionLabel?: string | null; - focusType?: string | null; - }; -}; - function buildFocusTypeHint(reviewContext: ReviewChatContext | null) { const focusType = reviewContext?.metadata?.focusType?.trim(); if (focusType === "clause") { @@ -637,11 +622,23 @@ function buildSourceFocusHint(reviewContext: ReviewChatContext | null, sources: } function buildSourceTitle(reviewContext: ReviewChatContext | null) { - return reviewContext?.metadata?.focusLabel ? `依据来源(聚焦:${reviewContext.metadata.focusLabel})` : "依据来源"; + if (reviewContext?.metadata?.focusLabel) { + return `依据来源(聚焦:${reviewContext.metadata.focusLabel})`; + } + if (reviewContext?.aiTrace?.knowledge_source) { + return `依据来源(${reviewContext.aiTrace.knowledge_source})`; + } + return "依据来源"; } function buildSourceDescription(reviewContext: ReviewChatContext | null, sources: ChatSourceItem[]) { - return buildSourceFocusHint(reviewContext, sources) ?? "可结合下方依据来源继续人工复核。"; + if (reviewContext?.metadata?.focusLabel) { + return buildSourceFocusHint(reviewContext, sources) ?? "可结合下方依据来源继续人工复核。"; + } + if (reviewContext?.aiTrace?.knowledge_source) { + return `当前问答优先参考 ${reviewContext.aiTrace.knowledge_source},并保留返回的引文来源。`; + } + return "可结合下方依据来源继续人工复核。"; } function buildSourceTagline(item: ChatSourceItem, reviewContext: ReviewChatContext | null) { @@ -823,11 +820,20 @@ export default function ChatPage() { if (!reviewContext?.metadata) { return [] as Array<{ label: string; value: string }>; } + const aiTrace = reviewContext.aiTrace; + const aiRunLabel = [aiTrace?.provider, aiTrace?.model].filter(Boolean).join(" / "); const baseCards = [ reviewContext.metadata.reviewTitle ? { label: "当前合同", value: reviewContext.metadata.reviewTitle } : null, reviewContext.metadata.contractType ? { label: "合同类型", value: reviewContext.metadata.contractType } : null, reviewContext.metadata.overallRisk ? { label: "当前风险", value: reviewContext.metadata.overallRisk } : null, reviewContext.metadata.reviewStatus ? { label: "任务状态", value: reviewContext.metadata.reviewStatus } : null, + reviewContext.sourceDocument?.file_name ? { label: "解析文件", value: reviewContext.sourceDocument.file_name } : null, + reviewContext.sourceDocument?.parse_status ? { label: "文本解析", value: reviewContext.sourceDocument.parse_status } : null, + reviewContext.sourceDocument?.ocr_gate_status ? { label: "OCR 门禁", value: reviewContext.sourceDocument.ocr_gate_status } : null, + aiTrace?.knowledge_source ? { label: "知识来源", value: aiTrace.knowledge_source } : null, + aiRunLabel ? { label: "AI 运行", value: aiRunLabel } : null, + aiTrace?.prompt_version ? { label: "提示词版本", value: aiTrace.prompt_version } : null, + reviewContext.workflowState?.current_stage_label ? { label: "当前阶段", value: reviewContext.workflowState.current_stage_label } : null, reviewContext.metadata.focusLabel ? { label: "当前追问对象", value: reviewContext.metadata.focusLabel } : null, reviewContext.metadata.actionLabel ? { label: "当前动作", value: reviewContext.metadata.actionLabel } : null, ].filter((item): item is { label: string; value: string } => item !== null); @@ -1468,7 +1474,21 @@ export default function ChatPage() { ) : ( )} - {errorText ? : null} + {errorText ? ( + + {errorText} + 可以点击“重试上一次”,或缩短问题并聚焦一个风险点后重新发送。 + + } + /> + ) : null} diff --git a/frontend/src/app/contracts/[id]/page.tsx b/frontend/src/app/contracts/[id]/page.tsx index 12eef9e4..fb14314c 100644 --- a/frontend/src/app/contracts/[id]/page.tsx +++ b/frontend/src/app/contracts/[id]/page.tsx @@ -9,9 +9,11 @@ import { downloadContract, getGeneratedContract, getGeneratedContractPreview, + type AutoReviewSummary, type GeneratedContract, type GeneratedContractPreview, } from "@/services/contract"; +import { downloadReviewReport } from "@/services/review"; import { isRequestError } from "@/services/http"; import { useAuthStore } from "@/stores/auth.store"; @@ -33,6 +35,76 @@ function formatFileSize(value: number | null | undefined) { return `${(value / 1024).toFixed(2)} KB`; } +const autoReviewStatusLabelMap: Record = { + completed: "已完成", + processing: "处理中", + pending: "待处理", + failed: "失败", + partial: "部分完成", +}; + +const autoReviewStatusColorMap: Record = { + completed: "green", + processing: "blue", + pending: "gold", + failed: "red", + partial: "orange", +}; + +const autoReviewRiskLabelMap: Record = { + high: "高风险", + medium: "中风险", + low: "低风险", +}; + +const autoReviewRiskColorMap: Record = { + high: "red", + medium: "gold", + low: "blue", +}; + +function formatAutoReviewStatus(value: string | null | undefined) { + if (!value) { + return "未触发"; + } + return autoReviewStatusLabelMap[value] ?? value; +} + +function getAutoReviewStatusColor(value: string | null | undefined) { + if (!value) { + return "default"; + } + return autoReviewStatusColorMap[value] ?? "default"; +} + +function formatAutoReviewRisk(value: string | null | undefined) { + if (!value) { + return "未评级"; + } + return autoReviewRiskLabelMap[value] ?? value; +} + +function getAutoReviewRiskColor(value: string | null | undefined) { + if (!value) { + return "default"; + } + return autoReviewRiskColorMap[value] ?? "default"; +} + +function buildContractReadabilityItems( + detail: GeneratedContract | null, + autoReview: AutoReviewSummary | null, +) { + return [ + { label: "交付文件", value: detail?.file_name ?? "-" }, + { label: "导出格式", value: detail?.export_format?.toUpperCase() ?? "-" }, + { label: "生成模板", value: detail?.template_name ?? "-" }, + { label: "自动审查", value: formatAutoReviewStatus(autoReview?.status) }, + { label: "总体风险", value: formatAutoReviewRisk(autoReview?.risk_level) }, + { label: "下一步动作", value: autoReview?.next_action ?? autoReview?.status_detail ?? "可进入详情继续审查或问答复核" }, + ]; +} + function buildContractChatHref( detail: GeneratedContract | null, prompt: string, @@ -122,6 +194,12 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis })); }, [detail]); + const autoReview = detail?.auto_review ?? null; + const readabilityItems = useMemo( + () => buildContractReadabilityItems(detail, autoReview), + [detail, autoReview], + ); + const handleDownload = async () => { if (!detail) { return; @@ -138,6 +216,39 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis } }; + const handleDownloadAutoReviewReport = async (autoReview?: AutoReviewSummary | null) => { + if (!autoReview?.report_download_url) { + messageApi.warning("当前还没有可下载的审查报告"); + return; + } + + try { + await downloadReviewReport({ + file_name: autoReview.report_file_name ?? `review-report-${autoReview.review_id ?? "auto"}.pdf`, + download_url: autoReview.report_download_url, + file_id: null, + }); + messageApi.success("审查报告下载开始"); + } catch { + messageApi.error("审查报告下载失败,请稍后重试"); + } + }; + + const handleOpenAutoReviewDetail = (autoReview?: AutoReviewSummary | null) => { + if (!autoReview) { + messageApi.warning("自动审查结果暂不可用"); + return; + } + + const target = autoReview.detail_url ?? (autoReview.review_id ? `/reviews/${autoReview.review_id}` : null); + if (!target) { + messageApi.warning("自动审查详情尚未生成"); + return; + } + + router.push(target); + }; + const handleRestore = () => { if (!detail) { return; @@ -264,6 +375,30 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
+ +
+ {readabilityItems.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ + + 审查状态:{formatAutoReviewStatus(autoReview?.status)} + + + 总体风险:{formatAutoReviewRisk(autoReview?.risk_level)} + + {autoReview?.report_download_url ? 报告可下载 : 报告待生成} + +
+ {detail ? ( @@ -286,6 +421,47 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis )} + {autoReview ? ( + + + {autoReview.review_id ?? "-"} + + {formatAutoReviewStatus(autoReview.status)} + + {autoReview.risk_level ?? "-"} + {autoReview.current_stage_label ?? "-"} + + {autoReview.status_detail ?? "-"} + + + {autoReview.next_action ?? "-"} + + + {autoReview.review_opinion ?? "-"} + + {autoReview.report_file_name ?? "-"} + {autoReview.source_file_name ?? detail?.file_name ?? "-"} + + +
+ + + + + +
+
+ ) : null} +
= { + completed: "已完成", + processing: "处理中", + pending: "待处理", + failed: "失败", + partial: "部分完成", +}; + +const autoReviewStatusColorMap: Record = { + completed: "green", + processing: "blue", + pending: "gold", + failed: "red", + partial: "orange", +}; + +function formatAutoReviewStatus(value: string | null | undefined) { + if (!value) { + return "未触发"; + } + return autoReviewStatusLabelMap[value] ?? value; +} + +function getAutoReviewStatusColor(value: string | null | undefined) { + if (!value) { + return "default"; + } + return autoReviewStatusColorMap[value] ?? "default"; +} + function parseRestorePayload(value: string | null): RefillPayload | null { if (!value) { return null; @@ -155,6 +188,8 @@ export default function ContractsPage() { const [currentStep, setCurrentStep] = useState(0); const [templates, setTemplates] = useState([]); const [selectedTemplate, setSelectedTemplate] = useState(null); + const [templateCategoryFilter, setTemplateCategoryFilter] = useState(undefined); + const [industryCategoryFilter, setIndustryCategoryFilter] = useState(undefined); const [previewResult, setPreviewResult] = useState(null); const [generatedContract, setGeneratedContract] = useState(null); const [historyContracts, setHistoryContracts] = useState([]); @@ -212,7 +247,10 @@ export default function ContractsPage() { const loadTemplates = useCallback(async () => { setTemplateLoading(true); try { - const result = await listContractTemplates(); + const result = await listContractTemplates({ + template_category: templateCategoryFilter, + industry_category: industryCategoryFilter, + }); if (result.code !== 0) { messageApi.error(result.message || "获取模板列表失败"); return; @@ -223,7 +261,7 @@ export default function ContractsPage() { } finally { setTemplateLoading(false); } - }, [messageApi]); + }, [industryCategoryFilter, messageApi, templateCategoryFilter]); const loadHistory = useCallback( async (page = historyPage, pageSize = historyPageSize, contractType = historyContractType, keyword = historyKeyword) => { @@ -444,6 +482,48 @@ export default function ContractsPage() { clearRestoreQuery(); }; + const handleOpenAutoReviewDetail = (autoReview?: AutoReviewSummary | null) => { + if (!autoReview) { + messageApi.warning("自动审查结果暂不可用"); + return; + } + + const target = autoReview.detail_url ?? (autoReview.review_id ? `/reviews/${autoReview.review_id}` : null); + if (!target) { + messageApi.warning("自动审查详情尚未生成"); + return; + } + + router.push(target); + }; + + const handleOpenAutoReviewChat = (autoReview?: AutoReviewSummary | null) => { + if (!autoReview?.chat_url) { + messageApi.warning("自动审查问答上下文尚未生成"); + return; + } + + router.push(autoReview.chat_url); + }; + + const handleDownloadAutoReviewReport = async (autoReview?: AutoReviewSummary | null) => { + if (!autoReview?.report_download_url) { + messageApi.warning("当前还没有可下载的审查报告"); + return; + } + + try { + await downloadReviewReport({ + file_name: autoReview.report_file_name ?? `review-report-${autoReview.review_id ?? "auto"}.pdf`, + download_url: autoReview.report_download_url, + file_id: null, + }); + messageApi.success("审查报告下载开始"); + } catch { + messageApi.error("审查报告下载失败,请稍后重试"); + } + }; + const templateColumns: ColumnsType = [ { title: "模板名称", @@ -456,6 +536,10 @@ export default function ContractsPage() { {record.contract_type} + + {formatTemplateCategory(record.template_category)} + {formatIndustryCategory(record.industry_category)} + ), }, @@ -540,6 +624,29 @@ export default function ContractsPage() { width: 180, render: (value: string) => new Date(value).toLocaleString("zh-CN"), }, + { + title: "自动审查", + dataIndex: "auto_review", + key: "auto_review", + width: 210, + render: (autoReview: AutoReviewSummary | null | undefined) => { + if (!autoReview) { + return 未触发; + } + + return ( + + {formatAutoReviewStatus(autoReview.status)} + + {autoReview.current_stage_label ?? autoReview.next_action ?? "-"} + + + + ); + }, + }, { title: "生成人员", dataIndex: ["metadata", "generated_by_name"], @@ -616,6 +723,24 @@ export default function ContractsPage() { {currentStep === 0 && ( + + setIndustryCategoryFilter(value)} + /> + {reviewContext ? ( + {generatedContract.auto_review ? ( +
+
+ 自动审查 + + {formatAutoReviewStatus(generatedContract.auto_review.status)} + +
+ + 审查任务: {generatedContract.auto_review.review_id ?? "-"} + 当前阶段: {generatedContract.auto_review.current_stage_label ?? "-"} + 总体风险: {generatedContract.auto_review.risk_level ?? "-"} + 状态说明: {generatedContract.auto_review.status_detail ?? "-"} + 下一步: {generatedContract.auto_review.next_action ?? "-"} + {generatedContract.auto_review.review_opinion ? 审查意见: {generatedContract.auto_review.review_opinion} : null} + + + + + + +
+ ) : null} +
生成信息 @@ -853,7 +1008,7 @@ export default function ContractsPage() { showTotal: total => `共 ${total} 条`, }} onChange={handleHistoryTableChange} - scroll={{ x: 1200 }} + scroll={{ x: 1380 }} /> diff --git a/frontend/src/app/demo-request/page.tsx b/frontend/src/app/demo-request/page.tsx new file mode 100644 index 00000000..95d25305 --- /dev/null +++ b/frontend/src/app/demo-request/page.tsx @@ -0,0 +1,204 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { Alert, Button, Card, Form, Input, Select, Space, Typography, message } from "antd"; + +import { + submitPilotRequest, + type PilotRequestDeploymentPreference, + type PilotRequestItem, + type PilotRequestPrimaryGoal, + type SubmitPilotRequestPayload, +} from "@/services/pilot-request"; + +const { Title, Text } = Typography; + +const primaryGoalOptions: Array<{ label: string; value: PilotRequestPrimaryGoal }> = [ + { label: "合同审查试点", value: "contract_review" }, + { label: "合同起草试点", value: "contract_generation" }, + { label: "知识库增强", value: "knowledge_base" }, + { label: "综合试点交付", value: "pilot_delivery" }, +]; + +const deploymentOptions: Array<{ label: string; value: PilotRequestDeploymentPreference }> = [ + { label: "SaaS", value: "saas" }, + { label: "私有化", value: "private" }, + { label: "混合部署", value: "hybrid" }, + { label: "待评估", value: "undecided" }, +]; + +const pilotFlow = [ + "提交演示申请,说明合同场景和目标。", + "我们按试点目标准备演示账号、样本和链路。", + "管理员进入试点工作台跟进状态并安排演示。", +]; + +export default function DemoRequestPage() { + const [messageApi, contextHolder] = message.useMessage(); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const [submittedRequest, setSubmittedRequest] = useState(null); + + const handleSubmit = async (values: SubmitPilotRequestPayload) => { + setSubmitting(true); + try { + const result = await submitPilotRequest(values); + if (result.code !== 0) { + messageApi.error(result.message || "演示申请提交失败"); + return; + } + setSubmittedRequest(result.data); + messageApi.success("演示申请已提交"); + form.resetFields(); + } catch (error: unknown) { + messageApi.error(error instanceof Error ? error.message : "演示申请提交失败,请稍后重试"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ {contextHolder} +
+
+ Pilot Intake +

把演示申请变成一条可跟进的试点入口。

+

围绕合同审查、合同起草和法务知识库三条主链,收集场景、规模和部署偏好,直接进入管理员试点工作台。

+
+ + 先注册试点账号 + + + 已有账号,进入工作台 + +
+
+ 合同审查 + 合同起草 + 知识库增强 + 试点交付 +
+
+ + + Pilot Flow + + 提交后会发生什么 + +
    + {pilotFlow.map((item) => ( +
  • {item}
  • + ))} +
+ 这一步先解决“客户怎么进入试点”,再继续打磨后续交付材料和运营动作。 +
+
+ +
+
+ + +
+

演示申请表

+

留下联系人、企业和场景,我们会据此准备演示链路与试点建议。

+
+ + {submittedRequest ? ( + + ) : null} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+

适合这一步的人

+

不是所有人都要先注册;如果还在评估阶段,先走演示申请会更顺。

+
+ +
+
+ Legal Owner + 法务负责人 + 先验证合同审查、风险报告和法务追问是否真的省时间。 +
+
+ Business Owner + 业务负责人 + 先确认模板起草、协同回改和试点价值表达是否贴近业务。 +
+
+ IT / Admin + 信息化或管理员 + 先明确部署方式、账号策略和受控试点的交付边界。 +
+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 925cad97..68c77d3a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3917,6 +3917,37 @@ body::selection { font-weight: 900; } +.review-detail-readability-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.review-detail-readability-item { + display: grid; + gap: 6px; + min-height: 88px; + padding: 14px; + border-radius: 8px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(247, 250, 253, 0.82)); + border: 1px solid rgba(17, 18, 23, 0.08); +} + +.review-detail-readability-item span { + color: rgba(29, 36, 48, 0.62); + font-size: 12px; + font-weight: 900; +} + +.review-detail-readability-item strong { + min-width: 0; + color: #111217; + font-size: 15px; + font-weight: 900; + line-height: 1.45; + overflow-wrap: anywhere; +} + .auth-access-strip, .contract-progress-strip, .admin-signal-strip { @@ -4161,6 +4192,7 @@ body::selection { .review-detail-hero-stats, .review-detail-decision-strip, .review-detail-group-grid, + .review-detail-readability-grid, .review-detail-structure-grid, .review-detail-structure-section-grid, .review-detail-ocr-grid, diff --git a/frontend/src/app/knowledge-bases/page.tsx b/frontend/src/app/knowledge-bases/page.tsx index ad8eaf3c..c2943c7b 100644 --- a/frontend/src/app/knowledge-bases/page.tsx +++ b/frontend/src/app/knowledge-bases/page.tsx @@ -76,8 +76,11 @@ function getRetrievalModeMeta(value: string | null | undefined) { if (mode === "vector") { return { color: "cyan", label: "Vector" }; } - if (mode === "bm25" || mode === "local-bm25") { - return { color: "blue", label: mode === "local-bm25" ? "Local BM25" : "BM25" }; + if (mode === "bm25") { + return { color: "blue", label: "BM25" }; + } + if (mode === "local-bm25") { + return { color: "gold", label: "Legacy Local BM25" }; } return { color: "default", label: value || "Default" }; diff --git a/frontend/src/app/pilot-readiness/page.tsx b/frontend/src/app/pilot-readiness/page.tsx index a2bced7d..645e7ec3 100644 --- a/frontend/src/app/pilot-readiness/page.tsx +++ b/frontend/src/app/pilot-readiness/page.tsx @@ -6,11 +6,17 @@ import { ReloadOutlined, WarningOutlined, } from "@ant-design/icons"; -import { Alert, Button, Card, Descriptions, Empty, Space, Spin, Tag, Timeline, Typography } from "antd"; +import { Alert, Button, Card, Descriptions, Empty, Space, Spin, Tag, Timeline, Typography, message } from "antd"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { getSystemReadiness, type SystemReadinessCheck, type SystemReadinessResponse } from "@/services/system"; +import { + getSystemReadiness, + seedPilotDemoAssets, + type PilotDemoSeedResponse, + type SystemReadinessCheck, + type SystemReadinessResponse, +} from "@/services/system"; import { useAuthStore } from "@/stores/auth.store"; const { Paragraph, Title } = Typography; @@ -43,11 +49,27 @@ function buildCheckStats(checks: SystemReadinessCheck[]) { }; } +function formatOwnerLabel(owner: string) { + if (owner === "admin") { + return "管理员"; + } + if (owner === "legal") { + return "法务用户"; + } + if (owner === "business") { + return "业务用户"; + } + return owner; +} + export default function PilotReadinessPage() { + const [messageApi, contextHolder] = message.useMessage(); const currentUser = useAuthStore((state) => state.currentUser); const [loading, setLoading] = useState(true); + const [seedingDemoAssets, setSeedingDemoAssets] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [readiness, setReadiness] = useState(null); + const [seedResult, setSeedResult] = useState(null); const loadReadiness = useCallback(async () => { setLoading(true); @@ -66,6 +88,25 @@ export default function PilotReadinessPage() { } }, []); + const handleSeedDemoAssets = useCallback(async () => { + setSeedingDemoAssets(true); + setErrorMessage(null); + try { + const result = await seedPilotDemoAssets(); + if (result.code !== 0) { + messageApi.error(result.message || "演示资产初始化失败"); + return; + } + setSeedResult(result.data); + messageApi.success("演示资产初始化完成"); + await loadReadiness(); + } catch (error: unknown) { + messageApi.error(error instanceof Error ? error.message : "演示资产初始化失败"); + } finally { + setSeedingDemoAssets(false); + } + }, [loadReadiness, messageApi]); + useEffect(() => { const timer = window.setTimeout(() => { if (currentUser?.role === "admin") { @@ -84,6 +125,7 @@ export default function PilotReadinessPage() { if (currentUser && currentUser.role !== "admin") { return (
+ {contextHolder} + {contextHolder}
@@ -118,6 +161,14 @@ export default function PilotReadinessPage() { +
@@ -191,7 +242,338 @@ export default function PilotReadinessPage() { })} +
+
+ + + + P0 主线对齐矩阵 + + {readiness.p0_mainline.version} + {readiness.p0_mainline.status} + {readiness.p0_mainline.source_document} + + + {readiness.p0_mainline.summary} + + +
+ {readiness.p0_mainline.capabilities.map((item) => ( + + + + {item.source_project} + {item.target_layer} + + + {item.absorbed_capability} + + + 合同平台职责: + {item.contract_platform_responsibility} + + + 验收口径: + {item.acceptance} + + + 吸收原则: + {item.reference_policy} + +
+ + {item.evidence.map((entry) => ( + {entry} + ))} + +
+
+
+ ))} + + + + 主线范围: + {readiness.p0_mainline.phase_boundary.mainline_scope} + + + 下一阶段: + {readiness.p0_mainline.phase_boundary.next_stage_scope} + + + {readiness.p0_mainline.phase_boundary.rule} + + + + + + {readiness.p0_mainline.guardrails.map((item) => ( + + ))} + + + + + +
+ +
+
+ + + + 试点版功能边界 + + {readiness.pilot_scope.version} + {readiness.pilot_scope.status} + {readiness.pilot_scope.source_document} + + + {readiness.pilot_scope.summary} + + +
+ + + {readiness.pilot_scope.included.map((item) => ( +
+ + {item.label} + {formatOwnerLabel(item.owner)} + + + {item.acceptance} + +
+ ))} +
+
+ + + {readiness.pilot_scope.excluded.map((item) => ( +
+ {item.label} + + {item.reason} + +
+ ))} +
+
+ + + {readiness.pilot_scope.defect_policy.map((item) => ( +
+ + + {item.level} + + {item.label} + + + {item.description} + +
+ ))} +
+
+
+ +
+
+ + + + 试点材料包 + + {readiness.pilot_materials.version} + {readiness.pilot_materials.status} + {readiness.pilot_materials.source_document} + + + {readiness.pilot_materials.summary} + + +
+ {readiness.pilot_materials.materials.map((item) => ( + + + + {item.status} + {item.audience} + + + {item.purpose} + +
+ + {item.checklist.map((entry) => ( + {entry} + ))} + +
+ + 交付物:{item.deliverable} + +
+
+ ))} + + + {readiness.pilot_materials.feedback_template.map((item) => ( + + {item.placeholder} + + ))} + + + + + +
+ +
+
+ + + + 错误与异常反馈 + + {readiness.pilot_error_feedback.version} + {readiness.pilot_error_feedback.status} + {readiness.pilot_error_feedback.source_document} + + + {readiness.pilot_error_feedback.summary} + + +
+ {readiness.pilot_error_feedback.items.map((item) => ( + + + + {item.scope} + {item.qa_selector} + + +
+ 下一步动作 + +
+
+ + {item.technical_signals.map((signal) => ( + {signal} + ))} + +
+
+
+ ))} + + + +
+ +
+
+ + + + 试点启动包 + + {readiness.pilot_launch_pack.version} + {readiness.pilot_launch_pack.status} + {readiness.pilot_launch_pack.source_document} + + + {readiness.pilot_launch_pack.summary} + + +
+ {readiness.pilot_launch_pack.script_steps.map((item) => ( + + + + {item.owner} + {item.route} + + + {item.talk_track} + + + 成功信号:{item.success_signal} + +
+ + {item.evidence.map((entry) => ( + {entry} + ))} + +
+
+
+ ))} + + + {readiness.pilot_launch_pack.readiness_gates.map((item) => ( + + {item.check} {"->"} {item.expected} + + ))} + + + + + {readiness.pilot_launch_pack.roles.map((item) => ( +
+ {item.role} + + {item.responsibility} + +
+ ))} +
+
+ + + {readiness.pilot_launch_pack.blockers.map((item) => ( + + ))} + + + + + +
+
+ {seedResult ? ( + + + + 新增 {seedResult.templates.created_count} 个,已存在 {seedResult.templates.existing_count} 个,总计{" "} + {seedResult.templates.total} 个 + + + + {seedResult.knowledge.status} + {seedResult.knowledge.knowledge_base.name} + + + + + + {seedResult.knowledge.document.status} + + {seedResult.knowledge.document.file_name} + + + {seedResult.demo_accounts.roles.join(" / ")} + + + ) : null} + {readiness.pilot_assets.default_admin.username} diff --git a/frontend/src/app/pilot-requests/page.tsx b/frontend/src/app/pilot-requests/page.tsx new file mode 100644 index 00000000..90bfedea --- /dev/null +++ b/frontend/src/app/pilot-requests/page.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { ReloadOutlined } from "@ant-design/icons"; +import { Alert, Button, Card, Empty, Form, Input, Modal, Select, Space, Spin, Table, Tag, Typography, message } from "antd"; +import type { TableColumnsType } from "antd"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { + getPilotRequestSummary, + getPilotRequests, + updatePilotRequest, + type PilotRequestItem, + type PilotRequestStatus, + type PilotRequestSummaryResponse, +} from "@/services/pilot-request"; +import { useAuthStore } from "@/stores/auth.store"; + +const { Paragraph, Title } = Typography; + +const statusOptions: Array<{ label: string; value: PilotRequestStatus }> = [ + { label: "新线索", value: "new" }, + { label: "已联系", value: "contacted" }, + { label: "已排演示", value: "scheduled" }, + { label: "已关闭", value: "closed" }, +]; + +const statusLabelMap: Record = { + new: "新线索", + contacted: "已联系", + scheduled: "已排演示", + closed: "已关闭", +}; + +const statusColorMap: Record = { + new: "gold", + contacted: "blue", + scheduled: "green", + closed: "default", +}; + +const primaryGoalLabelMap: Record = { + contract_review: "合同审查试点", + contract_generation: "合同起草试点", + knowledge_base: "知识库增强", + pilot_delivery: "综合试点交付", +}; + +const deploymentLabelMap: Record = { + saas: "SaaS", + private: "私有化", + hybrid: "混合部署", + undecided: "待评估", +}; + +function formatDateTime(value: string | null | undefined) { + if (!value) { + return "-"; + } + return new Date(value).toLocaleString("zh-CN", { hour12: false }); +} + +type UpdateFormValues = { + status: PilotRequestStatus; + admin_notes?: string; +}; + +export default function PilotRequestsPage() { + const [messageApi, contextHolder] = message.useMessage(); + const currentUser = useAuthStore((state) => state.currentUser); + const [loading, setLoading] = useState(true); + const [updating, setUpdating] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [statusFilter, setStatusFilter] = useState(undefined); + const [items, setItems] = useState([]); + const [summary, setSummary] = useState(null); + const [selectedItem, setSelectedItem] = useState(null); + const [updateForm] = Form.useForm(); + + const loadData = useCallback(async () => { + setLoading(true); + setErrorMessage(null); + try { + const [listResult, summaryResult] = await Promise.all([ + getPilotRequests(1, 50, statusFilter), + getPilotRequestSummary(), + ]); + if (listResult.code !== 0) { + setErrorMessage(listResult.message || "试点申请列表加载失败"); + return; + } + if (summaryResult.code !== 0) { + setErrorMessage(summaryResult.message || "试点申请汇总加载失败"); + return; + } + setItems(listResult.data.items); + setSummary(summaryResult.data); + } catch (error: unknown) { + setErrorMessage(error instanceof Error ? error.message : "试点申请加载失败"); + } finally { + setLoading(false); + } + }, [statusFilter]); + + useEffect(() => { + const timer = window.setTimeout(() => { + if (currentUser?.role === "admin") { + void loadData(); + } else { + setLoading(false); + } + }, 0); + + return () => window.clearTimeout(timer); + }, [currentUser?.role, loadData]); + + const handleOpenUpdate = useCallback((item: PilotRequestItem) => { + setSelectedItem(item); + updateForm.setFieldsValue({ + status: item.status, + admin_notes: item.admin_notes || "", + }); + }, [updateForm]); + + const handleSubmitUpdate = async (values: UpdateFormValues) => { + if (!selectedItem) { + return; + } + setUpdating(true); + try { + const result = await updatePilotRequest(selectedItem.id, values); + if (result.code !== 0) { + messageApi.error(result.message || "试点申请更新失败"); + return; + } + messageApi.success("试点申请状态已更新"); + setSelectedItem(null); + updateForm.resetFields(); + await loadData(); + } catch (error: unknown) { + messageApi.error(error instanceof Error ? error.message : "试点申请更新失败"); + } finally { + setUpdating(false); + } + }; + + const columns = useMemo>( + () => [ + { + title: "企业 / 联系人", + key: "company", + render: (_, item) => ( + + {item.company_name} + {item.contact_name}{item.role_title ? ` / ${item.role_title}` : ""} + {item.email}{item.phone ? ` / ${item.phone}` : ""} + + ), + }, + { + title: "试点目标", + key: "goal", + render: (_, item) => ( + + {primaryGoalLabelMap[item.primary_goal] ?? item.primary_goal} + {item.deployment_preference ? deploymentLabelMap[item.deployment_preference] ?? item.deployment_preference : "未填写部署偏好"} + + ), + }, + { + title: "场景说明", + dataIndex: "scenario_description", + key: "scenario_description", + render: (value: string) => {value}, + }, + { + title: "状态", + dataIndex: "status", + key: "status", + render: (value: PilotRequestStatus) => {statusLabelMap[value]}, + }, + { + title: "提交时间", + dataIndex: "created_at", + key: "created_at", + render: (value: string) => formatDateTime(value), + }, + { + title: "操作", + key: "actions", + render: (_, item) => ( + + ), + }, + ], + [handleOpenUpdate], + ); + + if (currentUser && currentUser.role !== "admin") { + return ( +
+ {contextHolder} + +
+ ); + } + + return ( +
+ {contextHolder} +
+
+ Pilot Requests +

把公开演示入口收到的线索,收束到管理员试点工作台里。

+

这里处理试点申请、演示排期和客户场景说明,确保公开入口不是孤立表单,而是可推进的线索流。

+
+ +
+
+
+ +
+ {errorMessage ? : null} + + {loading ? ( +
+ +
+ ) : ( + +
+ + 总线索 + {summary?.total ?? 0} + 进入公开演示入口后的累计申请数。 + + + 待跟进 + {summary?.counts.new ?? 0} + 刚提交、还未联系的试点线索。 + + + 已排演示 + {summary?.counts.scheduled ?? 0} + 已经进入演示安排或试点推进阶段。 + + + 最近线索 + {summary?.latest_items[0]?.company_name ?? "暂无"} + {summary?.latest_items[0]?.contact_name ?? "等待新的演示申请进入。"} + +
+ + + +
+ 试点申请列表 + 按状态筛选并推进每条申请的跟进状态。 +
+ + allowClear + options={statusOptions} + placeholder="按状态筛选" + style={{ minWidth: 180 }} + value={statusFilter} + onChange={(value) => setStatusFilter(value)} + /> +
+ + {items.length === 0 ? ( + + ) : ( +
+ )} + + + )} + + + { + setSelectedItem(null); + updateForm.resetFields(); + }} + onOk={() => void updateForm.submit()} + open={Boolean(selectedItem)} + title={selectedItem ? `跟进:${selectedItem.company_name}` : "跟进试点申请"} + > +
+ + + - + + + - + + + + +
变量配置 -
- - {(watchedVariables ?? [{}]).map((_, index) => ( - - - - - - - - - ))} - + )} +
+
变量 JSON 预览
{variablePreview}
+ + {editingTemplate ? (
+ ) : ( + + )} + + + ); + }, }} - scroll={{ x: 1000 }} + scroll={{ x: 1400 }} /> diff --git a/frontend/src/app/users/page.tsx b/frontend/src/app/users/page.tsx index 85dec348..faff0b18 100644 --- a/frontend/src/app/users/page.tsx +++ b/frontend/src/app/users/page.tsx @@ -4,6 +4,7 @@ import { Alert, Button, Card, Empty, Form, Input, Select, Space, Switch, Table, import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { formatRoleLabel } from "@/lib/roles"; import { createUser, listUsers, updateUser } from "@/services/user"; import { useAuthStore } from "@/stores/auth.store"; import type { UserItem } from "@/types/api"; @@ -20,7 +21,8 @@ type UserFormValues = { const roleOptions = [ { value: "admin", label: "管理员" }, - { value: "member", label: "普通成员" }, + { value: "legal", label: "法务用户" }, + { value: "business", label: "业务用户" }, ]; const statusOptions = [ @@ -47,28 +49,36 @@ export default function UsersPage() { const editingUser = useMemo(() => items.find((item) => item.id === editingId) ?? null, [editingId, items]); - const loadUsers = useCallback(async (nextPage = page, nextPageSize = pageSize) => { - if (currentUser?.role !== "admin") { - setLoading(false); - return; - } + const resetFormDefaults = useCallback(() => { + form.resetFields(); + form.setFieldsValue({ role: "business", status: "active" }); + }, [form]); - try { - const result = await listUsers(nextPage, nextPageSize); - if (result.code !== 0) { - messageApi.error(result.message || "获取用户列表失败"); + const loadUsers = useCallback( + async (nextPage = page, nextPageSize = pageSize) => { + if (currentUser?.role !== "admin") { + setLoading(false); return; } - setItems(result.data.items); - setPage(result.data.page); - setPageSize(result.data.page_size); - setTotal(result.data.total); - } catch { - messageApi.error("用户列表加载失败,请稍后重试"); - } finally { - setLoading(false); - } - }, [currentUser?.role, messageApi, page, pageSize]); + + try { + const result = await listUsers(nextPage, nextPageSize); + if (result.code !== 0) { + messageApi.error(result.message || "获取用户列表失败"); + return; + } + setItems(result.data.items); + setPage(result.data.page); + setPageSize(result.data.page_size); + setTotal(result.data.total); + } catch { + messageApi.error("用户列表加载失败,请稍后重试"); + } finally { + setLoading(false); + } + }, + [currentUser?.role, messageApi, page, pageSize], + ); useEffect(() => { const timer = window.setTimeout(() => { @@ -80,8 +90,7 @@ export default function UsersPage() { useEffect(() => { if (!editingUser) { - form.resetFields(); - form.setFieldsValue({ role: "member", status: "active" }); + resetFormDefaults(); return; } @@ -89,10 +98,10 @@ export default function UsersPage() { username: editingUser.username, display_name: editingUser.display_name, password: "", - role: editingUser.role, + role: editingUser.role === "member" ? "business" : editingUser.role, status: editingUser.status, }); - }, [editingUser, form]); + }, [editingUser, form, resetFormDefaults]); const handleSubmit = async (values: UserFormValues) => { setSubmitting(true); @@ -113,14 +122,15 @@ export default function UsersPage() { role: payload.role, status: payload.status, }); + if (result.code !== 0) { messageApi.error(result.message || "保存用户失败"); return; } + messageApi.success(editingUser ? "用户已更新" : "用户已创建"); setEditingId(null); - form.resetFields(); - form.setFieldsValue({ role: "member", status: "active" }); + resetFormDefaults(); await loadUsers(page, pageSize); } catch { messageApi.error("保存用户失败,请稍后重试"); @@ -135,13 +145,15 @@ export default function UsersPage() { const result = await updateUser(item.id, { username: item.username, display_name: item.display_name, - role: item.role, + role: item.role === "member" ? "business" : item.role, status: checked ? "active" : "disabled", }); + if (result.code !== 0) { messageApi.error(result.message || "更新用户状态失败"); return; } + messageApi.success(checked ? "用户已启用" : "用户已停用"); await loadUsers(page, pageSize); if (editingId === item.id) { @@ -178,8 +190,10 @@ export default function UsersPage() { title: "角色", dataIndex: "role", key: "role", - width: 120, - render: (value: string) => {value === "admin" ? "管理员" : "普通成员"}, + width: 140, + render: (value: string) => ( + {formatRoleLabel(value)} + ), }, { title: "状态", @@ -224,7 +238,7 @@ export default function UsersPage() {
{contextHolder} - +
); @@ -237,25 +251,25 @@ export default function UsersPage() {
User Management - 管理成员账号、角色和启停状态。 - 账号表单和成员列表并排处理,适合管理员快速开通、调整和停用。 + 管理试点成员账号、角色和启停状态。 + 让管理员可以把同一套试点环境里的法务用户、业务用户和管理员账号明确分开。
- Admin Workspace + Pilot Roles - 成员治理 + 最小角色集
    -
  • 登录账号统一按邮箱格式管理,和注册页口径保持一致。
  • -
  • 支持调整角色与账号状态。
  • -
  • 编辑用户时可选填新密码,不填则保持原密码。
  • +
  • 管理员:负责模板、规则、知识库、试点申请和治理后台。
  • +
  • 法务用户:优先进入合同审查、审查记录和法律问答链路。
  • +
  • 业务用户:优先进入合同生成、审查结果跟进和试点使用链路。
- + - + @@ -290,8 +311,7 @@ export default function UsersPage() {