From d6ca533fea14b1729d38c1baf79e4402c5b2af2a Mon Sep 17 00:00:00 2001 From: wjw <3268651376@qq.com> Date: Mon, 22 Dec 2025 13:56:20 +0800 Subject: [PATCH 1/3] upload contract-agent --- README.md | 264 +++++- contract_llm.py | 149 +++ contract_review_mcp.py | 457 +++++++++ contracts/test_contract.txt | 37 + index.html | 885 ++++++++++++++++++ requirements.txt | 6 + web_server.py | 141 +++ .../ScreenShot_2025-11-24_190342_756.png" | Bin 193170 -> 0 bytes ...7\220\344\272\244\350\246\201\346\261\202" | 5 - 9 files changed, 1899 insertions(+), 45 deletions(-) create mode 100644 contract_llm.py create mode 100644 contract_review_mcp.py create mode 100644 contracts/test_contract.txt create mode 100644 index.html create mode 100644 requirements.txt create mode 100644 web_server.py delete mode 100644 "\344\275\234\345\223\201\346\217\220\344\272\244/ScreenShot_2025-11-24_190342_756.png" delete mode 100644 "\344\275\234\345\223\201\346\217\220\344\272\244/\346\217\220\344\272\244\350\246\201\346\261\202" diff --git a/README.md b/README.md index 6446d62..1cbf6ea 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,239 @@ -# 造浪2025AIAgent创新赛 +# 智能合同审查Agent -#### 介绍 -在数字化转型与行业智能化升级浪潮下,企业业务流程复杂度攀升,用户需求多元化。AI Agent 凭借自动化、智能化特征,可优化流程、提升效率、降低出错率,满足更多业务场景的多元化需求,提升服务质量与用户体验,革新我们的工作和生活方式。 -对此,开源中国 携手 独家冠名厂商-商汤大装置 聚焦多业务场景,携手知名技术专家、合作社区推出“造浪 2025 AI Agent 创新赛”,聚焦智慧金融、教育科技、出海辅助、本地生活等多个重点行业领域,面向企业开发者、高校科研团队及个人创客征集具备商业价值与社会效益的 AI Agent 应用。 +## 🌟 项目概述 - +智能合同审查系统是一款基于Web的合同智能审查应用,利用AI技术和关键字匹配算法,为用户提供便捷的合同风险识别和分析服务。系统支持多种文档格式(PDF、Word、TXT、图片等)的上传和解析,并能识别合同中的潜在风险点,如高额违约金、争议解决条款、知识产权归属等问题。 -#### 我们希望看到 -- 通过 Agent 实现的创造性解决方案和产出 -- 能显著提升工作效率的 Agent 工作流 -- 探索 Agent 能力边界的实验性项目 -- 能为公众带来实际价值的 Agent 应用 +项目采用前后端分离架构,前端使用原生HTML/CSS/JavaScript构建,后端采用Python FastAPI框架,结合LazyLLM框架接入大语言模型,提供智能化的合同分析能力。 -#### 大赛亮点 -本次 AI Agent 创新赛官方指定开发框架 LazyLLM ,由商汤 LazyAGI 团队开发,具备一键部署所有模块的能力,简化了多 Agent 应用的部署流程,可依次启动各个子模块(如 LLM 、Embedding 等)服务并配置 URL 的问题,使整个过程更加顺畅高效。 + -- 跨平台兼容:无需修改代码,即可一键切换操作系统和IaaS平台,目前兼容裸金属服务器、开发机、Slurm集群、公有云等。这使得开发中的应用可以无缝迁移到其他IaaS平台,大大减少了代码修改的工作量。 -- 统一的使用体验:统一了不同服务商的线上模型和本地部署模型的使用体验,使得开发者可以随意的切换和升级自己的模型进行尝试。此外,还统一了主流的推理框架、微调框架、关系型数据库、向量数据库、文档数据库的使用体验。 -- 高效的模型微调:支持对应用中的模型进行微调,持续提升应用效果。根据微调场景,自动选择最佳的微调框架和模型切分策略。这不仅简化了模型迭代的维护工作,还让算法研究员能够将更多精力集中在算法和数据迭代上,而无需处理繁琐的工程化任务。 -#### 赛题设计 +## 💼 应用场景与商业价值 -1. 智慧金融 -- 开发一款能分析合同审核的智能Agent -- 开发一款具备风险预警的多模态Agent +在企业日常运营中,合同管理是一项重要而复杂的工作。本系统可以帮助企业和个人: -2. 教育科技 -- 开发一款教案/长篇技术文档生成Agent -- 开发一款多模态作业批改的教培Agent +1. **提高审查效率**:自动化识别合同风险,大幅减少人工审查时间 +2. **降低法律风险**:及时发现合同中的潜在问题,避免经济损失 +3. **标准化审查流程**:建立统一的合同审查标准,提升业务规范性 +4. **节约人力成本**:减少对专业法务人员的依赖,降低企业运营成本 -3. 出海辅助 -- 开发一款跨境电商选品的Agent -- 开发一款收录各大独立站的资讯Agent +## ✨ 核心功能 -#### 参赛规则 +1. **多格式文档支持**:支持PDF、DOC、DOCX、TXT、PNG、JPG、JPEG等多种格式的合同文档上传和解析 +2. **智能风险识别**:基于预定义的风险关键词库,自动识别合同中的潜在风险点 +3. **大语言模型分析**:集成DeepSeek-V3大语言模型,提供自然语言形式的合同分析和建议 +4. **可视化界面**:直观的Web界面展示审查结果,包括风险统计和详细说明 +5. **流式响应**:支持流式输出大模型分析结果,提供更好的用户体验 -一、作品要求: -- 需提交完整的作品方案介绍文档、项目源代码。 -- 参赛者提交的参赛作品必须为原创作品,不得侵犯任何第三方的著作权、商标权及其他知识产权,且不得违反国家相关法律法规,否则将取消其本届大赛的参赛资格; -- 参赛作品应能正常运行并可达到参赛赛项规定的预期结果。参赛作品应与设计文档描述的功能一致。如参赛作品未能实现设计文档中描述的所有功能,应注明未实现功能、占比及其重要程度。 -- 参赛作品的代码注释应清晰、简洁、准确地描述其设计思路、功能和原理等,以提升代码的可读性和可维护性。 +## 🎯 技术亮点 -二、LazyLLM 相关链接 -- 官方文档:https://docs.lazyllm.ai/zh-cn/latest/ -- 下载链接:https://github.com/LazyAGI/LazyLLM +- **多格式文档解析**:支持PDF、Word、TXT、图片等多种文档格式的解析 +- **OCR文字识别**:集成Tesseract OCR引擎,支持图片类合同文档的文字提取 +- **关键字匹配算法**:基于正则表达式的智能风险识别算法 +- **大语言模型集成**:通过LazyLLM框架接入DeepSeek-V3大语言模型 +- **流式响应处理**:支持大模型输出的流式传输,实现实时响应效果 +- **现代化Web技术**:采用原生HTML/CSS/JavaScript构建前端界面 -三、作品提交 -- 作品需要使用商汤 LazyAGI 团队开发到 LazyLLM 开源低代码大模型应用开发工具进行开发,该工具提供从应用搭建、数据准备、模型部署、微调到评测的一站式工具支持,以极低的成本快速构建 AI 应用—— -- 作品提交内容包含:Agent 系统本体、技术文档(项目简介、成员贡献清单、技术栈、架构设计、部署说明)可选:操作录屏视频(≤5min,可以发布在网盘设置公开可见) +## 📁 项目结构 -#### 组委会联系方式 -刘老师 :liuyang3@oschina.cn 王老师 :wanghao@oschina.cn +``` +contract_review/ +├── contracts/ # 上传的合同文件存储目录 +├── contract_llm.py # 大语言模型集成模块 +├── contract_review_mcp.py # 合同审查核心逻辑模块 +├── web_server.py # Web服务主程序 +├── index.html # 前端界面文件 +├── requirements.txt # Python依赖包列表 +├── contract_review_server.log # 运行日志文件 +└── README.md # 项目说明文档 +``` -* 大赛解释权归大会组委会所有 +## 🔧 部署说明 + +### 环境要求 + +- Python >= 3.8 +- pip包管理器 +- LazyLLM框架 +- Tesseract OCR引擎(用于图片文字识别) + +### 快速开始 + +#### 1. 安装依赖 + +```bash +# 安装LazyLLM库 +pip install lazyllm + +# 安装项目依赖 +pip install -r requirements.txt + +``` + +#### 2. 配置环境变量 + +```bash +# 设置LazyLLM服务的API密钥(在商汤大模型平台获取) +export LAZYLLM_SENSENOVA_API_KEY=your_api_key_here +``` + +#### 3. 启动服务 + +```bash +# 启动Web服务 +python web_server.py +``` + +#### 4. 访问应用 + +打开浏览器访问 `http://localhost:8001` + +## 🛠️ 技术栈 + + + + + +### 后端 + +- Python 3.8+ +- FastAPI(Web框架) +- LazyLLM框架 +- DeepSeek-V3大语言模型 +- PyMuPDF(PDF解析) +- python-docx(Word文档解析) +- Pillow + pytesseract(图像OCR) + +### 前端 + +- HTML5 +- CSS3 +- JavaScript ES6+ +- Font Awesome(图标库) + +### 第三方服务 + +- LazyLLM服务(Sensenova) +- Tesseract OCR(光学字符识别) + +## 📱 使用指南 + +1. **上传合同文件**: + - 点击"上传合同文件"区域或拖拽文件到该区域 + - 支持PDF、DOC、DOCX、TXT、PNG、JPG、JPEG格式 + - 选择文件后点击"开始审查"按钮 + +2. **查看审查结果**: + - 左侧面板显示风险统计(高、中、低风险数量) + - 右侧详细列出发现的风险项及其描述和建议 + - 中间面板显示大语言模型的智能分析结果 + +3. **重新审查**: + - 点击"清除文件"按钮可重新上传新合同 + - 系统支持连续审查多个合同文件 + +## 📡 API接口文档 + +### 上传文件 +``` +POST /upload +Content-Type: multipart/form-data + +参数: +- file: 合同文件 + +返回: +{ + "filename": "文件名", + "file_path": "文件存储路径", + "message": "文件上传成功" +} +``` + +### 审查合同 +``` +POST /review +Content-Type: application/json + +参数: +{ + "file_path": "文件存储路径" +} + +返回: +{ + "risks": [ + { + "category": "风险类别", + "severity": "风险等级(高/中/低)", + "location": "风险位置上下文", + "description": "风险描述", + "suggestion": "修改建议" + } + ], + "summary": "总体评估", + "total_risks_found": 风险总数 +} +``` + +### 审查合同并使用大模型分析 +``` +POST /review_with_llm +Content-Type: application/json + +参数: +{ + "file_path": "文件存储路径" +} + +返回: +{ + "review_result": { /* 关键字匹配审查结果 */ }, + "llm_analysis": "大语言模型分析结果" +} +``` + +### 审查合同并流式返回大模型分析 +``` +POST /review_with_llm_stream +Content-Type: application/json + +参数: +{ + "file_path": "文件存储路径" +} + +返回: +文本流(大语言模型分析结果) +``` + +## � 常见问题 + +1. **上传文件后没有反应**: + - 检查文件格式是否受支持 + - 查看浏览器控制台是否有错误信息 + - 确认后端服务是否正常运行 + +2. **大模型分析结果为空**: + - 检查LAZYLLM_SENSENOVA_API_KEY环境变量是否正确设置 + - 确认网络连接是否正常 + - 查看contract_review_server.log日志文件 + +3. **图片文件无法识别文字**: + - 确认已正确安装Tesseract OCR引擎 + - 检查图片质量是否足够清晰 + - 尝试调整图片对比度和亮度 + +4. **响应速度慢**: + - 大语言模型分析需要一定时间,请耐心等待 + - 检查网络连接状况 + - 确认服务器资源配置是否充足 + +## 📄 许可证 + +本项目仅供学习和参考使用,未经许可,禁止任何形式的商业使用。 + +## 💡 总结 + +智能合同审查系统结合了传统的关键字匹配技术和先进的大语言模型能力,为企业和个人提供了一套完整的合同风险识别解决方案。通过自动化和智能化的手段,大幅提升了合同审查的效率和准确性,具有广泛的实用价值和商业前景。 \ No newline at end of file diff --git a/contract_llm.py b/contract_llm.py new file mode 100644 index 0000000..f9279ad --- /dev/null +++ b/contract_llm.py @@ -0,0 +1,149 @@ +import lazyllm +import asyncio +import json +from typing import Dict, Any, AsyncGenerator +from contract_review_mcp import review_local_contract + +class ContractReviewLLM: + def __init__(self): + # 初始化大模型,启用流式输出 + self.chat = lazyllm.OnlineChatModule("DeepSeek-V3", stream=True) + + async def analyze_contract_with_llm(self, file_path: str, review_result: Dict[str, Any]) -> str: + """ + 使用大模型分析合同审查结果并生成自然语言回复 + """ + try: + # 构建审查报告 + report = self._format_review_result(review_result) + + # 构造提示词,要求口语化回复且不要Markdown格式 + prompt = f""" +你是一个专业的合同法律专家和顾问。请仔细分析以下合同审查结果,并用通俗易懂、口语化的语言向用户解释: + +合同文件: {file_path} + +审查结果: +{report} + +请根据以上信息,提供以下内容: +1. 对合同整体风险的简要评估 +2. 详细解释发现的主要风险点 +3. 针对每个风险点提供具体的修改建议 +4. 总结性建议 + +请注意: +- 回复要口语化,就像在跟朋友聊天一样,不要用太多专业术语 +- 不要使用Markdown格式,只用纯文本回复 +- 可以适当使用一些连接词让语句更流畅 +- 回复要有条理,但不需要严格按照1、2、3、4点来写 +""" + + # 调用大模型 + response = self.chat.forward(prompt) + return response + + except Exception as e: + return f"大模型分析过程中出现错误: {str(e)}" + + async def analyze_contract_with_llm_stream(self, file_path: str, review_result: Dict[str, Any]) -> AsyncGenerator[str, None]: + """ + 使用大模型分析合同审查结果并流式生成自然语言回复 + """ + try: + # 构建审查报告 + report = self._format_review_result(review_result) + + # 构造提示词,要求口语化回复且不要Markdown格式 + prompt = f""" +你是一个专业的合同法律专家和顾问。请仔细分析以下合同审查结果,并用通俗易懂、口语化的语言向用户解释: + +合同文件: {file_path} + +审查结果: +{report} + +请根据以上信息,提供以下内容: +1. 对合同整体风险的简要评估 +2. 详细解释发现的主要风险点 +3. 针对每个风险点提供具体的修改建议 +4. 总结性建议 + +请注意: +- 回复要口语化,就像在跟朋友聊天一样,不要用太多专业术语 +- 不要使用Markdown格式,只用纯文本回复 +- 可以适当使用一些连接词让语句更流畅 +- 回复要有条理,但不需要严格按照1、2、3、4点来写 +""" + + # 先尝试获取完整的响应 + response = self.chat.forward(prompt) + + # 如果响应是字符串,模拟流式传输 + if isinstance(response, str): + # 逐字符yield以模拟流式传输 + for char in response: + yield char + else: + # 尝试处理真正的流式响应 + try: + # 调用大模型(已启用流式输出) + response_generator = self.chat.forward(prompt, stream=True) + + # 流式返回结果 + for chunk in response_generator: + if chunk and hasattr(chunk, 'choices') and len(chunk.choices) > 0: + content = chunk.choices[0].delta.content + if content: + yield content + except Exception: + # 如果流式传输失败,回退到完整响应 + response_str = str(response) + for char in response_str: + yield char + + except Exception as e: + yield f"大模型分析过程中出现错误: {str(e)}" + + def _format_review_result(self, review_result: Dict) -> str: + """ + 格式化审查结果 + """ + if "error" in review_result: + return f"审查过程中出现错误: {review_result['error']}" + + # 构建易于理解的审查报告 + report_lines = [] + report_lines.append(f"发现风险总数: {review_result.get('total_risks_found', 0)}") + report_lines.append(f"总体评价: {review_result.get('summary', '无')}") + report_lines.append("") + + risks = review_result.get("risks", []) + if risks: + report_lines.append("发现的风险详情:") + for i, risk in enumerate(risks, 1): + report_lines.append(f"{i}. 风险类别: {risk.get('category', '未知')}") + report_lines.append(f" 风险等级: {risk.get('severity', '未知')}") + report_lines.append(f" 描述: {risk.get('description', '无')}") + report_lines.append(f" 建议: {risk.get('suggestion', '无')}") + report_lines.append("") + else: + report_lines.append("未发现明显风险。") + + return "\n".join(report_lines) + +# 创建全局实例 +contract_llm = ContractReviewLLM() + +async def get_llm_analysis(file_path: str, review_result: Dict[str, Any]) -> str: + """ + 获取大模型对合同审查结果的分析 + """ + return await contract_llm.analyze_contract_with_llm(file_path, review_result) + +async def get_llm_analysis_stream(file_path: str, review_result: Dict[str, Any]): + """ + 流式获取大模型对合同审查结果的分析 + """ + async for chunk in contract_llm.analyze_contract_with_llm_stream(file_path, review_result): + yield chunk \ No newline at end of file diff --git a/contract_review_mcp.py b/contract_review_mcp.py new file mode 100644 index 0000000..721e0f4 --- /dev/null +++ b/contract_review_mcp.py @@ -0,0 +1,457 @@ +import asyncio +import httpx +import logging +import time +import os +import json +import re +from typing import Annotated, Dict, Optional, List +from fastmcp import FastMCP +from pydantic import Field +import io +import tempfile +from urllib.parse import urlparse, unquote +import fitz # PyMuPDF for PDF parsing +from docx import Document # python-docx for DOCX parsing +from PIL import Image +import pytesseract + +# 配置日志记录 +logging.basicConfig( + level=logging.INFO, + filename="contract_review_server.log", + filemode="a", + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger("contract_review_server") + +# 创建MCP服务器 +mcp = FastMCP("contract_review_server") + +# 风险词定义 +RISK_KEYWORDS = { + "高额违约金": { + "patterns": [r"违约金.*?(超过|超出|高于).*?[0-9]+%", r"违约责任.*?支付.*?[0-9]+%"], + "severity": "高", + "description": "合同中约定的违约金比例过高,可能存在法律风险", + "suggestion": "建议调整违约金比例至合理范围(通常不超过实际损失的30%)" + }, + "争议解决地": { + "patterns": [r"争议.*?提交.*?(法院|仲裁|诉讼).*?管辖", r"管辖权.*?归属"], + "severity": "中", + "description": "合同中争议解决地点可能对己方不利", + "suggestion": "建议明确有利于己方的争议解决地点和方式" + }, + "单方解除权": { + "patterns": [r"(\b(?:甲方|乙方)\b).*?可以.*?解除.*?合同", r"任意解除权", r"单方面终止"], + "severity": "高", + "description": "对方拥有单方解除合同的权利,存在较大风险", + "suggestion": "建议取消或限制单方解除权,确保双方权利对等" + }, + "知识产权归属": { + "patterns": [r"知识产权.*?归.*?所有", r"作品.*?权利.*?转移", r"著作权.*?归属"], + "severity": "中", + "description": "知识产权归属条款不明确或不利于己方", + "suggestion": "建议明确知识产权归属,保护己方权益" + }, + "保密条款": { + "patterns": [r"保密.*?义务", r"商业秘密.*?保密", r"保密期限.*?[0-9]"], + "severity": "中", + "description": "保密条款缺失或期限不合理", + "suggestion": "建议完善保密条款,明确保密信息范围及期限" + }, + "不可抗力": { + "patterns": [r"不可抗力.*?免责", r"因.*?原因.*?免除.*?责任"], + "severity": "中", + "description": "不可抗力条款范围过宽,可能导致过多免责情况", + "suggestion": "建议明确不可抗力的具体情形,避免滥用免责条款" + }, + "付款条款": { + "patterns": [r"预付.*?[0-9]+%", r"一次性.*?支付.*?全部", r"款项.*?先付"], + "severity": "中", + "description": "付款方式可能对企业现金流造成压力或风险", + "suggestion": "建议设置分期付款,降低财务风险" + }, + "合同期限": { + "patterns": [r"有效期.*?[0-9]+年", r"长期.*?合作.*?期限"], + "severity": "低", + "description": "合同期限过长可能限制企业经营灵活性", + "suggestion": "建议设置合理的合同期限和续约条件" + } +} + + +def _review_contract_by_keywords(contract_text: str) -> Dict: + """ + 基于风险关键词匹配审查合同内容 + """ + try: + risks = [] + # 对每个风险词进行匹配检查 + for risk_category, risk_info in RISK_KEYWORDS.items(): + patterns = risk_info["patterns"] + matched_positions = [] + + # 在整个合同文本中查找匹配项 + for pattern in patterns: + matches = re.finditer(pattern, contract_text, re.IGNORECASE) + for match in matches: + # 记录匹配位置和内容 + matched_positions.append({ + "position": match.span(), + "content": match.group() + }) + + # 如果找到匹配项,则添加风险 + if matched_positions: + # 获取上下文(匹配词前后各20个字符) + locations = [] + for pos in matched_positions: + start = max(0, pos["position"][0] - 20) + end = min(len(contract_text), pos["position"][1] + 20) + context = contract_text[start:end] + locations.append(context) + + risks.append({ + "category": risk_category, + "severity": risk_info["severity"], + "location": "; ".join(locations[:3]), # 只显示前3个位置 + "description": risk_info["description"], + "suggestion": risk_info["suggestion"] + }) + + # 生成总结 + if risks: + severity_count = {} + for risk in risks: + severity = risk["severity"] + severity_count[severity] = severity_count.get(severity, 0) + 1 + + summary_parts = [] + if severity_count.get("高", 0) > 0: + summary_parts.append(f"发现{severity_count['高']}个高风险问题") + if severity_count.get("中", 0) > 0: + summary_parts.append(f"发现{severity_count['中']}个中风险问题") + if severity_count.get("低", 0) > 0: + summary_parts.append(f"发现{severity_count['低']}个低风险问题") + + summary = ",".join(summary_parts) + ",建议重点关注高风险问题并及时修改。" + else: + summary = "未发现预定义的风险关键词匹配项,合同相对较为规范。" + + return { + "risks": risks, + "summary": summary, + "total_risks_found": len(risks) + } + + except Exception as e: + logger.error(f"关键词匹配审查失败: {str(e)}") + return {"error": f"关键词匹配审查失败: {str(e)}"} + + +@mcp.tool(description="审查合同文档,识别潜在风险") +async def review_contract( + file_url: Annotated[str, Field(description="合同文件URL,支持.pdf、.doc、.docx、.png、.jpg、.jpeg等格式")], + model_version: Annotated[str, Field(description="文档解析模型版本,可选值: pipeline, vlm,默认为pipeline")] = "pipeline", + is_ocr: Annotated[bool, Field(description="是否启用OCR功能,默认true")] = True, + language: Annotated[str, Field(description="文档语言,默认ch")] = "ch" +) -> Annotated[Dict, Field(description="合同审查结果,包含风险列表和整体评估")]: + """ + 审查合同文档,识别潜在风险并返回JSON格式的审查结果 + """ + logger.info(f"开始审查合同: {file_url}") + + try: + # 步骤1: 解析文档 + contract_text = await _parse_document(file_url) + + if not contract_text: + return {"error": "无法从文档中提取文本内容"} + + logger.info(f"成功提取文本内容,长度: {len(contract_text)}") + + # 步骤2: 使用关键词匹配审查 + review_result = _review_contract_by_keywords(contract_text) + + # 添加原始文本到结果中(可选) + review_result["contract_text"] = contract_text[:500] + "..." if len(contract_text) > 500 else contract_text + + return review_result + + except Exception as e: + logger.error(f"审查合同失败: {str(e)}") + return {"error": f"审查合同失败: {str(e)}"} + + +@mcp.tool(description="批量审查多个合同文档") +async def batch_review_contracts( + file_urls: Annotated[List[str], Field(description="合同文件URL列表")], + model_version: Annotated[str, Field(description="文档解析模型版本,可选值: pipeline, vlm,默认为pipeline")] = "pipeline", + is_ocr: Annotated[bool, Field(description="是否启用OCR功能,默认true")] = True, + language: Annotated[str, Field(description="文档语言,默认ch")] = "ch" +) -> Annotated[Dict, Field(description="批量合同审查结果")]: + """ + 批量审查多个合同文档 + """ + results = [] + + for file_url in file_urls: + logger.info(f"开始审查合同: {file_url}") + try: + # 直接调用内部函数而不是被装饰的工具函数 + contract_text = await _parse_document(file_url) + + if not contract_text: + result = {"error": "无法从文档中提取文本内容"} + else: + result = _review_contract_by_keywords(contract_text) + # 添加原始文本到结果中(可选) + result["contract_text"] = contract_text[:500] + "..." if len(contract_text) > 500 else contract_text + except Exception as e: + result = {"error": f"处理文件时发生错误: {str(e)}"} + + results.append({ + "file_url": file_url, + "review_result": result + }) + + return { + "total_files": len(file_urls), + "results": results + } + + +async def _download_file(file_url: str) -> bytes: + """ + 下载文件内容 + """ + try: + async with httpx.AsyncClient() as client: + response = await client.get(file_url) + response.raise_for_status() + return response.content + except Exception as e: + logger.error(f"下载文件失败: {str(e)}") + raise + + +def _get_file_extension(file_url: str) -> str: + """ + 从URL中提取文件扩展名 + """ + # 移除查询参数 + url_path = urlparse(file_url).path + # URL解码 + decoded_path = unquote(url_path) + # 获取扩展名 + extension = os.path.splitext(decoded_path)[1].lower() + return extension + + +def _parse_pdf_content(content: bytes) -> str: + """ + 使用PyMuPDF解析PDF内容 + """ + try: + # 创建内存中的PDF文档 + pdf_document = fitz.open(stream=io.BytesIO(content), filetype="pdf") + text = "" + + # 遍历所有页面 + for page_num in range(len(pdf_document)): + page = pdf_document[page_num] + text += page.get_text() + + pdf_document.close() + return text.strip() + except Exception as e: + logger.error(f"解析PDF文件失败: {str(e)}") + raise + + +def _parse_docx_content(content: bytes) -> str: + """ + 使用python-docx解析DOCX内容 + """ + try: + # 创建内存中的DOCX文档 + doc = Document(io.BytesIO(content)) + text = "" + + # 遍历所有段落 + for paragraph in doc.paragraphs: + text += paragraph.text + "\n" + + return text.strip() + except Exception as e: + logger.error(f"解析DOCX文件失败: {str(e)}") + raise + + +def _parse_image_content(content: bytes) -> str: + """ + 使用OCR解析图像内容 + """ + try: + # 从字节创建图像 + image = Image.open(io.BytesIO(content)) + # 使用tesseract进行OCR识别 + text = pytesseract.image_to_string(image, lang='chi_sim+eng') + return text.strip() + except Exception as e: + logger.error(f"OCR识别图像失败: {str(e)}") + raise + + +def _parse_txt_content(content: bytes) -> str: + """ + 解析TXT文本内容 + """ + try: + # 尝试不同的编码 + encodings = ['utf-8', 'gbk', 'gb2312'] + for encoding in encodings: + try: + return content.decode(encoding).strip() + except UnicodeDecodeError: + continue + # 如果所有编码都失败,使用utf-8并忽略错误 + return content.decode('utf-8', errors='ignore').strip() + except Exception as e: + logger.error(f"解析TXT文件失败: {str(e)}") + raise + + +async def _parse_document(file_url: str) -> str: + """ + 解析文档并提取文本内容 + """ + try: + # 下载文件 + logger.info(f"开始下载文件: {file_url}") + content = await _download_file(file_url) + logger.info(f"文件下载完成,大小: {len(content)} 字节") + + # 获取文件扩展名 + extension = _get_file_extension(file_url) + logger.info(f"文件扩展名: {extension}") + + # 根据扩展名选择解析方法 + if extension == ".pdf": + return _parse_pdf_content(content) + elif extension in [".docx", ".doc"]: + return _parse_docx_content(content) + elif extension in [".jpg", ".jpeg", ".png", ".bmp", ".tiff"]: + return _parse_image_content(content) + elif extension == ".txt": + return _parse_txt_content(content) + else: + # 默认尝试作为文本文件处理 + logger.warning(f"未知文件类型 {extension},尝试作为文本文件处理") + return _parse_txt_content(content) + except Exception as e: + logger.error(f"解析文档失败: {str(e)}") + raise + + +@mcp.tool(description="审查本地合同文档,识别潜在风险") +async def review_local_contract( + file_path: Annotated[str, Field(description="本地合同文件路径,支持.pdf、.doc、.docx、.png、.jpg、.jpeg等格式")], + model_version: Annotated[str, Field(description="文档解析模型版本,可选值: pipeline, vlm,默认为pipeline")] = "pipeline", + is_ocr: Annotated[bool, Field(description="是否启用OCR功能,默认true")] = True, + language: Annotated[str, Field(description="文档语言,默认ch")] = "ch" +) -> Annotated[Dict, Field(description="合同审查结果,包含风险列表和整体评估")]: + """ + 审查本地合同文档,识别潜在风险并返回JSON格式的审查结果 + """ + logger.info(f"开始审查本地合同: {file_path}") + + try: + # 步骤1: 解析文档 + contract_text = await _parse_local_document(file_path) + + if not contract_text: + return {"error": "无法从文档中提取文本内容"} + + logger.info(f"成功提取文本内容,长度: {len(contract_text)}") + + # 步骤2: 使用关键词匹配审查 + review_result = _review_contract_by_keywords(contract_text) + + # 添加原始文本到结果中(可选) + review_result["contract_text"] = contract_text[:500] + "..." if len(contract_text) > 500 else contract_text + + return review_result + + except Exception as e: + logger.error(f"审查合同失败: {str(e)}") + return {"error": f"审查合同失败: {str(e)}"} + + +async def _parse_local_document(file_path: str) -> str: + """ + 解析本地文档并提取文本内容 + """ + try: + # 检查文件是否存在 + if not os.path.exists(file_path): + raise FileNotFoundError(f"文件不存在: {file_path}") + + # 获取文件扩展名 + extension = os.path.splitext(file_path)[1].lower() + logger.info(f"本地文件扩展名: {extension}") + + # 读取文件内容 + with open(file_path, "rb") as f: + content = f.read() + logger.info(f"本地文件读取完成,大小: {len(content)} 字节") + + # 根据扩展名选择解析方法 + if extension == ".pdf": + return _parse_pdf_content(content) + elif extension in [".docx", ".doc"]: + return _parse_docx_content(content) + elif extension in [".jpg", ".jpeg", ".png", ".bmp", ".tiff"]: + return _parse_image_content(content) + elif extension == ".txt": + return _parse_txt_content(content) + else: + # 默认尝试作为文本文件处理 + logger.warning(f"未知文件类型 {extension},尝试作为文本文件处理") + return _parse_txt_content(content) + except Exception as e: + logger.error(f"解析本地文档失败: {str(e)}") + raise + + +async def review_local_contract(file_path: str) -> Dict: + """ + 直接审查本地合同文档的函数,供web_server.py调用 + """ + logger.info(f"开始审查本地合同: {file_path}") + + try: + # 步骤1: 解析文档 + contract_text = await _parse_local_document(file_path) + + if not contract_text: + return {"error": "无法从文档中提取文本内容"} + + logger.info(f"成功提取文本内容,长度: {len(contract_text)}") + + # 步骤2: 使用关键词匹配审查 + review_result = _review_contract_by_keywords(contract_text) + + # 添加原始文本到结果中(可选) + review_result["contract_text"] = contract_text[:500] + "..." if len(contract_text) > 500 else contract_text + + return review_result + + except Exception as e: + logger.error(f"审查合同失败: {str(e)}") + raise + + +if __name__ == "__main__": + mcp.run(transport="http", host="0.0.0.0", port=8010) diff --git a/contracts/test_contract.txt b/contracts/test_contract.txt new file mode 100644 index 0000000..83b73ff --- /dev/null +++ b/contracts/test_contract.txt @@ -0,0 +1,37 @@ +合同编号:HT20251222001 + +甲方:北京科技有限公司 +乙方:上海软件开发公司 + +鉴于甲方需要乙方提供软件开发服务,双方经友好协商,达成如下协议: + +第一条 项目内容 +1.1 项目名称:企业管理系统开发 +1.2 项目内容:开发一套完整的企业管理信息系统 + +第二条 合同期限 +本合同有效期为3年,自2025年1月1日起至2028年12月31日止。 + +第三条 费用及支付方式 +3.1 项目总费用为人民币100万元整 +3.2 甲方需在合同签订后3日内预付全部费用的80% + +第四条 违约责任 +4.1 若乙方未能按期完成项目,每逾期一日,需向甲方支付合同总金额1%的违约金 +4.2 若甲方中途解除合同,需向乙方支付合同总金额50%的赔偿金 + +第五条 争议解决 +5.1 因本合同引起的争议,提交北京市朝阳区人民法院管辖 + +第六条 知识产权 +6.1 项目开发过程中产生的所有知识产权归甲方所有 + +第七条 保密条款 +7.1 乙方应对在履行本合同过程中知悉的甲方商业秘密承担保密义务,保密期限为合同终止后5年 + +第八条 不可抗力 +8.1 因不可抗力导致合同无法履行的,双方互不承担责任 + +甲方(盖章): 乙方(盖章): +法定代表人: 法定代表人: +日期:2025年12月22日 日期:2025年12月22日 \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..0f0e061 --- /dev/null +++ b/index.html @@ -0,0 +1,885 @@ + + +
+ + +y(Nl3`;gHQj^Y|_;JMIpq7>t72`_wM%-7-l<-g15ZgxD3v#%ikA;ke zzN1=ApCpALewL=N<-ukN;-58hqNMwbtYn&`Jm4{P{t%A7zzGHOn*E;SyR*~6V+0~1FL9WU*ZyGtwc|^~N@a%5W4AWfo|NUB1si5V1t{zY2e};; z3J@kbjmgPhvxM| 68grI*^90yYDls8jMIO-b z6;r4IiZ$~{%yV^nNBO0K9_oEEEQdc6Xp@(4i4b4jR2J{6nWAp6aVtu&nl=e3gWl!_ zT>w3x5lJWliz}%xTo5R%Wji%=r!4O|JKx+Sdl=rlINP# @^&l)VwYjH DzyZL>c#;;PQ)0-cAo%}1zT8eLBsG^W_UnNUVd{vh3{F+R1UP1Q zp9jhUhDf;rxuLKn@o93x0r;T614LyRSb+7IUI?H203%znJfp@pZI4(|O8kifMv_4G z5|NxdaVUsgyQm2LJ<4z?^nof6LJ9%e#mWI9GBDnf5vSy;Al*wg21g2qxY-_YivzCc z6yK0j(a{3-BSB{a?r0k*D<6}aJK;q2 zvbZRV 6bDEiX}zK%7=*vF`gj% zoo!R+h5tmT^mvDw!2^A_0jGZR@=*JWf_$cZzv_E~$*$6<62vXe6-~4jLRLwzka30N zIGH|VVuS>13Xuim1?7jrRtJPpx(5U>1RrH%qyI&ccwBIb6V8_<6poj gHnwb$w!;=}Q8Yz@Hb@Z!0Rke>gn&i^=m|X!Z~Vuqx|zp|%rj-)`uhQ- zZUFu2uevvH4kyp~&X}e2DF5jZ1B-T1#Sj571IO`+mLh6_jNWkghE=|k{Ri#8_i_N7 z*)?3L*UxS}8AjHIS%z|3R==>F
Ef^6ZXb3T)02vnAeycyufcJi&l&y*-K%9l0nLmDlr&rBE7KQp2KwW2oUM z&9H={ai5sSRdc$i|7b>TF{3fOm#>5Ls##|7-)Ni@lQF3YP;d7Xsv0CXeNp#?@%Cz2 z4}DV?KyIM1LFV!b=P5%zFMcHLA1`IgCbDXm{Al=r{lu4v_;aRCzLsUyY+8MF QoK$Y=a1c$($u(dUl)CNIBxTN$rBf z_n^ov@8$6X?BsZ(WOFNSPb2pe1xIp&JLI=^mglhdezA&OYxe3pMK **VM7Fk}S59^qf>7bB z tO{TtCdq2pGD*yPG|Z@GL>N_kAI zmlfZUMo88~;}v(c*&xY!x&nC{4W&ZDf5nkS6b<8*d+nqCYz#kifJ$5RUp5^dS3K z&SLYWeTAUSUp87vh|JaK16z5@98_qA-0)u^52=Mmp4=ru)AnOFXT#jGvkI7I*Jxn2 zET1+r8!puBQ!RkIJ8sA(E4dytr{~(|YfujlP?F3VVPK+vb{)xw91kETWKz!^%)-3m z-k71-?$`DY^9mc)Ny@#x$IE4ZQ6q%<4b?CjQ{r6PGm1o;p{3p~iO_~tzBVU-8JboE zFw-|bN!|AA(+;U8MNU>HPrkbrs&!KB !p7-xaYkrh|E^oWcOxN|8QI872cLee0cO@+Nn2&}LnejOso3CYqPSmM*2 z%+SO2*_i#^JT`OQ?bND~kmcDPt2#}D2o2cM!AcO&94!dXWi8(=1c7S(b8ZCy1FP-Y zx0l@G-B2dv6GL^Uvyw-m5my{#Nz$n}R{!G~*{nJ9no`>t# J z*-E<_YDZ@OVs@{t!LA$j9F7)+qXRXUUNko$8abxI!=YJ;X%a)0f~jug7FVMlMd*;* zEF@r&kjby7A6v7@*k bS_WawNM6Y-B`ZZeuiL`Zcqi*oMlM(DFK-G$DoF@!bD8yX~l(^h*GH_b{QzI1NqH zAmwsYeCb?fp_o}qA)PIA2{{|8Gr>=ma_Z+y`fP20v~|ItvIx~%FX`sI0*2nZ?W6Vq zSGZMd*X{kXla-o^_q1$+47}HDyv3#H$&ua6mrZ|XAH1haApHC<-K*@!o?XtreIN;w z*=2cg@XE$)s#G?n7iwd(nz}q@>!NSR$pLf(Y3-jq3?~1u-($^Ao)aV8C;mc@H4XkA z3mrO0Gy{rUoA-Rp avgb3F9}%#1^v<;5BK4}l(kdo)>igMr!qa;yNMBm;MgSyr z<=6fLyL^NX$lA#apl&s-wL^*zNJqi6jv)G<%j^)xh9$0mHZa$i=*=j1(P$^F?*%f0 z6U1mjtNn!lxvI#uP_bHjMJyXvtZ-jW0qj9{c@>0;P1?8+#juho?lRnGggUVF6h1S< zG6fg&kzQu8{cI|6=<&&QUmMy{<3|l^-R;lt1MIrgzTa( ZLnu@jnHt5v(P^wG9S9z@yUrlg&tVd n*6}O$rL{oSx2n6)omfbW(2;lhV7x8_{+*YwK9` W^;A35|+Fy){>wIWoZ#G8|`eQvOI%G@8Y=KG1fcehBt(92At*!9hhq zEWui=;5H966Up0xWL8jgW&(zTGq6^Cmd5o?{072uC<}637hphR`oO@&7rQ2e0M1BL z_o0GH?v1I5MN*-N!@{MITqUYzPw$Y){nx^)<}OpejEI18O(id`Iueo=ahD~}OW_vs z!LA_O(nvTh0%kq6W*ZXgv1%@I;VjP3=OP`=4gw*!7E0FYTwczQN&ygf!CkgkT?n!4 zQ&j@l4 qkI?G-y0HDInt|>0Y!|R-$s#FST)V4& zxT^)ZU+}Pbr?r7K36VzHS3@5Ym40^p*Mcl6&oA$f{@EP$)v#eplUg9g$r_ClttLK_ z;&trMLXCU_De0-DuSH!F9;g8|T9*bvjaKZCWH8lmQZ&|wmiSCRBp953BC{cZ7$~@z zlwOz>VOE5q)6C31^BhFTeE0z~+B$yAd|9^W(~Wsb(K6W$FTa6XXFWrr8qIW0`4QIX zQ97zN2Yo`L+t3U`=LSFpk49CA>Af<+mX(`n!%&%PUx zn#N$%`YB3rr3T{jNGu1JO(=}dy-dNGOhhBKc5t?Lmf-v{>>k1XD?7hNYbf0K+1HMR zR=W2xZMV^8Vqio@Z>C$gYuZhlMp^00;{vnG+Gxjsc1_Vc%$u50boE;`RNt?!Ga#qu zerm sOw}ILI*TwF2frQ677WGj)$Nph`+L{G5mGEx(?Jq4l?sW zQBV`9(VOv&i$WAhl<-%!D0MN2tUCAVVQtUrY?fi@pJGO1TlvfYK?Sf3s^vD!B1I_z z<0==-Czkfsq^fn5U88bH7`0u|U4*{ZHlwV;i$){0Hci7Xo23a&C1D;vvc*kWH(|dH z%67upQEPz}!sBH0$g)*Ra#FRxSr)AHsV%C2=jFFHsHV4MwyR Y_;PI`+@$C^(DO;6_8ta}ZCoR{l*ja~YxTTu1(>G-W4++{l~flz_ToT_{A+ zsEGz;Su;S1qCi=Ak`~zfW1SH-bex+GY%Y*zWau4}Y81Hun1y)jctxoOqIzCMIX&8t zHgE}(+7kzQ)H&9?9+fV3OXUKxtm_F7>d9Cpek&6JWy-Zb!(vD{o+n6z32;MnmSGDJ z2qF*?jYx|Cs{uyLkuv2BIaAKAt5{dfNa)&alt2-2#TzD5CnRTM>~`l!0&AZtx-Ie` zp$-J(Vp`qml{ rXV&Jk(|vZ(`UGd^g_oH>hU`iw8~+hvE6Y+ zPk_N7f|fRU8Cpf$FmNrQVC9Xu*-&p6v}8twx_ *KlmzsaA*z-H}34 z1#XQDRxIxGjahwjJ~&e=tTv9`LLwwb{!foYgg^_RHO$Ds$2s_H0ZRkazsJi-5*eFE z8;XR@bI-mehcjxK1sA1hSo5qv>wDrys*IuiW+y0BozGzCUZ0bE9^b1s`H#=lw%q_XA^A z-NGIX7}TWFn`nlR$`>3!>=xQ|$Ci#`G;UiI3}@!Tn4-DUttQMEL8LCQbp<>DiG;+a zU8_3rJn*!;28_{LIGppsTuzk^I?Uc@DZxfr&zrTCu(Ug#|Hx#RU~2_}q_Yg#6wY+h zZ-s7G2$`5der_o|moe&{y+n&E`W~{Prc>U{mhVYx)^mH??A`KmDWe8eC8kW7QqGw$ zMjRIx6mOlS^^`L}D(H|mPf!65M36IE`bk9}v5FJ}juDpwMxY3UkOzbTVF(Z*2a;1u zM9*czqv}N}q?>d{ zxMO$(c8B&N>Ye%HU*+ZUAf$f)q#{|YvugQl0zlh7I|*l0B)O5gg+Tx1+(r?! 0uQUL)e1LU{+c!Rp8IcESU-r`Itr<{rMu)Mnsx6jh;i*zw%D!MyjKnRF2 zU;u~_2ZVqS5d%O7CC3tpC?gU oMucyy{PvY5cBPMSl=`zM=Uptn*H+;N&BWF`fk2U+njMwsF?SXUiUjmkz zd^h_@qXu2#d9}!?3A473nD3kPqj~+c<4p5i-SLWfG1-}-^+t2EpWE)vS#jP^ntM!t zpr*w}D(&bzswdCViAmwsHanP#BH&wTf~aaMStl) 6cH^NqT_3B?=>@2Kie{PGtqgotH>b9TaTPy z&VX4~xg7s6xkXL$Hay3t5cHe{v`Klb3Gm@iYB_WPWxb7c8-!kXAke<6+Mqc_({#yf z7f%z4rmaHdxqY1 l7(<8=140ZK z5C;TAh=>Rj5o3TDFe1c&dg2! ^ael zXQI{A=lJel_VS`b> ;;=bUo&yLRc%zj{7#VQ z@@`zdCNuJ8zS}!l)12xt!?k8#+IMXJCu;&~*muvznv>VtOI=dFc{?r_;^g-mv?v5Y zrJ6y0^W0lKg{-)uPwtw!Bs3+8hR)A87lP8{W=XaK9HC^{VTGD8OpQF~u>AV(CcU zn_^5=CiD7xx#r-ZiIp^)z@iH)wxD63mFIR{KoM@fP1_piQdrI0F(DBO4FfYN88gdf zN6Nu?Mwd_wL7pbLd);tItL>^tuDUVhVe$4RzI=0ynXZiV>T7pz-8i{%ym|BX#mV^u z0OM*L_r|^b j-@*VX^`b@log_+3^+^6{Lc^t-|P&gwr_lYxdC z&PwiG{A^Ze?YgIxZ9AAzyKZwF-!?cWWoCcvSNF26_5-{`>g*EO9b8Zi+ww7-|8FZJ z>sp#14EFuF$^qJD=RxR~9zZ2fqkw?Se|tmalRffi+V>*C6dw?#&r?9)3tHJ8xa3Qo zN7?`Kjp>WbZ?DYGEv6O8MnC3`x=asa>E+q7N1C7;xfPRaHqZsJ=>TrobiAy*vxU~` zKf 2b{l*k8l z&lndq8w#o-9>o3$>U3{nQaQ0WTVYH^GtN1ul*i>du5O=C+l%enZ`^(3jl17{?fA|n zUu^R>QOZ<$h2^mXKpBTPEQVpZ7*@kD#yG?<#9;`_<#@0+?(dCz%eWlkID{Cgy*UJo zh)~5ghM r}E)j|d5D^vumVrjh17+B5AtlIK4YWQP zYKb5b*T`H$C9}*LYWD;;=&Tw$Yoq28PkE)-U@GY nm019X@x<&y!_}s H5HTRe0E+ |Sg95ehJZt$1=5%k0F6L$zvcuf zQO CAOW_YAk13@*L_E5(TC9dGz&2+xv{S;kkn>>qJd7$1&Nu1y$@cc~ z=Hz_4-lU67zIn2J^Y;4fJL{A4?ZuSSRNT`LBVq`)3 woXAc2^lCNSKC8f|`>e&4A`qBG9eUb(eND+q0orAvF7I z+lM)*!F@3AcbZizx_ZH^rDZQYDRWk`c0g^GL+*qiI|RHMes|5)j&A6yW~NAjvAYg; z@9KjSnxT1qY=33m+@l-QXQNU1Y9|DmO^=NU+D~;q$xRtU?vG?)0`$0Mw$*+{q%MOO z$$EP|HJL=YbH}z=Rk+^^>?~4Yn59ONp$ ~D8yxBx>f>Y{7UpvCh5nH2&+%7UDwwg$IrBIH$@Lx2*o&$Q7-yE@M_ z*QTz<=ah0zX|agQqtzB*n{tL?-_|)$1LuCAI<0 ziiUfxF48jY(kElMo*8iW<=iM+H*??4B^ui <(}d{{Iq!a>aFxM15sw&E+(4|H3Umd#~!-% zRHSQT#2nfJ#j 8;;t!30mPjfhm|XoO@%4)yl1YMQV8l@R8@TVN`l? z$GOUh-ihv`)C`;3RsCv7Src`py7ui;4b^rX;Kl`|){kwRL65spO_82JI~;9SxGb`8 zWeZY>btW~F2;6jL%-XQIOK87UsioT_^;=2;mhl{?ltX}FHRR ?9J1VJ1PFkely9G ~Er96~5j-4R0w7( HboK1$2)HB6{fB+r@3989W<-b@6=Gcm(EHi z`_-&eYh~4LeY)*WC*LlYnzKLcvJmW&m$sX;0BE+uyKbJd8QjwibL8jJ)kPTj JzffdtH2+Yk@Qr*VWdx8e{`x?Bq0XM#sag*_0zP zo)gt6ZJaY1vrMzBec3EC!B#^hQ7cHd<0VUe$u`hYnZC^KPcIN z0US*{((v7qht@o37P>ewWyWWiG!H^&Sz&rT$f%gAYm!sSalp90*dR;<+YBkgI>R>4 z%wc(5InFg8M1;T<0PBgsqD-^i9E*P!Awmco&Ky)+fdpS}COX}u<8?aU 2$o#n?&1`nY9-mE &;PK8R%P> z?N6hJ&0d$7zLlZbMhtjYUg4@tn3Tpj@jtIjz|14N%mDLhh7(}aFH Gz;H+?Yh(q( DW!CUX+fp^tUxfATFVvY~sg%H~U#K&oAj1{~}SPE{CE247@ z5y&GNHViyi0 HM`ze6c1g5sIBU fUK}cHuorVZ;-+@l!|XS+YGOYd^Ewd!+{xEg;LW(ROt{eu{;;>BDeAjh z4DUi1yCn4Nm+z=>1Asfu+jEr5bsp>Xur5rwZC1~AjZ0Lj+`Ow)g{j90b|oZ;p#uSS zNYxLj+*S_-5bj-X?%vKwLd$HK(?E3HK~jl0%}L~_1J`p?YGppKVytu`u+Q9V9js;t zi;gbmW*MNMr&ik?j>=)tZ+5bs(=`JN{C9!GMoe}$DE;s<%+Q OWA(%b%1B%& zI@Lm(Kp>y%sb87jLO=nq_+Lxiu7M50MQ=u=DxNt{DI!Kh0AOVrBU``4X^mztV3qC| z5T*<#TYBp(-Mq*r8`@^rBsx!Yx}|l3DMQMXa+bqsSbxP*v}7^Hy%F~ZTn!Z~Fb#un zf`XqAF#xe~yV|;uQFz%GW5D~6R_pVkE{7OG3;_cMk;tA#yzy1`5!SNZj*2V{Gt{Wx zWNN9#{(6Z (N#5`6C1Mwkcex2&})#r?9?~3{EB?eL;KENp!WUTr5#=} zh_ 9unU>|o}sD0c$IH%vtPO|0n zq!L1v#a(?fu`|lJ1<;#v&@Pt8Og=xaNJunnC+%3Cc`dv`H19b^=^N=*F>y&sNFb3y z+GWrA4W~fYJR|c=sw-<#{W***sKlz~U!?YZH(S^QZ-Zame)DzI) {pqN`Fz0tkkhs#zNV+0b1}Zz8d@#=ue>d3PA=cU7#5 zOp6jeRh+(32MYlJQ>KeV=M!uaWq>W=$%bxi==PdUC)k$5sECO3sQ{q(ssq9pa1n7a zgvC&!wMzw(0co3Q%ZQ}fw@Zl<&RQ;$J_bArd4GLQ+Z1AmF@{iM#A^z2OK=9b+n6s~ zL&3y0QbRn200M_tYP0CLUcYf>6ZX2cJwZF2!TyRSpoDfXVrgRNv8hYs@?+4>cD7w= z{)JiW(v4gHdoOqH(1+R~+A|jUr >B}7xPItX1<66Z_)5rXE Js3K7rNxqX&_l2_kXbrXVl&g6j z)EH^zY9~)J20@PRLw*yx^1!69D_PsDI3g+gTCK`5Z=CdONVew-P`t=FXN ~#zjn`GngsJW^B%lnGa}m;jh>4en1Oj4_-;@DD)$Ge77?6NqDt9IdJlG)s02O6= zEbiNY0|EkJD5qx