From e23f1fec086155d16f81a62f2bf36316142d2226 Mon Sep 17 00:00:00 2001 From: AutoFix Bot Date: Mon, 2 Mar 2026 06:09:49 +0800 Subject: [PATCH] fix: auto-fix code issues (cron) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复重复导入/字段 - 修复异常处理 - 修复PEP8格式问题 - 修复语法错误(运算符空格问题) - 修复类型注解格式 --- AUTO_CODE_REVIEW_REPORT.md | 6 +- __pycache__/auto_code_fixer.cpython-312.pyc | Bin 0 -> 22569 bytes __pycache__/auto_fix_code.cpython-312.pyc | Bin 0 -> 8724 bytes __pycache__/code_review_fixer.cpython-312.pyc | Bin 0 -> 16914 bytes __pycache__/code_reviewer.cpython-312.pyc | Bin 0 -> 19681 bytes auto_code_fixer.py | 156 +- auto_fix_code.py | 150 +- .../__pycache__/ai_manager.cpython-312.pyc | Bin 0 -> 61794 bytes .../api_key_manager.cpython-312.pyc | Bin 0 -> 22174 bytes .../collaboration_manager.cpython-312.pyc | Bin 0 -> 37145 bytes .../__pycache__/db_manager.cpython-312.pyc | Bin 0 -> 61761 bytes ...eveloper_ecosystem_manager.cpython-312.pyc | Bin 0 -> 77439 bytes .../document_processor.cpython-312.pyc | Bin 0 -> 8039 bytes .../enterprise_manager.cpython-312.pyc | Bin 0 -> 82776 bytes .../entity_aligner.cpython-312.pyc | Bin 0 -> 12018 bytes .../export_manager.cpython-312.pyc | Bin 0 -> 24268 bytes .../growth_manager.cpython-312.pyc | Bin 0 -> 83815 bytes .../image_processor.cpython-312.pyc | Bin 0 -> 19286 bytes backend/__pycache__/init_db.cpython-312.pyc | Bin 0 -> 1773 bytes .../knowledge_reasoner.cpython-312.pyc | Bin 0 -> 20077 bytes .../__pycache__/llm_client.cpython-312.pyc | Bin 0 -> 12470 bytes .../localization_manager.cpython-312.pyc | Bin 0 -> 68582 bytes backend/__pycache__/main.cpython-312.pyc | Bin 0 -> 597702 bytes .../multimodal_entity_linker.cpython-312.pyc | Bin 0 -> 17871 bytes .../multimodal_processor.cpython-312.pyc | Bin 0 -> 17309 bytes .../__pycache__/neo4j_manager.cpython-312.pyc | Bin 0 -> 37549 bytes .../__pycache__/ops_manager.cpython-312.pyc | Bin 0 -> 127400 bytes .../__pycache__/oss_uploader.cpython-312.pyc | Bin 0 -> 2950 bytes .../performance_manager.cpython-312.pyc | Bin 0 -> 67152 bytes .../plugin_manager.cpython-312.pyc | Bin 0 -> 59837 bytes .../__pycache__/rate_limiter.cpython-312.pyc | Bin 0 -> 10421 bytes .../search_manager.cpython-312.pyc | Bin 0 -> 77922 bytes .../security_manager.cpython-312.pyc | Bin 0 -> 46290 bytes .../subscription_manager.cpython-312.pyc | Bin 0 -> 79075 bytes .../tenant_manager.cpython-312.pyc | Bin 0 -> 61828 bytes .../test_multimodal.cpython-312.pyc | Bin 0 -> 7017 bytes .../test_phase7_task6_8.cpython-312.pyc | Bin 0 -> 18508 bytes .../test_phase8_task1.cpython-312.pyc | Bin 0 -> 14779 bytes .../test_phase8_task2.cpython-312.pyc | Bin 0 -> 11286 bytes .../test_phase8_task4.cpython-312.pyc | Bin 0 -> 15518 bytes .../test_phase8_task5.cpython-312.pyc | Bin 0 -> 34225 bytes .../test_phase8_task6.cpython-312.pyc | Bin 0 -> 39086 bytes .../test_phase8_task8.cpython-312.pyc | Bin 0 -> 37024 bytes .../__pycache__/tingwu_client.cpython-312.pyc | Bin 0 -> 7629 bytes .../workflow_manager.cpython-312.pyc | Bin 0 -> 65577 bytes backend/ai_manager.py | 786 ++++---- backend/api_key_manager.py | 252 +-- backend/collaboration_manager.py | 546 +++--- backend/db_manager.py | 804 ++++---- backend/developer_ecosystem_manager.py | 1272 ++++++------ backend/document_processor.py | 60 +- backend/enterprise_manager.py | 1072 +++++----- backend/entity_aligner.py | 136 +- backend/export_manager.py | 262 +-- backend/growth_manager.py | 1088 +++++------ backend/image_processor.py | 248 +-- backend/init_db.py | 18 +- backend/knowledge_reasoner.py | 252 +-- backend/llm_client.py | 106 +- backend/localization_manager.py | 742 +++---- backend/multimodal_entity_linker.py | 172 +- backend/multimodal_processor.py | 230 +-- backend/neo4j_manager.py | 420 ++-- backend/ops_manager.py | 1722 ++++++++--------- backend/oss_uploader.py | 26 +- backend/performance_manager.py | 684 +++---- backend/plugin_manager.py | 866 ++++----- backend/rate_limiter.py | 116 +- backend/search_manager.py | 994 +++++----- backend/security_manager.py | 808 ++++---- backend/subscription_manager.py | 924 ++++----- backend/tenant_manager.py | 710 +++---- backend/test_multimodal.py | 36 +- backend/test_phase7_task6_8.py | 166 +- backend/test_phase8_task1.py | 154 +- backend/test_phase8_task2.py | 124 +- backend/test_phase8_task4.py | 178 +- backend/test_phase8_task5.py | 412 ++-- backend/test_phase8_task6.py | 558 +++--- backend/test_phase8_task8.py | 502 ++--- backend/tingwu_client.py | 69 +- backend/workflow_manager.py | 756 ++++---- code_review_fixer.py | 234 +-- code_reviewer.py | 166 +- 84 files changed, 9492 insertions(+), 9491 deletions(-) create mode 100644 __pycache__/auto_code_fixer.cpython-312.pyc create mode 100644 __pycache__/auto_fix_code.cpython-312.pyc create mode 100644 __pycache__/code_review_fixer.cpython-312.pyc create mode 100644 __pycache__/code_reviewer.cpython-312.pyc create mode 100644 backend/__pycache__/ai_manager.cpython-312.pyc create mode 100644 backend/__pycache__/api_key_manager.cpython-312.pyc create mode 100644 backend/__pycache__/collaboration_manager.cpython-312.pyc create mode 100644 backend/__pycache__/db_manager.cpython-312.pyc create mode 100644 backend/__pycache__/developer_ecosystem_manager.cpython-312.pyc create mode 100644 backend/__pycache__/document_processor.cpython-312.pyc create mode 100644 backend/__pycache__/enterprise_manager.cpython-312.pyc create mode 100644 backend/__pycache__/entity_aligner.cpython-312.pyc create mode 100644 backend/__pycache__/export_manager.cpython-312.pyc create mode 100644 backend/__pycache__/growth_manager.cpython-312.pyc create mode 100644 backend/__pycache__/image_processor.cpython-312.pyc create mode 100644 backend/__pycache__/init_db.cpython-312.pyc create mode 100644 backend/__pycache__/knowledge_reasoner.cpython-312.pyc create mode 100644 backend/__pycache__/llm_client.cpython-312.pyc create mode 100644 backend/__pycache__/localization_manager.cpython-312.pyc create mode 100644 backend/__pycache__/main.cpython-312.pyc create mode 100644 backend/__pycache__/multimodal_entity_linker.cpython-312.pyc create mode 100644 backend/__pycache__/multimodal_processor.cpython-312.pyc create mode 100644 backend/__pycache__/neo4j_manager.cpython-312.pyc create mode 100644 backend/__pycache__/ops_manager.cpython-312.pyc create mode 100644 backend/__pycache__/oss_uploader.cpython-312.pyc create mode 100644 backend/__pycache__/performance_manager.cpython-312.pyc create mode 100644 backend/__pycache__/plugin_manager.cpython-312.pyc create mode 100644 backend/__pycache__/rate_limiter.cpython-312.pyc create mode 100644 backend/__pycache__/search_manager.cpython-312.pyc create mode 100644 backend/__pycache__/security_manager.cpython-312.pyc create mode 100644 backend/__pycache__/subscription_manager.cpython-312.pyc create mode 100644 backend/__pycache__/tenant_manager.cpython-312.pyc create mode 100644 backend/__pycache__/test_multimodal.cpython-312.pyc create mode 100644 backend/__pycache__/test_phase7_task6_8.cpython-312.pyc create mode 100644 backend/__pycache__/test_phase8_task1.cpython-312.pyc create mode 100644 backend/__pycache__/test_phase8_task2.cpython-312.pyc create mode 100644 backend/__pycache__/test_phase8_task4.cpython-312.pyc create mode 100644 backend/__pycache__/test_phase8_task5.cpython-312.pyc create mode 100644 backend/__pycache__/test_phase8_task6.cpython-312.pyc create mode 100644 backend/__pycache__/test_phase8_task8.cpython-312.pyc create mode 100644 backend/__pycache__/tingwu_client.cpython-312.pyc create mode 100644 backend/__pycache__/workflow_manager.cpython-312.pyc diff --git a/AUTO_CODE_REVIEW_REPORT.md b/AUTO_CODE_REVIEW_REPORT.md index 6e68ecc..0e0ba5a 100644 --- a/AUTO_CODE_REVIEW_REPORT.md +++ b/AUTO_CODE_REVIEW_REPORT.md @@ -224,4 +224,8 @@ - 第 490 行: line_too_long - 第 541 行: line_too_long - 第 579 行: line_too_long -- ... 还有 2 个类似问题 \ No newline at end of file +- ... 还有 2 个类似问题 + +## Git 提交结果 + +✅ 提交并推送成功 diff --git a/__pycache__/auto_code_fixer.cpython-312.pyc b/__pycache__/auto_code_fixer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1dd0708369ee98a0c3d93d67ca6b95ad28b9c1b9 GIT binary patch literal 22569 zcmdUXYjhLWx#)~EqxZ|U{5Bx`mccd-Z(`my1PF!zfdHY3;}OQjSnf!|SdkmjCXGp) zVw#6V9>=1PA`%KtnkG)5B{ywPIce{`5*D>Om%A409BkmnUB^JqO-t9h>we!JjU)@1 zke;5i*4ei9-m_n`AK(67``hz-yLS_d8p}dXVr`VyODH*kz;+W?tj(uMywzNY@(iD)UjHRhKm0iuL z?Haq*uHG%ng`&F|PV+poL&xbkErfczfz$0Rrzl<}qwMxX(YH?h42D=jb^8(n*vqOFs_7=5_20wVpCoq~~5IO-j8HKnFF9VfFhoZQZG z41_HF6!25RPsOQs8((`|RBLz`kxZ}y+L`4iubGlQeQ zd|goQZfUdII-o0pvaO}vZfkc4nii~Kb9Hst1?4`w)7jW;7gSFBemmdd>Js!0zNNXP zy|K-P6$Axuce(lYh9+gS`xxL7$l-TqH+cP&m-bRNh>6ZCgC{!nB!{Qyl2MeGfhVj< zoR@_sCibL&CsummuuTbQs4mJXYglDf@|eP7S9leAYV;83crADWySwFpowF(h#%XWc zegDEvY{eqFyXlQ?m&4Ws(`myd_^OUBL1nYGw70lyHcxtNo~xuZ6I6E21MeU;o=)W# z_b|PNNX|3}bdmfL2#nHG?sRz4N2bo`VZy4sNLGFi^NQvU)TEkXlLQYq5;CPfga}M2 z)e2#EY{2|2JFtvHcM?k(ya^=8V-Hr%Bbd1RKI zcV)XQcV&w#RyJWy$+X8-!AkQ8;7%Nqg~#c^3FPe^eDVZ}Y16UrGVl&kk@T$osv}jw zwCVmd|D2Px$7=mmgR;RnpDg}h@nH4m>7UpAZo_}u5L~$>xMgc_+s=_~O`&Z~!L3}- zXb-CFJc7PeCMY`_`SzCfW`Q~-<8_eMU{zrk+8g)TZ8kw;v+Z+mZrBYrL1(jl*WK6_ z%~9HHoTCYnT~ev%xz>k><&Gc!W*j=^^5L61?ZBKQRU4CMvHeGSza+ zXk-hnW@L@C5RNOT^vrQ3##B^h&bS(58cfq-Oh-XEJ;n@FPX0L7gl05`J82O6s*q-P z)8dQqofEK#v|4x4VWuZy5fOiI3}8GKFdfU8IR%6Yz#&RbPSREItRl~9z$+R%_=qD( zu8QDC&7`?HsH=_TVtas%AWaubOK#5qx%x?SjgV`YG}i>VMi@aFgeJg>X?6yBn@%vg zh}UIYI^<Gz{?z)!K%X4tTCEJvQOHgV)BwlT?549j&h4r5ce_Zlt^daFsSvHA{S~t`TvT6>!&ORUU!J2}lc(3Yn#A5Sa{V zwZ>)w3Pl|ko5eb6*CvYU23Eioo0a$+@28x4PM$i4r+cTSMr^H!Op{!yHl&rL5#1sI zNpkUi&{Xdhh63d*sA^Z_WiibWQAG2xy|u3KMmU9E5H*JjU65Kb`DS!E-P8n=#mP`jxKSH z6P8slkYbH(ZDN)u2g#GT!V`0

^j|;2or{X6KJ&mxr>;N3yFz*;Rr4!`bt0Q;fQ# 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 a37TSbJ)TSQ+&?j@7gmkSDMtK;;Qt4cl_~E4 literal 0 HcmV?d00001 diff --git a/__pycache__/auto_fix_code.cpython-312.pyc b/__pycache__/auto_fix_code.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5d36e6f874b9097785dcf7949673ee690e269cdb GIT binary patch literal 8724 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 d)H72(p+n(+gDW>q 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 bWqvt9=?40)%5(losb1MSFDIy6ERp{YmieT7 literal 0 HcmV?d00001 diff --git a/auto_code_fixer.py b/auto_code_fixer.py index 858c810..1b62948 100644 --- a/auto_code_fixer.py +++ b/auto_code_fixer.py @@ -19,30 +19,30 @@ class CodeIssue: line_no: int, issue_type: str, message: str, - severity: str = "warning", - original_line: str = "", - ): - self.file_path = file_path - self.line_no = line_no - self.issue_type = issue_type - self.message = message - self.severity = severity - self.original_line = original_line - self.fixed = False + severity: str = "warning", + original_line: str = "", + ) -> None: + self.file_path = file_path + self.line_no = line_no + self.issue_type = issue_type + self.message = message + self.severity = severity + self.original_line = original_line + self.fixed = False - def __repr__(self): + def __repr__(self) -> None: return f"{self.file_path}:{self.line_no} [{self.severity}] {self.issue_type}: {self.message}" class CodeFixer: """代码自动修复器""" - def __init__(self, project_path: str): - self.project_path = Path(project_path) - self.issues: list[CodeIssue] = [] - self.fixed_issues: list[CodeIssue] = [] - self.manual_issues: list[CodeIssue] = [] - self.scanned_files: list[str] = [] + def __init__(self, project_path: str) -> None: + self.project_path = Path(project_path) + self.issues: list[CodeIssue] = [] + self.fixed_issues: list[CodeIssue] = [] + self.manual_issues: list[CodeIssue] = [] + self.scanned_files: list[str] = [] def scan_all_files(self) -> None: """扫描所有 Python 文件""" @@ -55,9 +55,9 @@ class CodeFixer: def _scan_file(self, file_path: Path) -> None: """扫描单个文件""" try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - lines = content.split("\n") + with open(file_path, "r", encoding = "utf-8") as f: + content = f.read() + lines = content.split("\n") except Exception as e: print(f"Error reading {file_path}: {e}") return @@ -85,7 +85,7 @@ class CodeFixer: ) -> None: """检查裸异常捕获""" for i, line in enumerate(lines, 1): - # 匹配 except: 但不匹配 except Exception: 或 except SpecificError: + # 匹配 except Exception: 但不匹配 except Exception: 或 except SpecificError: if re.search(r"except\s*:\s*$", line) or re.search(r"except\s*:\s*#", line): # 跳过注释说明的情况 if "# noqa" in line or "# intentional" in line.lower(): @@ -130,25 +130,25 @@ class CodeFixer: def _check_unused_imports(self, file_path: Path, content: str) -> None: """检查未使用的导入""" try: - tree = ast.parse(content) + tree = ast.parse(content) except SyntaxError: return - imports = {} + imports = {} for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: - name = alias.asname if alias.asname else alias.name - imports[name] = node.lineno + name = alias.asname if alias.asname else alias.name + imports[name] = node.lineno elif isinstance(node, ast.ImportFrom): for alias in node.names: - name = alias.asname if alias.asname else alias.name + name = alias.asname if alias.asname else alias.name if alias.name == "*": continue - imports[name] = node.lineno + imports[name] = node.lineno # 检查使用 - used_names = set() + used_names = set() for node in ast.walk(tree): if isinstance(node, ast.Name): used_names.add(node.id) @@ -216,15 +216,15 @@ class CodeFixer: ) -> None: """检查敏感信息泄露""" # 排除的文件 - excluded_files = ["auto_code_fixer.py", "code_reviewer.py"] + excluded_files = ["auto_code_fixer.py", "code_reviewer.py"] if any(excluded in str(file_path) for excluded in excluded_files): return - patterns = [ - (r'password\s*=\s*["\'][^"\']{8,}["\']', "硬编码密码"), - (r'secret_key\s*=\s*["\'][^"\']{8,}["\']', "硬编码密钥"), - (r'api_key\s*=\s*["\'][^"\']{8,}["\']', "硬编码 API Key"), - (r'token\s*=\s*["\'][^"\']{8,}["\']', "硬编码 Token"), + patterns = [ + (r'password\s* = \s*["\'][^"\']{8, }["\']', "硬编码密码"), + (r'secret_key\s* = \s*["\'][^"\']{8, }["\']', "硬编码密钥"), + (r'api_key\s* = \s*["\'][^"\']{8, }["\']', "硬编码 API Key"), + (r'token\s* = \s*["\'][^"\']{8, }["\']', "硬编码 Token"), ] for i, line in enumerate(lines, 1): @@ -241,7 +241,7 @@ class CodeFixer: if any(x in line.lower() for x in ["your_", "example", "placeholder", "test", "demo"]): continue # 排除 Enum 定义 - if re.search(r'^\s*[A-Z_]+\s*=', line.strip()): + if re.search(r'^\s*[A-Z_]+\s* = ', line.strip()): continue self.manual_issues.append( CodeIssue( @@ -256,17 +256,17 @@ class CodeFixer: def fix_auto_fixable(self) -> None: """自动修复可修复的问题""" - auto_fix_types = { + auto_fix_types = { "trailing_whitespace", "bare_exception", } # 按文件分组 - files_to_fix = {} + files_to_fix = {} for issue in self.issues: if issue.issue_type in auto_fix_types: if issue.file_path not in files_to_fix: - files_to_fix[issue.file_path] = [] + files_to_fix[issue.file_path] = [] files_to_fix[issue.file_path].append(issue) for file_path, file_issues in files_to_fix.items(): @@ -275,43 +275,43 @@ class CodeFixer: continue try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - lines = content.split("\n") + with open(file_path, "r", encoding = "utf-8") as f: + content = f.read() + lines = content.split("\n") except Exception: continue - original_lines = lines.copy() - fixed_lines = set() + original_lines = lines.copy() + fixed_lines = set() # 修复行尾空格 for issue in file_issues: if issue.issue_type == "trailing_whitespace": - line_idx = issue.line_no - 1 + line_idx = issue.line_no - 1 if 0 <= line_idx < len(lines) and line_idx not in fixed_lines: if lines[line_idx].rstrip() != lines[line_idx]: - lines[line_idx] = lines[line_idx].rstrip() + lines[line_idx] = lines[line_idx].rstrip() fixed_lines.add(line_idx) - issue.fixed = True + issue.fixed = True self.fixed_issues.append(issue) # 修复裸异常 for issue in file_issues: if issue.issue_type == "bare_exception": - line_idx = issue.line_no - 1 + line_idx = issue.line_no - 1 if 0 <= line_idx < len(lines) and line_idx not in fixed_lines: - line = lines[line_idx] - # 将 except: 改为 except Exception: + line = lines[line_idx] + # 将 except Exception: 改为 except Exception: if re.search(r"except\s*:\s*$", line.strip()): - lines[line_idx] = line.replace("except:", "except Exception:") + lines[line_idx] = line.replace("except Exception:", "except Exception:") fixed_lines.add(line_idx) - issue.fixed = True + issue.fixed = True self.fixed_issues.append(issue) # 如果文件有修改,写回 if lines != original_lines: try: - with open(file_path, "w", encoding="utf-8") as f: + with open(file_path, "w", encoding = "utf-8") as f: f.write("\n".join(lines)) print(f"Fixed issues in {file_path}") except Exception as e: @@ -319,7 +319,7 @@ class CodeFixer: def categorize_issues(self) -> dict[str, list[CodeIssue]]: """分类问题""" - categories = { + categories = { "critical": [], "error": [], "warning": [], @@ -334,7 +334,7 @@ class CodeFixer: def generate_report(self) -> str: """生成修复报告""" - report = [] + report = [] report.append("# InsightFlow 代码审查报告") report.append("") report.append(f"扫描时间: {os.popen('date').read().strip()}") @@ -349,9 +349,9 @@ class CodeFixer: report.append("") # 问题统计 - categories = self.categorize_issues() - manual_critical = [i for i in self.manual_issues if i.severity == "critical"] - manual_warning = [i for i in self.manual_issues if i.severity == "warning"] + categories = self.categorize_issues() + manual_critical = [i for i in self.manual_issues if i.severity == "critical"] + manual_warning = [i for i in self.manual_issues if i.severity == "warning"] report.append("## 问题分类统计") report.append("") @@ -393,17 +393,17 @@ class CodeFixer: # 其他问题 report.append("## 📋 其他发现的问题") report.append("") - other_issues = [ + other_issues = [ i for i in self.issues if i not in self.fixed_issues ] # 按类型分组 - by_type = {} + by_type = {} for issue in other_issues: if issue.issue_type not in by_type: - by_type[issue.issue_type] = [] + by_type[issue.issue_type] = [] by_type[issue.issue_type].append(issue) for issue_type, issues in sorted(by_type.items()): @@ -424,21 +424,21 @@ def git_commit_and_push(project_path: str) -> tuple[bool, str]: """Git 提交和推送""" try: # 检查是否有变更 - result = subprocess.run( + result = subprocess.run( ["git", "status", "--porcelain"], - cwd=project_path, - capture_output=True, - text=True, + cwd = project_path, + capture_output = True, + text = True, ) if not result.stdout.strip(): return True, "没有需要提交的变更" # 添加所有变更 - subprocess.run(["git", "add", "-A"], cwd=project_path, check=True) + subprocess.run(["git", "add", "-A"], cwd = project_path, check = True) # 提交 - commit_msg = """fix: auto-fix code issues (cron) + commit_msg = """fix: auto-fix code issues (cron) - 修复重复导入/字段 - 修复异常处理 @@ -446,11 +446,11 @@ def git_commit_and_push(project_path: str) -> tuple[bool, str]: - 添加类型注解""" subprocess.run( - ["git", "commit", "-m", commit_msg], cwd=project_path, check=True + ["git", "commit", "-m", commit_msg], cwd = project_path, check = True ) # 推送 - subprocess.run(["git", "push"], cwd=project_path, check=True) + subprocess.run(["git", "push"], cwd = project_path, check = True) return True, "提交并推送成功" except subprocess.CalledProcessError as e: @@ -459,11 +459,11 @@ def git_commit_and_push(project_path: str) -> tuple[bool, str]: return False, f"Git 操作异常: {e}" -def main(): - project_path = "/root/.openclaw/workspace/projects/insightflow" +def main() -> None: + project_path = "/root/.openclaw/workspace/projects/insightflow" print("🔍 开始扫描代码...") - fixer = CodeFixer(project_path) + fixer = CodeFixer(project_path) fixer.scan_all_files() print(f"📊 发现 {len(fixer.issues)} 个可自动修复问题") @@ -475,30 +475,30 @@ def main(): print(f"✅ 已修复 {len(fixer.fixed_issues)} 个问题") # 生成报告 - report = fixer.generate_report() + report = fixer.generate_report() # 保存报告 - report_path = Path(project_path) / "AUTO_CODE_REVIEW_REPORT.md" - with open(report_path, "w", encoding="utf-8") as f: + report_path = Path(project_path) / "AUTO_CODE_REVIEW_REPORT.md" + with open(report_path, "w", encoding = "utf-8") as f: f.write(report) print(f"📝 报告已保存到: {report_path}") # Git 提交 print("📤 提交变更到 Git...") - success, msg = git_commit_and_push(project_path) + success, msg = git_commit_and_push(project_path) print(f"{'✅' if success else '❌'} {msg}") # 添加 Git 结果到报告 report += f"\n\n## Git 提交结果\n\n{'✅' if success else '❌'} {msg}\n" # 重新保存完整报告 - with open(report_path, "w", encoding="utf-8") as f: + with open(report_path, "w", encoding = "utf-8") as f: f.write(report) - print("\n" + "=" * 60) + print("\n" + " = " * 60) print(report) - print("=" * 60) + print(" = " * 60) return report diff --git a/auto_fix_code.py b/auto_fix_code.py index b0e6043..caf66e1 100644 --- a/auto_fix_code.py +++ b/auto_fix_code.py @@ -14,11 +14,11 @@ from pathlib import Path def run_ruff_check(directory: str) -> list[dict]: """运行 ruff 检查并返回问题列表""" try: - result = subprocess.run( - ["ruff", "check", "--select=E,W,F,I", "--output-format=json", directory], - capture_output=True, - text=True, - check=False, + result = subprocess.run( + ["ruff", "check", "--select = E, W, F, I", "--output-format = json", directory], + capture_output = True, + text = True, + check = False, ) if result.stdout: return json.loads(result.stdout) @@ -29,18 +29,18 @@ def run_ruff_check(directory: str) -> list[dict]: def fix_bare_except(content: str) -> str: - """修复裸异常捕获 - 将 bare except: 改为 except Exception:""" - pattern = r'except\s*:\s*\n' - replacement = 'except Exception:\n' + """修复裸异常捕获 - 将 bare except Exception: 改为 except Exception:""" + pattern = r'except\s*:\s*\n' + replacement = 'except Exception:\n' return re.sub(pattern, replacement, content) def fix_undefined_names(content: str, filepath: str) -> str: """修复未定义的名称""" - lines = content.split('\n') - modified = False - - import_map = { + lines = content.split('\n') + modified = False + + import_map = { 'ExportEntity': 'from export_manager import ExportEntity', 'ExportRelation': 'from export_manager import ExportRelation', 'ExportTranscript': 'from export_manager import ExportTranscript', @@ -49,23 +49,23 @@ def fix_undefined_names(content: str, filepath: str) -> str: 'OpsManager': 'from ops_manager import OpsManager', 'urllib': 'import urllib.parse', } - - undefined_names = set() + + undefined_names = set() for name, import_stmt in import_map.items(): if name in content and import_stmt not in content: undefined_names.add((name, import_stmt)) - + if undefined_names: - import_idx = 0 + import_idx = 0 for i, line in enumerate(lines): if line.startswith('import ') or line.startswith('from '): - import_idx = i + 1 - + import_idx = i + 1 + for name, import_stmt in sorted(undefined_names): lines.insert(import_idx, import_stmt) import_idx += 1 - modified = True - + modified = True + if modified: return '\n'.join(lines) return content @@ -73,100 +73,100 @@ def fix_undefined_names(content: str, filepath: str) -> str: def fix_file(filepath: str, issues: list[dict]) -> tuple[bool, list[str], list[str]]: """修复单个文件的问题""" - with open(filepath, 'r', encoding='utf-8') as f: - original_content = f.read() - - content = original_content - fixed_issues = [] - manual_fix_needed = [] - + with open(filepath, 'r', encoding = 'utf-8') as f: + original_content = f.read() + + content = original_content + fixed_issues = [] + manual_fix_needed = [] + for issue in issues: - code = issue.get('code', '') - message = issue.get('message', '') - line_num = issue['location']['row'] - + code = issue.get('code', '') + message = issue.get('message', '') + line_num = issue['location']['row'] + if code == 'F821': - content = fix_undefined_names(content, filepath) + content = fix_undefined_names(content, filepath) if content != original_content: fixed_issues.append(f"F821 - {message} (line {line_num})") else: manual_fix_needed.append(f"F821 - {message} (line {line_num})") elif code == 'E501': manual_fix_needed.append(f"E501 (line {line_num})") - - content = fix_bare_except(content) - + + content = fix_bare_except(content) + if content != original_content: - with open(filepath, 'w', encoding='utf-8') as f: + with open(filepath, 'w', encoding = 'utf-8') as f: f.write(content) return True, fixed_issues, manual_fix_needed - + return False, fixed_issues, manual_fix_needed -def main(): - base_dir = Path("/root/.openclaw/workspace/projects/insightflow") - backend_dir = base_dir / "backend" - - print("=" * 60) +def main() -> None: + base_dir = Path("/root/.openclaw/workspace/projects/insightflow") + backend_dir = base_dir / "backend" + + print(" = " * 60) print("InsightFlow 代码自动修复") - print("=" * 60) - + print(" = " * 60) + print("\n1. 扫描代码问题...") - issues = run_ruff_check(str(backend_dir)) - - issues_by_file = {} + issues = run_ruff_check(str(backend_dir)) + + issues_by_file = {} for issue in issues: - filepath = issue.get('filename', '') + filepath = issue.get('filename', '') if filepath not in issues_by_file: - issues_by_file[filepath] = [] + issues_by_file[filepath] = [] issues_by_file[filepath].append(issue) - + print(f" 发现 {len(issues)} 个问题,分布在 {len(issues_by_file)} 个文件中") - - issue_types = {} + + issue_types = {} for issue in issues: - code = issue.get('code', 'UNKNOWN') - issue_types[code] = issue_types.get(code, 0) + 1 - + code = issue.get('code', 'UNKNOWN') + issue_types[code] = issue_types.get(code, 0) + 1 + print("\n2. 问题类型统计:") - for code, count in sorted(issue_types.items(), key=lambda x: -x[1]): + for code, count in sorted(issue_types.items(), key = lambda x: -x[1]): print(f" - {code}: {count} 个") - + print("\n3. 尝试自动修复...") - fixed_files = [] - all_fixed_issues = [] - all_manual_fixes = [] - + fixed_files = [] + all_fixed_issues = [] + all_manual_fixes = [] + for filepath, file_issues in issues_by_file.items(): if not os.path.exists(filepath): continue - - modified, fixed, manual = fix_file(filepath, file_issues) + + modified, fixed, manual = fix_file(filepath, file_issues) if modified: fixed_files.append(filepath) all_fixed_issues.extend(fixed) all_manual_fixes.extend([(filepath, m) for m in manual]) - + print(f" 直接修改了 {len(fixed_files)} 个文件") print(f" 自动修复了 {len(all_fixed_issues)} 个问题") - + print("\n4. 运行 ruff 自动格式化...") try: subprocess.run( ["ruff", "format", str(backend_dir)], - capture_output=True, - check=False, + capture_output = True, + check = False, ) print(" 格式化完成") except Exception as e: print(f" 格式化失败: {e}") - + print("\n5. 再次检查...") - remaining_issues = run_ruff_check(str(backend_dir)) + remaining_issues = run_ruff_check(str(backend_dir)) print(f" 剩余 {len(remaining_issues)} 个问题需要手动处理") - - report = { + + report = { 'total_issues': len(issues), 'fixed_files': len(fixed_files), 'fixed_issues': len(all_fixed_issues), @@ -174,15 +174,15 @@ def main(): 'issue_types': issue_types, 'manual_fix_needed': all_manual_fixes[:30], } - + return report if __name__ == "__main__": - report = main() - print("\n" + "=" * 60) + report = main() + print("\n" + " = " * 60) print("修复报告") - print("=" * 60) + print(" = " * 60) print(f"总问题数: {report['total_issues']}") print(f"修复文件数: {report['fixed_files']}") print(f"自动修复问题数: {report['fixed_issues']}") diff --git a/backend/__pycache__/ai_manager.cpython-312.pyc b/backend/__pycache__/ai_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e33da322ebe20c6f784f9dbfca6673b6c60ca10 GIT binary patch literal 61794 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 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/api_key_manager.cpython-312.pyc b/backend/__pycache__/api_key_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8da9471021ec8eadad184e115cf92bbc752cd4f0 GIT binary patch literal 22174 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

83T!rFlM$bb29QPB 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 S0z11$h2XEP76U&g=l=sPh>sWm literal 0 HcmV?d00001 diff --git a/backend/__pycache__/db_manager.cpython-312.pyc b/backend/__pycache__/db_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b6828e83e1a5729d02c8e97763de208db3daea0 GIT binary patch literal 61761 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 PSWAaOpIC5UL-GFs)Dmu@ literal 0 HcmV?d00001 diff --git a/backend/__pycache__/developer_ecosystem_manager.cpython-312.pyc b/backend/__pycache__/developer_ecosystem_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a56c60d3a61cc1ac398a3fa81d69b9a846ef262 GIT binary patch literal 77439 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 cZW%nPx1Kjf$6O<)PFW`2QF`UrfD{V)mzW@LL literal 0 HcmV?d00001 diff --git a/backend/__pycache__/document_processor.cpython-312.pyc b/backend/__pycache__/document_processor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c95ae783eacee99893a2fc3319d160332510892d GIT binary patch literal 8039 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
%Wpfhh5* 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 To0_8NEx=;C4)WJGNfrA)vi=`z literal 0 HcmV?d00001 diff --git a/backend/__pycache__/entity_aligner.cpython-312.pyc b/backend/__pycache__/entity_aligner.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7496bc303cac7441328f80968406ac1aed5e646e GIT binary patch literal 12018 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 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/growth_manager.cpython-312.pyc b/backend/__pycache__/growth_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9a408ba002d5efe49fc2b7c6e6df1f5d416d2394 GIT binary patch literal 83815 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 BpyU7m literal 0 HcmV?d00001 diff --git a/backend/__pycache__/image_processor.cpython-312.pyc b/backend/__pycache__/image_processor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..325396c410c4a91ad850628e9a7cdc21a6729595 GIT binary patch literal 19286 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 PBKIf7c^~xZAdjEITa;u#xXN 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 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/llm_client.cpython-312.pyc b/backend/__pycache__/llm_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96ebdb110b4078ce45a159f6da0fd58989a46deb GIT binary patch literal 12470 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 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/localization_manager.cpython-312.pyc b/backend/__pycache__/localization_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f832bd2d2ebd11b1507d337517382ec4f339e7df GIT binary patch literal 68582 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�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 z zQhCeUEFsT++x@o3zs>)u{%C(x?dkNsY8xoEKWeO<39n2Jpzy%A2H$#v5AMQL&^e?F zo7@rvS;9kb2YYs7WNvA9Z(O!^S^cK68N!PoZ{fd*`)A;yb{A5s>=720CETI#okC+M z9AY&zAeGqe#(UsqK>jZY7a^_#xgU7$Dq5cK_asOrQ+*^&0F;B@5{em{cz+i>B~aLo za64T|x{a^}MIv?C7n{O&leZiX?PO8&D{x^VZF{(JC*i=8)*zgq3jP;Wu$W$$R0~8l zJNY%j>vXn}D*Xjr(u}kG{kHH6j1f@`5|}woNEb*?abq;@DTiP?fC`ylgCNQ;f8G5? z+G}aw^TnpG6sNC@PG23_{8=&mj*jKOc3Rxo7gOA+lRnBUjb@HJSQpRAIkKp0(UaF5 zTpCZyIK1}I+U|mZv`X4veQ5RLYvVPu4leIpET)z96nv021xmz}1qYo6_lfSJ?#-fm zBGova*H}0+S~&aQ+IT@#tYDT{Fe_Rx=ir)nT7cahOPe63P3ZLwq|J`|3l9I)p}*?h z8}(QB-u8iiZoFb@r?c~mVn*4o(=6_?c=c@g3AcXWt~ygaSNSQrtLTBcOK=RaTxo%h zbr&6$N%OSVp7CaOtoz-suCrw1{}NCXlj1Yxzms+1#x4E<-5eRDe=;3{G9*O zw4&c%bl}S0|Efs2Y4)Ob>Q7qVUVdVJWad)&reZOz#(2{oS~jCM4@_N_Z|$qK)i1J~ zn&roSzpd7_bdu$?X9j+pUX;3Yg6&84{H5i#9~C=rG|M}y`&cFWVg#*XW9e*v6zO{MhPQ(dqVe!rl!*b>cG-~m%$~f&^p@O|{nD)Hj zYaxvaLyWP9j!_XMuKzr)MZUa@oX)16`v)@S5^_P*K9?PcOlTg+*ujtN_%OGqXG7oT z26C4Y>O<7N6qi6#iB#SWoR&-{T?GeMhZFf33QBu~zWWFAms33XfPFcSS*Azo$PcoM zZI{1>KU(rL3$(^DrzTMUnh+t9f&q9HfB+c`AT6vOQ^5cvqT`SNS{Mm7+$b##)Zj)1 zC7WPW@k=rm0vhT!_(~1*MkD=HJC604zB<8AJ@pY?mqB}me)Q2XD*lD~F@(!>vEsv_ zCseSBsJ++#Qc^i~^NK^KhP}%FN_9uK? zY-|X(KqqJkbL%N6X<1kjwU@-Rf>C=go{{z74JhOUmsGc|(-iqX#n5RZ%m2L&s38H~pxjeW zmd2!&8)l4LAd~043DyXcU@QSC~*F(Hy5pn{o}K%e~HOZz5aUP@rTk?yd_M&)kSJ z(gp95hF+54U|4VASi0sI>oFYjX^t_Of@6NoF(zGbEJJgQ&t9yXGd0J2B!zHYKy!?# zF*uf`ImTvXIF_wBmMPtvqd69kjs-QxvgjBfNU~)rtCM_8<&la^APi(?55$yk7eUns zdcVg+8l_-{<(UK5WNdA0y6aA6pOlen$friy53ow~P&Ya3*s$CD?6Bru?>3|c-mmL{Q@&6qUfsCzmWWc_akDyeQuD5KclD5m= zK%tfHf6NF9$;I_L8Zo(E2a~iks>&m%{9DrOl=G5)r~Ec*dG2bTECqQVn~b%p>xJ5) zH1swIwCRFqSzZ$Qg{-PK_=J>$W(aRxQTbTn45Q8pus185aM zDU@yiB#bGwd`$a z4ew$kY@&Col7+RN8OGlXYCS6EQR|Ux)6uwQHj)X`K^Nv81#!QAzCm}Jx7X_A?L|{Q zu8z|lpCral+gP4Ez?Qfk?eh0GN-Z!-57w*@P&s5BzJT zUw(OJ2SlL|P4E2b)UI_8CU)8b$Vbt-y+O#KhB1iY*>sGGT57EEys?;y%_m}fnwC>Q zjg*rYwdbK6u;+2IVmFfsxk;uc+;5?UlLztd@*ltyV_kU<+4tKu-q)s~`XPtR{K7)4 ziQ;37o7sw|P&xW*&AfrXXr;{I^nMIW&SY5!=y1wnh5AM~YO+QOp8<=R1ERXXgY$W6 zE%Khc7=yC~@-g-AE6$h6M+qBqvNT!FA-1E9`4}t9Hw^7f>rL!x9mlW3(u{d%zuP3V zKUMfP4Nisy*T+zk@Dd96S26x)c8m|mRe?zY#V95D31=Ey69kbe_`Y0=!0u$AX=-h( zBH~rmm5kacx(}w-#gQr=h(oc!1Tion7MLjpW_}Qu-?#Ok>!RI@82+4!Sk4SFXGT0T_efzE zf;{-2+>&@+Ni1)Yn1^+b!tUkItvR|TnmLh$;!hI`rXkmCaOP!;E9f~$$!9YPV*YWW ze_YgG5ph@i?jvu`$nb6?U=P$zuU~9EHLG@9eU9aHwiQ24=cLvzu$?Xm*3Y(`p6#Ih z1vVUK9imIeL=?*8#YOClQC?tiQ}Al!g#x?*KLR`X=HM*JC$=FX7!ry$cGiUjPcQ$i zE#b+7L`u5EO74Ab1ofvjrQC^xC3k?E_owblHCy_9S*i%GxY^7VH&sb$lzK1sX3(Pf zn|j$dz*kGP@Aacb!3SLmw5#{n5y7GV*H5L$^z9o(OBG0p=Pn9*}kq>@>m?J`I@4%zQP&E)yTH}3G5!}9|$o@r6Zz8*DhC$hg6_i(ZwA;A4w^%eY)p{#Ri=UPwY=GM-zpPJ^zzPZER3wX~lQr@T>_D1yP zv6*@E$P^^`zBq;NnMhRTHTgPD40Ge+_-Kp*wDFqMW%N~LT4zn;_?WymdA@3Ch=Lmt!#R~YmkpxK7cW`o7HT?Ix>;q} zmFVOruBnr!iRsgNTNw`hFbF*?JsUe&ry_z>xyYwfKVxJ(8`LbjZcS*MtCa%n*tX9t4`jLkFZ+J?{chE} zg}apsZw?nA4I11rip_#)$1t`Opr3@3RJ=vE6U}MVs8sF_%Dg^Q68sTe{$ncshKiq1 z@e90f3k|^XI3jt@2c_6qiMAElvl(s3QX&q77I)h1Jiov^8;n+QDrj>Mx79%dT8bw`8rgQh-)kaAfU>RxS=)UpL!& z%Ic|eIZvfIsLZs{k(?QI>6U({3;X@)sdY1K{lQ?}6kGok2kp}T)2MWeTr=k3 zirg~yS-uX>r)O=URDw}q^yOi&xq}*rJNPS*9ki<>xu1(w( zQ}|mVn7^aq9nhQ{Y^c)La5%d}P=|o0O1HHjqt}RxZXN|0#eGFF-z3pDsTcS=2m~4q zpOIVGJE5<-ukggm$i!u$cX`CVTy`gR)u!qL>_{{{h<}%-;7i8XX-VTTWcDZLKb`O~ z;GM9Us-pkmt)z|6am)mpJg;W6M-$gZS;(^4qh3^LXOB*es4|~DngYWK?+q7K21!Ms zX$DdC2Hn(81zVpqh$_i*ot;uiiDrR(nkRYYGC75VvYg`TZUKS7Ng|40sFK)vkl1M= zF`7(rNo4}qtxokoWLiDx!(k}2cDppCnK&EH(SW7V zyoxfxnb50DcgnoVuFR`sSTTEBA$7zPZwwxOn$N37h7}v8I_s1z-rLe#N(=qPlr4h( z68;W_0i_{Pq?sQ{PT!ho(ngTXKNHEUp*OYsuEX-{K8_O_+48HqSqqa=?itvwU`%S_>ux_9M<9}iArD6D4tglS)vl7J;@KzGmz6JgC z@6=E0@mg0?LNudCUvt#=8SVJ7e!zEQ#C_wL+``Dj<)U{*#J=K+@b9pHw9l6VQ1~n8 z;g=-h(*deA0<-cSq?7-P>Q*)jdaq;X^|uG#_%82Xa&9z(_++-f_PnJWVNLp4I3P+0 zYMa`N^`{J(=9u1K667W~n;e00GrHEuw57^LN$6oSZn~nU2NT#8Nz^lXm?HY7^xk#? zoLi@*hlT;)4H5SZj2>2q-jxyiN=^@dNxdpWiR}zFUN(mvU|ji63RnJ=_RLzmSPI-s z(%zp%dwVKB0sfOs5X677-p2UPT+RLqvI-@V^ufmg*{?dc=( zPBCkfgf&OBp_-5dllguNs+3h|X{KMQpXn=rM#_}G5%)|c0(x8_pybjO0UzV1zPRq# zx^LbPt6U&fE{IkxiUrn5Q~oc!xnG|0ucImdx--5h2Y1JOTpWwOYlv?crVFN~}s#zXMFOua$1jSGzwZ4>1Kyh=_M2oV{QTNNYx|^;s73O- zuiFg#jweIz)T;?(mS)#x^z}r zWzl_~OMa@(O6%V5y5D86(xwXMhzATx)nj-CzHly0ZJ4i}0=&f+(>b=5F&&TTn)wBK z2CHKw)yyhtq&%#pezFb%0oSf(_eC4=QNiTX<;?$Xh(hfkpO@U(i1 zrT==Me;< zGPOm@TKC))-aXuOlgZm|i+ZK3-Uh+eJR2XQUm{(@8fd|=d)0x0MB^7mt=!U5=u|O% zYHzsjmT3BQ+F<(I27H?%?#+gEvI)eH#D+Ryuh`)J&)9p~&O9LS7`_=iARPNrbp8=a zHgL&%!fvJzz;+B&lUE!Hd=nPHV8wH5yd&T*G18)!V#NbAa#M;G|Mt6O?+D=+)FygC z*ifCqy&0&fhHI>=@NL3MD*l7+Wg4rrLS-T25R-GN?Y94`|Y!h(nX8wElZmFB(Y3L{nQXT_Z%XY2l4MRMTL)1&^X8mN26<) zqIc_b4bv-<=o+KGc0^sn30=dHOxMsXWGKu5;&&PXHX*~9;63x9yI6@?2#j`5*JEyM~Pe|o|Syk!(6XVGtukQS=bH-z~hTj<}i2) zcd9J7Nw`Ri!`8z&H+qbdV>7?lOLWt};=5VEcD1C+GafXs-$c5SR!6v#2DnKX{3KOg zZ>5!(P*LRt-0@xFzwhGCPnG|e+XncLJ8hh(KDjv>Sa0UDQCJ$8vP$%?j<{Ekp4F^i zwW_)TOMkw#-fHbHNUd99>#qpbEdawgXn%p~e{(VG6V>r|dFS&AJDa_76m9Yeqq7@w9?i z+ITT-d{1*UZF0hkf>T!uRh>QATZGgzH9a<2s0A zWb*^Ya>Z|x(Y)S0NpWpT65L|yp2Ws-o>D{#6J!yA2D!*a@Q-~P(8THzoJ8+N7yd-9 zq~Nlq*K~%_A}4t9fR}KVLMbcL3oF}hN;1d~v*Dkakn^Bu(ITf&C_f%GD?L6D5qG0Vz z+o_oj+Mj2`@%G8rK;P!F{R|#AMk*drw#&1>t2yVXUNE3mbOhgqN$Av8uY68L>(gnl z9uo=eWohlWU*^aXnyZ}q@B*9K^BLKiWgq5>C^t46S7{Pn$qkdpSfhMOvYAn>O0cTR zIZQU|y*U|EDC*_|ctP8EkZK!RgUjaRYc`UrFLcH!Vt_m`T+zU1%m|e<#ucJ}WyHO5 z)O0${dTe~H%W}$Tt(|5)iY@>-exS(j|hP zpf6Cn8n%(7C5(3|hiy$Z7Tg4>$-LrI_5(O}pAG3qQedu2LokzVzioetl39TLl8tt4 z6Yw~`4#<-y4ju;hYA<|!q)Cr9bXsrQKb;(}Xa3qB_<_*`_s z=VQ5zuVwdT_QBV@cU&}YN;J3eqAi8cOaU!+<)3pKue6ZbbzpIAp7n?R+JO61x);0c zxkdpd0MvRXJkkbmwQk&1<`Cb(nK4oZIabmDVc5tKn5DvzXUoQM*BH%km2sE(9PkXU zXWxF}{Ht#ay>{U2d&hc9X{*LC8)E1=u zNNWD@`&8Ed68d!>_3H|yUy~0dMnBx)(E;8-N5d820%nX&s88AGg=o7>el1$ag|Xsa zkiANe9urDs$RrZ}1*<~|{OThM0X+%Cq;L&Z{_bwvTXS!?X?tU9 zA_M!o2=EEO2=_FH0Dy~*=J1y1U;Qcun86ob0v2Y%gPR+761rVW66nb{*#7TIZPEfIq2<0YR0Z5LlyzZ>Xq=f@ni-1bMF=}7*VSY)^-pG>A4&>iK z0FqJr9UoRqjAvv=%2xquveKzQAjx=>!0@Q;TQU%=>sZ}s8?e`LTm^cLIqj^*gU3ia zZuC6IhfJ;ZBr@x_H}1YOydD9+N{^F%IrMa&zPr7&G$3{PCgxkuGZ73uerV{`r_TQH zFsXkj-P$|4c^l(^pgM|WeU>x4YlcSsh~+C2LSylwP~DwUZLV?`e_E}@f4k) z;$~`lq8GG%Zc+Ef-dzK^i#ev};t#X)dnzNdZyd)7VBf&olI}Ba%PFX6^lizY z1M}+}cQ=K1n()b{HUdxJO)~gQFK2*rZ$CNo>~lj$et;N+p&uNS=)VyJ92T1Qgh&FJ zK%9`9nQ&BT4AwRV)Q)aCOU3QfBD7+J7AYw0wMQ0i9w@jSv^RUeemidfy3D)*J^?|b z5R)XtQq_sbW&+|dV&|$5SRTOkcqK8#!7jZ7gTw{Vdd}zyMUusf%ZLk)c3pV>SuTN` zjk_2!imkrkwl-37&%gfS;E4x#GrM`hi>AFoCmJB(mSsi*2k+qm=jk^G-#MJ{$#P{^ zxOMxUP!gH(47E8!zhph~4f@H5_c_|@p~BEhU#DYKY@uFS!+R+^zq_J$$w2l(;6E;l z+7}Ytj_Hgq62(9d zQd-&pK&9;U!k#9eF&kPsNG<*3m(RZY%)&AN6Jmr6*9hSqVc?xy#3|vQF;+IP;LM@N zzH;vEXL+kqa3=dnLh8MT2aml>o|!aGv-ZY%k(uD8x-`vg*2v$YMy6sXHS#1>2nof( zPiqGPb<~7WdmW9Jt&s(6yu^{t%0nRSJ9xv=&E^gJN60O!Ht#zyzeh>hjPOal^2$b> z%|P{5)GG$$1_cTbeH$3&=}nXqN+JGP_-dZCN5<7|g}c0>Vbw?wPBi~xpu9D3aM~kC z{YANT+Q3vEcW)ZDtajeH6Rzx>s+?|vCNR`c$*)^JPBuB|l;D5~ae zyhFm?w!68tfk{OpdRY?r#*+D{qj4{FAQN{l(Owu&G&0hQbb^X6P#>?w73kyqlJ1ts z^zcCbHm(6}`;b_x_x6F{0*JG4)V@Fh=w3dMv4Ugzt@yB{qIcxOcK5o@h_JjY+-kZS3QJwjs-Rjob zQfuo)yIPyI*_Ic1-;m@5V_P`3Spx{6Zi9?1c}fz&!X^TeU?&8mkVJ9khio&sc~{0vCUA4-zW;YlZCyGo$q{6hH}(6eRDJ5y zsZ&+o_nmM5y8C)}^K9uFB;X;yLmRkfiE*G$qWzy}ZNoW@Yv$NTEGbjjDXj6)!HLg< z41VMCsmG8R9_d>8;2j7bW~4e%1shcTmz1#!jJ zXCtfFWB9w;J9ledT=5u|IE+pkYLg@$wiyCidz64yJfwMpHU^qD^M+P}4%3EKT2@UP zS~aVlehRH*y)|uUC7#i=p_LX9(}vbOQxkroRr7h)&!XyR8nlCbEY*=+(+wt2E(=8y zL}M8>gGR}c=>a}0H*(iLB@q`hooHoEq*07gYn5KYn~Wwh$UuUlm(j?5pa-#LGKxhx z^g2-HMc6jxrW*J>>Fe5-%tx-HA^JY%bY-Q}gbYU0ZkVAywm!rq-q{n|8G| z-Q2WwdsFlFO%j=lB%*gwZsTS%5Zzz;A!aDOOgrsn(qGa~cBe%41cs`TKcG{?7}#tM z_cHyZ5h0i?)+3$96^{}!k{O8Wq|zg|=t=_TWf zt{YD;38psm%V5~#%`ROR%l+mYdX&ql-AvPct}1x@IC3HYs6FKu3F zeWht-^U|bq4XHTpuZ)lT4G$^O=MhFcV-#P6B@DAiK1R&e@p>l3mnecFPUaY!5W{Uk z47Uj}Tv&1qcN1Z_O^D$(A%@#T7>;pC%VXO1m6{sxli-my&;*B~2ux<~-yHjmcz^k& zZ(n@uvs?@KeZqiIT*tJ4{mz{r`?}o}tcw84r^hmuNwhXg`Voz+V{Qs*1PP-4$Prl; za_An}`pGoPtUMbfFEr^M-E^lZs4KmSo+k-&Nt>@3u;O8^BZtR9j%*1!DhD?H%8{v{ z_{NZP(|8J@ac1sQ_a3_!+NR;up;Yysw=nFjmc7-H{Niway_{dqybx;TyxNE>Z+OSh z4)tG-XSjZ-KH|W>@h!%@Z}lhW+}5Mk-bA&R3RW!ZW?U175CMf5PQ>msXca86SlX3& zuzV<@wMLnACDk^o(oHfhU-o2ID(LD8NuWgFkM{+ND(y%R#+Xt$-KAdC<%}VJb zHj=wKF(oN!#!a*frb}kFVzfcmD-%zz)l%0xSb$Jbmr|cqRSmM*OYncv9z*CnI8V!WBC?P72w~Xj0N^m@uPr(ogB=b=sY!ot4i1 z1KtC-?q8rI5*-nY!LELi8d%D5jWR~lWA6SN^q7)IRr(nXPP^BMFxKHNnwc#QVVq3x z!t2Ug680{Uy-UdJ%2hagvLGGP!seJ69`vYa<0^;erOapaIccc}o%9m6vuj zeKNhkY?T&2N z7T$2v`3*OX6!F`H^H<6Ft3vr}#y{D{YQ`;=3l@h9*2o2Gf(7e9Z?n=sZ&TBL|9*ib zBmbGY=Q_t7i>6OC#{I%jDedG&)Ks(le4dqRs#UwFrrH`0)l@T9QzO%fx@m&Z+i3lh z*bU)O?oT8E0Rf_<;h?B|R>y{K7Nk_IAP+sZ&fMmLTX{ z^%7(4i8^>IQnsTEQY<~K%{7y4ufb>?Lp;wAnPiKLKC^cEL`?A2C&G$(GSdB5bjANc zJJU^#KG}?crDtY2noT08^d_C4-2~C*bsBAksXPvCMse|M zj+@X+r@wz>mO-(SOX$!ZhXYSCIs^+Sn=a(rYhdsw}1a32&{}`Fyg@o#oypwiaWB?M1UB!gJdz7Gsw!?jW`Wr z!w8tc7mUoH#s@N@Dox|1oTuC`=JA-I{&#q`G;s7iXYt}WV~7a9*%Kc{V%`;U?jBFs zJ!yAR9AADor%KMLiWJqHxbgUnk-XAyUcH=GudtDbxyqe))ncb;zDcJgw_vz#s17XU z@TZ499q|+ocMWw#yi2a6CK9`rNbFkrY}vIsJjA2xThfqCrpeQsXF2D!;>Wo>S4%Qd zz(#t=g8pwn;;K}xK$WfCW?#LyF`%)4`FrI zVmlLI1q|RmH^cjwovM}q`-F&JH( zfAW_=A4Jao4gN9rlGc9DZi9uz$Khswzz^}25ebIaI_IEKirP#ep~zJ@9wXxNftWLk zX*9;02&jhiUogL^lHoYbns&8B0Ky*)0mx7YzydjEfkFUIuL!T%DX-ZHIW89|KoS_@ zr>TgqV0>kLxbix=^18_SP2u&QIKTcABP+s%3+2Lv;lh=2;mT0q>hW7X8Syq;aU>C4 zNFuu6yhge(kLbdZ=0?l8rPh{t)^m-nmI~V&DV~-R6xB$i;|d$vW6&J0hjRgkKSngi z%1rk$ZDO9sNaTkW&RWd^D^Gn0|3*`I-5my0I_+)v89YYS(^=ALP#bGaLeZtx_PaYStKTYGBEM{7cLHe3qGs9nytHUyg2$3_=VN7ld2D&`DBb@o7BQg zv3(u~b6j63MT=X{ks(Gll@-T&gX~>M+cMJ?TDA&yMG}D9fvK7{p2|gIv60I{0p~y^I zN1N^{gjS`(dEyjvo=6rkg3x3kErpUHDOu!F!QhLpI!a+m7G0$oRW<7a^N?gN3Cw$y z@n+=03a|e5PEt-qQ}?!af)^w`6>0lx6W~J2y>fe`G-@z2fhgA8*#ncR+KDYP@$Mu2 z--NV`2Yj23m_Y&cRV*qcU^6<0H^+)y8OXtZrS8l{0tXhKvau)+=^yAE?GDE>iAX6I zZ$--VgflDU%*xT*P9>dAJpDj0bMwIUlO+~U$?x(r+-Z|}kTO!Zd7#>oy1>84x`Vvo6)atHLNUW5J#fZM8X*+DWe+52g zh9Q}vIp{kck0%-}9_J^p^IjwOIcwe#;|eGVS_PB>tpZytsFARJItL6a__W;nOs^L| z*=U0HS&UXbbu`>Vq?w=2H6-~Nr@~;Q7+gb){^HZPhGg&LV61zGwAMNeoHNO}(KKWL z>?RK|=*MDdQ8ZVL2vA8D_0b3+Cs0J2qE4cOeNq5Xh3!4vU7Q?FQPw+{KM9`_H-a(( z$fWHH7{+va=~vWdx}8oBe@$nJD5@+u^KjRgA|lf)6w1t#|37pc?Y==2@L%wiX5`6< z$0?JYWutXL=K_|Ue(>I~r&{(@kF7fIX;hSETS86>iY7M`&r|!3?TfgJ!tQypdtRil zJY2X$E`;qB41JC4si88J3x^gmKE6_RRq6+&^TMU8<{%jv zmIOVGSBf)<)z8#e{f&z@E}4N(Q{aU2X%kNkUHaCTkxz?Z!ALY^EZFI}lgK}c1sk;9 ziBJ5WOxSen6v;VY=|rS8ISy$Rw$I`oV$*A~$Rs<9(L@A!8rBtTWBO3Sp!Erd!Z=o_ zq**si#9TnIKanY8Pge2jXG6QiX@q+L(OPcG>wN~wfXx3QHOF&93*phg}W_} zbK|}$X?oNfcg{`0Rfy8;4RB)1$Q{^B6%{#~nPZYD6*g1r#Hj2=B72OOUZSJRv}4@u zyL80J>Oay^08cFahK?BjNq3ZH#dZF7Tobk7rULo%w=^E@UMHGMArqYDUNhHOY02~b zb7>a1&Js(uYqHSdEg0TAv^P>-6)ta-%NrvF#V6{H*F{RoPIMjbij4QO8)h=JoV5=_4&PQn+Y|T(l%q)c9s)WcjLB?*GC4BTG-LIld-bxJWKs6e?T- zy3A+5M+%m#d_O;fSXRvZ{c@FMt;!`DoC_NKUII?2EbFEM>*X-yl3X~Ro@G@( za>GN)5hW>-Xf=&tSv7h*51NBHip2j~EUUO5W zY=sgSSnTPiU?TpLM6C4{;0pbGk9J08oAK)gW?Q90q~`U}#?=j1={nlILxg{$M);@k zi1{O#ZH-Y@SZkH#WzLOtvbRp>vDdbQ*X}&OcIQZPIB&k3H$R-WT+Uk_%3De6z{(Bb zl{cJUc>{6y#Hw?aIpk@iwSb%`*;v}>jcdY<8_zdx9NZFi5hovZEtFjgL#{7|R9(I&;q<)wsswXw- zlP5Tv5BKFmxc2S8^irc&7SGX)G!a7d6O54dJ3ya?z?_(HfZE$PAuB zX7HTZn!&j@b4!)=WOH`&Qp>p|Rve#O>S`&6M~tVX*!D(oA|038(9X;#946vQ?V4h9 z;lGm9e&^5sZOiPCmamcj`$**L>G?8};RU=OI4NQKbleYV1VFMJFtYrWA}f~xY1pQd zp*XTKkvJ|Zb1<9M$f884#iKFO?+9}lRd_H7M~7&47=}0)S(24ijSIioX5lxT*;v#* zjLyvBE{yiX-fDJY@BgHUvh>V2;H;0v{rrK>(XNKb&pY&fnE8Jiv6r}?a@kovx@v6i zsle%*gU*cuTPDjbp5osXXS&iRi^$lLuNqspT_h4~b5VXN|CA6^{x9rXDf?CieXAo% zoa)+jukSdsW8}{$Xf<5ATrOQ6DqZ~cm0d3Mks-hf^X5-wv%U+@B=8W5i6KlgW^6#|jYLuD4N7+?8fn(g zo+Pwwi=QkVMUL@g28&i2(z0pq`B5v2QE>JVS(tp==RvNPOUSV`__>Lp z5v_inX4;J=D_LK7;B3||23NYo)?ci&DBb+N_Ab9q0Ymuw*|8(t6Y_ShmOW(^!F{mscT0yljEG5y%}CX+=T54UuG#k~`I>#7 zQVu7@@qJ1@oO0N9Sa(BAJ)8=+mo&xg<$e&tw8LpabWalzGV~RYD6|TEiqR?%zR)V* z$k3_@TsnC~t4QCb`4j6szoQ(27fJyybb#?fX$FxjRrM~5;f2yPhZ9|Mbou3PBMs5S zlb@S->IWuatQDRd41b@)?F`0GQo)muwKpIIkV_AXAu>X?BMu+O?R#Mh;F_2O=?G*u z&IOspFCqpODGcLi2v8QsB&RoLKp zqC29osXdHY^w7Eoj!50OUMPj`K)XXkSN<<_;dI3n*A3;G9MWoKXYE++shdu>oOQpM z6m;GQ9;%3VC~rD=C=c;axhfB(B<_gip%nH=VV;z19%e6;>{Q}~Fd_m_6<$a;tG<_j z6Cd>C;?W*$s%>eo{;VEFit6ld6uR(ZdRo$<p7}#rp?JN zRIFT!`evpC0dQ<#su7I>^lDhI2OBA>k=XhhnyoKRc6V|84VDq?=+`G+J>%QW-m6c| zMdf?vCtth#(z7P?RCOg^q}B6#v}5_6SSD1i^hh_Y+0ha;ikyw#rhdn^9-i!q2LqXg z5-B{CH1KT6)|9N$V^FS&OLTa|_mv#Lux}EE(MTM_;)erG+F8{uMEg*&0z;g*7Vx8~ zLJV=@xvQW8VIcrnsN{9j`TMui^JW!`T|<~r(wu8OxuL1d@-v?mhd(QGHD}p=w#?J) zu$^-x;`m&a4ehf;7w}VX`j9ARnDkxR(YK0Gerni5BxjD;Gp+K|oFSY+I%dN+z0yXn z#`Fu6fQh}Nkzt!IeH)o!f07`C&RA)NI|@y__$-26und_X`~@2)>I}jSaGhd?Yc)Fp zf5C0t5@i+^0PYJ)JexkC4!s9xJ^2kDW(vb8IcYVZ-5J2@)r086VHK&v<2WTw+LNKC z%kqY^>g6ox+DgZ~XEP9xl06>*DcNa96Jf;2avx2fEi_)O!c8A7X{xoJbg!`d$clqk zTvVUH;b|(f{j4mJ)he)|d6uBa^D+Pb5cY`g;;BC-e)=Niu~!^!1>h%T(0AvgG%m>T z7a1Sqq)ZGl6{K=$XxhmDDJhG7WnUZ+{59&{q?NZJ$d_v>dVO!3%1iBa|)=Gc<3=9%I!cZJ6FAI~75pXnli> zjBq+5Sy*9tVkwi8(^xh@WwpD7Ke&&J%_dUN8cpKN2KNzp9>OR4G9TuvmnlLaWE>y& zdwwC&KqkuRg6)`m)QMPFKZ+$O@A(BgmpqF|gU7<>shWu?|C5v7TfIgqz>D||(Smz0 zBBupYEru76#$@}@_NQB+F&o(Owvxb&1te6;&dSkS$37Kwu7JBOy(?eeiRe`IC#Pyjp$Q#Mz zjIW))6%&rm@Y)p$s=oGeyy%P?<7@B0EVPXPo3^W6!05RqkB!aE%u>iAHL#(|0#e9o zebUzAwn`s|p%3zCYkgQcS*4G$S_7pVR`Wo!nhi>yOv5-1L!T_ey|N8`(hYrb41ID9 zeKHJvT!ubwLm#K1kH^r*Yv_|{=#yvYlW*vgW$062=u>FulWpizr1rVj;xGQD)n8)h zmlMbhB$6o!X?aW4e*Q$i&(Oyea3gDZ;@$-6r@hfKt#`S=bo%XZ?`&sDib?!6 zb-H!O_Nd%Gs{vcD5$J}~QOY6m-nmZ(2B=G^W8q{7~4hH}1#{p=%Y zn|HhTyz3`hwNMqAK<2*fV8!1Z#RFG zKK$y$FY1BZ8%f;<16}ZP{bn+F0xEN{7qL?P>HNyw9XQ(qmV^Cg`JE@e|BZ_S&tDuF zojCQGXm)!qB6#qUp?I59E!Md?<2 z{b-KA`#^hV7u4UxmT>iafiTNEiwuk4jq>sw1Upg{t!DFRS53Pb+SSsoj&}95n@_t1 zv|C8K#kBj7Mp;TnR7h27q$5`8V;LPSr=2O&bS1UXt^(A&hvM0hTilf&NpXzl??o*Y zcM=sxP|K(kmycDBuiYLh+A*;8XwTrDWBoz<4ptE3^ukcVR!U4g*m&&rpna>jk@Am^ zum41-WS5$NJZRtbR%zwfp79O4L#2BLwhvZ}*oW$Z_C0Z0i^^yQJ3>XR`V3m%@>Pxb z$FI97F6nC>laiH8tRBCq)Typ7|# z?hfU(BbENf!Q5k8gZB2f8X8e;>funsT{Lj-NXuyM$n`@HkJ)AK{Gk1=w+ek@uJJWD zh6<^W#Kxd~=UaKjqxYR|4drd4Ck)!RVQ!}@$G3NcD*fYj&xn0w_wm$_-Osag)8~j; zPQmrF?dpU0KE~zLZ@t|Xu=}m7ctN5+fk+_Qk_>G&r7hXeW>?x$3~h-@TdJXr6{Wy< zX@<6BrJuvl#>!KmU%H_!Rq2;uXk&{r`Z*144y9kFp^Yuj=$9494%pcuqCO$3V1hHW z4A}#AWrfiBBvTpRV;G+;-00_>GJdwwJI~OYE#Zf(wYi3c<)(w=Pv0ud z1z8!MG8EOZlUU)}d^4F~chM=t$449(CvKt;D~UqTZWU38Bt{`3MLr$t=R4*<;qB9jRA#gOt+shsDh~@b~(K$Xs?JgEEVDD(n$F{ z!q1V$p>09?JkxCI8#Kfm-H58fK|69+7E?V9UOl6j<0`$F7+lp@-(Le4jkwB~$(Ve* zx7!(W!>~~~8(SELY9GWa48!XtFwA4u##YbZg~xP8H^c8V)YfnWz@lmlT82{kgkcIB zE5n~<=$*lOI~810doxTqlu?@#&D!0vbw@0}MWXQKGv9gV)z>tROXr#T$O1>#_%1&4 z#fi_Ky!4%yCZ0Jp@$5h}qobXnDo`|NgPK=L` zNE^>nOAJ17@q6ETclhOZzxHL=?t-93l8I#8ZT(C`**mHxvG|F*y&CAn?w0qc&&#mN@a)ZLY^jGNT-Ra7fItA zZVQ#&K5ow+K~c2ZXIiIf7M$|G=?vA}f+6gKyGJSpZyj=uy5y{?ko^`8-!!!t_}12< zuVJo6_y#?l{aTcw(PwonB8Z(z(bZMS!Oo_e=)Jl=8P^!LK7m@N7~xVS*QSrez7P8} zjP5$k-FzAPu84qMN1vE>H`6EH$3Af+mb?|0(MKHg52Xd|Ws#cs>;sOvj)RSv zAD4R7%%AevsJnnj(amE%qDG|7m_FjiOQZs+klWKV=`Afl)vX-LM^%fI=|sx`ryZx( zo1|*B4`(0t2Rl03{5?Iq z-bya>*y&`(dj$D)`P(Hw5)z5XQVT(w4zs$cok;{V3r>eNQa{sD-%M?^Ya>XNFj=k8 z&?iFqyEy6DrI4QML!Pai^lVj1scjCqws3;A<*ll^(~WQ56{@;}iUbZ;4E7Ax9J_18 zFK3ho?RUI2ubvfa+yw!n8pRsDBe^5J#|z%43cj4|&?Gu=e{ItswjT_Lk?hO@VrQuK zrHo)ue&0S*Q6j49sl%z&=v;O?G(5l^G{><-YX;2)yn5x<)wwSVcqDp}=q!x*SbEQ>gVR@)sNWxY!&@=q$7= zrBU}Tc#c8c(cQJPdw*xgAqDH0{C(+rU%m8|Z$SpW^!zEl;J4#4UJ8((GZ)F~TZ0a< z#k(NplS5pCUc{?`?$>*$p`CEOZGZQ@4WuWakk)pVcZ=Qoo!6he^p)ciyfi5S2b4*& z@}zw}+yz2Eui7Sc+}DY!ZM^DvLwi?u*P#QQ{fLf*vXZ0k^WR3Kt^s|cX()n=d*h*d zHuEk_iE#1Q^OsLP5lv_6gTRA<4k#N~(J+1j#ooY@_-Z;N`14&pId=J{Kc#$E(KMwi zlD@nPO@oE0CBP{mLGrr;U%PbTTgp@9bag}dbExf}04NlN@E+=6H=lUzOBYZ6IjWLf zdhNw%Ry%(lyccMRFvC5DJ`L}n&1Cdf>dd$42l;ZW9c zUaM?5uT|C+D%qjS4?DmIG0nuyARGlrM2}R>M4@lgcB&*)a2=Iy4BD?#YT#`Pt-8O)9Qm3BY!I-luFWIK-tkazV41t@tykOd?!t-iDdhd zog^RFNVu=4IpsRLK2)(wD?6)dHBLVqDs5$QM9ALCDH}a^3}pjFX4}>G@y|%v=FCv( ziUF04N((@255#AqoElrEiup#dTov=PP*PMCxm7VAg1XkGiupMKeLPjn&o#6$3JO*& zV+u`C`85WO3y=gL zL~0c}ky?dLq*jp=DaI|*@1h=|RS=kiR-f8K|Bu#k{g--#)=KqQ|Bu#r`Y-hetpH#1 zh8RlaJy0g8eUwt!w4rsrsR=*Pnj&l^M%c8WuPC!=ynIpuCtQr+GYyKLXcaxZUHJ6& zeT$}!u#XCinl{zrf&!hGTUMjmp?A4W%O^l>~8_i<*A!=8lA zHExKh;&Dl|QTJztDoLN?Z*YnQUUN@o=Eoc@nDQ)CvHV~+6jsu$pvs@81*HpbNprre zQL;td9CA{?!8^{%pmQGFlkJsoXrqi#3uX60a=vsI4z~@pA#gP8nJ0VZQ4ZJEp;q-@ zUP(BwPR^@iIbP?>`A{Ap*(*EHuAorVg>u2dNJ%BnL!u9+bw;Sgx}kM?kHRvZ#6%yi zv?^S>QZ8LNS!MAwT(#tS(vGH0&bQOJfmS@wW*vA#HDDl z#EimARX#~c;dv7c#fl;_>XpUIjB0eD=bFYh1XN5ZD|0*DC;{3MQ^vYzzWJ6qjkyEY zyh=3Y`*>E(Q7#URiBNFqJlToJsGxH(Xby6C79)ozLLy>wcq+*|m5>Nts5DYoMA8F3t$}YMgQ5jDWku88^-d_-mZ;;0&30@tgpDL|mTWSc zT5>F@MI$%!ijR}I)Z#LZ>ZTTt@hUI1aMI!ArxTvY0Ow^pf&@a(S)a5X zNjaP%S`5%CSP0N6SOlg`fs{-E{VXLryQdN(o66iKFMj>yi(fruAZt7Q> zlTbip(nkLmuXv^K`-ML&4lmv)FWwkjyg6KY>kF>2?6Iad%l>-7sC9JlNqeyL)+^K* zOktXuzSxvVO;1FYYzLNF>`3@=HQD03&HBMrTH_yWTHoTdp0lL2q&N|~NBb;l5wl}| zi!1(ZvtuX>3j8r|It8$TUE_f`EYv22!Slq?(RYtOH!FA^2KekjHIUe~(+IlU%4G!I zc7Tt0O)HqvO$WFA=$})IYwp8y%qSjQ8@SmWVSBM`FCOU(*=r){aHbr06ph=9)WRR- zO_kREwSNRF-=I20Ghb!yXf$!+%%zh9%+WYSbux+}{zR-9@&6qT<_K6HGw}!M7XsGd z1l80`*+Vo?8^0Risuasx)D5QokY9-o>xK@=@B!~3f@R~wuci&E_ z`vGK;lzf`|d0aZ%pRIl}@JS8iGT+c|Z)or6!Mx8tXJ*n1BA-e}54n3WcjxIq z*>i88D|H5#@4%`jZ;eBa-wZ zVdgGSF^mOr=7O=#(|N(nW)#9mvgDUV@_d8+<7KGgda8chy*}b88N7YGbkV8&Q;yS( zm-X+x`kkwL3KCPk}29!0A_7DcOo{eU$?nHy7-30M(emp~Rpt7z^*=~T>`p;a_@ zp%lAs`jjF(BcvwD_WIOU+N(*bLaRfS+V7gI4)hjPs{?st8q5v`HJK8)%|yd^u696I z#xXHzOBW(pcjh_CL_%&3$Th8N(HvD%PJW_XIOWn{yt^6h#n?SI_%-V99fULL-F|}? zF-3u2ICWY&A$bs(bsZ6s-{R7lxV1^U8yb8{47*77EHY^DH8ngHT&t12HIed~aQS+< ze7&xAS5Hl5TD*&|T2j1eM;(yHa!Xj5i2TZM{z5r_VI;4DXRM8Q%9KJ9#WmsL<#O@z zNY$cn)n>VBbEKe(SBwZ3td&c~Bgcy4H*#GYQFFE0)3VO? z#=1lt|2)Bl_80{^t!->2K7%DnLEzF4*y zaeWpyW2A)cC_Mx;=i}SC@E0rcnZlU!7~`SS6`foh!8rF2xp-Y87c(f4>gA03F({Fi zGbNIuH`*3*ZXZwC4#vV<&rvDkn3Km%Z)$bNvTDVwwx%h(ruF=qR#ov6&RZtuEeqwX z7~gYigmTU@3!B!R424w0M$^AsaXW}BacEpgOMXk?OtseyT!~R3AH$vhr)mZe@$X1A zLp+13nu*IZFoc)-33(U;AD|=h7o4JxiDsH`hU;c>R7Hu7Og#88o^nRI7q<|pTneX- zq@(4dxae>#F8pf^Es+FiI&q*<&Oo)hU`9h+-L8V87o0VubE%P?HGgWIi`0q7>R&Zm zfx(EH4MbdHBnZt)_9kA@9M|lR*-C~rO(;F9rei90uB>25EWU{Q^YVp@#|%%fXd$5l zOwc@4kR3oP2iZ)vF0qw{El%hg&-NhwJyxtK{AQkM03A#}i<^9<7MJ)nFr8ubm_3-1 zX2;O`f+Kri#|Kin4G=VMMhIdL%3`M9dG)gwM}B(gxic4Ee)aMzkMXw|14tzFG3JlL zT|Xv3f*`6XN}#F%Sxq$LpQLJGB2h11NdS?UnkZ!X;*}2&h^d%^faOE0Ks22$AGhCb z$Um9btNBzQ11H`uZ84q_iHdGLiFu`SPYEO7n?PPkKN)od9)$B*7sBfhum5Y}h*;2m zy1%pM;QsbQZC&jL0?{POQ~Ln^-z#qxAC_?LJStFVK#0Xe{}pf@NQVs>GlodIFuI*1oAYX1bw{V^86VZ`T6H z#_;O6KN2=4#^xPhvZF97Z^(OA%|p+>LtJJsz)k#dO{763cyDaXS`Z~qT1EuF(ELAQGI5igqBE} zRk3aftpcPqXq>2`vjBn7D)4bYt4OOQto}TmvlPY=JpT+=2%ZDu2xLLDYI-O}PuMPQ z=>UxY>3zSW96~lt#ArVKjCwTPT#-IEmHFUmny+5XC!?3N*=$p^VJU`;XUV-lW4;|u zQK7+@1I?d!B<10hDL%OLv`Tuq7f)V(@gz#xPrN?NLk{10^;N_)n~9{N8DqhDdPv@R zB1J4DkD~FkqS1^QFm+|&dQ63t0HV$F>XMQSVLY+Yte&)&MJ%!^?@=xFA`7767%j0o zXm>Y=)|_P0ZS;Hn10GrWZ?wBWy9jphieocG}WIP9*M-SuNz!t>Y5^VbL68;+*E zom&{rt(9|Y$Ew41tL3`Y!Q3@RQ&@`c`EvGr(z28lD zvTqG^YB>v_Q_D$1a&hR?c%2sVfUTADYa_)K;o`+|@nWrVOSoW}T(B%yutKTb63$yJ z=PeHAEsa#vhAY;|74To>wOvTw-hO_~c5;89nlJpyP{9gRN|-v_mE1JSVxC4dUOd-U z<7LxQ>&cC!o2-^!Sgbhyh1Iobk?j|5&!&3YFX|KNc##e5F{~I-MaGKN&7H=_!WQK)@O6j5BzR$=>e^QN*axJp2&r@M;c z8?-_$iM?fj2euiQ$0UQBt;#$m_i-9YZnhIsXe2M{Vxp0)YVbfj$`?(g>`!ey2-T#- z<{d~N%hh_(41f2hy7qUsQ;EBK4(U{j9C20lg*oD8n}}sJhjDpE@}s!o>XTs9kRrP! zod$mylu7yu?Y>GoolF`T&2xBB21aw3dj1A&Q&`QysguyT&EvT2y;@xLYg{@bw@J>q zq(~UcKeZ|7Tn$f*cvWA->BuA8Yd2i9NG@6wELsw&t_fGKm8;jPNT&= zKQn)%{lxy``@jS=W7O+?Z?q5$e7;?Qd*w;kbXW6 z`SWVWF;opp5~pg-_^cn3fN53v39>Q5$xCcIoYBc^Mt@!bUk7hd^5+#;8uwB3kZDsX zA+91{Ujc2K;VSY27VwG~kNkiIye`g=AFzN1;tcr#3#<@0Lw>*l?h|L|?F#4`&X6Cl z&-B3fi8JH}Tt4*-D66o2x_1D|nu8I^OIYJO^^w_#g7#UANZ!R7|EiBT-IbUJc^=p3 zz11T&4?+8MqbW~D9?Ny=IQsvmRqtXiqxRg&BcS##ef#2LpEXf?Zs^I{#1eOy9gKD2 z8%uYU72sfd?|qU+f}<%cLw--+0Y;2t7K8TQRs(US(qB?Bc*$Cic^$|i-Li2R;H)iE zGU&4rEd|dKS=S~beS?0oMd1V;=_`YNbH&s6Yjpk`aoH@PG4aV}uPo-CVw{z}ES$vE zE_{=oH#FXO21Q?$oKZE}8O&G|S0BAO@$eX}$wd4nUt@vi zZ8DvL+p*$?9Cj^`T?>M)hRIYq0srHYkq5%1tK`yElMZTu|FPY;DN0BBX~r<3ORp8D1X)X zEuWmsr(W<%cAt76yyE)vE3S|ADh_#;zUiMVq)u>D7Cd+{og$Cr)63%<&n8Q$7rdDD z*_Ba?B^#zP#uPldxp*^@Mz8;)7P|(O35rqDKoQ56y1jstIWBb{w@aH)yXsH6e+6(P z!zi}La;naMdJ49NTff5*k6A-|F0Al zqs8c*(_gs!+G|tE;l-~$PFaD-u|ap8;byRBh$K7@Lcqdd`;F8Q(XReONNwGIu(RQT z#SN-!SAQl?ilS=i8u}9(h)Zq->zl3u1oN&VN(71Bq%0VvJ2EMrbPG|>fp+QsHicD#XFHuK{p{_a|`xYk9NObHhE}TimXTHp{rehb9 zGaf(k$dM7p`Q!@4Dy~wvBuip?*j_H%sYK%bkbMoPR$=|vs@*7wQVyNYK#%ks z-dqfmO7O`<90mBGotim)i_dfyS2#Vsv z+c#}!71@M@F&OA8V9e1fpgL$3u+L}}u%~Dhv69uMsp6-~)(yAf-udcv zLRY~C_3Smd3ewl%%2A%9n&)NW*`G{Q4>o>Ad)je{A+}(QMqx4U`b+F zsun8|TkcbsXCi@7ucCw0aI%fzb^*Pi-{k0Y~=AS7?8|C80h}weMvz}#FEg38efNx3`09Of9y%tyVIJ1Zr z->*gdYk~e+=76aY|8r23=2%a*tl#Lf{JhkfoJ`TA>u>?7!m(6KEqszIJ_3(_*AQm4iPkiDf`ezqdyfh zMQ9ahPSGmRAfQ!5;)JGbrrQ({{^?BIq}v3cqcV#Rv4j9(Gq+e%$q5!|DrY2ETCqhD zZP3U~G>ds3DoNh_$nsX3AU#hg&XkZ-Y};O@|1;4a;t81eIqj{a?-B#Y$cs_+$TGI+ zvL(Csu#k|%xwTG6Uc?O^CPMNGuAfo=NRbT-<;;a+hfXgDW?qlT23WgGVC{CBt=&q+ zL`4m$AWf&I!0C*E@gBt2T(*wj! z4-i)+I1L{M66eNSjgve8XDC5{#@VUg!D87r&6t`3FVCRBn@R+y$r6yobAXx^csfhQ z*f>3b=56`qS!cqSrx$e_k!baW2W;Z*kfL~n_m zR1*eBF{~RCLMb1-SxG31y`rr*%tc?KWg$(ANlsRfEVU1jB<7AWrYaQzHwf$C0G=uDDE^p z*>3s^R1v)uhZCFe#P{kES z9NA$<`FTe(8lXHF2{i*rw%uDpWzoh7>R8s5T8A|O+(&6=)Ua4sO1^#!tX!JJ&yQ!Kd!!*xS- zs=H2j{`Kx1)ekR98`?c3DU06p{_DK&tNQUf%vqtpPj&svHWF)Q}BxMGf|7|&nj zBeLwyZu6V%=J(L)_& z3#gFo@TCpe7aFq#NC$A&{^mcPpKnlvwHROm8Q^sNiMiADe`R1|Zd4+Tq|gp>EuuXW z*99W~bo3g$Y!dDv67r{~@@h~x=j2_ZMAmN;+oRR`Y z$C5lXW&0E~VatSRG$xP}m#ErH*m+$bCmJ8MVJ1^Fdu8$Nqhr|Xfb~m+VPhXXhNWeP z)9U23y0KNkv}FS=U=DLV!}EsbMcjGAcMjbd$#E08jx?@#rS%7`gB`tpNTY{cNR~$*iH6>~G&6Wg8&9`9=cuMaqhWXMoY#)J#;>XNg<>*BKJ(}(dshjEt!1r)EgWDN$Qrk6C(_^b^>m$Bg~4=Z04F& zcI+ZXg!Cyf`olajn5Hm=i^iNV;f3ku2PVTxzd+BZbzcveynM>0 zIBg3ipi`$eah;_e)aiHl&$-U{PTSYWbvkmwj(M^JsYg#$1sy8~Hj;L+0NTZDvvyHY zBgS(~T)*MMHE$XMzi-w?Oi?lSv8RsmS=lmAAQzK zufKZvrJ;Af{35UI@+~y|lioft@Du6RuvJU%+0afjYhvg%ss;1H8B{^xg^W@u46Ns1 zdq<%4pUAiToztI(p9EYYF2D5h#gQkaUy_eOnz?)f<*t`wm$cW2iy5slX| zI8Ob3$xdK5pFnXNnowdS(>1tz^!`xhl7W_^xk3Aqx3WEhJ!6hgcH_XNqo|kG_?Ek1 zbmi%iko$V7ZWy#*&#IV?uih3a*sfMM4B5By`_L`KUvwvKca2|kh7v_6yptO9Rc>Ml z=@v?VL(e%$PfYKlRr)*n%}NlnH<==QlkC++=%Np3M?KB&Z=A-V-BNmgwdjWTH|2E| zl#bcsydAn${B~%z*Z6j<_nILd1)8W@S4@3Gf_s|=_f%Re$EaWQAaV6& z))g_DC@vJu?ddysuv?-vNbTJdwcWIPqjBt)pPxAWNK_lPqfe4h6X=j>Sk_A!)};@t z^oD5)p5}c!d_k~wc&x!jDM4ha@HT1toK!)kI)=sI_$7n=|z*dLszo(xc zVniv-PS7q&yZ=hN3$&wD0|vTrp4w=)lHmS}I0?9?g25=>TLhJaYp{c=_g2-N+IVXD z_!bm6YoqdIu91%8`9XUdb*(ymSEy*a(sesZot?Vr)RytBcZcS+kK6M`Do1*b*9K8T zcxqQ~(dezG*N40}P{pI5{RW~wY2(-J2^HK#m108noA^v=elfTOdds$}-^M>9ZpAGj zglfQqKU&EiwMwwDBCuYW(cD`vC!`nsQEM?8>jJAqgp758SrH)={Ye#L1Sf2*68nFf941)+qGsgTb6McM}+-Gk7tpnZ|K%Ywz)haUCI1t|Tq z*xV&vC7_VKihpC8Ma(zW_=Oux-*^`M!j#nol!Ye9*%fV6|xN#7Ot zvp$q9UnspzFN7^G3}gO*e*QPwu_cBOi1=aR^vu;!S$`c9MY#434Me-`^tP+m+kVSi zG_rfFE970rabR6M9H8sO@2Be{ct2WEyx97>$=d3|3k0%t18Z}7)V;Y|I?&#WHyPLi z(x<*aM$WeGzLD3E*%TNz=z^z|1}50cYRPWMw(0?NWU`+RsNJ_h$R zS?vQo3^Y)u20EgHsDqcWZ0kLAFaX~u(O02c8tCJ@^acKrb|x}-k=khY3Hmf^_@{9f zj4U0We_hDk#JQs;R_7c9tMCS@`Z&1p*ghg!pklX=uWJi=@1{=}vfs@Cnnn|Uu#bRU zN{Rhoj9tp*pQN?#z&!y;{UCj$)5H2Q@x5XG5pDtP6$~sNCki@Y^|W+%JrIz3i6Rg) zq`pV#5vx8B~gXX}lv@rq)#;(>UEELJv^%kI!Vv(ebNY)D-NO@o`Y&(eo< zZ`x5=iJn_9rl!HEQ(Wk84LNTcPr2 z&|S#&j9!0w)th~?@*fcgEUGr&-X@}=l&S)gU(@bM8uvc$E<2tB+h4M-ZB zL(C4XBE_sQJuQ6B3BIv0pYtq!Y}`XRj8|gb&?>O4pjC9|c%JAYWi5$IrtLe*Z#YsW zY00lhmBqce8@M5z>wviN{GSP2M$rS#Fgolq^#X%7rh`c0hIIYB2HUF9`S>EByq0C8#CAaRXB`G&) zU>kg4G7FySI@T4zzfT|gbi`E}cGbwPnuyyMcGt=7x=40WIJ;cVE@w$v=gXe?Vb5~e zvpncoc_ky6j4H|Git=sDwL<|c%+`P;>+x3RNJJ?}ijdB*#E>k9 z`guQsbv+)*!U_&f&h{N}+q9X?tamGBR?+$^Otm$4lPpFMt?NRA>$K#`{!B8ApZ6%n zPtxhJ&0zdARzXbGa{Dp!46lNy^Rbe)psQKh1yn8S=|WVXedH|!ts zNARz2s830s5{b>>rzG|>XG6(M$#A}B!%*+%S#sQicLcM`uM|3IZFgdAe=h-Nl;VcV z5m0)%sj7Lt{amq&f=jjX8mdgy!yICeTJ!SjU!M5-H!i+Dfb5!@fuIAVLEDJYV&wE7 zTIWbkt>A%0EP_Q#euCudaA>e84h{DK(}?#LVcNfebn2LyM|)4&0b0pbz_cN&W-2gm zAHzJGA**JB`t0DN_4=bW4fDvuiD%U`nQ=@!H8kXQGLEn~M1%ywaN4$?Y)H{0Lcrer z62%ur)9^zv5=9-P3vAKise$^d*6u+>S`~T zX?WfDG(x0yJc8t)qjcJ_IL8b+a?&o7c3HGzjs|w=OHjJ;Bp2CejoN8YQ$BQO%-wn< z7drlyQ1vfxs~MT_EN?h#ftVd6r|IRN*Nm36hN$Q?-Tk?xfq#jR=0=tua6X$ow)1xT@j_Rv?5&EAeS~ow1cv$aM@D1Z0S{{7O6+6MS9!Q zC9$N zeP*N)n(t6=&@q2txb$i z?)>4qhVF_ejm2f*;-zx&(ulV(?5&o))%rnUX}GXKE^LV87l-rf<^1}H)}mddo-%k2 zap9+>u%+0*MB@v#FX&*FxgN$uU;6xyE)G1x-G4ryX!u18rKuQZK6ysRcd*KTq{SrSV3qF)|JC@97 zw(fbszibc7>H?r>Q-Ff6-ElZ=f*!3SB)hfAamFk=ambyS=sf#m- zrOsq5HEyD?)Q!#a5e!`3Tx~y>=fZJ+k-A13k*?t(e+Py8Mibfpn3Wn&%`t1|+%aob zeDr_(m6|7VW=fT0CeUz83-<@&*fh-w>fQ)|_i@ZHETX9yO<*ACUH zzluwhG{!)>%;T9zHcYsa2Hd$yhVw_8E1Tz8`>Rz{QL|7PQyUr3CuSkSvvG9sLqeaU z*co>rQbjKkGG{;+XyeQq1U;IiY178EEDZR;2-Dp|Sdnbn%7`h?pfaMOmnBx- zSbNM!NQ~vjOssgZk&qnVE>Su`X@#cNw3_}B_fa)wEcBf%wB+*?O*<&Tg+w~k=I@B6 z_aJF83E}jM8^1EmM8;T)%t&j9HbgV@!LTYtGmYa>OcR?{>;$;*Koev)yV~8 zizX{iDb?XNQayH4G|BVn4DH&9LX~69n~~)vP4ax%Ie+Z7pmT*$i@ZDJ+%ulC2NViM zvg{f;yJpNAu3azJt`BB!7`WlW+LrLz-RIZtKAId(DV0-7N0Y<86|!$dC}rjNZFeG` zJ24wU>6xCV_8r?7aTkT%^JMqDh_@u{t&_cVESdWvxnR+iEK61a>~7w&;R8blBBizA z(q(e#vPfPz5w{2>5o(Z&8s1Ay%y1%rJ;!q;$C6b<7gR9TzqCGFx^Wy3;8f)+Mro)*xpxYR^+k9e%TfL)(1Y6uE6HQX{PcOapBCjK8#AP#! zZR4>m8WzGbEJ6({-adj2Ov}87nV$n)?Oh}bYb>7fKi2g=s@y(Go3OKG9d8@q%2N290$elWP-uwerG-RQ^Vd zwVKM`SO?1ZJGnl3_oq05oEJ{l`zt1#x zEF30Gq?Y;llYTv#@<2P1WWh=ZlA5XkMH~|#C#L$UZLk2YC#YpqKHjaQ4b(+ZC$df& z36-v+QwbmqCf+E@&4Bv6n$huX!i&F{4PKBLaK4;1f9&Q{pA2RpN&$vIPpM`I%ppTy z!HJ~fNfD*Eo>uB`%gs}{5 z8F!-LE<>o=EVHLU(vNCRrt}^N{+)>&L4E>*kp+C?tbQ+zd=3C|p zIu{x>0$W1P>&H{BCnH?maQaYs1pkVMipkrMeyKt>oN9*`4lRrnA)i~LT!f(fdEwI4 za_MTlg$lNGg?h}nHd`4wZ# zFC++OY^0)rP?<@tmM|^LJk+CfyCLe^2h9Qt`!-Mmr$I9jFXlCxt44uCAECMQXpIIx zAMSR;%;}o)gS+VEn&}3sEn|f0Hg$Hhn31%VR`45W#~ghGcjRgjJJ-Z$nnvj_;Vx$4 zfVa^wv@2MEb3uB`mfsIp{%IOILTQE<$(f5zE?py+uF+e3HDTX6*|$z_(fViy`DNk!206bW zQd|`-ULhB+hsSSIU%ATb`&oYC3Uz&iV7^ClS z;6a&WL_E?Q`Ri%)9kW*gUQ0X+`ik@!R-ry4UP)X<)527aBHf12`~V(Xq@jtYa!gk+ z+3+u}DpT}C@#1ep8X95YvT2PKiO-}dTt!kJkry6mGaK@PuxqApj7KwcbtN>MO#d;< z?s4oAYNb{&#h7^(hB;WVP)?^bWhBC+G~Gcof_5v3Moi#U&8Rdb8nIN)TneQrV*wV+ z`HLBwutF|cp|=!QhKpCp#jErdVjQYv7kqkPP*@;)7r@ImtMRHOH7g1E%=6|(S!P2u z+zw(-9AHnRjZn*rsm6zMXE$tO?_G8A6B#wtbL8XmRAPs%6WX3TLCNj_4Lf3I5tY^_ zP)6`@;?oIFWZ2SVhw(PZbS`H^YXMNI2iO!}q+0Ho)+O_!Hqpye}%{f^a z#uczD`^pUDNjX&CG*}SOh>ip+;NVE&5xe3?oXqHj5}fq>iHk>H(R5q!*%9e_V9>SF zgS!D}wb>^2LDDkYMvIh0v|`F|O{9lz#VLbA-Av{bJuB{CQBOuFXffalSk=ICBhd=l z-A=UPA7?=;E>zY(zy0L)KW`0Ju9PcR1}j&GbG9GfIMOuoXQ$hLd|+_#VEa(xc+U2L zZF0``NaoU`pW>wD)K)oj$Js*cJ_a7-+9<`<3`_0~>jykj?uM2%)}L9^T9zgKEIpC- z+0Aum(rr52MO;fA5hKuO#>ofpj_1giB&1q;wfF$xx@{4;T!`wqQGpLN{f6p90XL5X zQEj%vplpXNLSDoo@+SUniWV7U$8`W5^9G7`O(XhK$#hv^>D62?D&hZXwHT_{A&5-R z9q_32Q*A?}!9a=3Wjn z`eRvwk^;CVQ3`@+vJ_xBGFW+|XeJ+rtfwq93D-YTo)H*g8A|IK@jZmBH)Cg31?HIs z(K8I*hTabmYQKR=%)|yv+HHwDt#4~>7Ms2R+@*iaLhK|*cx))IG%Fjq>bAC&_kBEM@yPl zSx+`Jms)<7)s%_jbEV$q<+gLnZ8+|CtEvh9QX04?cMRGR!^Ff1qPTNKTcDMOwir&? zfEps!n-D{QG6tUlTbwI_rm*p=hMJ^4(xvb;Hw5%NOUoIe^qPYX+~`=o6$U`RC5W;~ zH>n`RpcWyLCWC&40!jR(@$2c*7wl$WWN=6pD_T&v%eg1!VjT$Y#7&0@2yaLCnSc;# ztZ-J9oK+Qp&&tEc9v(|M>kT*EA~)SaP&H45suWAU?}YQX^VDr`Ua#ay;2tDRo~FF1 z)LZr67lXksF|R)<7zlyjuyqz-Aik0Vhh3A8Vt|7uoH5ZtzyixJ!wj|rAuzx(;9w2( zapORUO0d*7Y8d7YTotRYh^mxH?X3gF-RS&00Y&F*fr6161t^lkH7n(sm1kE@AvMFT zL#;ZAVHD>|Y9a{gBlx+3oPjjOAbX@XJU*C+T#A?+!uH2$Go^FKs*uDD@P#j zwJq(mR$&9PAO^H+7!6{~bcr)kDN)QE7JX7yTz>7l7r*#TB^OvM@smD5AiRrqRLfA> zjU&K3TSB$eKMKr8Gk6*yCG`-Z0g*>Y{Q=7#gOPxLYIXv8Tl&f_-9mSxqJ_~ctky>Gjy{#`EjiYck<1H=*Y7dy?;W;bB5l+3>v^|UDj*lteUar)5))A{y6i^ zPyL@q{`$yxR!uPLPORlg`dSY1TY9_{YxwYyp(9w?!|oc{T@!TI>Dc!B_Z_oZ&07*_ zHLs`Dyb#Cd>b)%qwl@-NIM!Elf1npRqq+M;ppVSGO&uM9gS`v1Sg?ubUhX3na_df` z%T+Y9o%Pd#un+}$P&ER@EC;lhF|MEs2OrmRz=%8mk~Z?Z)*LTLrFHSo2QL2Q7cPBy z%qXHxJo41Uw_Y=o?*!>yg0}mx0~DK9aW8*aZGAlfsqLT?*c+h4rSQ|Y>sK-F`l!Qj z)&zbT${O6am!&RHDEt3GuDXmsupEZi)eONu4kJ8t)U zAPwOy^!rq1O^vyQmDd=3dxRySoA~j|@4WgN&qw#nQ5-T&denX>&@M$24|I3+-WQ|z z(fGTM;7d-UE{=7NQ@*6#5XcBwWa7!sz4Q9Xi;uiy{(w_HS~fEkst@1UwYM7*0J9hz zpfBA;J4P=q;{%u^0O=r|rQJL9iB@Z$Xc|{43i7Lz9hIYZp4xaCDZ;NC*m%K_Ik1g? znGOu0ZA9m2ySfKEV^X+)Er6`1l+~9VC1^)Bc9e*-EHf&S$0hj_KV$iBk}8g}Ox$)q z%X)e!1@YT?Jil*CUr%rMfgNzq*sr931KoeXa(37F?QQbvy9apk+!w6UZD1&(nfD?0 zq;#-dLa;h+*dxdes^BR*W9z7c26U?U~p%tHe6vBr>l5%MAWL-s}dP0%IcH$jmX z;x}<9q1MI{j(2t$;WnDWp|#WBDmBx?nLgUXG)3Aa(-cXB#1x&`pV`4@LTAJ?DUUl7 z9SINFpu%wOlmh#5**`rRK98$4xcOC3Qx-;O7dfX@N z?~xj?lC(+z@ayJx{1`nF?Xu{RXo*!HiJ?RmlH3oa2JQKg^vus}<4;Jv#ZP$M?4Ho> zHxWNPHS}@xehwwoCP#BP?bxGE zaY6B~N>i)!2P}5x$9|k1n|5A$Y*M1D(9h{XCeZ^=LC{`+SnQC!kUu$HAb#>$LtHj{ zs!#Ln90fV-+U;zlNrASaz*LPDH`fCEVf+t8i#^w+J2l&+$NnKs>6 zn9fYMi0O2o)8EYhocng&lH|x_(le=_ef8db@7;Ibz2}^J?peVID*u%*w!s6agDF13 zJ!=jQ^6@@GUqD3_eE~r@##x==H_YI3Iz3x}NS&Vj zAR0OXf!OM(#h?aa>=nURK?;)NMFQwFf&lojQYv0yVKpwO1pybK*v5EPZ-+um2|m8N zcOO?OYcM$RG(p)gFx$GfPY!BrQtG2LFebTnP{1$JRVwOf*cvQgnv(|t9h+MRM)8>E zSmz_Dqu#n0DEH#S-B`q|BT0|TM{)|FC4}Tfi^*h`HY#m!V9{ZKNhR(3d3Kc8pQ^Mj?vLmD3hz7QnFQVJ_H|Q+8 z%=D|HyJ;(PaE%NiJ$=x3YMS~B{hztEZ7VR!_+i}^8?L{@K+kf+Sj% zPKEmz#4$zsHRxap4<=c4FqU*tN%}ZgwKAQ_2a`ULbdOy-lfv2=VXJZBbKg4u^q1fG z(c$qE$IpND0p1b4m)Csl6hVODDay7O9q1M>kV+51YcCbd3BQ6>Uw2vKoA|m50401l zN%*s|E+ZapzBW-f;G58PJbNqW?r9g$+s@Ey?mQGC%g$Gm@<7C! zdMF7|+OqS8Y6oj$;ryZN2Cs`1ltv5YAtyt$fILKFk;0*!gFA%>r*RFsn~YSXt@Ee; z<{fmEbvIXVf1hbR<^I9CvEb^Fl-2)E>YFQm+f#?ny04_Q&V9V4taXm-tlN#_vvd5d z)t<9~NNc(0YQ z%{ATT^`SgT_fc^lk%X5Gk^n311Zz2`2B=cgs)lbJO`G4p_Vqx9w&<@N3$7bUAsP@0 zAG!1Jow1yPq4vS{SVqoJ>R@Utwa_~yyp(ze5VOE8Ly|trN1n#Qv`Iy)(kfRLz*d9%o6 zS;Wei!>w@}+Djio4}~ttr%gAFTN!?0dHgov0u_%FE_@2t41Hw+T;L_JR!y%(F!kZ& zC(@3moxCTy{AzXi)gw24IC|q9>Wz0mqZF)zMk(mS>WJtjIY+t=cgMm7Lz@OS#WEs8 zfx&=&7AcBGYSc)LX=J2@8fgi%q`Pfv7CI?F(>r_6oWYva32xF7?-v3>1~u7URc;Y1 z4Y717z)GM7P7X4it#b}~ES#JfftLVKqw|RYFrKVUy$*AxM4ust;l=NSxiHaY2h3@l zo9uZF-dF|pT>`4#0?_O_OXVchQ}GM|+oxv$Y{)Wa$%rI4CT6P5aYSEu)0aK;aNhCH85N^%_}qAP<@X{1{jX~pwFe1qeM zS_wG?gu+Z@x$D6zA)7FWCCer}HrWJ4ism08*JE7hXOm5M4|+b~amXgbK1C2#2N&a@ zutpp!a}UA107)0>0#3lr;z_fM5}0`M)68e^#)g|PB?eXiH{}F&YWjTwa4P-_0r*4s z%IT=-gx3!#0Xc;(P$LUY=8Z;HMKe|%N;(G)f%f{gU43p5TXX0ef0%O6=03>}n095Z zes=~t{1xaD>@$np$HUD_T|aa;FZMor1&&THa-;I}VmBQx^|$1EPOpiyggs}%NjN@} z@4;2m8eq~^Llbez5ux^dMFc6ZORb}HlWPge$gM%8tsIyGpod)ufW8DkZ}27(peHjw z{#_E#lN#)epZpYNCJvx~>uaBe`N<^E*LYo%CNtEhIH5jYo1^x7x_j@m1HZ|FSfQPu zS8&j*`(>$^9onBEXs6=u3EGM1O%Lr}Ks$LcuTt|@omw7UyzZM- zlzy}&pqzC6jvrc(h|f`L2+qw1ASgq8Md0yA zfyag`(*4B zFzPc@u+m%)#Asgo1#Tq+$h*3B_mKTqr}7G5%#@|WyUDS2$m$?n{r;|Y6GW8{qHD4j ziv2#Xfb=5eS?Y=T0+Tvu(sS;(NhjV*C_R6U8lvKDq7>AQpcDucmtdDM;SFn)VzHXP zn55^q<*((`oX{+bM{`!Pe?py_TW20*7YwZ#Tmycv06vb%1*!k$N)D$UN+HR4A%{NC zD-+QVqj^ngUK6`JH>w4VaChb?iG`I~!1Hj?(Dj4Y8`s|srO-l~f`v94Ex!9s)wH5? zvL}EjeTn;cbD%Zdb-Kab8t|TOY%a#h*;F?!oDI0?INjg6&~r8tX>IVFZAhZyg&tfr z*-sfAvQZqis(kDCkC1xz+BPACw{7Wz=n4~|-&gb_?3}Fej}+{jh&0H!QYmSWlL0AL z>XmV&cwSD1!Waq15BJLCc~X5H?v)T1umhAmrB}pQx9rk%|3={tEp}-Rf&lZw^GCk# zAPeynM%f-t)60yhgFp~%P$US7O!5xo&!s(_iTLe$!vfLGW*Oy~_#|vQNhr+t8Q$dm zh;Vu@wjK^+$9NtK{AF)_0gq%g;fWKNh3O~&98992`D$qXiG8Q$jfU33VHumhY@JiTi(4$hWqy| zMbx9`nwPgM@|~IQrwB-^e<}+#s%$HqL&fFB8ZSdY z>9+8)a0yyJ81?1oDCc$7zDt&tQt3pnQfdP;oth@)s~v)2r){^Wyd*Y(%^$oVs^b~@j*`v!5Lz~#sn;l7A_sPl zZQZ~)3AT;`2+VffNNk=ZT{YyA5v(z$UPih*l<)2?WgVC$4gs@fyprHv$qo~{MnCTw zcVO3Ou!E9aqZyR=Qg)4HnAP)-KmW$_4_Pr7F_i}GmqxAzXi@g7qF*KfwPhF8@io#c=1$#7aWlGweRY&>?A|Zr7VrXx?Nkz%x3v5 z)VA^$^m3zJq<}XZ(n{!qDBpRos!Gbk1~Dls^^gy0gzS7y9ug_y`1(18 zL$?jy7Sqmhi#h2?L0Pn*UM)Zttnz4KqgvP)GivmMx3g1eqnL_~VhAnIWTUtmpM;HK zx%+rS%PQCDba%@N@9AK39Zt?HcccEy3O60E^0$_F&UzxP`JS`+NpxJ|!PWh1ekVS( zhi$46=h#M4jHUL}WgscPe^yi?nwq0&MQHyiUuZ9d_{hnZWT~D#nL!H%aRG#AKh(+v zS%|9xK~|rZZ-{#I=21k_85n=$`3v{|WPI@0g>Qb!Nj9uP9J^7yG600zs7KdvT>BXs zkEh<%wXc2ep6=bfJ@G7k#X>-Hn4-f^yVF7h2>#$VIZRD6Nx$zP+@Y1w58qP0j3FVfg$ zBc_(YCsT@oIyI+`^LNkzBv3hvA%V*AK>`)>L2VhTg)))`#K}6p+;%h-L74p5;LyouiHJ7y(xKCHOTl2i9E1Q?$WOh`d zt4G*+e&rkE&ktB_Jz4TJ&egi)$(+Hhq%6>Xbj&iS4LFz`v*L!vbGwV=EDY^Q*6dmq zXt%~eH}-dNzee~VTk$y7H!P!Ucf^dW0f%-Cc3i}fw!$nW0r*?M)3*tp8t`=jo{*K? zDcSU9!S?|CX?otVtdT&`h_^@!Z(dR5-oNg5BIBlOfo9NeBj<)>;h!%9IZw-FNO1z@QreUjy)Z85a1*YvObBE z^!<*8{WMNO_8}C97OQ^K!dExoH+OGeA_94o3i2gV{uD>L8lnR&Dvs;MCXwSfz?9#C zRqX&NtZ9`Gb@)K=luKd9-_RO_ zj=_!Nvq69BBG1{}NNc0#Y-17~FY=&%|AMJV4>q;t%;`kNDxL}9T};^z&7R2oF5;Pk z@27Ys0naEQLBD5?57Aa33i{*}q9B3(Bnp}W{n>I}=kpTL0O%vOB0x~b)_w8N&JLti zX8Rd7>m{j$;oK%7tP{FQ_&t%##=Keh6$mtUyqK}xq(GyDyJJ-Rf(mZm%I`6&XLVvCEVy ztD==l)XF8%%2jIRs#tYRw0f0VjiBzuk6-u5b^0Iuu9%UiK?tGrj5g!|kUl{l0OBteCTmWpQIkc29&i;7zI6DJN$^&zzjE4GqRZPg( z2QU>fmf;UMFS}{!PppQ}En4wBv6;W}O&|j@!m`xHWO!w3J1@i%^kbP0a;ZC%jchP# zp$ClOiQnTu^C@4Zd<&hn8}Y{ov8nJZz@?uUh&_!Ff32EUdm?W%ZP84O_;sUw-I`yw z&yN-^Qwx{HN-Coz%hZx(U&GL$)dGpk~dHT72d#tEDTC_+lTBJJ%=r*52nwcNYVSVcT zgm;qlDXpMlM6HPy3{n@q$|)77~-Y4kLNU(yPkD7mwKP8!O`gwH!4q;y6L#w-;(4xJulMY z@|H!g97;`pf}9YvIb8A<0!6#Xq?;Sb?P;$`|tD`mOPpbn)s@PU}aP_ z^%*4iB6d`0Gya}hpkgUu>RI|mJvyLMT4d})gRIRU-TLr7(VFFI&GJ*Z(Ut4fmFq`y zH$*cxKz-rU)fagsoDR5ju1F88We3SGjpo;>`E@b9-c(#nQf0iI=_f?@1JRRQSqQ`Z zPTsVLuBkAJVR75Cj4W3EsHwZL=csz_^&=WI?Aj?Wf& zaCLvvl(=rXu{J4?MDFbE5fZHhJT$!+PinaEI(3JNjfB?UHqbip0?d9*oKXDMlYOJXD|OE%7`GL}xD8%P zdF0t-7`N#gMr~WEnJnnFx}BS5&`?(XMDTb}uhi@svQx?biCVT9xWSrL`7wIFzgmuj z+VC}P)1ungmR#aPn2Y>A4rV9SKbUYIDP0t$3oe4pe2{Ez@|Q^j^@#hp~UJTu3cX~{*ih?#a#qvx8urm%SX%`h!p~=*PP4OoAb-=pveH8K3F1pyh8*w2; zK*T6UWusC=*8-hgcXw~?YNzm^@f42is!%TIzTRE!!tO#G?}E^*Z&y5x>bJrRyQ6#i zZjiEHqZi7*QSlopVpP0K1qV{(Ad~bmewXql0mB6<-lF24sQ4un|4vW*mWoHHI8Sv{ z^b^qRM-`%ddB@5MDR%pxA$sp(TUAtxr_R*8}N>L7sZOpapW2397J@l@>lbU zPjrsuE$&}GfRL4oV}+%N%eZx*jY2h++HZB7l~(p|7^pnv9h^7jtrXp&&d9-P8G0uN ztIjzTR1Kga;>7v7lC?;MW_-eI5kzfW-|pRx=%f$OICPK9}y6f9`8|d?ufZk4OACd^~?FV#lRu zAJ4JmI(=LGe3hfM0%KCUQji7&M2LP{0$sqWuC#4)v}YK1Cf${eyS$x@L);c)4&Rk* zX)B0#>v8M^9;I&yb!FhmAY)Z%OJ-LZ&N3LEGV8M9xnfe*ejLG65wqLGst)u`F()P! z;a2zb-r0qql9bt3>FyxY0z@a=-MMEMLft7H+qQLYZSUAg@s7#JfN_t*%<_evy*s;m z;~_01UPoVNH-%J=2lwKw2p&ke)*%XsXZCa*g+*K$WNQ~*((OGki`l-7 zfB$?H#|q!p-AS#r?*!`a+z826LPTZYd7EJ5J^DUI*>~uaG5BBUh$Jq`uj%MFRJ=rb_fRX6uvKd=PB z`aX0lRn4v(^)@5a?8&OJqU986cGSB(7UuY~m9?kBW0h-0yg38C$65#Xje6I_3QKU$ zO{3nDSV_f*H$2ca=B*HrO&ya%_7e0|4%r$-H$pRMD*&T4htdl-_w;}q-Yj51C&EmB z_u+l<5Qoj~=*4{A(bwBGH8Puo^FL@L9i#hOs-xm-G`bNnx_Qt7;&0aOnfn5v-wh32GBj|T(*^Rj~gk9@O;A-GZbA}MMepZ34# z)2MiYK8?6=01ii0WvVZtUuNZwd2_|b(XH~2do1BpNM?Bb!{AKAT!5(Mr0K)1NQ4cF&)?}?}KEnUZZ%C^DgtD7?*&Qk?ZL$LY`wFqvu`JBAg2m%YLeiPWH{5Qj*91L zIOkf1Q;W(S9@tF0PeIHaO@oD@=i~!(Tf%{HurLOqXh>o5#Do+YPu%4<7#%Yfd#M<% z=01dC=e6*bJ<44Kh_-=y038ubPZ~(&_U*_uKw;Aaq+>x9RkRv*(|XzyPex=?Iv~Tp zgkZ}HoJm!)X%Kmw9l)AN7pOQ*qr1>Dx+b5*u=;9g)yd6crK@PY0^hkRmYz9Kb$E+G zn5Yf;NPqm2N1FEzBK|IoK9eM_z)n;!F(u6c8;@F1clA!>aH%^y`lxPl5gd3`SHlE zj*sJB(T|>X%rU2yP|7htaqZ0H0Wg zW#~kFUVSPOg^4evuchJ_^tD>3VL^{GvOm2^^q8*8dwfU29w(v}QWz$B!7$9<$qOkT zF(mIxtqbT&pvPD4zbh@it-Zo(*h4JA3#Z@SV zT)im8uRo)guJKPv?FDBceotwCJ1qC!gmBuZhB&F7z9#SL8#mpu`5H;w+gos#1FhnP zvz4892rS2`9cwVpz1eD^Xm4 z-tMhEq(XZb|Wy7QZ@V5I=zp15E3^D8C^&(ir*xEtT~;@ z&<$3ItJV^=Rn~KHyrX7voet!kDVid_0lL6i{l4CL#!-WQw9Uind8>xd-wVqu@k^i~ zOp*kLO6}W@XHrSEcHyT#xp3-RA}!M!&p+_S3(uxZog=YR62;wrR>Z=8Dm zUw-`H`ETC$*6|;{^_8!U|M19#Cl3810(5--aX3ndxH~vS>N4bQeDgbpxg})`xc^>} zARAMK!}chnw7bv7?eP>{ut%O)b{V2FPP;RSC{IWSIq6K#X3zIwO z>AF0n5hyR>?Go}MCI}&MJKt(~+lMenEVfMeO65Cri$jFS77hHi8$bY*|4z5Crt=&f z#ZbUn-jGo%{|>Sz!e*m`kXCE*>Uh}FDKk6TQuOyC5vxjkoe*Hdo2yC-sCna9@TQTJ zo8Ac4jRxx}igvDMsZggz>R5_R;R4~UOu+;lA+*Uq!X6RCsL_LN8&WD_GF{t;Itc4*(tFJBMI$Psz3wzJj*5ml4Y&Ys(3cKkz z5?-6+d1+o`t;_SWD+$LhCwXvne~qEqF-U%AN6(#i>;WTcb-=bQ0W_2MMZ$u>o;({wW4K4M$7XcQz(1Kzwk7pq_TjJU>NrU z932S3dO!q?r<65aU~#qY1yV6ClA1p5H>z#6WpNIh!Lc%EeKa3dR^om6*v#}Sr#`xV z)9?CM*qbE)UA7*duVx^C#c0OL{%g+#!VsUmt2_jhG9BTK*EDH9P_`LCFO47Z7Ca-Q ze3|8pAS0=gW5!XJ>ewd7R-f((L#W5m*k>4T{eVy6$s_2snECPVeQvz}1;>PMgwwix z#uXYXPWKouAYMs`33Z6qJ4*HghxV`R)mWDGx6qQrBkMPsV; z#*`}n4|53~?!W^A9-#A001vM(y5hlWqG=UsTE+0Hv9$RwR`suab^fvkYon=UYHHbV z-dO6qQ*HgN=P2go0yVhcWd5nOFD@PpUfqB7xj9qKB3W_N3;?1KkoWK8w10y(y_x{L-019Oq zC|F{7rwRiImL;cd>sDaK-_zB#JML$Tem)HL?&*b?0C9(USRRxn&Y7h^0={is9ld=@ z7w`(ZrnqNk@7>Ui+}Wq>ZdbZ{?rQIW5W6d$0r3JZlcGn|z!kubrXuDygQeC>vuDa0 zKqhBkRj#6=4^YueMGF%b8co{Gq88$rF98jB%-+@GF(J#SH1F2V&p3?Tu95l$V=rvyk zLnfJtWTLQv61)dqA!4lsue97Z%=K9NBev7a|8u z1YE(&i!L9B`OebAd~5N&GjloFjS=75O!6(_)VFwAkU!Wob=Mc_ChhB*?$thoBW%*u z{asMRFrbrG>@5I-@=qwX(z>tz2s#+ve>;14|AXR@Z0jein5_2e2YfQSC)7$XNGXul z%hHoSrHv|ab9hR!CUR?%`Wdt)TT6XX(Th*ImilCCDQvsoPOI5=QOs$LgJitc_Vf>Z z&Vk??#mbqnj$zBPW+p2eX!$T8jRJ4d3Q3DsPdtN6Nl1TqH_mv$%-VxPSSVO3b>4B)g(?UD}|Ozjn8X$xNL1XH_UmBG{&jHNcb=mk?t z(fsDBp}E7|Cv#4DMnhNhU-Nn(TU%I>hwAE)6pHR1L8kH=HK!(a<=W_#H@EBMnzwu`Z{^6Xx5ZXAM^|omW#xu}JEIYj3q&J})yU$p$kLG;Z;s`bYN20> zs-i_p)uN@ahKQD|R7+OID8T1pwQw;4dXjft3cTyItQo6XuaIt(k>rzv<$HEp+3I*; z>z+IJAZ!Ip%)8>rWZJT)(;l74*sT2x262`wwr8drHBNw)WBT1o$%45_^K3^bg|E2} z(nd|fKsys-SvG2P_l2jAzxnjgn}7Pq`R^Qia}rWvmAo2Yr~C=%uyQ>Zv$u#CvEB)q zfJ7*uRM0bxD3I~3W9QaB(&R(6rOko9dD~2&{~;=P8}Addfo$nK5$)t_8uuGdO#*%8 zR(h0*2MNqSN*}{}`NU$VH_~&W>6L1FSOSczp~5FiV)3L(E!2$E?f2 zu1V$ay_xt1{y~N0QqXpS!>QdzJ#Bh?v4nJl%%lOb%BxE=%Y`5`= zOlpqjC5td>t0zAX))bK|XDC6prhZJeNYD){0xN^inIef$?e8NszF>dLnwwh65M2Jk zcmEWz>fZX+gN`LqxdzArr%?yv%Z6^hBJP)M+}OO0E!+N$>P+)CwrpbtImyBxyNERK z({4HvsURl_wlg)h)r&@aPOT3X653#BW zIZ?r+-kR%bJI3(qL~t_jxzjP?=Q7IrubuGbL-Y=Y3^u^W9%> z&1=2Fb#}EI$7inyx7B-I%80a8dtRzeqT_lG>TQy0viWDS8N;~Fl5A2I$vFpLe`PMY z2V6SQB83`u!0sKIN)7Kdeh212c?Ggfzk@I>Ip9U^proyHwqrkj`%%;-9Z1>%tZ@EN zEAd%Ct%PjI*%oeTl+WRCo1+#b*1 zh!rNjTa#9r^uy5}^XVj2hIE>5V!0IW+KF_uLtWq7*Q0cR63Elc-a%KW_&E`Z5p-la zLJ=cE@p|LpvCQ(}>aonaQ{n#W5bZFUHBZf&cVgwq9iv&z{p-P5`y+@#7AgOAP9QH8 zM^Ij6>Y=l>c&L`Nlv+@WipprkBDG>sY}M+YHay>OtmE;WkL-+=EKy6AjFl|=SzBz` z^5=GZf5)+A3T_cCTA&s!7%N)zV&}zB3V9pRn>^{nX{U1>3!LkTEPbu>+^17o@>}Qn z&X)UeVkWY*jCs?&<_U!ej;GtsXcNiYQ0B`MQAuK*TRi@XQf0ZJ6{Zy@is040uvC9|! zT3o&i^&(guEmQ`n_)UGH5DA3W8l1W*79dMGld5!~hYC{xlbvB^$Yg;`N|Wi>_{4*x zRx&|4al6c{n4FfQaf9qU=}|g~f-u?nD2^n0P4jgoYjP)UJx>(pU+_88Qk=}ZXl9L? zS#u(NG;>-1b*~3RP2hQLoma~suMr$i{_+3O&#o?U=y<&yZu)%ezRmjrckI!h2L%&TVXmqQqn>jrE&@n zDbfLnO&z}j@Wdml9t~^A(h0URhF%uo^TjHB{>U-Jws!0npk-$`WZevVUJ!S`KW`n( z)xi|7#dD&91Ct5 zN!fI+c;3l-p8MqYJ~?vp$J9W_h_^%2<{Ft=O}s=@eDj1O;|qTeyR^Vi?feWq|3iQ| zz>$oVb<0>;z$f7he!?l&2(v#LrC-mXoz3}4%(xIFW5KqOl(ut$%>L`&RlJ~WW)>*(^^+$Py9%Y(HkF3))@8_AJg8M%(J;2k3=cbJTvgI|+#VWW}dh!aKIJ&GmZbKTK{_t3E%Sg(WgutA5*I)%^ z-ZdB%?<#%hoas!#j-O4vn(t|k4Qh$N+>iekuel6@36f7Hbf|#jQpU=g3PD_#uxPqa z16L&k6}1vO8q{V9$h4NImB}4vt=55}Mw6X#4t9yK0V+)CCPwzXQl|`$^xG5J{FPb1 zS{y@t14~w@Zz}X_y;a7Mq3+QfAgu8TlY2?_&A$Zk2~!SEj!)>bSzwrE9_dN}- z2g)@1m7hAfA=ccaS~NH03#}vpdf5+JVLxPv_c3;0rj)~chVQiKYjT_-jT~JALaxv- znG)XELT9rXAE}sakso%#_lvhrt)$WHqYNRd@+qvqB!3YU%9#HT__GB;)3||FZ zmxJ(6;e{7n0nTAsq~|cza+4O%jTSFfix+Eh5i)wHQFCiz`Q_34dNse^OkSFvOcC9Z z{i(ls2it|2xg2QPmA6v_}HzCsx?9rABft|1n53Uua>+G!3%RtQ6dB8MtiKgfKEQk}^eM>cGwU1fCnH8TY|{lHAX!Gs@?%5;}FK%0Z$@RD6aI@d>a>n(m{# z;)&AZrP1IgrT6jCBo#=KHV3kS?loqzuLHH+m^&ansCzNzb8$ z(6%h3iS+`?dO|2Iy;=tzp23@wi83(WeWV=4(Q={!21B+nESQg@lPZ=eiG_fdTQEN4wK z@Yn%ha{iRkUc<0Mrc=S@M)H)tc`}ScG>bB8>o5+P{np9@Uv5oi&em#S>l?&zLTfYG z2JX@lpG&@+xD-P_L^M>(HpDda!oWA)I@&+}%nOjK$+jdLZEh4C{>h^*jgjLgt#}|$-;2hAAr8*7HHp^=&&ZXCfs%qZ?p*F4pRpv?J>B~ zg}CHMEf=@%G4$q5Y!PihY92$d+MtEvu_>e^%o*FhnZP-g^6$~{EHW^0J27!J% zJ(;L*GEw0eQDIX}%2ZhYR7j@vL)KdLfUdw)T8rw0DjTO`s2jeC;g~H}PsiyP=?CAGuphq-&vL+&(2FHy3=}{ge z*h`>~8#(c(|p$u`Mj?#KU&y_bt@%h}lkQZJBPO=g5X7{B);kO-tx8hK>t zl#DPZl1@qFZ894@NVjChI8uL7VMc5P*Fz*Dy70Mgk&5HNhtEIvjW>Ss*!T}WB_qqA zw+7lsoX8m#97`DR*RMxITip7Ta!So1}E$FB)%X1v8@GP_U>EtHVi*K%r4Z504_5yIsXTIW%bPelP09Nf)_>t-{amEF#A zo=NWAdcH>;INLSgv|zlfG5fm(0pJ%n(H`zr8nn}9jzqjd!ds@HzVp2%-am|Q3@--W zkKw_Y@n7T%-lzr}C3qhrYk4+FBSU(!Sz|-`QY>Ltb(_$23<_fj!idcDVw0w(FqqUCLbqbm>OUCW8Y@!or zek7|u*3lUwt5irOMb#bTY3lerrJdVWV`8PWyxfeiZw^mdgxOB6%(Ro!=ci1c$1<8I zeHktzw0(pMwj3bk1*}sY%dnYiTWGvu2G106VHLE73i;{$k=xAgqyi**jYGzBedw)ciEa`)UbHs?WZCMQQ(J6eyEFnLk zS&bB$rM#>mW}3zroeW+OqD_ymcu^P}%KkO2IJgGsPlk`xE9weH|0^5t^uFGnuHg>}XoGnpQo$ zpYu1K3uN?f(86iu>ex%6N*j%!r1sG!$s8|SH=~!cMPJrRUWN_n`^6FW8jF&2Ln)1C zsYA-N&q}ys{aNtQniTbNm}+3bmTZwxdntUhWa~VY*9cmdeBV@0V39{Iyh&T`^N)V* ztz(a$|H9|qeEx~?pZ1^s{P!IACERMY=#U+NP7zP03Rt1crJ|aO8Y*h3s6zq6W^)HV z^_>|`PCyrcay(xHNi3YXtUW`1YW6|&fb6+IAxtvOer|rFUB}*B7mwOsZqkA$@Nst% zE%@yWXaSpGu*ZF)njvM+bw=Avd_?k4_CnE3_Fp5NlrC1iLY;TTX!ez_g^Qx$d1{zb zysuH~*Nld*n#gu#f>%$?^g)?i;0om(*?D+pEF*s?I2erOmPKdRIIRdAXszfoh#%HT#V?&9<00gpN%1euaZsl0C}WDpe^TfXkbn zXAAzXUYq|bJd4=>Rc4_zYp+-&q4UC6VsS!a2_NSR9RGU%_~YLYC@GNf!Vf<^KKx?` zO2)Ihc6M*?Mh*x>SJvWW_W;UGoHKB8vWr0(&k7W22$yX<-7XsJMt+)o(q=S~+X=$( zC#hggJJo2QNBJ_XsJVOHx&rrh;RL?kf(Vs*Pp7emnP|^Ns4$j~0A<}%`vgYOP zQ_Gqc`A*OGo;`l-rh%{=lnR)?#tM$*-;pG2K5?o$NqUn(`CkM%k;?K-MjW8ic1+Z z#e0-hWn0m2<407FeivUvALAjeATGPeGC=hr%RrKr%Sog=Nurb( zxW}uDd zB#HDBX`NkrN$I?MYj;M^yZn z3JPN(a!~w)PC4fixAq!cpe7x$)RkxHJ}RCk)ak`-dPMnP=Ka@;yl5h#d`_;&lad<~ ziB*bADK!!jrDy*xz=j5;@o~@`XrRMS0mU4@d}55i5eX{GpzK&4>3> z?g=xq3-aW|s%m+1k8K{r#oAc;T$&CprV(RF<@~PY6 zm^wyAHz6aZb)YG;WW>RlBPj_o@o-Dr*Nyrw{oCstRm@w*%XxL%k1hpd{SRcA$#R+p@`N)xJ3&V({f%b$$ zTDBdZK})SEs3gPPnF{D9yc2m~yjv)jq@Kb^q^0t1p-nYT`%EB| z5%-trF)IEWLE^UrByyT8LDHGT+la!ek-`&gW08fR8dZmS4%dx(7sj$9{c8^`CD;t- z6TPOqjt=@@=F6hxz;V$|S+WcIuReqndIgZ*>#IcKC4o9`1OFs^1pO{vhr$Z}0A^38 zyK_#b$8`AK;-wU;f}$WbB&9Th{EtB>1q448XC@s9>+I5vJnsUK=8Mp6q_%%@PJGVx zuK1jtU6hNl7w$k}j^741x-K3DwZXdHy$vG2_Pu=y7>Ay&jZaR>B*c?FLi6$(K0vJG z)Ph*pRl2}*#z9m5nl4cBFPO=T^YG|rT(4&39`g*lhdaiyYKb5dq0Y_`qni^eN7}=@ zV^w3`YC)mtVcST0&0f=XiNMknSom&fQ|LhV0=w<$BAK6#0$&(wtH7u0PB^^x^q zro@Oxg^GVAV)P4iU*|MUVl-3olV2*9&q=gqN^R@9eIeTk%8?x`BWFt0u7Db8fAExOEkBePw4JhK%m$J&p>%5r_WYIn5_UoW(l8=whbBWX6;W=2ndhvh~5-jUthvA#dOm&x0$+ysHE@(F^6X{F@oh zGWIbbT9|X4$suy((`HGqI>h*xbwaAe8Oe?N#0>i=m9J5!OS#0}O3x*Zy%pYNO0?)q zFgZSEhV(ZJCdbFjkX|SDOg?6Y^g5lb5xXj8Yxeo-BJr#nAouIo*}7-?~zH^tAH|drOR50a#i;n(@3I`;gr#dPc36j4dAlVMEvCIN&p0Lp` zu#k94JMIAzy+C)0`Ma6zCZwZ8Om1Eu|0wJj1 z@0A(rs38&71;Sb?$fEX6T~^)Rr3wmG}`qyec zFj~_xl2Y|r06Wwg9xl@+#%Q_;w6bS^wlRXHP~B!CMk;{&@&DpU(Art5oG9FZJn6{) zrNIth5m1D&bNoI-$P4cJjLtJWqh> z_@PzW{X2e8{J|C$0T&wd3!Gch8d||;iHvP*-mSrrhL9E+8-ms{IDDKx zbw6tg->WlW-yjWBrGyMh6)n#Z5<8^h&r=->)L4&UC`3eR8EYt8Q)@#Kb%h^v(~%xS z`=6;PGIvo3-{V@6&1*Sj!@1G&g=+ai%Ecs-N5+htkVXgwO3Boy<~N$bv5j!q#wD`Jy;3c| zQWDBR58OZjw2NJ#x{Iz*DD{wMqRN#W8LA$vj)ilFt{J>0mRIiRK=gnQA-R*aklY7g zl8}5LS!xcPEVVgGmf0LhE%%e9L2#0#c38<$$DU-V1rpipIez#8*;6zcc3bO}it}^YXh4P=&6_X;phv)i;BKXmF7<%Z}fd+?)p+f&u3bS5okd#9=^2A8@xHcT4ON zaU~U3B-o290bB_?-|TYXN*b<2q*u~$B~N-Kh$|BQ39W^2MM5cXB?DI^oMnP<%*5Sg z(syU!iiGRL8?tdl!fWD67+0#Lt>xf~glEJv5nPdQgSe85D+VuUw%YCfhFi1F%;TIV z!tU8Z;jE!P3_8b-yYHnF9fbb8Y#ZoTr^SBDLGluEO-zMkT%AF$;%R&P?f|j56H2s> z-nv`|b&Q8-b7vum&Q3Te;w*Zi{0ER2Aw^h>#rE76QDL{|&U5s}B#JIz$}u8}_Zw^) zk|QS|im}wpXll8dilhajsdfFWuLpu65ULggb;6sS=8L7I9ol+i+u>~!9-N&^2@ZIM zQU+5dl5j>;GJBwPXv5%!Xt-JpS5G8UZHmjEHL!SS_2B9WKb@x{@4-ON&?g5!IT4`q zG{gcLxM}G2!P_U&={)H2L!lVWt5x%ACqh({;qn(9yD3_{NG)D8kx4aKE`Ry7l|wZVmp^l$k}^U?vntfAiiupR&2#xnhdt4<6>8aviF~Rlus&HxHAQBR zis`(>_^ydkIxn-ny_{+)T>hMatwXy9cTZH(d6lKjx93ugE8Daqy=tx7ZQGnf@hTX% z-Lq%h=ExoIpVF{TmMKNQ24>82#JkngO&L-EX18IPIOw6!7X9uA{m3Zl{nDHV)4i}9 zrP?_p1dtHv1FmiEZr6dNKXX6iKIl8(vxPc$hFiIWJc-D6C?VxCB91#H1RTs3@{DeU zE7ex?{-DRkVvRrg%=p76Ed)pyq1s80@>>jw&GM8<$;XI;3OHzlFnW6tm*QD{Y>JhD zU~m&mFu0v$f$F9SH}Uey%T)XY6^#B6XzHwy;EW01lb8e8tcn5xKns6(t-m!YWsd5o~;e%WFR)-m!4Gq7Yh6G zR3v%S1kPjt=LiAk9q7Syz{yM0Ts3v>@V3#^#>s$o4uFMC{c!6O*B!quTD3&2TJqx7 z=(?L#{0rPN;=M(Sc--93Jl_)WH~|CQr01 zG^O(U&G`&qKL4KPbM;K-lY;D5sNRaO@nd*)ZF^ z$^e35IVHp6_j`&CB0&CuIka?Ya*V5K(S&?dhTI|gWn~` z=#gx~fdVpKR2roRh=H`8>>TK00o(X z*m?v22UNQ;jmXpKz=%*sXD6?v8b(arv_Kce6mCw#50goxcRKJR&+!HmJ+d&J9@vBT zd=*bkx8$B9x&2%(kZS2N2w+Z`p7nh3c<)fZhU z98)8r$JEF!7`kfkD$b%w@iK7t(ELFp(?tB`S~XHTIUGPk3zqA`$s8XXmdKl>tEZ2M zEn+BL=>N>MZG8l;;^h3m7v6m0WZaMFL&y$IVKc1q@@(A4sP;1~akE4<4|Jjl9Py;6 zCJi6(euTmzH$dnl!}wOm54AExlKLUz-U7$tI7O{Qf`wX%^a{0Q(iSSDwG!zRT98Pl zKt_nD;>R%>Q-rC)-0Ce(;(X@$)XXDQpK zsKdX+mpGj~<9UwmnshaSu`4IK+K7jzQ$b94OP~~`0D$w-dO%17`^fVF*8>&)G&uVB)4Lz&=%tWOtTz}D( z#S!l3IU?Lsc(gh-uTJDwLm-!-J%f9+{DC>eLmwUdXo48`GgOdPKtY<{w%Ya5D)-t7 z_e-n&YfC*Zdn0QLJTDg{(Q&B<^%{JC_Ra6TplL@5k#*{?RnZzS{#gGTKY8el7oLMt zF+YTgx1KsCDp)ZkY9IRI`R9*}Km4QfKR&|H7WcECBzfZCu;b$hm_>)lJ+L2ZzhmE4 zjcvgjTrQ$LyoO}Veho>UQsEW;-ab!esUtdM3ykR-F=CHNEBwnUW@Y zt5QhaHQ$vsldog%`gGkT4g^(K2Ai1)(5kD<@)OdCTzhv-Qe1-jHxel(vwA^_BMB8B zuP<6P7Oon;bu2vpMK9FB4U3M>ACADA>691h;Edd8Mzxwz9m_04%fKwpT6TJQmB&z2lA>6~4oY^jxcmRG@>oWA(W^^VDC3zwVC6Apq`|qIVaqW` z#!REuV57}vP`k)H(~mlHAM;w&&m=h0!fr9MoLKYBVg98w*yK)4Hqt?o9Ug2fI$&B! zw7Sk4PZPeJf>`5BIQbjlW?q##5-FNgh|JhAM~GkA@al;|%I)ExZw-hrW|?TTFOxMtbNJN^vof zgfo)hMFu_|&8=5+>nD<_20G~Mfor1SxoUWBG+eKS>nHqF&$?*+&H&Xw8y(WxN~d#Z zqr(HYifkAYA*z8k+H5_OYM_nI9H^y8?IObAL^jnzBb_mjH&ilMGLb{)SRRZ%Oyp7x zbkRk}ZjBZ%SBsZV(8Bgco4{zH$)_^W!l)fF)m)oaVZZSP+=h1|uyUf@QA*}J=|&aJ!y ztfF|OCc$TIfp<*2@@Vn|_Aq$@!;` zj}M=EtN+Wi8If(l*^-6n$&+&g))Kh@FX#{xW@sboKyjumgxF9W~EH zI~y@Evlh>NgD~@U^m;l`V9bz|2Tp!uG}J7?&2tF!SwC2RBJaDUPnJgO)~NUwxN5|E z)w{|rhzKqPmj0-}O%@wnwsfBcij*WwT3EW&pI-~89 zZF?PKecFpro#py8?OsQs!jo030YmmC;RATrqAJ>AIavk+bTJDSKDtM{u_FY-@~c^9*K{|Ys5<%su6 zO}&2!tc74DgrbL{Bp-TCQ}l=vq3BWmAqghWG<*7w^g=A2OT+p)%bA27T!Ki8Bw%w( z)jXG=QH3Id_SpDwWJbwu^gxpI9f{&KO7^Z$I=}`_(|i)AA~c@@$}W8O(-4h1Ri8cq zB(Gy+d3Z%62aXk^VgvA`7Ko9yF&x@K82tO4oJ=;UBMo>Gv*~C`FhVS%(q8%ydZ?Ih zW;u~hg9I8YArKO>pd!f&OainFaQhDgxBYlbFgh6t1w!Z2!_o9wHN7?#E``pMq&=F- z6WIVlgCQe#0CI^0;Zl?w^!2X;zaPpu(tWr)mX*hH`Rx3mHG^wnks^^*)@tBJeZQ|| zPK(#B`X{t!U|lqOV4V?L49gV{WWZic27IHYLz7vqc%|xfuZ$hO#GGdYhK?Uvkm%6> zyvdNZZf0q#*CK88YSLDbv(LVnmwDr{CGNb1#CWTfJnQHlFdY zZNICvD!^FMrm`9nEYKrKAk`Usw^&VA(xuOUqj#n-E0(>&gzGB_W|rZxhw)dybz(<6 z_Q9K{2IrkveJW`*xCX9y*eYiMawEmR&OuUR96@H8smm<2Y^$1;i)Q*#TNkZeqt>p` z<&l~oGL%AsNNU5APP@o-7DVFw-HUAVva}`F{akZyOP23U(2wK&`IA|>y&Ph+$EKqv z4K?FL8CI!T67W9+9lg-T%V7ThVjVp+_cFb_OR7V6$;5tG95sr-TK@Tf3davLLr%9O z3#n9C=#>cz6Wl4AzeL#^mpD3#Q=(+7`v>NVpG5b+QonX7x_{;WCt5ZJTru4P>%tb5 zI_r!Qs``J&U|X#Ix4J`{DlJymbF;LtYc>GI4(>qt5cRqPA1oq$oFrz#?5U~ppGE&P zvJ4U{?3}qfren0+4B%$!*DukpNoD|gaw9{Dg1EXAAlW3%9{_1CA=11FA1+99VvocL zZw1T&$bPC?&4jLreWVS&|7$sw!&eJ?=Fyy``c=6VKtXx5ph+!gn#vqtvJ180cx@a^ zZz)jc%7X4ICoA=kZ(@N9>`vWaT`XKMw0>}XEU)D8&?6yDfbT%EP}oCRul4(23Y7IJ zVAT8?qjmy}n*V@b%6$1{dMw;4*$tppLdayS7w)8m*zrTH#OX1?^97EFaf;_<=3{=T zvfjU=Oxi+)v{s_5$J?qW-wPFfI+)6DF;nTcFqM8?g|E25t~*F7<;pc_oT;{FWql^5 z4Hloz;AIw?#w)5$gTo(~hDlD$!;t+&5c zX(8UzQ0u^YJK>kHgt?e*XJ2jlP-O_O?;+D$u^FRMa}{9aypulmm;|yF4Q(O<`5Qbf z2qZ!&B=AdmeZh*O8;4hng&R(FLPtMu!O@0ecaLRNpX`K=o@`Vr)L?~8M-T0Mz!j=Q zj$O7sg_0%^z<_NeE+|xqKD#M!ZoAT;Fm!$v>$3$=*Q+?DEX63CBJezQ7SuE zEL3nGO++^xEHbN1X3B>?g?>Q7&gy+8u1w;Y!jhuSq8IJx74m>d3~_s+elb0tZRZkp zO<7CC-3^8nK7k*hJ}}8AHrTG|XR(Bnwwt8=YBci+BJHOP-ggR(KZ)B))%4O~|7iNW z{&lYhGPOWL2%~fLND2Z7$``L+fs)4>gGN88vbRwT> zSlch2DWr4O_3L*OQw?>eamZiE^reuHWo&CDl~9z*LK< zNoCd1vejzYYKKrVw^)o-%s9}+l)qX?Ex7UxBY<`8bso2wP|uk2CQVvnU5%QA2&}IZ z2q1LeZ+`yS@%~S(qAY}D8PJJ4NJZ4u*+;yuxaW=MpTD*(p2R5;Y{Gl8Rb^KF4nWZ? zS#_`9)oa-8n(>MtYWK=q;t#;k2o|%Rr!-~43>?{I?nhGhb}54{b#8|vyM#d(j_ioC z7RLDSkEI96y;$|duQR!q!l3f+K(Zc@#TI2P)VWtP=1Gg)p8!tSYkY?&$@Jtuk`hY2ih<29ds$Uwl2U{?8YCTV6H=AJx7kRbCN z*AR7Lip2bT927+C~$V+lE6vse5&K%IR7&9y&e0_aBE`Vs-?Rs%q%Svo7#^vdCDN7Ls^ zmd zo+YWCf$C~%ow;lp;SzrX@0zC7nCF=qb1|d(e!Na&iRj~*3OQy}-?nQ{89MV1Vn#=4 zMz5U7jFL-ymFle;{_siPn0L9&DV`0x?pwNi#`vwNq-?7y(Mn!b|ELnjNRTXEsuE`) zN~98J*4R``Gk`iAuPQdQr|a`eVVIdCq(@sCPxndJ1+ zl-`MfR$&eEDvb7|P(^W0A#H<9>nEB{l=;8BE|bN^H(JZGV9YpSPe-|3UEiumPXb zsIV!F?1}u7Ye$2tem^Q~Wexf$bDRMl!&|AVr|Di}Uqqo7Obv~SrMsZ+9&4d`-RP3F(P!U$^z$Mj zk7K*EF`l9Wh?XN7K~rD@)!w^rFFZ^6aCgV9E@U0(Kz7@XT@Rby%MU##k1`|A8<57tIg%hc4e;k>cbd8gX?ThEbwdZQX@JhAiC z+7}m(hOXA5xkMZ0CY=U6Nnhn&Un_ueqW%!*@2#j}w{EyFMEZy3y3rw`*62vd<>fH;hqtV60}z zTF*$TuNeq>`w~L?`1d|H-v5FF+948fa6Aqnq4v@3+DC$&W(Z@r$^}T-+39L24CxP> zpwtOax{^Q%ONa(aQ|y;WQ?ytOE>rq*-_>g5S-#&5|wdh3Pt14q+mI?O~lVIsr>P; zj~_qefcSV)Ur(3PzELU10}%N0hQP@ErNklPbY$+*V`Mn)WC<5j-7E(VDenQulz&GN z_uSd_aU=SKN!TG*-PXOcOPTV$g6i{IO_3YF(5vt!gOQN0F93Wy9Ei%bkQb9)YGPqG zuy6xm;m{FD{+D)&==#IT3HL~3-bhaUNU#A|Nmx2x4bG<>h!I>U zm~&*;;a#z`>?7+Bug4X(#H9clbJgs*97%%?DB@64qKHFe+|r=tHIM~O@nEqQcqp1x zt!7o5;fKlhf@hKwV}_CxG9E^co|q{SLTvUhNM>8w-6U zIY&#V3$9RPZ3#bzD->Bqc6DoKc#cDMv)?YM3J0)r;H+r z2-$^g`tGAl15{_cYzO_3P%1W(iwAo5AcRCav8O%pkT}s}MD`G;$|i5@r5q0$*8SSK z=F!}N5NAkT%vwd|XMkd4B6cte>^D4#x>$(T)WppsX*-E2-9^Q2D)vyZmkOeF;^kJZ zgJ~VH%w@XTpv2B~Xv1gHIR%aFAUa1Xsp;q(+gsPG!TJ;6Hdgju{dyovQ*3^4EO_Hc z%8e7=OrhA^eq_ht9TOg$olD6aSWe;5CX#SQip|_($%1N4BvTC(n;8SSM7}2cbPknf z@X$R+?mc|(M1anr(#+CYNT+isG;;>Fi^ysdA*z8w)2zv)8g^i=p&)F67EWYSEmWG3 zfqSC4^VHmV6FF1^rDnmg<gXy3$c(tm!Qu=~>(f*D~q z?jR63Wu{?fX3Et~0vwQYSR;`+Mgn zJ?GqemV55G=f9l)**NLtYML`Q66W+qb@3M^in8hXrFC5SA3!#TYBtG2%Pdy*886nx zHi~PDyE}__b`|Dueov_X;B29JmkWRbrIC!9%4|(ai_rLX!Hf2Es;28D# zwhp;l9H@@&dDnitdPjT2C(${~>TBmPI9ku>5Speb*g?1Kct-s&O~J13a)9-Tm)zyx zDwc3r@fb{$uu$v3Ik4Y2CL`#)X2Md2_--~f@tb5RgVSOgLzg)7>VvO;7N10KqEQ_% zWe%6%Ip!_Qsbr`t4WgH9$=`xTA4*j|x?vtuSd76~L=MJ8ggB)Yi*UhW#PkF58SBLX zInntWt@;pJ^$Tp(Uq*!}ECyr$lT3OYaU^m60k=EPti}8TPtK61+V83ETRe~#@H8Ec zKbMlJ&OO*V=(*9Ccq8W?;)jWo=y+gUGaaMRCOouRrttOV7UhgJ<7+=vz_EZ_A-9L(>*7 zqw>c|cUzyp^+YO-OR{N2aLFR;_(p4b0IlhVY)y+$@XHD;eYf?+gO2H9V0pPep~2^9 zP?uv{oG8_XlbmZil%t#@7v|{$8Q;Bju1B*FxoFf6W-2Rf$84_$cfyYGJgGf{0FN}D*ty94iFd3<-&oYbG=eZ{%knl>m>izAwhgA2^4lKZ{Co1s^2?ROd=DTWb3(8S$+aAfXrBsjF62+Q^LQTO`?ODvB-0v+HSqTr!WQoTTEp1y5LVO*d)V}b&r!Mq; zCgQkBHaZ@&wPuB~ood7c5^4dWNA<+DQ~u<8Dv-jA>6fussve6KP;u%Cs0TqwI9te) zKg3z~rYxsoA_pt}a21m=tX=jv#We;7TZBk1*yh@B&?wAEadblo_CT6ANI_@}o%i z8`3ggUZLg5_k$1{Usd^2s`@JXR}b8A>W)Cl25o3DpF@iUDHspT=g?w~Hnga2;Tf@_ zNE=FwTy3NbECy9aT~4HqHfAMqw2`^fHl)}@F2>+mJxO9-W32RAgT2ufcY3i4eye%f z5hA?)KJ~+kX$o%RBEod7Q~3C`Q}|`ou*;dQVfwc0`DmtZX|i+3uEKtT-jzk)=4vb` zh-*dhmL_V%wW4^-;~){&Vqgn*i_vhSez)e8 zKW0uc`Tm%PG8-YWi`6z|n4G~ICS@^h7{25nj2yDT0t`8B#SEesL59TPGG;y#G97B9 zHE{B;KV&KF@?mlw!Gn6>iyXTaXVaLAWH6r3B61FFH(f)lT`}`v@_{m8K%U}Hp3-|L zkUZ0DVp>0#yus((a8_687Y;h=(D_phPO3hjo0cY34D=YPqM%=^XL4}k_&R@%aX5#r zN=!JlqbQXeDpj}IHqL8YU^)3=a)wk-(GtOl{--;H)Q`?T_qp?jzhkC>DBXC=p#;(L zQF_>mE`QRN)+qV5==2tZd+O2mp8ZNxt5C<{7>>F;K;vj@_wLSpCdL@6 zu%ttHZJCF}hOnLQK8%*Zii&e*%~v}QPMlDj&#@}y1g;i*F&y4zdP-Ez@{?p&e+EE< z6MY~%KQI`tP1BbuE|s0p_MSo>YuNU>koK|+jAzQ$`Q3E^_w*k9L|NZ#ZTw3=7&1MR z2gC+5vUv_{Fgs5<{#Eywjs%imeP^e0XR=n^}LACiK z4F^HE|54N#&XCmAgDigkCtf{&h6|&5oBRWG%;g+( z$OR>s+H3S`vXB@pzEp!h$+kXees=jd!-eSn0(_C8TMPhC$lPfDPb0++Hvgkn+We>b z-8@^)U4JD6ptt<|qM`ih{`~1W2~18KX)X$07w4-qqY@7lPo$0gIJu;CPiMossf7rqe(P)E9>ZFy&el~ANf|I6-WJG z8`Q*IoR|U{H5KlICGx70O&gxP2Z1L4Ifr7tOgKsSBH?Mmmk1Xa z<$c0igbxV+3eb+yYemsyk>s?knq<1kFHjsIcFp)f1s*>=-px$`Jn zB??O@+VaVqm6^AIZ>bZ#xWrtwP)BBbvq&tcdGGc z)`)$i>iyaELU|W=^f-Dx`W4a;swBvI0 zD)*v^q8DmKDp4YxGF&0S2?yV%krXaZdy{5pmv2KCPSM^cis0M{xf>MrRTTd-dp%@*HZ+}ydZBb3$3QZ89six30Q)psG63Xn+L(|*6rZOjYq#!iI6q;!YO%XNu$PNJnVCDYU>8nju098G5j&syLLg9BWIuHy_y3(I|Jo zFjXgpjL#o?{QYl!?fvIJdH(Q8b?f~Rq)@c(&%ie!)_X^ey#IrKd6cmz(+^v`;H;-PgIdbo<8>86VJc**+<{&eN_Gsq$1M3 z_vg=Ihnt1POQ#hto>shc2^{<{!GFn;kPC9DeK*Z@2Wa>F;KbqcJ>Qf64DCuTVClv5 z$tj3p@AV_>gD-ww4-Tbwe7tqf z0bvLPq2OJ3Nd-`OmG=gEKX~ZR)~vt??)%U7$v1-lLe92bt^4+M?CBCk!8=^|=J!K! zds=tuxBjZ{gGV0^B_I~2>$A{Z%~hE)?X>c52)`xJUZZ@VAbr0h2he zt@5T`JYBqx{1zj>#DL$^_bvKz8QM?Z%Y;a-Aiv0agrAcE{0yl+WgAY-INIi$x@a(U zanFjwWdXaI789$UQO3H?3nZ+QE!Ara3i$yMiw{^t9=Mwkm6CAB0zQZm!fulV^gg6l*xLF5%N#dRhKEcDp zlTySzmQ8T3gK1TsP?}Evn>)Jpqh)nOD*%%=A5wzSXheH4n#v7qa1xu(?SN+NI2zSd zjRq>3;9ICDrQwM-w~8sFn%iHoxe>x_Znq-Ar))uI!XvAK?xaW71k=0$hc}qwd89d* zoc73uU}DN6>y*nB5h8BpiIQU+~N=<)cV=y)SlN*)q z&BsN2Z(VLZ5%s;VKyFFxu_g(j$Sv``nYP2(9{-|K=khLm!8ql6i=5q`w6!Og!rdK^ zKdKZEU;JQFRdUE9zGS1qOEhMVSFkBOCz_2P*r*3-40=EHk@f@K;LDKr7^>LaF|GKH zj@@0m4ooX<$I^7z81J4|+_k5*4Nk}2*4=w>wYO_p@jV?ocXf8&9ZFPLnOFfYWQ6kY z3y-~a{-rOfHYGAW(T9qtTwTjy*WkGAPFuV57nsgo71NB<6Jz#I$Nvoc&&2;MrFBF# zk$c%A$+M`0#d})!?rd*enz#T>2QtIfV|%lBevhK#D6AaExV`=yJGVmaDe@+i(uHMK z^6u_~Em(jFPTpLV7~PPqc0}5F9MmHpEpqXzzoAsdE(u zIv1=6%{=Js7>l*%#PzH{iv4cwv2kIi&70Hb^)1>sn7&EjTAPMTrVMA#_pQ8b%pOx3 z*R%2HmJ?;iwvE|hV&jzd%x6VZxz4{Z+v;lg7x|LHzv6Trq;Ro#geY8$JV@bU^eJ4- z-tIbt`p31x!N`dO?>gwJN(_0;pU#3AiF!4vR4|!#Hl+?S8QD&+U^;iu7ggEcXJr#o zS=q})Wt;eiM{ohb5HcW*j%^D#3RR|%o*8gtf-8VEcrs2nj#ZP17)*kBT371VuC7v9 z8CNG1_!**wf?lOiq!ZQ3Rz!hutq25uDj^UKCRU|}k`yrzAp(x;dV`HMdJylUC=^t& zWU;HCP?!YczYia_Ssq7{PzuWf&*Av`7&2 zBjiHVhdjj(Nhsil+-mxeNo3~+_fJTEWUVR}gfvKCup$k_cNSkX&3m-_SZ=_PhtZc4 zvyW{LIEsQxpFfd3Wsaj#VZpw|;`k`GGHWwL&a(sR-KH63n9heTa^ zBy_kTJ;X;BrAls3c`;QFRr)4ri&1_N>S<3mg(i!hi&qcLXwNi-rif6{H#wMHl@m%^ z-O;+IdspMGj<&lsfg>hVJoMG`-+lGHuk^h4%vUFfopL<+!qYEN%~ksaP&q!nyA7hI zy|ue_XKNRH7@(8?25sp>j({k!*Q}7=LNh#2fjdgpTKZkx-TS8%ceerdLr9?`aiFWa zb8pDq#)2uL2p>{SWfDRZSB#+!DHw{sD0!4-FS<4%r>4`(+yhk+9^d&C`Uuh8gfey| zK1RBc30;8Zs?_LKZzVtY-r`l|zhzq&l<;@-ogoOC5ScT}Xg2plMj_OZI*y=-lW659 zZt&G@8O++MXysdlg53xGn(8!^@_ECVc_$iuwOa-=xAv?(YNzg;Q`pyis&Fu86Qodi zz_H0f<4(`+_4sD29!y`;v+D2-0mmBZ|JV074`wy;L4%G)g|1k_2)Y785x>!0aIR64 zCRyriW@ODD#M@D4hW<#Md`h)HO4p_;%-uE}cy{J6vEQI*VyfXiqeS zx=SR9Tq3iUvsC+;N}@Dc*nO zlka`^sK#*G_Cp`u+p)J(hNj%Uy9>(jzK-sDI%Rn7WSGIo+FHS4rWJqeK*xa&c_k@( z6&Qn9`iA*M=lhSGf9cCujPc$V6w^s4X)lhL5gSdyz}BTO84jp1@^jDYkrA);UQ@hz zG=(sp!Yj`oegR8+FFf&R$kW{_(`I_0d-tB*hlK6m!WU2S_rWq@J9+Q17tX)*nqvP5 zdAd*^9W4j;x9GMS`CnOHG(#*SFvavLF}YWO5!aOAd*A%(2VeL>C`r$-tGi>rDH}CB z#gq+W$en1`f~%hY^sDDT_w6XI8cJ#Fg#CyQ-4D&Yv%MK(Kv9Z>6-yNr|NoIF-@=Q? z|483I5k&XXPv4t_0AY~uQ$m#L@e|%7G?A)bM^07>drl$rN?*?H(86=$dNv%LHA)Q} zn@8pr^?uAZchz9dYTgYvR)eGV%D#E42Xodach(FSSM;^{mTej=zTW4^JP{jkTrX}7 zJUCd~tlnuJE}hzM2O}=s|?DV z%_mVmtWm#;>AKzeHfBg=F-2D!v+YjP9)oxm(<-!h2&HvB>W90TQMYi3yV;g^b1m=Y zTi(6K=@s|Km}Y*(cC$Ale(hPiu~jk744b0eFiC9iWzg;L)8}9Ru6ck?Ow#*(@kXdb%HUA3I*Kf&+_ZiiW?Hgo zxfjpG48E5^!^${O2X2V5Hp|hWNzMCjQjO)1$2H}mccH`}fwG;0;L}yP@(bk8WE5hE zIh58Wh9+ACn`_c;! z@>0w&KE}%U1p+26P|TfBY-6&*J9xu33LhVGQl^r4VBIEm(2=f%;x4a;+$$s z7M$wOoGRuB3J)g&@?e}`@l^E?8U2o$Mn5bvs}QpVQ*jh_N~-&?W31GcT5{2rmg)|s zq#wEG@q4g(G1YxGE#r93lR4Pe3R_Ub*>Van$N$ajO6+QdYo{$MPu;KL&CF`-Y=wJn zTV^ho0}N$UzL`;p-K}sh(dNxQzUawCBT2kU7O9@xhkdPZm8xB(@ya7oKY1JWvci?u zR#GukJRf#_>|}*&>C=| zyt1WnV~z4F+*o5(q4ey^jFt0je>+!#|8M8HR^{3M&X&0<)BbmvvGnKJabIS$Ytt+s zXh}AxGRowryU_Q#MzKBV)(iJb5JbqIKWJP5M?^}+vLu@h*_ny-9NZ2ptg?r4N+1cRjl_m zWI&e~54L=`;O18)2_jiP_OuXEQrtCo{Zr)iWcXe9$GR&1lsQs4(Pc9@{m)Tb$I0n8 z=$sxq^Gw3&vHR&zV!1yNr-KYE^d*)D5?A&#ja1t*a>u87F@j~T!_a<;(+%s`sT$#8pu1wq)S0?U@s|fCkYZLdym5KY}DuVms+QfZv zW#YcL(zx%%61?q2J8Qt^#U#3mHh9$~@^e|H-H3hVOg90st(k5jLado?5<;w*ZZblwnQjU~teI{qLW=Pnqkdq9X$Y}qx*mjB zGu?EAOo_@0mx8t0CtJljxOE@nSYzV+Oq?)4K+K zr|8`iIBv3-)=t}kqI4VTax3b>G`Z}W`K?11gUtr36(apl&rm{%KcS>|Y9OJyXT`aM6jkY7JD9xA=Un%0LOz%)wrx#FIWq6@dCI1( zNohy6J-$ubvNaUE!Q?|P0v!dR(;bmMccI1lgTYyhtkU5Oe@zmZcX(bx$p7& zpxT0uCEJ+l$vSPzR`p8Vu9c~uEYP-TEhrhvpZ#Y3Y;BWPHB{Z8Rn1l1oK-KIx-~1* z>19tYgKCRs7E%r7mAWOXdNpA?)|wTylAMFukI+hWM!ECa8Kpgj`?NF1sNemzJEYw( z)I29WAyF&6DrRZ90U}5?9)_#Qn5E@bTS()w-VSk6ozrr2u1R7iAn(bTrDIZN>6nyR zT5bV`cSyl=CS#V4NtvZ%QfBFxlvz3^WtNUfnWf{h%#!9Pb+x4RM{i)U3}k< zh%2V4oAw!+=pRuLja3*UiBXQ?ju>|q+)-6Qb0-wb;E*4ncHSn3TyaGlvi$J8p@edO zLV52^{)GAc@qquwD8V)-Fv{8$^-`NznWV%L$|Oxi*VuEvW@WNHt{sE=Uy4zc50`2_ z?ET>6b7tjZQhM5QNSRWqC?D+|dpfXhg|_=iROc*B2{xJNa9m83Oh1ms7j^PP?u2AU zqx~fst+WtHno*bfin+-;PCQa#sy}&ZUwj~Wx_KuZRjgxQ6kp|Hf5H-8YlY^SJScY<~U;VeniPwQDA)I#gWT?>sL zh95Ey{2p+0rz<{;j#!E?+_fkb?pl<^N~j^GRkhROl#j!%}#|ARjr!^#!*{ zqD2M?o1v`Fel<;gRZeJ5k2aoYi|7@hr8aZLioP9HB1dH}rprbd#;u-an8;O&3HhEO zs6z4DlYW8w6RalMg8GO;84MtdCYDYH@GB%%7=UL|%gKl%6E<=#map@Bu$`4=BY!KW zs&DF0^-_QJ(m>8K%^03vHk3chpFe9P!IqBGWa84@hvUamZK>tlj`PrChcp8_x5ujW z=hP16%<|{VicTHmZdyE&KA2mgoZ)WV(NgMn#xfFUK~A6rIr|DN$SXY3vyHCC8r$Dg zOYr|qjcY}U{cmPxu5jCb=#GW|hbea4j}Sqe1thrDlVGJ4g)61Oj8*AMsY71E9uB^JDak0L;qpO>9kHwlk z+=-yhk6b~Nv9m4@lA{`E8iD3)PyJ+phb8r3G|}OD1zQDwK~WGb8|0Ql`CIUc#e!=W zcVUK7ao2%1%vS8;ET)4(K@`KehjLb8=((evM@S$VTs;|-!>IP`VW6r$s&$7*AEGfC z;zYL4AoC`sGuq;Bk$el=V)K=>MXHeE=-J#r@@%M(@yWrwBHy&N{uE&sO3v%E`(|$( zOy1;kZcDrE>MxamQtvx8t(n)c3nAd9}-mS6gyw zmlda;B&HSjc3EnPE=w)ZHA%i!x}(cVcf{N3`l3>2MBFVmt~4L+T3C{C*J9}I#W-zO zC`gdM77_$|I;axFrAm-^WrD2O13GG$E=sv<#&UnLc1PHTPoqSb{~^@6t6tk9miEYR z2zmtIjIT%nmT@F7j+_OA5)Ap!C6?+$KT?P!QLckb)IktyiXxX2ML*K5Xv&c-b-I$ven8ev2fRQ$EjC)qlR` z#2tg4ivBG<>xS#+eSSl4<)F7_U`x*ivC~trKdHDkr+?9z0^g1{Us7=(sl8_ZR0Y$sk1sy9SUnHX zlXbl270CwiXyvZ)fHm?>yG2rU+)`4`ogDXH7L?=fk2r!=FDRSXeexILBW& z2U}cI6tXNfj~HQtS3B}HV?~klMpmP3MXuwASx#ISM<(jR5Jx2{YCJ`tX#rTssojp~ zQ|of@D601wwbV;Yarg!}E`OIZB~LJw;OpBiJo^$(3{oUv6k#c2Gs@lx#;}ZTFfqOV zqy`gH97XIw1N$$MSJVWf|HQp$4J>y;Vx!^OF>mS#Hr!2k1*PF$X1uw=pIFhmt3N)F zIKQXqTtcck9?~?Jywc}f8EH8ASJ>Kff9dWNpEE!v1YQbIp3eKz~@+?>M;4{ugO9jklQ+H_jYv4yW6^C2^xbOLwEt~ zRhH4YYcm^!^tnpi*WTT9!Z_x%o?>iLYV;HDd4L2@slPuC9WQla@&hgiR;x~@%c|3P zt%?kuW!34lRArPSSuibBS6+mn$!$`~~SCFnVRW2$E%K21q;fC3TF5VW(*a~^B2qua@(j{e_m}czjP?S-k)C|#I9}Yj;#}0 zvsL+Xs&Gso4My=a7~$-SD?7_;KF;#0Z%DJ9PL&$wOQ+LZ4RdgcS7yU>`|0Vi^v|*5 zzI+|}C!zQ*e6)S-kxK?xjiR%%^4jV7l83*5X3;+RdjQkkO-H{#hrGI@U#uwmqdoWq zv>gwHeTPukaqyC=up5WAkmi+r6wMThRe$e&PTgbGpi@b9qfvrfg`z$wxsn{QAw{GX z9`bOK1R6bh<*B zFbV7ntODC_du$(GLLJe&;5q&4&Sd$LD+9^bWBzlt&65LT4U|)_Eh%{<&6e&xUVf}R zSWx&(%gL6Zf|>q;nZdmLXO^5?qB;2m&orNG4tg_=uROLgh|^l~PUelKIH}|^A6tS@ za^Y3&J#!mMq=Drn4f&4KIZpT^M~B*4_qVoTCy?6xds_E($v)_0q13hmGS_FdXn~;= zZ8s|{jlzv{-b0P)b*FwPF-=3mT^}rEloo|@%P7DO3HXEda{(1@s?fw zc&jcyo@FH|NYKHRk!1_C{z+YDK&cj$$zbw33{%BNGq(kbL7GkyPOu-b)DP z40VA-jX$NvESL)*Uu%L{B^c;QorZxPE?>apRm`@UdTeSC6RQg9{RQ2AtuH)VC^ z70T+yEa};XDCbz4p@!G8-|(lM)O6-+1k~AJL6Mr6~Mk&$aW9E1F+m z8r+eUZuBI>!4^8V6(7OSi^VVx?ph2}4x#sb*cp>{vFcG!(|v8u9(b?8I^5n@?T%!dIg;54Hc?id4#e zBVyYNW5LU7N})W!a(ivzh!jCmsZgpr~tLq!YxMNl;~Pf6ua$r68wDY7}%x6 zSF70Ed9{koBpmCbs4%-HRbfg{%c>c|ikDdGGTM<&t&?#Sogk}{q7|N8 zWoT^zl|ddya~nb1QQW}DS)C+j=seGpwn^a8I#1c;k2FZT7^K}w(tef|B8(E3P<=Q@ zWrjaxMt@Zx1)F+cW=dAkcvd15lT2Gu`bheJoBk$H+i04LIuEn9B?_GfUTs23LltzM zX$|F$(kB=sgE3 z>mNn9dWwoByx;xqXRtZBHNnNmGF$g)q|%OK5I24XWi{l!L+;$GY@!)*LcEGIn`If= ztuoTjeK_U&0Ta6)%9Jrp9xE+HAwxZTWmnHqG#R;!V3n_GCe+Jl>hL>QjDLr1d7_z6 z>5D`@+#tvbJnSF|@&%nBFJUF*=_r-{q{_aWf!${g_>w9ENn0_Tj-#6M^)88rU6SnM zmB%WBIfc&@oh%AsOnlF=Jwut5{>;i?ZqZO~wLiBSL*maQpG;N*HAfHSIC-5vzs|Zd z(j_`0a~tNtayYMHrsH(I6aGUv<^i=`VvEdLvC(8?Cxptf5P4(QPM4P$0=HDTB}>bn zX)(1_)sB^> z+aj8-qPJiE;bPZIg{e%C-cR6j8q6XTtIz~z$_d?EF$?BfJlx1FDvbS%lZ zt%Z*nr^9ryr}A*)+{XEl>M>qh;T7CF{TgNX)vCw4{xItCq_p-CTK-fU-(8a`$R-<~ zi5@M*v^*6bWunUK5&EZ@bj+%56aiV0W3;Lpic{!BuEO)8wcWglPcw+64hgEaRLpVvr(R47T|uwiy~U{mwx8 zf}x}ZpauQBJ@sHcPxkTJW3@w`3cshqI7$!mJj6y@`N~FHn7_e(TzqWYNUSY+;qSZ1 z=pLTg;Fi8EH8>rwEce12xsRi=%V+IfojW5{Mcq2`kJsLY|F2OMp=?^%K%hXX>YuPe z|FP6YYoT?;svxK@-bx8H?HU_Jd9cqK>jWp=09EXLs&9Mxf;xw!l_YAU5&NYY+S!Dq zMcQ_I>YlS`jf(bXT7On;Ft2ndZ;n52jm=i}w9RI|zPiT%e>-hR(>w}p&-1ts)!YNKN z1t*LHWfK*G}wLtLtt4)7JH%zj2<`mMhWUOr&J3zs;d^nINZ>UB-+S zXx82g0*E4^Xq`>|5n8up6IV70Gi7hI@P#N34`k^&thew>sB7HDrp%vE*1INQ2_F&ZQ+p+s%7 z?ftFYyC6=YCMP6oI+XG`qq7jInJ_LcCfzv1hc$Zh@=r0c8>N+HMdoO<^rgsQ3S0Vp zSJKkiF)8yWmG!O+B-NNtYE^ehHMBEDEel3yXZo?m+M%pr;{0XvY(VtP~@;(hN9}7PHz_5uj(P7|E|gBeu*^wU3MFwBehG zB!R|xK`HE&DkIYBOm)RR9{F5@VjA$UMWmSLbc&f=`w4OuZIwT{s;_Eb<(Ud!a#bLC z6K2t3+^W!O+$uBw_}*iCgNiehS>?~fh6dU|aCYG{DJN5mYkju5=DF0E)!c?@FpW=Z znCduP?u7qPwpng)?qaRn!NZ~>DGlFA5fR0IWJ)v7K)39sTW+n6WwaLEblkP-s9WxH zi?~)Db<16D5!Yf10}l179f(+AMZWNP?ALmfe_wj>y~n?bbxcvhL#2%nmL{>r)dN&Q z?rt0@y9dKam`D0$@bHjpCpO!@n`f+q;<_=vxxFjoQjY88sI7!AGI7?q7iUFs^YIRm z56Fg3P$b9-l1Y=G7340jq;C~rH9=&j&feIIew)0Hp!fAdacc9R(x8xwI(gLUfd;v( zL*6%&T&QSVZt4b1!$V0`{v?n~|CWI{fu!Z;9>0Ffw=PIj8$d>$x_h=Nb@Xd3VmQr3 zRo|tn`VEN<$x*|$yW974fCQ8^S6vZ$5?()y5J zph0+ItTmLZPxn^NL$RJiDzelbql%}f1s)qnUd@D!1d_Hgn!5>?6XuYHk|VPWIrZG5 z#%J=lz$7xVL3dk_cPHuY>-b)mHMDWSrQV-W-|q@!EbM7UZ#rkHK7O0Q@!LGH#<`$k zs9=`AVAfE<0)N4RU|!i!UcEoBKB(Of=9UiS*7|d6gH!5;rZoAdGzGJZ)n(C{g<>Ie z;S^<=v$geE&PBN+*E9>+)QMAW_Nu35)pi}Y^G<#~9FZJBr}}<7pS*Tqb+z5~2miFY z>(@@H$RXcE`l=W7n*cLF&n3+OW`X$b_fDMu>{B>4|z{o8cN&W z*^QYrID4t3tM%iUNDTFnB*Y~gAve!v+TC_wPxrx)TgLfsEqgGVVh>2YtEF}S{yn>~ zO`ec!Dbby*DUou^+`w8)4a}-Y@{Kwjvk>x4yu6vPjUb*k!4B~Y4hmlnWi#5;R)1v$ zF-ZFsJmfG*dq5}cOK6}RUA@llx$aa>z_YGr9X9gLn}RI{AP@6xNm(N~w)6s8(1Y2f zL)kU{?3!Rkkt$8|Du(iA`1582OKQ|PiU zu(gl9VUgqXd?);(0wd;!Q;`!kia)@VsY}?~VXMaX;g7azEVa-MVCcdfo&|&!7yZLs z)xK>G!QKCHl@w*LlE9V+E1(1D1_d7w6g=@Dg17^mKA}#T5-P2kg01DS@)@;h%#q}* z(9Wb}GeO9fP{>gPv|K%fPWlv8%@x$DYq+vOq0u^SjTqa#WGM{t+yV0ZD#`N|R@~*5 zS&}>#`8|sU*7!Ex>hmlLcyOK>_TB=KYI|>GVUF=t5a|_=WKfGUdWmhf=3oNVtIOvb zlc|71c7EfhVV{ZRH{^q{Rq<}PkB*D#0DwHPQs z_wF`v4(H{^mCT92CQkd;$0llkJF6-_IbZ(YBoW%KeaO)cx!Y*@2J_A%X0nIvVy@|#<> zG~K+VWqnigsx7PK|3GbqQnWN%)@*3nxOIzsf^p;9cgv!S8sECJi-Q6oXG;s;rlmzW za1_R$7Q_j~;Xq<+dJ{_Q?CQedz zcOS&&I=kd&R{qPgd4CS! z9|L*BjULG0e!*^W+z@tX4){whnHf;HYKtr4_(n5L_sut#c1f z5X4EX;?P$u)(S>=nYEs~xoOkJ&0AVlH8nS(Rc_qevi!#7Yt}DsSl=WsBjqPo_5 zT!zwdQsEt)GBRrG&>y;r!5xgfm>sRvyj(+AOISzvIum=l4q$eSyjwYeRx8YUhJObM zc|I-be?PiqM&273x)uB(LBgwqUoy;XO7Uyn{a=JQd?49L;CQQ?OvohU6KG(U%LrWL zCRY)r6Xp>X5Ec=Z6IKw`5O|c8ypeDNfdgW43t<=GZo)l;gM|ADj}SgZc$Dxs;Yq^h z2~QE8COkvvCHw{9cYKAf(bq@dJ`M6W3Ev{T0tngnALy3fpf66@OkVy8!?;(w{Ll2g zLpVoxm+()_@&bLoAp9F4mUIxSj1uMYMg~*6#ocZ&ybk&IOf{2t=M$C?t|KH6+6jjV zj}tyeI7$%bi$6`@pA()TJV&^X@H%0TaGvlh!bQS|g#RSiz>?%RLINR$;2{(eDhbtu z>4cet`GkdprG!R;kii@1yPmLxzz(mxm+&#d0m55+%TLhv5aH8=6NE1jo+Ug__&VXQ z2(J=&y0Cnj@I%7i5#A((2yYSIC0rocY>XaF_w@-NF!tbLa~rYZM)=L-Y6iH z5-JHbgnGhqLL*@nVLf3J0aHXkgM{q_?97AHN(t=*OaR8a6R2az=!V+ldkLQ)JVZE5 z_#EN$gr^8!B)m!x$4mc!zP}?35#A#FoN$5g3&O7m|4G1PP@5c2NFXE;QVHpV3_>;` zk5EjgAXE~n2{QFA`oRyh8XM;SIu%2|pqH6CnTy#qRAqu&+B5f8gW0p;7G%+2sTKa%0(TUW_Yyu%I7oPq@G#+%gwGP5AUsL1LM@@5FoQ6gu$0h9XdC(I`-BwR;mAgm;8CfrQ8m9Ueri?D~# zMYxyn0O4W6rwB&~PZCZNo+W&h@O8pVgl`jGC486g1Hu`?-xGXj#CK1vInS>%j8KH(yN0>ucKv+yz zNmxg?fp9aSg|L&bi*Pq#AK_yJo?s*2OSq5l0O4W6rwN}S93ebOc#81ngkHi|2+tE< z1cdV1InL6F?n4V^=D=dm-LY3!z+YmtZxQ+lFBATf@K=Oy6MjYb4&eucHwZr>{Dd$> z7$&?!I7c{7_$A@jgfYVJ2{xD)WIG|AkU&TwcnFz2f0CA1TE6KLR&Ib~43hj57S0O29RqlC{Aju4(AJWcpA;V%eZB|J~~ zI^jivIO6dY`os}1r|A13;qM7QAq)|Ce!0w}ZDpRmD1SiUflYFlz%wA^-x9cuw#+Sw zW$t4ubGuEMTLQ}5-%I8eL^Ai;khvmX<|<&BOZ;RmU6Q$ML*~41nNwqB&Ki_C5lZIt z4w+-kGDoFk+P-Dl=EO0U@_$f?Jqh@|b1^#CI5=~ud=jD1>up^RJ!rFy*d*XUhntdU}$A zp3I(2#92S$j+M&acFaCoSn6}+F-76qj%jC6K1T+xO5SnIJ&Uss9ND}o zdfTz!Y)*mCk*VfAL(AJP<-g;&$?v#n#2v>hW~o^u#z|%GIF_C*DDgRRM{rg2j$^)> z1+Gfob}Uh|N4hCYS6uFM6fl>{cO2KBEuG?X6pbV%Nfp0|!TOosh($CW8zbd2yQ%Lu zR-P@X@Hq;3g*REIzDc4(ni|vtD&BD{KU-9$CaQkhvFL1hrO#2qLMu3@1n zs(g-823PT&%clAq#k?|yB}yd`^_gGHYMQL9Nm6OVE3ig#KqEL?!ksq~<4tqobmt`R z+0@ct%A#?b1ClorY#Yk zY+se)$?2=}I~RzMrGDqKU|PPfcDdi#Anq*oJC_903Vd~2{LZbxwA?;KSr|+!^UYr4 zcdo@+Ro~QQe&=<;v?Aa16@F(^FfFIAUPQrzTK&$QNaL&7?RVZ4Oe^-yxYzGIc-C7S z%)1)}#*xi#TSi$hZ=Zfq5zPCTeo+<7JD^|Gjl|gUSHvjSPMarJlzQZPsmc>Kwp>~( z#mDs6M{bU>Nsb|#=S|R$!xK!;JX#UV%vD@oU}HZieasdo#azsErpELnVkUfS#)v&8 zC8j31ge z8WsML{n8dm^7dto+USn#M^UnR?2jftoP7A>0b8aoYucc#b}ZQ;-69=bFlM7Wmh6@? zj&2*X!5Pbqk#bHT0M3{@MoKx6Lx0Mc+b)$d0h}?ku0s0ZjCt%*=Fx+^pE;K3lDtO| zAI?}d5*^*GrgccE^urnR#!C4|tHx|_#!?;9loKxApE8ylhqT+6r+3T~Bjp}l!?d|$ zZiiH%6tse+E@T`yW66?~&ga4zbE2?(t|+r4r7=Dn-p^Iak9=aJGR0pumYN{#l$a6S zv3#dg#S1uNnI4ql;Ft~0ST)}sIf=J-OKT)1r#qJGRPur|mYFDd84J!>VVu+;vEX#a zJn^V8T)-LgBA7KGVlm$;HQxlJh9AyYKJO!rc%CHPES=cK*SJ|413$}P2%NFhI4SeQ z8a^Tu8_TBV9F~t9eEi^v!ah$RWm->SFg53B%yHL~t`qgXn$>~SH9bjS)a8{uPM^Ct zV4FG?=Rm7Swbjk+N%o~y2W&HVCl_}XEb*avWX}oM7XQX6Nv<%S?c$?Lv1Lq5?4wx^ zXZc)H`wsQ(>B$O6i-xaTbtXA*-A!SeSBmSMg)`2jxc)>OaxTTeP!h8kd_syFsbk9A zhjV?2)&14|`8~M-Y3XqFjQ+Yn_3|*@dX>~W>!NU(XxRt^*D49~Es!ev;cTC~uK%w7 zTYIttQp349iwAZD=By9fbEUXoif2T)MKdnaW#UF@BnIA*>m)t(tbs)XwM@PG+>H4H zg@GBX!}da?PE8*XZc*(;x=g(Wso@=I5UE9pYx{5M-@p<#4A;%+-xsKB3gcU@lX|-@ z3Rin5@8MHuRaD(y(_g?US~k3Tqi@rW!0MK;Ed_Pdg;^b_qkR|YF!6PGCcI##TH3n) zz5Ojr+caE1xBtOF{Yq9+W}lsPl*u$Jkp^C+io_=|(wv*UXrMVTdtKO`j+ac&91(8u z^ow-0#PE*Igm7bHhJ$@YwYr`;P(6^(+FE&T*200Mfmv(A_8FqK6t|#$l-JA%-zWy& z@GdD|D(TG#+u-z;bn`D3m49oJiU!0HaLAv{pJ1LFINw=pPK#J zUA~#C{nOBx5pZ#zlrE*7xFKwV(`OgXzzrk3I#cPt;THeeTgP}YGE*(E-<{vLWgvGT z@gWqsX;>}tMUe4Iskat8nZwmyntr_%`(1gx3wx)rn(K#iOM2@9xid!bMYoIS(???H zRX!-%px-_HRH5&tR^N>~`JlGpMa$l>zrNtd@!F@|BeDI5ZFKvv`(z)Jh@yXFO? z`NPxa4I~GquL|3?A+#@jRJcsAARNP6BM-*t51bj0W(}{|-pShsY2 zvzkVQhe>CLV|Wi|x@eD@5s+pYlo~I2duOqdyo^07j5_ivRRmJ;CsqceDy_wmI12jg zj9kFT)r?$#iTqk6%nwKl&Q(r(IbL}UdnrU2NHQ!u!^IV&!o#Es!!ZmTSu2QAw02Rf znF|K$0yEcyZKMHcE$)=Z0h8(+Sn@njVm57#|`{`3`Uxd1g2UK85w6%#Y;+r1Xn1Y8ky@`bMVd zwJ|3k%^hCcc&a(D7;S%!w79oSIBZI|Vutg=G4MgM>xo1=XjpTmF3_+|v;&Zr;`U?1 zU^D^`(<$x2GjgwJ4=9S?H8&v58?Kqzzcf(O5Vn;_MZKsIIE+3&90T7-vsT%DmnhrZ zB?J2cb2o%-Ie5#iQ95d&;2W8**I#`=ny$40axD+r031f15eC!p=uFG+5}#4y$+iOI zTsBIFkjSaD0(HdA?TI4{?Y@=ARg zd?R-mZKEL|HJ)44aB68_(dMwdT4msL%cdyTv*(TSK9jEilY)1AJ9?LuEC8Glr(S?X z0cr8D-p(qdTO~3ox-Tb>3YVEI3CHl(_>EG$lzn3NhoJoIaWB*!#a}w^Mc3*?4mxvE z**J8>w&+r9*quCD04bEnI%8F0krNF0 z7HqVHB6&{ue2^lz2&N2yJC}?x}>NACb5X829w+0?Y_LW6MyBhp(-@FeoL#i!jV$p(VY^#eBFaa%q+GeK zo6*1Gg}X)>#ypzPsNfwb*1yw2D2(Sy%AUQa@4>*-rC~cBAu@qGoL4x?YbIQ5dbs!s z{J!VPDqhIyFAP*&chR1SbRbi@!v)2obeRsH8a@**S{aa53AI-z`}krcrI3MpHZ$kM zieq<;#=_6!t5IHfN7frvBq(p*(t!s9^O^gS+$1S7=}1g96Ki3v~|y6ShJcVv^1K)kHZ!sbhH19(|DjLw&Zcl>@Z zAN0D&e7xgMyOd2oobkjMDgA_tydoXFVyz5C0jYR6r?|H;kb?tjQqV1CW+f!VwJISB zy{#}H6%FfUT_VN#r_3F-!DaN~a14AS7!KF673`sQcK?n*?TRq^d1<|E*f&c`V{A}4 zd?p=cYCx(q@`g?=@>bRCs&EW^CK)eEtkEfT3V-QLT>_xsxMR{SD7ns7)xOs7##BYX z+Y_a&lAssiAseS>c%bZrphDKGVx(b z15%kG&fSvNUkn8tF0(FYReKfQA!5@GbSC$VQ1iDA>teSQ&w#E2hpBGFo4{*mics=> z)0UpP>(s3eLC@a|JKhV810Q>1m5`+u?In1h#FV4ukD-qP|L~M5#a#e}3)jr@24o5E z$ZCTW%lmhnS$Jj|bG&J|Vp`vhK*i#VHn5%(cw3bh-Hc>pMxvXN^KgzoVM_mlXSSbN z|4>dq+NKGsig z!m5FK-}SfnHi??N?Ob6=f7;7;3Z3wx9oz@?7^XX%ou{}pb)&pyadsfHCWo92CG=y*G!HfwD2t` z6uf4ZA)031nIyi`VBnPEq5FugkH z&Ny6vA@CUYsLc@*1C4gUk~iXox#%KT2<*Tp0o@{KQgBBT5G(A4PiHB^j%ss8E+wO6 zTW;ZSnm6c4*DCp90tGvD0t$Amg5ih76b+MNKkT$6rH;DbQ=bE$(YqLS)CAKj^%5%& z3cGDhFhu=$s&Dh{fwiqvpfIF01Za8Yo*hYRgkT4ci^OBa8L;l>`j&rJk>7qSfw9*mb7S@c?WkFkz z0lXti`CQQ=YWf%UPoqI>nZ~`@B326qM3<>3)9E+WiebYuj5K-Bf!P_St2HG0$5%>D zDeXknhp6MU@k}QyLc*U)zo&Nr?|a5GW2F3E^e5nq!#m@Zu;mI%V&GqM_@4zcoFWV6PBfEsYBT77bIK3{q zS`uwlwYpuVL(7vMVV9vtEgHQen~=3I3=cby8^~U*WSxi>H%dxB)V4s;MB%6zz&CQQ zK?C*u_xJBpET6i{R-$N^aQF@@@ec5s<*`+wQY8@_EfKtCy`ZLlaep13wA^ULup3i~ zs+QyiJc*WML;Nf{wfK}sbN#Tc%c3hvT~_UzZbq7@&S}G$X=e)gpqmX_`6g7`K>DbS zF3U$rNB{U9Dwu5RaK>}v6hEACj3VT+-NPC8#z+~xF1GuO@l=|fkBR`0jn^&}@dD0x zq8lS4+n7`OI7V|Yg2NOkq?C;k$%gKDGEI6&0cRXzG8j$51>Nz~Sm`>6DmdNoRJWAH z9N>)S+NJdp6>_@ciFT=gA#lbM;~|t!tYLOLP?`j!+5I7yL)Lh{U3nKcEZJs}Gu`pr zB#eC^J)H647{r1f&Nv2C3;1|AFfq=a@3&?9bEgm5=8a>>xZEVR^Im8^Rdp(l>~@2p zj8BC+1H}*yE4UdI4DZMmqp9WkYnGhac4{5j=O#++!cqusgE}Efb*T;i*);Fb?qj*( zSc=#@hSTeV?tGuiB&;#cJ8hO?AEZbfPMSJOSEbqr(dlKoa}R|JYT&)}RXcNF)b#z9Qd9H8iCf}0lS?bMNC-fpZ4J##fLg}*9RKw{X zG5QZxz8Q@e=OrU-8bQz93H`oc?=8>F{-# z2Huf7MEe$Ln$B!Jvx=3oMH>*hXwQLhKbV{rRy;+es;74PsJO$Nwjw7+Qv^P88g^6( zrk?F{<(NdL+qMFnM|3O0sPs60^)goe#1=bA%Ew;Z+t~Ckf++pieeC z(x;-0>f|XZb?K>nzNW3L)f=^uNY<*NdeSYeV4cPVYiF=Z1#h5653gRKes`&VR@0fA z&#YxTyh-bvhi#Rp#d_2tTz(05A>kdl(coDnec64Y|1&Fy;iEg%_6u>?pN`Qq3>v}9 zq%hyXr^-n2;x+!o&AuCV__nr$5e|hs2C8;A1 zoxW{tEOfhO;aAj6yUvn2Q07RMRM)?&e>;i$x?$DyLE_#ZaSVYji@O}D=vRnaeC?wC zIb_TWEX)xDONexR-MhAg$nxCqv9Ke>h67RMcREm}**oW=Eju)-dg@ z{*u|k9YBq!7Ep>ScoqArsE^#a}y+Rp>&hEsKImH)chP8ogiWlooOb6b@+!4j&77 z1D{D=&mGu3a0^LjjbR3HN;7Def^*em)%&pjITd-`P z`^=)j`8S2_4bY;7lL|+KCsz}*YKVyyW`Wt;6RYF{Emps#s{ zLCGV@wu-9Y)EfN(Nl-$CsSxg2cTzZ(N-1p(;duI-w(PtUTaFzHyLg3pPdO*jkFDu- z_`T)f1l~$y!(k?Bt?`~d;DRFbJN$Fj`=`UW(B|9H&VoSM2iri-1tl+MiAfe0?J(Y` zZy6STnuKWEQ5XzOJ7U~be1~cM&AwTUY!)lf?Jlb5-STq5VBxZhu6oL>^vtlGp0f$b zBeC?bmNLTe!ix!58Pv^2MO?NVsu?24G$2;&pV4?`{+Vii=B@1cATC z_Tr$)a0c&XvU>R>7M4a!-n`x5^P+xN`KPb;ZMyl)A-16H!P)bFoTUulU*zCxZ&%-z zXAgf)w*1>XkE0WNwfRP!gM%L28YF<3IXp(rQXjp zB~^zKF2ZWvikUBA;c~!&*IH`&O)RUXe?fl@S=Lh2Ur7ZFHhLwyAR|QcgnKqUlLoMG zEc}p2wAN69I9WxE$J{Yw0pIv_5^TccvT(+89ny4iVmRZN1(rk34QJemsbeP)2M)P) zrsB^W_lP-G?2UNGF~_Qs(g4mlX0#QYSfKV_FqMlvD>&nslmNId`X!hx$}SU}an5dI z7YB~X%wEvf?w{4@uUny8HP7Z2!V1V<1^&>z$ScC1YPHfDTSg7)0bPmNk8Kaz;n(W| zepH&$ti$oV(i(Z##Vbs-^a_Jw0tbxRJznGP?t)Nn%Tf*E>th!RfdBgq=y}K!Sp~ z#_=C9;wma@hEP?bD{D16?Mm;;UXHfI%7QywI5#Y=m#-M5t7ZtFF^^-|F(a6sg$;}R z>C>W$hXzz#_u8FeNL*20IYbIRGF*y5Cb+@*ixhQM(TQ~qBUF=W|01ZCxBIrXl2PqM zBgoFj+0sU{FD%ttPg=cb@MPxXu16h@RfoaP6`?1{04&|y*l`7Mkc!6s*nQyyUKx#@ zF_SeSN#Rv0+aI67??YoYZ?+4*DwLMm{_Xwisf;ey21oyQYgh8z=5gf#6BG$hqPa*S zDFWQZeUZ2?5EqdU#a$95O0widlBeP%6DP9vWt>W-YHD(eZ}N@2K{x6K-RLSSp>N+; z5)|Sl{09V3!Ay-W^ZUK-Zvjm^PGv59@B7&Ob@%JnuV25VLDS`=NHju9G(uLVJS6T? zKQJXNHtCd68{L_PzfpVWa9EHj0Zi1K6bJQActSa-o#TWhoD;sjeLpzoJ?GV7WpX~` zB;!Dss2LK^%FgMCIxDI#I!8s}!9JPfPP4qr?y2Iz+)2hE96e3t0m>xvnUf5tnFpAo zb(n#H*axviE|9r*!{P5PW8;6l^l2JvVL|yM1qszL5&&QkBw&tNjtlh2P~=6l z6arWYU!3CLu6ws{adg*ZL4X!SZ7E|0*c53IR3x*WON+9a~@J zr^hzs%UH*r_qteiOwmn-K)%50scqcBVWhMi-m`QOtOT+a0I?RRMDOMUM=@;iIGv;l z@Pri!9l}p6039O3T5JIf#Ii2WIO7U;ab_#!=^lWk5Kjn9o&Rj=i(@z1w@X)Vtk3rN zg6w$VIu&#yI9H7iWas!+Neda@3D@|9FC<{%G{UqnbHW#ZiJES;fl&(Uxdjn(=T->A zh8d~|pvPwSM0!n1zl*F=iI{-ux=WY{LB{iW;ZluOFV?+UjbQ|G?`ra4-%bL5z9P%lC= zqVoeH^eUAP2)PS!SwIX2tMZH=7&}F4FHm>A7Ao5yCWuH z!PyWN24A~arTO`9qSDSO8EB{ox zap>9aS*3@k4V=bHZO?wnueLEc?QrXQXL!1!561YpWTE}Y!{qHD93^qopH zcSS#c-FzyI<6PhwW)Of?$SOe9a+M`mwGmpE-k(TR)~*-7uI$Os7RJKZ3xQ<^^WfHM z<6*EOi=@38WmXikpE#`EgNZRv;61MVV8`jMt5S!}s%*gP4H5@%?by}pBkLXPXEzm* zV_!F#9b@tGur-;ff(o364nem9gdUX12V{qOC@#aU;C07Jm@KcdK4XVm_`P*FY-aKR zSsV61V8}UB&Rf&p9Q|eV8fRf7(b64dh^E-X*C7>Y4@VUkVQqW3P|H=t@`>ol7H5@t z60Q+F>8NYZxe|YN1MMUp-x-j$ZRdVp2yb0X7@N- z6Vfmw#T`gu`nxxOxhbQYO0=C&fuuCIt~c6NS2BZbbrl9Wz1_B5&Gs%?jzxA+@;m@& zF9%>xX~O~P+L^_-$fL$NcU6pzQe;q7l|k|_iZ?D1qDF~%IPLTljLn{EPUq-sR+ouz z>R6G$k`X04735kXgh*g5>R8zpbyB%6HWCv{%^9xcDMWX3bx(|7@)_iC4;`7J$_ROiu z-JeJ4119q>CADupjjef5b{xXm25#AdiLOa*)k1k%*@3$D>VILD+AYZg zRs>*K1c>KS#vKmVZimAG%YKST{aGD7FbjWufwxhG5;SHH!|X*LhFHJ6-Y_j~0NFx6 z1Q?DS0NKhi#P9cAoa!YRsqKJ^N7%)4XAtLcGt4g)mUr# z=hm_^x#&p?GcEw&WsrJ?P=##*-4$2)UQz36@gKznw3(wY?0k#>LdVkpn&1meyGrMk z$udt7Ch$Asgxa(ZY}ZPZf&bWLk|_VLY8wA+v)JgAS2=Rwjceb8gagngZ(y$UJC`VS z!Xg-A#(+FRTEVUGGbN65!p~;5=eEZv$}cA^T>x@mP8lx*scoQSP!Wyvy%OJFHn=-5 zt2;2OJD`Frlu$>0q(oWlkkA{P1CHT07$t%+1$lBU*l|FG^PbPdKWJwLW_D*`jA4whC@J;g{(Ot*^M z9Dy{YOjpJYVCwuU3wa=RHMT&CpOqy*lP9>GCQrRKl=kebji2BYFrG!Hf}8IKhGYFNKHx-JYy`}u-ictK+`N#qg5q(Q;daGyB0^CmCL+Ak-nA>w z!*XtvWkmsiBFeWS%gWgG;whh=wO5^@O>UtqiyY&?^j>YjJd$h@p|{^lsfs>O|$-C3*~Ej7t=~ zV3>+~jhcS7yg-*rNu86zbSFg@LR$6+d-^t~pFd*alYEE^4X7*cNpT(Yo<%FbhB|k6 zvphvWA2$H|Ko$h*nj4ogskcz7A(r9^%(XHaqHR&x?gwz82LwE98jk)^;tlR-<1&^< z(3bANZOcZc08i|PoXUc(b>mVLtyu-fmeZvTz$(-}fM(kdc&EMe7ZK8uw<=+Agw*t5 z_`&}OX|+4=+htrIX2!gd76M!6dTbsY=GwkC|ztCUO^ zlRC`SPau>`pO>gS=N_0k zH&=|l8TLGa^)z*2;$>qt=M*7!O5ClTC1;<*af4|p0Nn2MIfSx<=MWXz@UQe5 z+#$UNw0jL)_nMgMH888!K;4FpeS3{X!(Ich*8r@{UUN06A?x#y&{}pn>p+ydGHNt) z0VWUCGltY!8NjSA@uiE9-J8EP2F{R>v;WKtGG>MQ51>s8;F=c1oID5&$4YusY1T5F z>Fo()UIKKk!`}Ntb40qR8DdEqA@UB!P^7Ye1>u=#NFk2}vPyYTh;npHLXg)gp-HBC zF!o#HlnUoV#r+q;Px9&^xDcchfcEO3Tftf#sRBG=K=~EUYptW!yut z-y7pAT-WE4r)IW3dVT7mwVf-EbO?!dd$w^|aC>W`&11D2k6wNruUfK~mcLwhfR}R6 zhLg$%L{|@2WD0<>OH>W`(zz7C)uVs$o37h3)>n+ievWcr7yIvdTpz?O`MgXN_Clg+ zPnKZ?|No0;+snQG=B3YzFxy#*$T>U!WLpn-ugF=EkkK0wQwPBK_XCW<1K`lzg^1OBi~5>)*FN;&Q!cB?ssPEeSAYj(`@L+*0-kO{Z>Br?IE=V2Kj;CZr+> znCOJ(1CYJuSAeR30ntXMwu9Tnv^ZT(%q~leKpF?|5IqS1TTmYXSe1txpswhZ+3ly> zpR@1uz8(<+rZzcK>Dq`9-6)DYa zDhk6#wy$qXa?LfzCUXcO*SDy@meD2yKq_8GRiG~Y^<*pWB45zurV61S|v)&umN7-{bB(m%jtQE`T;4*Zmw9&5`JW}nn_>!c2AuS(z2(ITM4Jn$;?wc1nOLKrEnmGlBNm(S}GV?iLQ`|a$+k@zf(DB83pr7 zaS9iT2tsKqt1nnr2Xe)4UJ6(d{JP0+eY61oh#NqSKk3;xq%r zjUqlTd}0v)gp99NPlR-}tVuo-A#Iq=jMM|5<7Lgu@IOMv`Q4CRgOJBA9LU=EKOD$# zE8H0O5CE1Nes-0Ivti{?mEoL-6&pk9$b``xt=<@ZSrb2U)@BfY3EGg<3L@|P2UGb( zz_umjJZdVl3wI59?PRfZY>pefr%B~$7qfttyeJI795`b;D0odvE|uNg3hdLZA~2@( z3J{&zrN{w|i)&NlXsBuE9UW|>Hao|c!pFiueX((4be;@EF|_ZXC-j&n^pGd)5yNnz zq)-Xm;WAz!WbOKwM)M#=$54`Io|ohC>iI;lKHBthIUZcV`Q+JM^JFAmdS<`#2}B7R zlz_uL;9whxQea?^uuiIgn9^{8u?vVl0Omw5oBrVXveDSbF7zk5dSm_O(l6p&pUDot z@u|JT!;YT2!8b<7jrLJi!@UQPKF_eFYk7oxdq*7oV3^nn?t)vY&L(Uu&8Q>W0u5a2 z>KVS*z2+C4+{2dWmq<@%>p0GnJ<4sipkpDIEqY_!#>gBC&sz@fG%}CH;SInBDAa-K zuus|g^mKz27o0n95R`~Tx(mw@oJh#iX1JoBSUX-(M%)nXHrhtm=4i6EDSGkcm+{(V zIo7Hvt_*QQyb`axnl51EPT7%XzOULPTE})y<2|O4>94@p3@E)Pc1OpFqnVjxW$5c$ z&%caUjwcRRZ8W`Djvt;*=QElHA5Y~G!Iv}gh96^8xWw8xMp+jtB<;ybsIFd!SFWbH zVC;DU?%ub-A@+I^66A0QnCIEF%}wACa+9-1Xkud=3s!tVn(V~z0?h;5!fhsy8S0NM z;~obJi$&{hC%L*%W(d1=B7{4g^sY^_2~hWu($%I@9W6+8K&if`an}~Bhlke3lKms! zG?^D~#`|y4AdDzI*i8XzwG*$V=kM#jptw|pMr4RZNHuQm)usJd9P0?keL;L`^niAf8 zvkP;i{rhk?19Lfe>Y~L)%Me+5IN8vOw2tX3@rJ7jnA~8sS&E02)A@(I2y&@>*8dd( zxPj|~Xol}O0NnEg%%vl0agR?b5SR+17kS195*N%$DSNPx1jEdk|N@ak~V_;`Y;Gm5o$P^I{2Yk^tFa zT)j$%9YD5(`N4Rv^MfV4!H4h$l&Me&!4)iXgyoraj6G5^kO5p#N5jVCIb-5HCz#YblHN2jENPbhLZZ{F@9wsI3EvP za7j^0dr=_(Z5sfky_2AeC(?|G+5CqDippZ`HU`fa1A0qm7}GlBxsGe%R%iz{3Ex@0 z!Pw3VPel=P0M;@Gk#nkz05Rl~0+_R((9C5y@qoGHRlgBlG{%-Vl;xz_H9w4ZeWar8xIutkJ9UlgpKc(q zMw4+xlhKOKH3PH8%E!j?C+zeMIWVrP7{E<9F>!n}PhYex&^J+;CrZmxg4-62;j_k& z+SYQqfMcMDFhZ*f`jp6R&ORk734--+v`f~&DezTO>*jk@_8TuHfR>q4m*P!p%#uRH zO%3m$3%@&h4D0(>%%LLJ;ueRQuO4Suf{camC%#uEZD|0S?@i;pQk-RnpobP41+Os$ zFc&aSfUHclId=;|B=imA+)a}C7S<3p(iwC7LcC^GR3tOJ7!NL`{f9(}XFx*;Y@dR( z3880Jx(J`B*-OR%ZLB4G|z-@aVK!h@K^8R_6o2>9`Zp8)%l#a;)X#;fr;*U zbx!wNCEPp8m@&O-oL1)EoGEebD)yA!eMahYfy;x?AM6i1Ay z^Ty-_^3ZDXJ`QI7iQ$FMzr>0*jgOmHAz$Phqnf zx5WaoIJpL>E17B3K>q90D(B^5vhMivTjs>oc-;qz{Z&Twfy6OTT6bHeQM(hdtU;o| z#a(P`lU}oXmNprcZoqL8&$bqMn<$F`cx%E@9#R0Z2aD8=i2%%B8dg4|VaoJMmI25X zBcO|?AAoG758s*N`zFfq%JpAT)>mdr{OTqNfGlq3mRu=-Y@iS_9d!W^jnUEbhxg6W zmN%Ymrp{Koe2tAp$Ar;7N%Bwa(w$?rj_?95AsjtpdptZC_p&i!$v}Lat{ni= zSvO0|8ecSql$d!pzs&Qg@_`VlwVjf8rzUZ8FdV&j&o5Eb096uCWbcGAd(Aj=oip~K z$N+{@Llp3qLoFO=w4!jZ5;DxdaojRybgJW6n&QW7N6a?hcAqVncnZrEOxXA?8WWe; z)Y`k{3&I4lErA)32N3mbK+h#UHM4d18#v}PlUSMI(!O-sO)RY8>RO&1kI1(GC0zj! zYaNFmB#p^`Ekg1ij_tVCXc;q_m6%J*H1XnU%#E?SW=|J{*Phv`F`7q>;}Xae&QN7t z@54gEHAGsegN_8u73Gd?blw=S)-LEmKPdovMV_`PC9)A4;sxWB^1X3GL!dME9zt^h zY(az-xq67((#i~A170=8FOuyp(dQhBVdnra=dSbKGm&wWEI?h?o>}d2*>ZiAF@PMv zY6PfDxx@y1-Uutn!VXA;pAO!o1j&uw#g2H+7}M&Gly0;`eW}l|v@;*M%LZG9*}F(H zkiBfUi`Xf)roX{xA2-@2$WxOUQZafP10e%Ga4zInxjgJ6 zqRfQFv!Lf1DRWrai4Sl2NFcd`kL(H5MWEb4fQ!vuh%n8~T>C&>tI481+ECP|`SwSt zpZjr)%^U)!0&)QSIEU-lm)%Qsm#0Lt|D1i#2-m1{aFhX08fUK?vmcUyK9ai@Hz5yj zgx$_lIe2J~C3HzDS+jsjlN8#^UL@sm49mi&oCM-7Q@X{cm^bjL-&;!GApi$K z{sig@cFP?z2SKg?w8rcVLgpAZt;`rb5po4$HK9pz5CGfoQv#S*u^6;|NgWXA?r5>M z{lDKUV+zFb=SSaeyr<3=r=Rz%zsL0NFD9fB&Xj2$6)4Y2qBoK*bwwaeKv&YO^ zZ?=sat!hz;^~oGtj8`u?CknyLk@GzSa{z(G*R0d?)i*5@&X3zFr(F?(i=?g&@r8EE83gBxdGw#Sb|5*4BK&tMg-SV$GLagK4bB%s)e z0Y&4@@IUMf*_|(4F6L&ld)nwy!4@uCqhseGrY75^T_S8fDh#wwpr zzu)cR3g9+Z0Jpg!Rlp}p2T%F&1ov|ZZYlWF(^L_k4q8u#SQ07Qr5S#iBVjNHU*2@? z1F-0dSy&a5xGOw{^n{Vzx$FfO#F>!~j2Y#^Pt2{DS3Zf);r2qwaD|{Cqz}T%bftSf zC(||e3*1v>R|jDKSRAO@WkpJc&6%slv?R-1W2zGpY4cPwbbDIO(AV*k%@lgasXPE! z%>Y%rD9yNsg|pG&V;jvceibjDN*t-)IP3%*` zm7@DuSed?O9^NvzU_w}#c1phd#9@FYvvbPmI89M9{ZFv6!?wI9z!qk}ZMLNi*b-*e zO#?djPv0~E)?{rCnA<8Vxb6x#VGb`DL(A+Lj26f^r`Ev~bQGu2!pT9GIUF`8FBubS zT>HoGT#8rVcbv*OkIGXFh^{h8R}qQx7{jP zr`VCk5n;z#$u<5lc4Py&>}b&nq`jQ1Z+`v-bmVwFf>OY9awH%-^4@gKjwFQ5j-oE) z$PLSewkO<7xqWFc$9`lR(YQBRfi(6lcN8^yHZAv(A%V*2`_-9YTpmmfbLebIWK0aM zY{O{Hfd$*#O}Up#fSm5re8;q4ZO~(1at(U9rLjv|w<}=IS=!z=e@L45@)4;NYutr; za>i?(g@J|$nS~XJyMCm=8ZriP&1T_C@v^lv^RmSC00^$N^R$$l&_Tb#JEzV0kBvFY z4pxZpQD*rPveBDpl(>s>9cJHIqj!!hP4E4_dnAIp#Ae0NF9vA z;Z{1dMzj*IKIih=?6+N}{a^!IoRDvf-*MOR@-e3E*Jxg z6l6=H*YKlS(_!E$H67P=MZC12Z=Un9AU_#}+l6f9j*c4=!WGSD%^$u#pC+4I^O4K( zxrD%pjfBu=I1++qxIw7we>i9s*T3T0t=IsIi*@i;zj5yZ`vqyQoO&e?A610PeWahm(gxMND{xVXF&^{C< zd?B+Nww6zfGjQeD*5yLa_!+N#VNqVUi1S^-M5r?*&qJdbLLJZ!%&qa@;5BWuDc$W| zcdvcZILIoZ4=_>Jw0?J^$aZ*9e&kRcvGs}arky~e;cv=GI3U223w-w zs^q=Zi{$cY=y6U?gnR%eFjR{n({Z2gh#$Sj7&>PR>MJhwZwpZ!8|QggUvr=|^>LTU zzD_O&u{v*OWZv5A2*uRAq(;cH^IogM|P-05mW-1 zLqJ;%p_*XrH>mb%i#bG)68M!^#%rT%XNEeXxYi(5E4aDf*Tp#SGvCK4PA&mx*t?m=T1%KF*(Wewq>!xM-aRXkspyfCfQv1ZJHV4<*!2PT* z@90}KbCfJZIx!tz>Hqv~2(55&5Vpbvro_c~a%s~K)~qFPutqsuT(dYxz*Irwfwg9H zYzIw7XT<1G;or`!xTGOQqiM*vLDJB=Gs+C?zh-%S1LgJcjVqSNH^;~YBh-x19wAGd zamQ+^u2Ry5Bvw;6is$ScDJ!N*TUI-<46r@X5}~b86wb6pIR;UfPcIo`>LifRzX8Up z>E!kqy-QQ0&E2Klyv1Kfnf_)-{j^CQq?fD&LMtN-xcoc{Ai2g8a*n;`=8za__V-NSe{1M0e)+(>W@qOwd!NvL$;}xNW=}k z#_u}gZRmSko5r`e8zJHI8wW$rZaiBx0`+fv4LjwP|GD`6Za)6?J=AVh7Z>R@zQ*r8 zqi&P)#hRVU&};neGv3xXZ*gB;-D~`?G2YfYZ)pLpN&_C;I^-`>76IMMV(D%mIkFmY=RZ1}oPLbz5 zZ}SiOR(JfZ4^&2@%}%jaDb}|+wTVWD-uQ-h8d~1^&>V`jeviFTP04X7))uXgmK%Yt zH@MfX7MJLP6exDw>065wa}S}u`K=G6K(Q95Sd$bxv01vAX9W7+_y#bQZ++EL%$`fs z_<3LZnSE=QuXRUnM{gN{-Z#F!ou;<8K2QvOYj^tgGr!g$UvtmG@%FdA2Kict^R+f9 z))l=GT{QwH-}riW8e8A`>ZDkkQ|upntwzs+n)>7GH`ejBwm0}Cl$@#mnUiG|YmmM* zuist=LE7Ko=T3t4Z+%!{AP5HXtJMR#SyIkCIJkLx69q@#_{KoFzgd*~-#asT9?!S_ z!pQGhJ>QlA40^t;E{WXsdA{u|jNESWd^-Tp>-ly(5c$I6d4-Z+6nkEwB;@z4~3RSHZ!8$Zc=ltJ@y0g1-F7?Rq@>4@GVV0fYLv z3FUh5+>LUCy?8za7{)W;7oLL17e#;puY$#RuEKLAo(Y3^ZUJn@GhsWP2LSu=OgNk$ zxpM^Y)S<|olDt=UO4Re6N%HKu# zyC^TuD1VRtNBMgwe-GvPd=KUCq5M6RzlZYoP+p!<{yzVY^7m2xKFag?KFZ%m`THn; zALZ|(ygZ})1O6Z7AE5jLl;`sUlz)Ko4^aLA%0EDPc}Dq%{6ES+MEQp(&*z6I{}AOL zqWnXYe~9w(jPj58f0TcO@{drS&yP_45z0S8`9~=K2<7D&r{{-cqp!^e*muHlJ%KxMMQ?vp>(}S1Boa3a)>jy3}=gGe!LyK1i2JOw9iWB+gPS z)kd*2tMJfmG~AUQMUS#gN#i@l!}O@yRFGD+sdW^qZqvAx3Tg+%YM-ZA-Mb0_)4L+V zHl3KOhg`#wT)miUgxr)Rxdt&e6>?2Wa*d=_GvuX7d2K1I!j;Nqu<33SYjLL>WmxO; zsy6e0qL^}PS5O@GThpd_k{UVm4ArlEl6r=AX8OI*S$%V_x2yA*ugTN*jAOUoMc;^NKWZ_T~`){W0zo%_y*x4-p$ zcr*8dv%mfHjoTkhEXcj`@tw~`=Uy4Pb>T<1ue@;kt(WIUgZev2qZTL4>sXi1)!}h@ zz3^b#@BUN2ySKx=+vV#x=H~8bp;T*A2NsqtrI?uShs0URO~Eu#ZHfahL2XJm<5sbX zO^`!UN;$>IDV3a3%PEbV(vp-Ku;?(w>S2nt@MIuQDXj5%rcF1Xcc${@IH3eO`Atw^ z5ovfu7t8D1gpTcy)Suw`y4@W!CHO2?Hzr+ zK6k&5*F)lH7mL(s;nTp>^rPg@|;TuLN+G3Q*lrPQray+XXr8 z@|yu_fVxb>oN#7%2eZL=-JYXf#OwGfr$=5E?c#7eWLZ?57~oUd+fVd)VNktYzV`OP z{Kb^1T99812)#J~hN-B*G;Jsh8wx`PXSB2|*m&Jo6jM^h@_ERnoC;pexnXp@ElxeJ zZEr_{wYT&7_I4P)--Bsmd;6dIT^=z<)85YZb--(-*T?au_I6iqZ=cVFbo9bo)lpBM z%SVRCGi+CfkIR5&E>m14+&C)&jRY{qy9K~^sNXR5S*_u@9Z^&Ib1hM`<+(jkeadsq z(KKs>u?jCUY!Sv5HD^SajA%h|h{+iBMVR9Ij6&rUkR#sY%h&@s|I^EuYz93p;Q@ow z$m@j#i~HJdgqMlin8Pi{A-Cks;%ba9U$f7}b-I1h3c$6Et3X)1Cb77*Goej0$debE`mG+`3M{c;uBPaj|j@(=Uoo~Cn!^xATl?Z_6TE- z3Xsg0Fgb}+l$!SZZUHA^Ns1!PC{* zi`#mi+Y7r%AU)xZw?29G=3D1E9W;r1n=n5%fy#PsS5KG6#leQ>^(32+oDh_ALaoJA zs=#*$kU-uN00NZ=J+iXLTF(qdm_l*E5~`Uqt}z(`O|V{an(T#~2hc?AW^gWH)B@wx zBv41n5om-gm9XCd4N%6|Z58l6(tsHa_d<${B&-ETkX~!Rp3NH zZW`noL6xZlwLy<+gIkX|tks?B){>MC)CoN<^`lxg0~HE36L4qAC2exbE~m2Plnzp9 zq%9+x18t?VxllV#&d--qI=2N{D1b6n){*d5>=BHv5b`oeU*q{j5`Gw&R8q|rLq9Wh z6qH>>dW&tYfL{sxO5wK>Ddx^Pn#z_zNgL~gx>@3Ak&flik{zUc6`#3SmI=y`AC$<& zvR+Vw=FUxkru2vCK*IhX9l0?zEHGg5;?|o}fGF|BC(qvuj^250VlMasDRJ}k=-lZS zq0p@lKXEd=#*0$X<>mF9+vD=V()ac8niDS0*WP!O*NSR{_cFt)TzE%{H!3I>^a3SX)kuy;ULl-bqG;MDM&uAps5H24nZw1%EnIiwhZb_0szAR;+> zK!NF#l$QxW>Lk8V`Xrl&Vj0YRSL{bOsws;usjvGfr^^2}WXj~e{r<&U@1LFf@!4DN z{rQclpUAWFzzMCXcgQtrWK?9XYZJ0+<4E=q6>!nQkQ;+tar3oFN7?FCHBK4bEols5 zBDT5n@BS9Af8X2!TSf(Tj+^6Ez}&k|a10dYRUB%GPNlGLw!AG;~x=87U;MSlbrlyRkqv|v~|K7)(ARX4KF4Zu4Y^XIPm#;*gV91NV0}uWs9o@O3D{n z&XPq6EBI?lNF|4q0VS&x*DI5-I#tiZUP_8Z-APHc8!2gxzAN@~C=rJSa3t@^gK?+= zOh7dV7-7d~m%)%Q+6V2O07LejcA2sKp;%NR7AhHMSO&N5Yv&`OghbIHHOz;85V%A@ z%^L7N2s1pS324BSL<3>bb^I_;i3xz}aa6INf_nXl zZoTpRtxrA$e}KH52`N%57Lm&&y?hVk&o|u=jB@;d@}deOL2a}8DJbd#WlkXgbUtymAl{7;agyK<7!_6zj?H@c2P9h zJ5PaKiTpU(oJs5Ub^0t#QzMxSnBYHFK0|XTT0xkh4RTq*E2>uWv@21PeWlyf>F#y+ zpWq%D-2SzUN1#dqv#Q$U=>xvxeWXf8^L^|$npwB0-w#t?WHpVu#^?*0bD9gfa8}co z^T;K?ot;bb8keUF+&~K*4*$I)=USmL?f`(qF>~L57ynJ8eGMCU5YN=FT6$9+YOj_C78$HeQ^88 z+zHxI9gKY&!7cwAuv49d2CdMecDQ_4{D(2_QLTs=TaxKCf8if3)|KOjkAWVvAXG;x^Pb2 z7lt|*(s)+LQWi3n&6;w?4u(v{A#JfBM2+C9l&KyHw*%6{6brzxK(zbILv4JlcAh+!2&s(_-AdTIyo5_LeGR3a)R z=@(F3~||^M^sc4{3(9z}HpB8)5e8ky^sz)$#ID3VA%_Zw+Xm zFX|yCpdHeUG&59y;i`P-xgggvQYhXx=4<+{`ZHZX^&a!C*z^6=01bNQ?*X~Y#OKaW z%nhHrGki*5@;9b_eDlJOZl3zN$h+ToUU>f?QIa@w^$w_h`^pc+T4aNXVCj^CWJzdx zE_n9##R<7%SSw-10rB@WNbMk|xS$oAjN~fy|Hb+gi(?h3*##$q$qVeNlQ%%%tn=LJ$E^P{zHeC&b4m=$x zZ4VoZf;-3bvnKo4o`|VpQa{B`Kk`KQktZUnpNJN(8Z|}pSB~l-b;cC409w$-jhQo+ zqG)c}bZ%uhw{oI2l3N>HQT0yC#g@ss$chcoyh`EkzCmqGjZx~9)R>tvTLljq(uGeY z=60MJq!PP3;h7lV@#i$j5|>|fByK~TWE8XW=|m~-L`I+pC{ame>GKRIXGy*Mz%(GP z1zUW%++RbkE}#wQh!RJS=mMH#TQs1JE8Ii+Wyn>e-qXG-veBVbVDadVT66_c zrLlmr_&!68{0sbIz=GAFZ0r0xKzhhrBdG@}&VH6^*YU_yz2nZsk8i#CKCr9!=5{z1 zE@voo;ln#WJd+seTTp!AYOiY;6<@d+fX%-7^INCByDau4dj**7>)*Y3@$CgQ zN4ZcN)g**jGZUcgc01RerDN2Jn~W+N37v4o zm|B6L1VJf+l?ZSbb7cUWDZGC7p2mj9np>N*RB9?_AG ziV6e6sUfgd3|GS2ns~C7XMmB{U_1^(7w8!WB-nW33Xx?%Hla&<2T}@Sb&}5>kPg-? zBMFwTat)gC=ELF!c~DNJTZ5{Ymdaca%_zn{oAa(tW!DFdz<)FBXZM`hbC0s=DuRts zTkZwZInzBVE2UzTiCRFddu3D9nme24oX%Sn&RaFXPHv6ltsm7zm%N$HExAXT(<(+A zXY-4u^Vf&-*H1B1U6K4pN1OgND{s7Xx~M)c(<6mNaDFX4@$jwW5niB>8lf7IE);_b=~6|sGs(N9rIx;d zI0bzk)J2e2f(rXLAQ997RWjZSTrY{j3JwfWEtc*yD4nbl8Dui2%Q9FXaw4g{33!sX zsFq4_#n6(5w1m`IbS=}8TwB6I`b(lI-6DriM6?W+1bMpvkM6%XZhgYh6OoP}J^nXH z4JF8D@N`CNC>XEs*06L0hI}>G?JF<8&B#3JnioW;knHBaq(t za6OajJ+n0HAemJ|S~iVM_h@>+c%dl;1#((kNfK_*ZS-7LK;MlKghdx?kxH>7rX@gv z*Wkkg1LOkDLKQFsv_1@-2yYmc%)V56K@OSEq&Z|xq#TKEtd-5krr6B$>IGO9l=UHj z$;D>HbtG`}i}TOg(51)P(Wxg<=e_K>$>q%Pe+5~3iMI=9b{55Ngb?k6Gz!td(8bm} zZcKe5n?Mp)yf5NYAoj^wBH3aR{CA6lGgeH(&!Dn&E=dfL3D%iBjHF6{Fg78^ESGeu zc@_F-z=Dr^fV>bch2LMjrD37}bI8gO5t=ZP+hf_e@9h|_HJ1E9deVcW; zyf;gtw(Pqpnmm0-Ym22*S@xiQHWL(xRNW3b*c3HpP8;*W#=Nnk7rM`NPgo=QRo9Hw z;HXN;8+#&bESWYs!{DDPy;6R;eDdJbzDW6&YsLo1H)oz6JT*90dCjykYIcn0h0WE| z=5=B7y2+Ct4Sq0qwKB4P&o%R20n?LV1EX50Yo&ubqQ=~@!(n5&i13*!&t86Z>fz6eBGtRE8C#;J%+t@F zdN!0(dEK-MdzKwGS5BMPgw1OvcYL(#gI%F5k4NhET{rKKX%HVoP`A)~>CoP%zS#Wq z$hMF;mY;gB8#jj7qj2I&XZxL=(v|%tD?e0FJ*f#7Y=~q%6g2$fLye<{rZXzT8I=={ zM>47>>2OBvWX%^DYdM+Qa8!f7cI?88lh0**~GdTqz$LdLY*s(E^vuVbj7s{`i zcm{O0h<(FMeo?4+&E#X@;)Y26)+m_tZQM3iKaE7X^VX8l>)4kh)BONfr`F44@^`ktC}Yv?&$H(^8N%%9Avw>I8WnAct%4 zP9$s+T`IsxG5~adM$`e4X;4xd(9GCFpWFlJea3QS;z6r3ijgt~{1-!-g!Z*d+CKpF)g|ZwatRnigE$8LkYSk; zU_g;k8o>{t4;YWbj6v_vZy|daU^c}IWV?J^7c6yMGW}?QrM?=h^ldMd+PUMc<`1sUsKRxxe;v7~v$XUT~{zJs8 z(A**co**1wg?ggK*<% z@6rHL!!pdDb^GvOuBf!&VOtNJ5cMVrl?|grK}>kd3-%NB zZBrV}n~(K@I2`D~6TAf1VCgoHm~i-pbFf`-YUfB-ryouo^maOY$J}tRT4EzkMj+57 zz{H)#ap=AN&Q9>wg2Z)d1z*8V%tTQQ2YGz%9xsP8js`m%=7F;eZZRAMR=(sBKy={O zl?KKeh$!vk`ur!n+*^pwK+uslJnber3pCx;gWg=OtDmR4cm=o#=x(^xLX-=lQp>sk zvX-IeVAX$({)dp*2*CKaQkk}(4#b%)8=b-V_b8RF1_Z1%`|Qp$JI79ra}jI#v~_jZ zx;kR54L0&73!44UhArh$XT|8QaVBgniJEi98pGzoX>(cFTsDz;-Ml(x06gG6$Xz*| zQyI>wjO0{-*FcwjYFDsvv}DGVKDzd$!Lf#yzB${_^bZA3Pc?jA`1d8hED7yBe0}Fr zh(YVK zWAs?b(<^s`SMK;+@%h%s%01&ska5$F zny{m0viQ1V-CYgTh$#Wny;RCpbg2VY-cyeYV?Bxdz6Y+IF2HlO3CJHQE$#MujPQFJc4I%VTUV*TJm zV7hU|F1q5v30UK1hDtDiXsIp;g*QZ&57Dn>N`SITgn|y;-LiY!m~jrTzo2l_!!~`{= zHmeZb*h(;J!C`V4p2WL;97h=B_M)Ts{ zUtUEF74ADQ7BHY%;6?)Z3868w$f3dM2B$)RPcM*XNE8eF=Fv>S163@I8X6Et(IOym zz#|xXbieSu1ukv6gEa>1~0 zq`nmdk$w4!Ioahk_L9!c+qSB8nI+ z4|XJul_TP73R3Wmi!8pp39RLWtBdmzqVf!;xu0OQ=Mel9tD(!eHcn$N>SBqLF9sYl zk`iHABQI&`5hPGG0FW}-M7$8YE|_{3j@e~LZKd}pMT&EjnX%=aKRV7%mu?J~Zk%eJ zet2K_;eDZ!{gM3Ei0uG)na%mr=Ay8L#=k+tOh!uP8OypuoKOKIVny|I`Oa|p&d=*Yj~|GX9}JsINA+VnXDxZ-*6G6faAAGK zQXjPzOj}FC*3#LP6(C}w<#qVCar@`Y7aJe@Koi>cjk^Z5J^Ob`ZARwZ9Ozk02|Wg3 zp|ehFZDIH;*l@KGKa&VPb$!d92vL;@qa{r=?KXRpQi z#2*nF6O~5vbO|^Yh0eqe5YXuZajGBu=;qHxg(GHi&v0Ffr4WlyW)T+AGa`Kv2BpYJ z@EcPv-k5p`qF)P;p`IsvX9HaEv-iP~2Ir;YgG|m0CI8t1!bn@Lg}Ado|3vZ%=MZpW z1zk1C5&%w#i$M6EX8=>^Z{(f@;M61psfcGNNqsNY78@7bIRzaYeC_6L?}-O9M9OpS zjy{eZT(=CH{F-`KarbY8*1*M6MVTElY1Z$zo^3nR_UhqCTG9BXOS?j8t3j`d+KZ!> zjA%Cad(hngkN4Ab=nBwDu7Jcii=@sc_X_+ov`LQELbHo6u%s0o;4J`48mzz}I7-ga zuPR0~UmxnSiX+$JxzopF&s>tJ@*2$_L zs{}h*mCRC?`nJ@@2IWy1x5NXoqL%PZR(%2xt>Vl9b+Wuc=mpE1SM4XC2jg9|!WCeJ ztNd@m0OehYC)M8?730&v7v`p(i*LuJJZJG57Yy%(8&ou_cj?beR2T~;mYNS zdznKMe1W!tv$zI+^N#?Kt#42qhv>MXAEz1Ea3)$8&M48p$0NHTu(3g$Byq(}UyXeBYwzry{T3+)L8y5rEe?{D{mT>RCO zp(opZ_0*-bu;b8|Ub*_p=F6M^!M1UH+n8m{ zbuKex+ZZ$g0FA|hI+dVeeUUqxe}bc=9+8fO3q)(L0qFE5T771%LaI3!ZbH zu(4v=SRFQ2Pbxl2{UG(aaWgulx6q)MROe8+W>g0;~VYky;MfWNwE%}@7-9@-gMyDMC{>#jkILO`q2gQGj$a@Qn1 zetEBuvTyRDC-1a=TRC%?77q{qzs=-?H*AN;aps!=IJ1*|jL7RBYi`-q-umQTI3q$9 z4f!S(_M0eIcVnsm!BqrAA76_pvZN|7#UUs{@B#wz)#BTj`Ut`QL4Zqx`y~P%fdfG% z0`zim>m`7VdGEvIK2QB4(+r0&tVK~chhZswG8$F=#DN$E zw<+H|-eT!?)!K=U7zMXUFS$+m=kY1Fu0pk9vMxr!?dl40`+RNa@q?jn911;tD7@?N zJm$m>)6`-5Pw2bKQk6BD3$uXtb@fwqA9M@pJ^MoY5B{>BJk08BV|8I&!MqAe#rD$m zs*G7{R!jwVm|J_^SnD}`OoLBa=}CuAa3EO7GvJev%5{u0=jvlA_>@ZNQ-j@MoqgVf zkNtErt}?Glcrv`VO?aHuWz4IvQ0$xIS2JE=7IGX3U zYjUXcWPEUk@xe$nn1W+6_LvS+dVFWVl#$BLi=|*HmCDGDVNGy->9gn4Fx5yGsq|N^ zp{9f3h9_baJcJJa$$h*F3xceL23!#61Xv(8l<)$7^SHu5%Lgc2!-lw?AD|>L?n)>D znmX-Y0i|z$@1wcX%YG?1_ws8ue*O~tTL60^KNguE2_ZsO2 zj9B^?7QnNS0&s-C>nON^-=z@7PNq;0grn?t)hfDV(jTMX7HV$4kN4ll;NNFx lE%zuIdzbs|JvfHEue|8^dR@^_PW_@>(@>}U#Tpvm{|9Rl0q_6- literal 0 HcmV?d00001 diff --git a/backend/__pycache__/multimodal_processor.cpython-312.pyc b/backend/__pycache__/multimodal_processor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..556c2e9137a4a1114557d5de9e0889826cd13208 GIT binary patch literal 17309 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 f<8$hGe$zt)ZuYIc>e~rzK7Ux!{ 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 z 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

nG4acbge?q;+xj>{fIPG6dFcW>q~%-vEug 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 G_WTd6dyL=! literal 0 HcmV?d00001 diff --git a/backend/__pycache__/performance_manager.cpython-312.pyc b/backend/__pycache__/performance_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0545cb7d539b35ac27613bf81b910a8acb55c8d8 GIT binary patch literal 67152 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 a4hkRUI+{zYANoZKgO27}>xVTW!v6(@%>F+B literal 0 HcmV?d00001 diff --git a/backend/__pycache__/plugin_manager.cpython-312.pyc b/backend/__pycache__/plugin_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2e42c6eb109e5aec6abe2b796e21c6b7152bb037 GIT binary patch literal 59837 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 GCH)^MmmWs| literal 0 HcmV?d00001 diff --git a/backend/__pycache__/search_manager.cpython-312.pyc b/backend/__pycache__/search_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8f6ece44690c07f7674e82c758a28308cbe99ea GIT binary patch literal 77922 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 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/subscription_manager.cpython-312.pyc b/backend/__pycache__/subscription_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48a83547729881ccdd1226db4339b78283972de3 GIT binary patch literal 79075 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Ξ7 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 TsmiH0vql6fY=2^&TukQQDzX(2 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/tenant_manager.cpython-312.pyc b/backend/__pycache__/tenant_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3363b77ea0e789f5421333b60e6b4c1185eb7e9f GIT binary patch literal 61828 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 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/test_phase8_task1.cpython-312.pyc b/backend/__pycache__/test_phase8_task1.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40d7b75f4d1149909a4bb1697f9f43030dc48346 GIT binary patch literal 14779 zcmdU0dvp`mnIB1`*H{lfWMdhO$H-s{u)!~k9TJ{N$|E!>c>pC@Tw(K2AA$a8; zCn3SGB$zZUCWIhw$y!M$n4UI}5H@U+vzv4FpHVH2-6?V zXGS9<)6(r9?HoL#x%a!@{qAG#_q)IE@}E{KL&5bwSI!0cDkzqZK`($-tG832Xt8c zxmb^hvqE|v=(oW$-(SGlpR1xM-lV1c`5%z#DGhZ{dr<4ni;O}7vpEzQ_@6?;M)=)jv&L{K?dzwExU7$*X6lE(|i$sIA-G8Zp1sANGX(L2oeZ z>F@@YPZ&=17h5wgt2J5rhAZOcoiF}C zn%6yal*TqWv)WV=rL)__&4--qGlfpurJCsr(y7pQ&bzJUO3yEpWqS0gl4arfvUF50 zOPr=S_Kf=L%Q_EsQ10?bF_b3D8(GoFijz8ecIxG8cYet}$o}2^ixeqMVryHZg_<-g z;(#nzy=z&raFZt2)YNoap7e*p?8eTHE^qLlXo`gVyl3UgT2a@-U!bM_=-C!Ff*du= zA|C{fVraO9?aslkJK7_y8QGU!n*4aEh5dt4VDjjP6TL5j4EpME8R8(oXQcW*g!B^9 z(mHnP^~bPP}m!eghV^<4|PU(pWlP)35iVF?PoxMy$3z0v}lds?vZs{+5;gOCWJ&?yFchQ zh(-l3kWLeg`}|?we&|!9tj0*xKnf8Fh0wYQa}KUM4)kGox)7#X?Fj>G&V zX&6Wi9WX@v5-5lqL+HN&dh}A`w7G9-k}kYSn+0Z3l6JuloG@-LAF;c7AH8j$^o+oi zC+S6Fw!%@HD`9hu*-PU~uRJAuFEFE{>=m~tt=-W32$8f@#GW47HMlFbRbW;o=~|@k zv4JILmz`QRP%1E$NxBLpZ5=S2%{i6R-y<-Klk}1~q)@@?Q`G}y0^?57OF>Gol*J6A z?5YI2N+@d*nAJ(TSuzo+9>kzP$I*v7CG%XQ4nh2yDX$+;bLU zJr%KMhV~5ZiES5{`Xt>TDXC7j2c} z%s_pHP1LinGwv~}N|r>8*=I3eR%7=0$1OOc^n`{wwv?V@1OQK#s_>+=|HT_)2~Ylt z`MX1tV6Ylc*0r$ecJXLIh?K$&w*Iy3#O150qp?{G0D_mf$RXDFU+{Kxwfk!TM?UG+ z;q_27aGf3AKu}}?ArIh!4ljRDL1CmOqErSYcg)~+tJ%$)T38;T!88CQ1jG2%b3iTt z0Az_#G5{pHh|I*X!>Nm(Ahd?QFAT^qIYH7}a4N24)Ak+g)P>>HwV$-GBEtnk9^UWk zP$657@#x($a-T)kA;Gs|UtKvqr#^xQzxt$4GV}OF*Z@tu{XT)J zUwv|fAnLjr8U9Ti>7966B6+|V9wF+8jP-~ucl5Xy?`YYoI~up!%A>v@xR3F2oQDiO zzZ9jSO$(2}vzV{$K2NywIe$>BKv0i}XY{To5DbO+h!6F2NVMXT2PHw#k;aFfy>cps z#3I3hL}7?Wu-t9J^N}w@b|8yHONMR}u?m2pSzeV@GVQy^lz+>gl0p z2cM07S6~{FbR$9~oBQ{j44eq`f1kielr$%>YfiAuLRpKzJdmWk{mGp=^V|Y)sOd z)F>)<-e~UPMDF5PV7TbA^OAEU_krH;;BpnQov|$fvm!~aR9lf(JepUX$g37=){W$? z@7?@cWA06pRmg1^-gWs0mwq5LY!Yk_-!MHg4vDtMlcp!6!U{G;m@T$>#9lK8U>mbK zzM(Yw6@41e+pq8Up4@+8fB)_QK2|Kmq-868L=44E$hJRXSbOl*g~ z1dQ#rfM5RG!FHUHGjV2);d0JfK9IP=9yQ9F3)$Qs_OoZlD4F;`<&|?m9`)F)Yfkw* z+2>;{mxb?-rg6ic;MLNaRQ9OZv+P(gtzV6|ioyzFiz;XvaLGJ1E>YD}9HqH@&RGJ8 zkt=jI<`{?oVd7yW?_RJ! z9PSFWtXQG)*DKmPTRUsIg00d-JpAYytgEesLHo9t8H?Q2c4}LV(`5J1y-E7T% zznAm#>_4)5y}svK`OZj?Yhfk12iTr&pq7H6PJjkbO;zQ_NGROd!EV{Xws%4~uG0NL zcS*Kt_qNuwb+t-Mw`f8(dLn$gm?PPPU#t?W@c260J9*I_0v8_S0B1-Ej1O1m@r6R7 zS^C`u&IGgtf}SpjCV2L?MixNFVsDLmlXRAvE|Hk4ZD0bpZ-u8CzjAfz$S>3>6Xyp> ziZlueKv5}%QP_^hnRtBl07<-!Y|h;R#OdSG5NxSuC*OqFPCrq>Zrrg00S5%MQDaCR zO2y%qqi5Nw2=-&CpP!sO|1a(c+9iQULju$Pd}wN@uZ5ZZ0@Av5pvKLp9J*a1TlVta zAQuR>@@qkiUyp7Z;3is-GnO;OT=g63dF+ER2ypAuj#xCwCHQirL<>h90aVt;|WQ)MEza?DXWR!}=u)HGIHK33uyD{LBbtQsqBn8~B^ zs%}x{JVWnyZaXkdQ&QaT7juc z()CJeU81B;aMly2eGjK4lPQ@z2!raCN+CK2q(*aGi5%qWa%y@vVwdVVw=u5!*>_?s z@tp#*DoHot)CB`OVvd+=VAsIbScsVVWlq8I2aY@-&_#FqG{h!ipZ3Q8Cw}%<=PNSe zXKG)D7>N5}ED!@B`9x@`a?R<55Cd^PUdsaVf0D77lbNw{wv3jl`3N&ahwi7P>d0Ak z|CY5UAX1i%Wyre+%kXFL8L|h?i0c%D6-FW>uH(q?l6u(TH^2^G%?%Vr4cYsGD}tP? z;mlkSJCR|Zn11P#voa_3y9G>xe_I|tL7nqZJ2XjsiR?GpSI(Ip~TYC9RXtw%O|>e%qpF%YJ8Ma5P)LJGsedZ;{$%K zhf$R*j56FJ!hykvFq}*JU6H}EESQUB$=>pPTEIq&RoDpmyb}ZJXx?4esG&tVbU>sC zHp1KW2Eaz-Bmz-`P?62cbp(RzFp&WShN#Eyyb(IaO%pCd?*}TQU8H$HGC*hqpASHA z2DYBqEaY#AwzOsx1ml;3hBRQ98hT?kV8ACKAU0<2@OSL>^Jx`5}e0*vNY!mXQj105rq0AZ`2 z^-i39mz>@}CKw@r*yi_zMFWZSM35s#eIbPy>)6!si{n?{B>~Cl!H*_SUjVoynbasj zR8lMH8>H3ne87Le&nwL$IZ}KpgF|VkStZBl-i*T`7ie7S(mP7*3LBnj&ZT$8KYpF% z(egvcf*jJ#9yF|cEhM{bD&S)!s_Ki?4ug)l#n=k@vgU119p>C$4p1h7=b5Ez9!QVLDEb5!8TNJMKP$b~0PooFHk zpy-xVrKsr;8J{;OpPO+Zqb+Kcq9zMjaCpo4SylD^Tk1~Kabu} zLMtS2Qz}$8T{j9(|4<#bAutERT+iIibF`jpJJHs^CrzO|Hc)wX`Kjdt3k3|32;8b1 zEvZhFR0~ds8X3d6u+eUKN&&gxl zFBQ?8rXpHTwD#{B*c;0mDjF<`nc{T(iHkem+Zo>?*qUybRwGKH`L-z?FU)t2<}Xd; zFO4@3w_SICv2!H<>E10E^;O5);?PZqrkM>kap288{LJOumv&z-9qjSewjVaD1uTLjvo4( zq*%`9e1Mz-Tp2+kRTxU;6jihf06?i6DNXJwLPHI4ZL)LDr~~L1E*H5~F7GrD3~HCG zsQB((D#ue4Wy&`IW`{F3gYq=#s4OayUTs2FO$iz?JP!ivjk(0Xc&iJ(-lbnCZ$RQ|&{R=-sa!1um7)pJh2|5ZLR*||uv}OzEZ-kjCh>lsxj6z=9o9;EE#joA1ijw*eSj9Hf7QqZs#i$ zj@l<$9i!HYgta2JYs9*u_hIBd87b%igwEoa1)xOSCphbdT?(y&G6c3MQL_3;<8U^>7gzG_J(L(~WHc78jx5M$nZz!$)DNUbg%(ZmXwKm~ed)@rS>JitI2~%00 zv47scViNKo;f~6fGhQBlINmh8=%dz4PkqrSG&~_Z@jbz|=4^jB_49BKE*`a7x&bsKLT3GfMqvbqG zhcw3wuvbL&&bnanr{%>zL%VJ^k8!LLcA*-c1wC0^a#WL54;|HH?zf?QRwtBpSSq)y zkIp(ja?s#5lJk34UV%gQ#BIPTGx6F5yr(6;0-Y;M?=e%Ce=#|9b^6pf*h|SdBq*CL zhir!+C)F3jWwrwgn-11md4EUe^L}ZIBg(I2&_yffZ}%%l_+{oY`ti$4nK{|kSiGjN-|Z&;Y5%MgH9f93!ai`Z`{oxZ8hEao`IECr*M z@`R;4=8o@5TAFTJ@&t!F?vBqF@*8hhR?Qee@@ora${RD;M@^*(Q|Uk;?o68MaTF2a zkg@Lzw%Vkr4ixJxqqH+YI|tzCTLu2WKy7Tkz*OI$YX}qtE0qh)4m`Vm@K=JO9Nj~~ zpQe@S(VD3&=enq{GP70CP!m?>X*w0=xH9|Fse8cE9vyrsFzZ{j5CcS;Jba~PGQJM= z|9g-aApn@FlQF6}tm1g1aPSRUkY@AXi;8Mxo(le-3Vo1vQDKD_t)Tej8BCx7fiN}n zgcv5>ZtO7tXQxElWZTmx-!#-N)PwpT>5pw7&jXqR)C;;@)B(+``ie8^GtI6~)^m`) zTMx9Z{$8y+K>cNP9gu8K+brAeHsh!a1eVA`kUD$~)&fFqt%h#+OaHGAUPZPmp&^L` z!v773oB=7vr(T%fpE<@#HRChWfRA7%QDl`NXG|vWIz=K?Evy_SS^`o$I{UzHL$_9R zL#|ykKl}osMSvoKqs!p*{U}H%n(--NkN*W9UKwP3p{y&SwxbsQ>O_s7e+|^!4JK%G zu!w&H@*FTKz z1wZ=Qk|yWZ4$4wEqt#jKZ|2S$FvptXZ8vh8XY`Qz$J=>u|AwOV3-OXv5ql!GQLt4d zP43^BioZ1Fjhf~sO!LXw_3exq4V7fqnH#`{UxP4Ya)l4Z%ZZs6glFKmYmv|9t=DoIdS;O-oZ_@cGZVPi)>24EqoCL-^o)=ivznhF!o2tOp|` zgmh5SBVk)Q1RIf#NXsx%TaHof zJvtamCzPb_5_Ux@NCPPdiHx2M7|pPk^HW7lLK<*L?UkY-zDuMeq#eQtX^-x8-H4QH zk4uhYCuE1P;}Vm~(*+aM-42)SXutc#LFaMf-hPXVG`1M`TU-OidSmQ&zh1oY;o>_V z$1cwpYmAH6e{+B0m-pu0U%WoGc=Oi6Z*MQ$o>bpQT6deIjBc-GWQcUQ_fpPdHiD!W z{XS2x%SzdX-8QFVm&IW@N>WzdMz%o_B7^@Ao4|h;FgK4Z1{fq(Afk#;5>dsxNE-XG z%Y|N|C1f*lLGBo)!rUpsm=~K-#786|*-C*b5nCt1*DJBB1SyGD@X?r5Bnx+^3LM_} zN|yTGE8U76Zp5%LnP{HME3<2anThriMyO#VNjAVpDvYEjc!eH}Xs(E?KhrG7Ublq^M`pomZ~c;(4a5a*7F zKg+9lF8*w<^11kPgte^{8AR?_3gD8YhImuJwrs&CiPoJf^zHdVFVWfy6Mdma-qaN>N?p;S z)MvD4?Q*+CKGTToSeF#>das6P$O9qlYU7nuf&qO!qTAOk*C0dy)YnxQ_6F?PV`*br zkuH@t4RCGrYKbN!1NzO-ZxQqmtxM`=nOwXdv?aAO9*1C=#NM#nCWwp;M7t>K*DvQu zn)Mws8{?6r6Rn}st9!19-UR$j%XKczGLif);BR>j{zBk?YE=PjHY}H!d?m!@Yy4?z zEL~*R7H|49>FG$KC+?-O43WgGqP;pp@a~GZY!f(E+6!Qfoln%ADboF-NOz_nd8ux1 zCbi3(No+^aIi?rQeaWl0Z(6Qt>1+}ICg9)v9Q=jA|5T5P$^}vPF9Kd9$BM&#okT5?PiblDJGAW_ig+G)hFIG3MBTX}-LHyt z=L(XS>h|WsZUHrUEKfA=pf}IHcexhWFJ6O@SCWn#f|2BPR%KP5Clo|5l4xBo5Us|Y?q~Rdy7Oe?cSoLF(4Wncm|IgUamR$ zNj$liW(MO{6^k?vd5i5wmP>}1b6k-ntOAc&mUAW7PdI0WQ&l|s?p~~H)K(*V2UZPEKF3B~I7fylV zj2KBDNiW0P{|_uVj>T_rj(N}%CVx}IUeGLDpISKkZuDYc^ro@H_&4{@gHD)FxRhGq z9*RR?R8~v$nk0~9Qoq%2n(Q6*Ixcfcm@mamDR=Ct+p@NI7sPFGNz(%WTp7G;B z&*^`@{DJZQ?TN+fpKwBAAI{!CHQr_%l^fxzY*cEj@}P%3Q=9P*pL)Z$A&D5etCZJ#;4JLnuuNwc+gD&+^HoSDuSVH zaWTm_(8VY+RV2L4=s{OQh?)=!L}UXZE)xi2$Od)&Qt)`#bZ*k+u5ptNi^E-0SGVpy zx{)%;85Kt|+XyCQc+lcNo%CVuiqH%IaCd}iH)XR7nhDE@%ah3)5cOS(-u{xiZ3Nxu z?h9eM@T<>bC*KwkIIMtxCL#{FtXw!Bi2nX$bo$rJ$P2eWh+UZkGCGrS05AUHtHt-f zh>m|0_029!PsV&-a7yD2=0?RsFrUSuLBIkOg5MXf&qlxckV$h>7DxQb^Zp6Y#3+Ys z4v(8;mvQg+A1_|NzWCJ)Fq;$%EENF{KN3nAwcF_ipIV(RH>2vMNXr1>Jnk5k zwizh{=x4LRSoMcb898EkBr$Q2t`{JUoxgGK_5>GRoXvM$+r-GW>^{UO-=eHef)ub* zT85k+hnvBz9*QCz))5AeN0wHIwIR~o?<5|JE=~t{6Gbap!u?a{?*HZro2@#Y6dON# z@9Rs^58rJwGHI4U8wfP7t8ZA}*c8WVVd^?m08Uu+`k9#TEQl1!14rgULnsg2QX7S( zM#s-Bd>Y`Eny`>13NtA5#Oo1k;wD7LzYqekNf34an#<{6G!*%k2hy7)%)KLV`xegscJam+ zoIWr^$j|8elhI3lz)EEyAm)*R_>|b`ThUYR3R6(b_jw$IfMz`LSp)^HIF1}48IH}u z;umlJWAwcbqwl{P9sg%G!BHU-6PL~&RUntYJ}NzIWTTOFJG`aB)GdNU&Ux_=xPR$g zaPDY9zuP_RYFoROJYg9c9wck6&Y`s~kJU=LT%-BP2CWu{l^it5*<`2E5W1=S1ha=4 zWYio3>a$xEGJGEt#bMI8rJuA8I6ZD7DjPQ-HW{@G^uWs$sHG-pLLz$vKof;PyaII$ z@PHY(lY&<x2kkl+Io|X>~JFRMD+2 zGphUW+DA1OQwEur#{rWvae+m&fQfC6t%pr%^f=3$buISDiA5T*Y3 zN3qX+@r|VbSWz5A&jn(aC%OG0cHwi_4}c4H^53cd<_=F_FuRY(PM?fjKFytp?w|a4 z@taetjNlSB2-LB;UxGjG&E15>LY0ZVcRl*$2W>`E6Qh9R2r8#;a%i~EHb}zeHGujQ zI>0dML7NM3g}vOx$OcIVBX?MaNJj3q!OOlhm`XptnL% zag%1Tn<;ctOQDx`jB?-pSGxCZV-&|MgC0=qa$DRU7n91J58xLCn+f*=T*~Ai`TPk( zED@C~E5y9n<3g_+Q7BUAfJ~v|ACrM*u_Iu{(2#{1VH7r4po`J+*722)QFiU@-rIGE zEr(Ponya%q9ep-x2u_piYfdJOgM<6!;(yv4$DDuz2;_Tahi9l4@KJMHm6?FWY5B5h z=E@+2-Wo7k%P`uzNS=uFahWF&G^{>uii^>srS#ec2W^g{=6-OD6BZ*!M+gcXP?%x? zZZ@&aLSCEsyk-P<@cSBDCgQ~7q_rPXh$CwIEe>LkG#}^EnMs8)$5E0E1g@az`5Lyw z5eA;PBo!H{)5XZqd7M#_C%|eK+G~bj*(M{Eg6vKi;$9R(EV6+nM-8LugTpbak=^0B zo(}2`e}d+WVb4>VC03{tF~TTW&#&X#dN>#HF@Ot4FbZyazy!g=D&FN#{BWM;V(_?& zQ}`ZQ0@}_PoOdC?z)YcRrU)KO5xgYnXS7_sjeA40#0{FI`18O+9JoA#Zm7L1{zwVC zw+xbm%LsNGpYnNRKo#3OjNb9LnBDM-zrJ>Ogvn#6V$jUI3#hk2Dmr+$j>BJUoW$zy}Ecoh%K4_TEy z6`+%64Cgika~mSLt0LLO7Y5D^+%2gMmox@T8p9#hIJ+^J-8gHT&)(!yM|63#u7bw5 z)2p}7!?&)2omv&hzfp9xC;%X|gz(nKR||6V`QgmUU}hy+XyFTyocxHsAd+A8AQjWE zeuydcYM+u{TU8+YM#gARy5M(O|!(@`a8;7?cd4hww>Q~iK80Q z47{ur#S5$yS!4?8O$foPyhu*r-JHsBPE9bUCY;j{%xQ=eRREfXHGrl;?MsPAO7nEv zRGYt=)~*fVbzuHk?ZolPpI-baU0j=>>HH~SV_nc#N0%%Ko`aJ|E*_x^VK&NYumWXU zbugoa;+LHJJKuH$?;ij5aCuv>yp4vi&TJ29IuLcYzd2mi z7%Xd~OPlD-=8&cZQFr=tri-VFX;^LMx{#)Rg>bD3W>z7v^Nf@E7xNM3S(T9j1hU?= zG-ONtP1CJYt^OBiZEXl&%Yl4sa_r*Rgfl_XOuYJ7DnfQHApyQ<>1%?9HG!rZtyf!T z2j&f%dDu$*ElYA0)^yX=FVor`A$%vS zQYfi+RP=$v;RA0554=g|Hq7T*d>YpJ3jg42Td;B?UC~KvH-+%7XP8qJD4ewiO5UVsL#d^A;dl4x?z2ZjnqqJ|ZKw$x4IF|L`(D%XK!It>{(7eV`6fJa zp-1Lh=4^`|`;)#)(VHt{a5J1x;DW*IL4UZW{_~YH{H1R6lg}1!e%fZdkN%Jl9x35m z=JrsI-6jK*Vm1>_tJzGMVTh5rMqE@Y>KT$Ae9#jK`^-bViI|i&2i%yz$&0X2>?2Q# zrPswCV!;&joCI>wSqWK&#?59%we5tJWdHDyk&qO+(ne1&7Ax6nu?{dQ_BqQ;p0F}< zI0n0~Vh@1>qfWxtBfNbGuB!&gjnsMgiI(D;hA+qtiR8cJQi=Q_j!D#i#NeN90o(Qi zCOd_{ji1uJtvf3@TYsVXT=Rv_bDjSB>ENl^K{b4=o F{|{f&U&#Of literal 0 HcmV?d00001 diff --git a/backend/__pycache__/test_phase8_task4.cpython-312.pyc b/backend/__pycache__/test_phase8_task4.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4a2651058fcd613fb04596e095bf45b9a923387 GIT binary patch literal 15518 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 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/test_phase8_task5.cpython-312.pyc b/backend/__pycache__/test_phase8_task5.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7ef10ed8f2451cc075a03b289f2a39fc57d71e63 GIT binary patch literal 34225 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 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/test_phase8_task6.cpython-312.pyc b/backend/__pycache__/test_phase8_task6.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..63c112bde948ccd8c9530eba5ed032c5a7469fca GIT binary patch literal 39086 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 O^o#32J*nfT#Qz0yxIdTx literal 0 HcmV?d00001 diff --git a/backend/__pycache__/test_phase8_task8.cpython-312.pyc b/backend/__pycache__/test_phase8_task8.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba0f0ccb8f1e4ac9fd0087ae18df8d9931b5c2f6 GIT binary patch literal 37024 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 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/tingwu_client.cpython-312.pyc b/backend/__pycache__/tingwu_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc92539be6a75c26120768c5fb39948184a73256 GIT binary patch literal 7629 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 BB1-@O literal 0 HcmV?d00001 diff --git a/backend/__pycache__/workflow_manager.cpython-312.pyc b/backend/__pycache__/workflow_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c0871215e09fd73a9370dce92dd92a1f2ffcc4d GIT binary patch literal 65577 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 literal 0 HcmV?d00001 diff --git a/backend/ai_manager.py b/backend/ai_manager.py index b8e7a68..b1e5699 100644 --- a/backend/ai_manager.py +++ b/backend/ai_manager.py @@ -25,44 +25,44 @@ from enum import StrEnum import httpx # Database path -DB_PATH = os.path.join(os.path.dirname(__file__), "insightflow.db") +DB_PATH = os.path.join(os.path.dirname(__file__), "insightflow.db") class ModelType(StrEnum): """模型类型""" - CUSTOM_NER = "custom_ner" # 自定义实体识别 - MULTIMODAL = "multimodal" # 多模态 - SUMMARIZATION = "summarization" # 摘要 - PREDICTION = "prediction" # 预测 + CUSTOM_NER = "custom_ner" # 自定义实体识别 + MULTIMODAL = "multimodal" # 多模态 + SUMMARIZATION = "summarization" # 摘要 + PREDICTION = "prediction" # 预测 class ModelStatus(StrEnum): """模型状态""" - PENDING = "pending" - TRAINING = "training" - READY = "ready" - FAILED = "failed" - ARCHIVED = "archived" + PENDING = "pending" + TRAINING = "training" + READY = "ready" + FAILED = "failed" + ARCHIVED = "archived" class MultimodalProvider(StrEnum): """多模态模型提供商""" - GPT4V = "gpt-4-vision" - CLAUDE3 = "claude-3" - GEMINI = "gemini-pro-vision" - KIMI_VL = "kimi-vl" + GPT4V = "gpt-4-vision" + CLAUDE3 = "claude-3" + GEMINI = "gemini-pro-vision" + KIMI_VL = "kimi-vl" class PredictionType(StrEnum): """预测类型""" - TREND = "trend" # 趋势预测 - ANOMALY = "anomaly" # 异常检测 - ENTITY_GROWTH = "entity_growth" # 实体增长预测 - RELATION_EVOLUTION = "relation_evolution" # 关系演变预测 + TREND = "trend" # 趋势预测 + ANOMALY = "anomaly" # 异常检测 + ENTITY_GROWTH = "entity_growth" # 实体增长预测 + RELATION_EVOLUTION = "relation_evolution" # 关系演变预测 @dataclass @@ -204,17 +204,17 @@ class SmartSummary: class AIManager: """AI 能力管理主类""" - def __init__(self, db_path: str = DB_PATH) -> None: - self.db_path = db_path - self.kimi_api_key = os.getenv("KIMI_API_KEY", "") - self.kimi_base_url = os.getenv("KIMI_BASE_URL", "https://api.kimi.com/coding") - self.openai_api_key = os.getenv("OPENAI_API_KEY", "") - self.anthropic_api_key = os.getenv("ANTHROPIC_API_KEY", "") + def __init__(self, db_path: str = DB_PATH) -> None: + self.db_path = db_path + self.kimi_api_key = os.getenv("KIMI_API_KEY", "") + self.kimi_base_url = os.getenv("KIMI_BASE_URL", "https://api.kimi.com/coding") + self.openai_api_key = os.getenv("OPENAI_API_KEY", "") + self.anthropic_api_key = os.getenv("ANTHROPIC_API_KEY", "") def _get_db(self) -> sqlite3.Connection: """获取数据库连接""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn # ==================== 自定义模型训练 ==================== @@ -230,24 +230,24 @@ class AIManager: created_by: str, ) -> CustomModel: """创建自定义模型""" - model_id = f"cm_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + model_id = f"cm_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - model = CustomModel( - id=model_id, - tenant_id=tenant_id, - name=name, - description=description, - model_type=model_type, - status=ModelStatus.PENDING, - training_data=training_data, - hyperparameters=hyperparameters, - metrics={}, - model_path=None, - created_at=now, - updated_at=now, - trained_at=None, - created_by=created_by, + model = CustomModel( + id = model_id, + tenant_id = tenant_id, + name = name, + description = description, + model_type = model_type, + status = ModelStatus.PENDING, + training_data = training_data, + hyperparameters = hyperparameters, + metrics = {}, + model_path = None, + created_at = now, + updated_at = now, + trained_at = None, + created_by = created_by, ) with self._get_db() as conn: @@ -283,7 +283,7 @@ class AIManager: def get_custom_model(self, model_id: str) -> CustomModel | None: """获取自定义模型""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM custom_models WHERE id = ?", (model_id,)).fetchone() + row = conn.execute("SELECT * FROM custom_models WHERE id = ?", (model_id, )).fetchone() if not row: return None @@ -291,39 +291,39 @@ class AIManager: return self._row_to_custom_model(row) def list_custom_models( - self, tenant_id: str, model_type: ModelType | None = None, status: ModelStatus | None = None + self, tenant_id: str, model_type: ModelType | None = None, status: ModelStatus | None = None ) -> list[CustomModel]: """列出自定义模型""" - query = "SELECT * FROM custom_models WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM custom_models WHERE tenant_id = ?" + params = [tenant_id] if model_type: - query += " AND model_type = ?" + query += " AND model_type = ?" params.append(model_type.value) if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status.value) query += " ORDER BY created_at DESC" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_custom_model(row) for row in rows] def add_training_sample( - self, model_id: str, text: str, entities: list[dict], metadata: dict = None + self, model_id: str, text: str, entities: list[dict], metadata: dict = None ) -> TrainingSample: """添加训练样本""" - sample_id = f"ts_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + sample_id = f"ts_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - sample = TrainingSample( - id=sample_id, - model_id=model_id, - text=text, - entities=entities, - metadata=metadata or {}, - created_at=now, + sample = TrainingSample( + id = sample_id, + model_id = model_id, + text = text, + entities = entities, + metadata = metadata or {}, + created_at = now, ) with self._get_db() as conn: @@ -349,29 +349,29 @@ class AIManager: def get_training_samples(self, model_id: str) -> list[TrainingSample]: """获取训练样本""" with self._get_db() as conn: - rows = conn.execute( - "SELECT * FROM training_samples WHERE model_id = ? ORDER BY created_at", (model_id,) + rows = conn.execute( + "SELECT * FROM training_samples WHERE model_id = ? ORDER BY created_at", (model_id, ) ).fetchall() return [self._row_to_training_sample(row) for row in rows] async def train_custom_model(self, model_id: str) -> CustomModel: """训练自定义模型""" - model = self.get_custom_model(model_id) + model = self.get_custom_model(model_id) if not model: raise ValueError(f"Model {model_id} not found") # 更新状态为训练中 with self._get_db() as conn: conn.execute( - "UPDATE custom_models SET status = ?, updated_at = ? WHERE id = ?", + "UPDATE custom_models SET status = ?, updated_at = ? WHERE id = ?", (ModelStatus.TRAINING.value, datetime.now().isoformat(), model_id), ) conn.commit() try: # 获取训练样本 - samples = self.get_training_samples(model_id) + samples = self.get_training_samples(model_id) if len(samples) < 10: raise ValueError("至少需要 10 个训练样本") @@ -380,7 +380,7 @@ class AIManager: await asyncio.sleep(2) # 模拟训练时间 # 计算训练指标 - metrics = { + metrics = { "samples_count": len(samples), "epochs": model.hyperparameters.get("epochs", 10), "learning_rate": model.hyperparameters.get("learning_rate", 0.001), @@ -391,17 +391,17 @@ class AIManager: } # 保存模型(模拟) - model_path = f"models/{model_id}.bin" - os.makedirs("models", exist_ok=True) + model_path = f"models/{model_id}.bin" + os.makedirs("models", exist_ok = True) - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE custom_models - SET status = ?, metrics = ?, model_path = ?, trained_at = ?, updated_at = ? - WHERE id = ? + SET status = ?, metrics = ?, model_path = ?, trained_at = ?, updated_at = ? + WHERE id = ? """, (ModelStatus.READY.value, json.dumps(metrics), model_path, now, now, model_id), ) @@ -412,7 +412,7 @@ class AIManager: except Exception as e: with self._get_db() as conn: conn.execute( - "UPDATE custom_models SET status = ?, updated_at = ? WHERE id = ?", + "UPDATE custom_models SET status = ?, updated_at = ? WHERE id = ?", (ModelStatus.FAILED.value, datetime.now().isoformat(), model_id), ) conn.commit() @@ -420,49 +420,49 @@ class AIManager: async def predict_with_custom_model(self, model_id: str, text: str) -> list[dict]: """使用自定义模型进行预测""" - model = self.get_custom_model(model_id) + model = self.get_custom_model(model_id) if not model or model.status != ModelStatus.READY: raise ValueError(f"Model {model_id} not ready") # 模拟预测(实际项目中加载模型并进行推理) # 这里使用 LLM 模拟领域特定实体识别 - entity_types = model.training_data.get("entity_types", ["PERSON", "ORG", "TECH", "PROJECT"]) + entity_types = model.training_data.get("entity_types", ["PERSON", "ORG", "TECH", "PROJECT"]) - prompt = f"""从以下文本中提取实体,类型限定为: {", ".join(entity_types)} + prompt = f"""从以下文本中提取实体,类型限定为: {", ".join(entity_types)} 文本: {text} 以 JSON 格式返回实体列表: [{{"text": "实体文本", "label": "类型", "start": 0, "end": 5, "confidence": 0.95}}] 只返回 JSON 数组,不要其他内容。""" - headers = { + headers = { "Authorization": f"Bearer {self.kimi_api_key}", "Content-Type": "application/json", } - payload = { + payload = { "model": "k2p5", "messages": [{"role": "user", "content": prompt}], "temperature": 0.1, } async with httpx.AsyncClient() as client: - response = await client.post( + response = await client.post( f"{self.kimi_base_url}/v1/chat/completions", - headers=headers, - json=payload, - timeout=60.0, + headers = headers, + json = payload, + timeout = 60.0, ) response.raise_for_status() - result = response.json() - content = result["choices"][0]["message"]["content"] + result = response.json() + content = result["choices"][0]["message"]["content"] # 解析 JSON - json_match = re.search(r"\[.*?\]", content, re.DOTALL) + json_match = re.search(r"\[.*?\]", content, re.DOTALL) if json_match: try: - entities = json.loads(json_match.group()) + entities = json.loads(json_match.group()) return entities except (json.JSONDecodeError, ValueError): pass @@ -481,30 +481,30 @@ class AIManager: prompt: str, ) -> MultimodalAnalysis: """多模态分析""" - analysis_id = f"ma_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + analysis_id = f"ma_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() # 根据提供商调用不同的 API if provider == MultimodalProvider.GPT4V and self.openai_api_key: - result = await self._call_gpt4v(input_urls, prompt) + result = await self._call_gpt4v(input_urls, prompt) elif provider == MultimodalProvider.CLAUDE3 and self.anthropic_api_key: - result = await self._call_claude3(input_urls, prompt) + result = await self._call_claude3(input_urls, prompt) else: # 默认使用 Kimi - result = await self._call_kimi_multimodal(input_urls, prompt) + result = await self._call_kimi_multimodal(input_urls, prompt) - analysis = MultimodalAnalysis( - id=analysis_id, - tenant_id=tenant_id, - project_id=project_id, - provider=provider, - input_type=input_type, - input_urls=input_urls, - prompt=prompt, - result=result, - tokens_used=result.get("tokens_used", 0), - cost=result.get("cost", 0.0), - created_at=now, + analysis = MultimodalAnalysis( + id = analysis_id, + tenant_id = tenant_id, + project_id = project_id, + provider = provider, + input_type = input_type, + input_urls = input_urls, + prompt = prompt, + result = result, + tokens_used = result.get("tokens_used", 0), + cost = result.get("cost", 0.0), + created_at = now, ) with self._get_db() as conn: @@ -535,30 +535,30 @@ class AIManager: async def _call_gpt4v(self, image_urls: list[str], prompt: str) -> dict: """调用 GPT-4V""" - headers = { + headers = { "Authorization": f"Bearer {self.openai_api_key}", "Content-Type": "application/json", } - content = [{"type": "text", "text": prompt}] + content = [{"type": "text", "text": prompt}] for url in image_urls: content.append({"type": "image_url", "image_url": {"url": url}}) - payload = { + payload = { "model": "gpt-4-vision-preview", "messages": [{"role": "user", "content": content}], "max_tokens": 2000, } async with httpx.AsyncClient() as client: - response = await client.post( + response = await client.post( "https://api.openai.com/v1/chat/completions", - headers=headers, - json=payload, - timeout=120.0, + headers = headers, + json = payload, + timeout = 120.0, ) response.raise_for_status() - result = response.json() + result = response.json() return { "content": result["choices"][0]["message"]["content"], @@ -568,32 +568,32 @@ class AIManager: async def _call_claude3(self, image_urls: list[str], prompt: str) -> dict: """调用 Claude 3""" - headers = { + headers = { "x-api-key": self.anthropic_api_key, "Content-Type": "application/json", "anthropic-version": "2023-06-01", } - content = [] + content = [] for url in image_urls: content.append({"type": "image", "source": {"type": "url", "url": url}}) content.append({"type": "text", "text": prompt}) - payload = { + payload = { "model": "claude-3-opus-20240229", "max_tokens": 2000, "messages": [{"role": "user", "content": content}], } async with httpx.AsyncClient() as client: - response = await client.post( + response = await client.post( "https://api.anthropic.com/v1/messages", - headers=headers, - json=payload, - timeout=120.0, + headers = headers, + json = payload, + timeout = 120.0, ) response.raise_for_status() - result = response.json() + result = response.json() return { "content": result["content"][0]["text"], @@ -604,7 +604,7 @@ class AIManager: async def _call_kimi_multimodal(self, image_urls: list[str], prompt: str) -> dict: """调用 Kimi 多模态模型""" - headers = { + headers = { "Authorization": f"Bearer {self.kimi_api_key}", "Content-Type": "application/json", } @@ -612,23 +612,23 @@ class AIManager: # Kimi 目前可能不支持真正的多模态,这里模拟返回 # 实际实现时需要根据 Kimi API 更新 - content = f"图片 URL: {', '.join(image_urls)}\n\n{prompt}\n\n注意:请基于图片 URL 描述的内容进行回答。" + content = f"图片 URL: {', '.join(image_urls)}\n\n{prompt}\n\n注意:请基于图片 URL 描述的内容进行回答。" - payload = { + payload = { "model": "k2p5", "messages": [{"role": "user", "content": content}], "temperature": 0.3, } async with httpx.AsyncClient() as client: - response = await client.post( + response = await client.post( f"{self.kimi_base_url}/v1/chat/completions", - headers=headers, - json=payload, - timeout=60.0, + headers = headers, + json = payload, + timeout = 60.0, ) response.raise_for_status() - result = response.json() + result = response.json() return { "content": result["choices"][0]["message"]["content"], @@ -637,20 +637,20 @@ class AIManager: } def get_multimodal_analyses( - self, tenant_id: str, project_id: str | None = None + self, tenant_id: str, project_id: str | None = None ) -> list[MultimodalAnalysis]: """获取多模态分析历史""" - query = "SELECT * FROM multimodal_analyses WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM multimodal_analyses WHERE tenant_id = ?" + params = [tenant_id] if project_id: - query += " AND project_id = ?" + query += " AND project_id = ?" params.append(project_id) query += " ORDER BY created_at DESC" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_multimodal_analysis(row) for row in rows] # ==================== 智能摘要与问答(基于知识图谱的 RAG) ==================== @@ -666,21 +666,21 @@ class AIManager: generation_config: dict, ) -> KnowledgeGraphRAG: """创建知识图谱 RAG 配置""" - rag_id = f"kgr_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + rag_id = f"kgr_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - rag = KnowledgeGraphRAG( - id=rag_id, - tenant_id=tenant_id, - project_id=project_id, - name=name, - description=description, - kg_config=kg_config, - retrieval_config=retrieval_config, - generation_config=generation_config, - is_active=True, - created_at=now, - updated_at=now, + rag = KnowledgeGraphRAG( + id = rag_id, + tenant_id = tenant_id, + project_id = project_id, + name = name, + description = description, + kg_config = kg_config, + retrieval_config = retrieval_config, + generation_config = generation_config, + is_active = True, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -712,7 +712,7 @@ class AIManager: def get_kg_rag(self, rag_id: str) -> KnowledgeGraphRAG | None: """获取知识图谱 RAG 配置""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM kg_rag_configs WHERE id = ?", (rag_id,)).fetchone() + row = conn.execute("SELECT * FROM kg_rag_configs WHERE id = ?", (rag_id, )).fetchone() if not row: return None @@ -720,43 +720,43 @@ class AIManager: return self._row_to_kg_rag(row) def list_kg_rags( - self, tenant_id: str, project_id: str | None = None + self, tenant_id: str, project_id: str | None = None ) -> list[KnowledgeGraphRAG]: """列出知识图谱 RAG 配置""" - query = "SELECT * FROM kg_rag_configs WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM kg_rag_configs WHERE tenant_id = ?" + params = [tenant_id] if project_id: - query += " AND project_id = ?" + query += " AND project_id = ?" params.append(project_id) query += " ORDER BY created_at DESC" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_kg_rag(row) for row in rows] async def query_kg_rag( self, rag_id: str, query: str, project_entities: list[dict], project_relations: list[dict] ) -> RAGQuery: """基于知识图谱的 RAG 查询""" - start_time = time.time() + start_time = time.time() - rag = self.get_kg_rag(rag_id) + rag = self.get_kg_rag(rag_id) if not rag: raise ValueError(f"RAG config {rag_id} not found") # 1. 检索相关实体和关系 - retrieval_config = rag.retrieval_config - top_k = retrieval_config.get("top_k", 5) + retrieval_config = rag.retrieval_config + top_k = retrieval_config.get("top_k", 5) # 简单的语义检索(基于实体名称匹配) - query_lower = query.lower() - relevant_entities = [] + query_lower = query.lower() + relevant_entities = [] for entity in project_entities: - score = 0 - name = entity.get("name", "").lower() - definition = entity.get("definition", "").lower() + score = 0 + name = entity.get("name", "").lower() + definition = entity.get("definition", "").lower() if name in query_lower or any(word in name for word in query_lower.split()): score += 0.5 @@ -766,12 +766,12 @@ class AIManager: if score > 0: relevant_entities.append({**entity, "relevance_score": score}) - relevant_entities.sort(key=lambda x: x["relevance_score"], reverse=True) - relevant_entities = relevant_entities[:top_k] + relevant_entities.sort(key = lambda x: x["relevance_score"], reverse = True) + relevant_entities = relevant_entities[:top_k] # 检索相关关系 - relevant_relations = [] - entity_ids = {e["id"] for e in relevant_entities} + relevant_relations = [] + entity_ids = {e["id"] for e in relevant_entities} for relation in project_relations: if ( relation.get("source_entity_id") in entity_ids @@ -780,16 +780,16 @@ class AIManager: relevant_relations.append(relation) # 2. 构建上下文 - context = {"entities": relevant_entities, "relations": relevant_relations[:10]} + context = {"entities": relevant_entities, "relations": relevant_relations[:10]} - context_text = self._build_kg_context(relevant_entities, relevant_relations) + context_text = self._build_kg_context(relevant_entities, relevant_relations) # 3. 生成回答 - generation_config = rag.generation_config - temperature = generation_config.get("temperature", 0.3) - max_tokens = generation_config.get("max_tokens", 1000) + generation_config = rag.generation_config + temperature = generation_config.get("temperature", 0.3) + max_tokens = generation_config.get("max_tokens", 1000) - prompt = f"""基于以下知识图谱信息回答问题: + prompt = f"""基于以下知识图谱信息回答问题: ## 知识图谱上下文 {context_text} @@ -803,12 +803,12 @@ class AIManager: 2. 如果涉及多个实体,说明它们之间的关联 3. 保持简洁专业""" - headers = { + headers = { "Authorization": f"Bearer {self.kimi_api_key}", "Content-Type": "application/json", } - payload = { + payload = { "model": "k2p5", "messages": [{"role": "user", "content": prompt}], "temperature": temperature, @@ -816,44 +816,44 @@ class AIManager: } async with httpx.AsyncClient() as client: - response = await client.post( + response = await client.post( f"{self.kimi_base_url}/v1/chat/completions", - headers=headers, - json=payload, - timeout=60.0, + headers = headers, + json = payload, + timeout = 60.0, ) response.raise_for_status() - result = response.json() + result = response.json() - answer = result["choices"][0]["message"]["content"] - tokens_used = result["usage"]["total_tokens"] + answer = result["choices"][0]["message"]["content"] + tokens_used = result["usage"]["total_tokens"] - latency_ms = int((time.time() - start_time) * 1000) + latency_ms = int((time.time() - start_time) * 1000) # 4. 保存查询记录 - query_id = f"rq_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + query_id = f"rq_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - sources = [ + sources = [ {"entity_id": e["id"], "entity_name": e["name"], "score": e["relevance_score"]} for e in relevant_entities ] - rag_query = RAGQuery( - id=query_id, - rag_id=rag_id, - query=query, - context=context, - answer=answer, - sources=sources, - confidence=( + rag_query = RAGQuery( + id = query_id, + rag_id = rag_id, + query = query, + context = context, + answer = answer, + sources = sources, + confidence = ( sum(e["relevance_score"] for e in relevant_entities) / len(relevant_entities) if relevant_entities else 0 ), - tokens_used=tokens_used, - latency_ms=latency_ms, - created_at=now, + tokens_used = tokens_used, + latency_ms = latency_ms, + created_at = now, ) with self._get_db() as conn: @@ -883,23 +883,23 @@ class AIManager: def _build_kg_context(self, entities: list[dict], relations: list[dict]) -> str: """构建知识图谱上下文文本""" - context = [] + context = [] if entities: context.append("### 相关实体") for entity in entities: - name = entity.get("name", "") - entity_type = entity.get("type", "") - definition = entity.get("definition", "") + name = entity.get("name", "") + entity_type = entity.get("type", "") + definition = entity.get("definition", "") context.append(f"- **{name}** ({entity_type}): {definition}") if relations: context.append("\n### 相关关系") for relation in relations: - source = relation.get("source_name", "") - target = relation.get("target_name", "") - rel_type = relation.get("relation_type", "") - evidence = relation.get("evidence", "") + source = relation.get("source_name", "") + target = relation.get("target_name", "") + rel_type = relation.get("relation_type", "") + evidence = relation.get("evidence", "") context.append(f"- {source} --[{rel_type}]--> {target}") if evidence: context.append(f" - 依据: {evidence[:100]}...") @@ -916,12 +916,12 @@ class AIManager: content_data: dict, ) -> SmartSummary: """生成智能摘要""" - summary_id = f"ss_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + summary_id = f"ss_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() # 根据摘要类型生成不同的提示 if summary_type == "extractive": - prompt = f"""从以下内容中提取关键句子作为摘要: + prompt = f"""从以下内容中提取关键句子作为摘要: {content_data.get("text", "")[:5000]} @@ -931,7 +931,7 @@ class AIManager: 3. 以 JSON 格式返回: {{"summary": "摘要内容", "key_points": ["要点1", "要点2"]}}""" elif summary_type == "abstractive": - prompt = f"""对以下内容生成简洁的摘要: + prompt = f"""对以下内容生成简洁的摘要: {content_data.get("text", "")[:5000]} @@ -941,7 +941,7 @@ class AIManager: 3. 包含关键实体和概念""" elif summary_type == "key_points": - prompt = f"""从以下内容中提取关键要点: + prompt = f"""从以下内容中提取关键要点: {content_data.get("text", "")[:5000]} @@ -951,7 +951,7 @@ class AIManager: 3. 以 JSON 格式返回: {{"key_points": ["要点1", "要点2", ...]}}""" else: # timeline - prompt = f"""基于以下内容生成时间线摘要: + prompt = f"""基于以下内容生成时间线摘要: {content_data.get("text", "")[:5000]} @@ -960,72 +960,72 @@ class AIManager: 2. 标注时间节点(如果有) 3. 突出里程碑事件""" - headers = { + headers = { "Authorization": f"Bearer {self.kimi_api_key}", "Content-Type": "application/json", } - payload = { + payload = { "model": "k2p5", "messages": [{"role": "user", "content": prompt}], "temperature": 0.3, } async with httpx.AsyncClient() as client: - response = await client.post( + response = await client.post( f"{self.kimi_base_url}/v1/chat/completions", - headers=headers, - json=payload, - timeout=60.0, + headers = headers, + json = payload, + timeout = 60.0, ) response.raise_for_status() - result = response.json() + result = response.json() - content = result["choices"][0]["message"]["content"] - tokens_used = result["usage"]["total_tokens"] + content = result["choices"][0]["message"]["content"] + tokens_used = result["usage"]["total_tokens"] # 解析关键要点 - key_points = [] + key_points = [] # 尝试从 JSON 中提取 - json_match = re.search(r"\{.*?\}", content, re.DOTALL) + json_match = re.search(r"\{.*?\}", content, re.DOTALL) if json_match: try: - data = json.loads(json_match.group()) - key_points = data.get("key_points", []) + data = json.loads(json_match.group()) + key_points = data.get("key_points", []) if "summary" in data: - content = data["summary"] + content = data["summary"] except (json.JSONDecodeError, ValueError): pass # 如果没有提取到关键要点,从文本中提取 if not key_points: - lines = content.split("\n") - key_points = [ + lines = content.split("\n") + key_points = [ line.strip("- ").strip() for line in lines if line.strip().startswith("-") or line.strip().startswith("•") ] if not key_points: - key_points = [content[:200] + "..."] if len(content) > 200 else [content] + key_points = [content[:200] + "..."] if len(content) > 200 else [content] # 提取提及的实体 - entities_mentioned = content_data.get("entities", []) - entity_names = [e.get("name", "") for e in entities_mentioned[:10]] + entities_mentioned = content_data.get("entities", []) + entity_names = [e.get("name", "") for e in entities_mentioned[:10]] - summary = SmartSummary( - id=summary_id, - tenant_id=tenant_id, - project_id=project_id, - source_type=source_type, - source_id=source_id, - summary_type=summary_type, - content=content, - key_points=key_points[:8], - entities_mentioned=entity_names, - confidence=0.85, - tokens_used=tokens_used, - created_at=now, + summary = SmartSummary( + id = summary_id, + tenant_id = tenant_id, + project_id = project_id, + source_type = source_type, + source_id = source_id, + summary_type = summary_type, + content = content, + key_points = key_points[:8], + entities_mentioned = entity_names, + confidence = 0.85, + tokens_used = tokens_used, + created_at = now, ) with self._get_db() as conn: @@ -1068,24 +1068,24 @@ class AIManager: model_config: dict, ) -> PredictionModel: """创建预测模型""" - model_id = f"pm_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + model_id = f"pm_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - model = PredictionModel( - id=model_id, - tenant_id=tenant_id, - project_id=project_id, - name=name, - prediction_type=prediction_type, - target_entity_type=target_entity_type, - features=features, - model_config=model_config, - accuracy=None, - last_trained_at=None, - prediction_count=0, - is_active=True, - created_at=now, - updated_at=now, + model = PredictionModel( + id = model_id, + tenant_id = tenant_id, + project_id = project_id, + name = name, + prediction_type = prediction_type, + target_entity_type = target_entity_type, + features = features, + model_config = model_config, + accuracy = None, + last_trained_at = None, + prediction_count = 0, + is_active = True, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -1121,8 +1121,8 @@ class AIManager: def get_prediction_model(self, model_id: str) -> PredictionModel | None: """获取预测模型""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM prediction_models WHERE id = ?", (model_id,) + row = conn.execute( + "SELECT * FROM prediction_models WHERE id = ?", (model_id, ) ).fetchone() if not row: @@ -1131,27 +1131,27 @@ class AIManager: return self._row_to_prediction_model(row) def list_prediction_models( - self, tenant_id: str, project_id: str | None = None + self, tenant_id: str, project_id: str | None = None ) -> list[PredictionModel]: """列出预测模型""" - query = "SELECT * FROM prediction_models WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM prediction_models WHERE tenant_id = ?" + params = [tenant_id] if project_id: - query += " AND project_id = ?" + query += " AND project_id = ?" params.append(project_id) query += " ORDER BY created_at DESC" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_prediction_model(row) for row in rows] async def train_prediction_model( self, model_id: str, historical_data: list[dict] ) -> PredictionModel: """训练预测模型""" - model = self.get_prediction_model(model_id) + model = self.get_prediction_model(model_id) if not model: raise ValueError(f"Prediction model {model_id} not found") @@ -1159,16 +1159,16 @@ class AIManager: await asyncio.sleep(1) # 计算准确率(模拟) - accuracy = round(0.75 + random.random() * 0.2, 4) + accuracy = round(0.75 + random.random() * 0.2, 4) - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE prediction_models - SET accuracy = ?, last_trained_at = ?, updated_at = ? - WHERE id = ? + SET accuracy = ?, last_trained_at = ?, updated_at = ? + WHERE id = ? """, (accuracy, now, now, model_id), ) @@ -1178,39 +1178,39 @@ class AIManager: async def predict(self, model_id: str, input_data: dict) -> PredictionResult: """进行预测""" - model = self.get_prediction_model(model_id) + model = self.get_prediction_model(model_id) if not model or not model.is_active: raise ValueError(f"Prediction model {model_id} not available") - prediction_id = f"pr_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + prediction_id = f"pr_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() # 根据预测类型进行不同的预测逻辑 if model.prediction_type == PredictionType.TREND: - prediction_data = self._predict_trend(input_data, model) + prediction_data = self._predict_trend(input_data, model) elif model.prediction_type == PredictionType.ANOMALY: - prediction_data = self._detect_anomaly(input_data, model) + prediction_data = self._detect_anomaly(input_data, model) elif model.prediction_type == PredictionType.ENTITY_GROWTH: - prediction_data = self._predict_entity_growth(input_data, model) + prediction_data = self._predict_entity_growth(input_data, model) elif model.prediction_type == PredictionType.RELATION_EVOLUTION: - prediction_data = self._predict_relation_evolution(input_data, model) + prediction_data = self._predict_relation_evolution(input_data, model) else: - prediction_data = {"value": "unknown", "confidence": 0} + prediction_data = {"value": "unknown", "confidence": 0} - confidence = prediction_data.get("confidence", 0.8) - explanation = prediction_data.get("explanation", "基于历史数据模式预测") + confidence = prediction_data.get("confidence", 0.8) + explanation = prediction_data.get("explanation", "基于历史数据模式预测") - result = PredictionResult( - id=prediction_id, - model_id=model_id, - prediction_type=model.prediction_type, - target_id=input_data.get("target_id"), - prediction_data=prediction_data, - confidence=confidence, - explanation=explanation, - actual_value=None, - is_correct=None, - created_at=now, + result = PredictionResult( + id = prediction_id, + model_id = model_id, + prediction_type = model.prediction_type, + target_id = input_data.get("target_id"), + prediction_data = prediction_data, + confidence = confidence, + explanation = explanation, + actual_value = None, + is_correct = None, + created_at = now, ) with self._get_db() as conn: @@ -1237,8 +1237,8 @@ class AIManager: # 更新预测计数 conn.execute( - "UPDATE prediction_models SET prediction_count = prediction_count + 1 WHERE id = ?", - (model_id,), + "UPDATE prediction_models SET prediction_count = prediction_count + 1 WHERE id = ?", + (model_id, ), ) conn.commit() @@ -1246,7 +1246,7 @@ class AIManager: def _predict_trend(self, input_data: dict, model: PredictionModel) -> dict: """趋势预测""" - historical_values = input_data.get("historical_values", []) + historical_values = input_data.get("historical_values", []) if len(historical_values) < 2: return { @@ -1257,23 +1257,23 @@ class AIManager: } # 简单线性趋势预测 - 使用最小二乘法计算斜率 - n = len(historical_values) - x = list(range(n)) - y = historical_values + n = len(historical_values) + x = list(range(n)) + y = historical_values # 计算均值 - mean_x = sum(x) / n - mean_y = sum(y) / n + mean_x = sum(x) / n + mean_y = sum(y) / n # 计算斜率 (最小二乘法) - numerator = sum((x[i] - mean_x) * (y[i] - mean_y) for i in range(n)) - denominator = sum((x[i] - mean_x) ** 2 for i in range(n)) - slope = numerator / denominator if denominator != 0 else 0 + numerator = sum((x[i] - mean_x) * (y[i] - mean_y) for i in range(n)) + denominator = sum((x[i] - mean_x) ** 2 for i in range(n)) + slope = numerator / denominator if denominator != 0 else 0 # 预测下一个值 - next_value = y[-1] + slope + next_value = y[-1] + slope - trend = "increasing" if slope > 0.01 else "decreasing" if slope < -0.01 else "stable" + trend = "increasing" if slope > 0.01 else "decreasing" if slope < -0.01 else "stable" return { "predicted_value": round(next_value, 2), @@ -1285,8 +1285,8 @@ class AIManager: def _detect_anomaly(self, input_data: dict, model: PredictionModel) -> dict: """异常检测""" - value = input_data.get("value") - historical_values = input_data.get("historical_values", []) + value = input_data.get("value") + historical_values = input_data.get("historical_values", []) if not historical_values or value is None: return { @@ -1297,15 +1297,15 @@ class AIManager: } # 计算均值和标准差 - mean = statistics.mean(historical_values) - std = statistics.stdev(historical_values) if len(historical_values) > 1 else 0 + mean = statistics.mean(historical_values) + std = statistics.stdev(historical_values) if len(historical_values) > 1 else 0 if std == 0: - is_anomaly = value != mean - z_score = 0 if value == mean else 3 + is_anomaly = value != mean + z_score = 0 if value == mean else 3 else: - z_score = abs(value - mean) / std - is_anomaly = z_score > 2.5 # 2.5 个标准差视为异常 + z_score = abs(value - mean) / std + is_anomaly = z_score > 2.5 # 2.5 个标准差视为异常 return { "is_anomaly": is_anomaly, @@ -1319,7 +1319,7 @@ class AIManager: def _predict_entity_growth(self, input_data: dict, model: PredictionModel) -> dict: """实体增长预测""" - entity_history = input_data.get("entity_history", []) + entity_history = input_data.get("entity_history", []) if len(entity_history) < 3: return { @@ -1330,14 +1330,14 @@ class AIManager: } # 计算增长率 - counts = [h.get("count", 0) for h in entity_history] - growth_rates = [ + counts = [h.get("count", 0) for h in entity_history] + growth_rates = [ (counts[i] - counts[i - 1]) / max(counts[i - 1], 1) for i in range(1, len(counts)) ] - avg_growth_rate = statistics.mean(growth_rates) if growth_rates else 0 + avg_growth_rate = statistics.mean(growth_rates) if growth_rates else 0 # 预测下一个周期的实体数量 - predicted_count = counts[-1] * (1 + avg_growth_rate) + predicted_count = counts[-1] * (1 + avg_growth_rate) return { "predicted_count": round(predicted_count), @@ -1349,7 +1349,7 @@ class AIManager: def _predict_relation_evolution(self, input_data: dict, model: PredictionModel) -> dict: """关系演变预测""" - relation_history = input_data.get("relation_history", []) + relation_history = input_data.get("relation_history", []) if len(relation_history) < 2: return { @@ -1359,16 +1359,16 @@ class AIManager: } # 分析关系变化趋势 - relation_counts = defaultdict(int) + relation_counts = defaultdict(int) for snapshot in relation_history: for rel in snapshot.get("relations", []): relation_counts[rel.get("type", "unknown")] += 1 # 预测可能出现的新关系类型 - predicted_relations = [ + predicted_relations = [ {"type": rel_type, "likelihood": min(count / len(relation_history), 0.95)} for rel_type, count in sorted( - relation_counts.items(), key=lambda x: x[1], reverse=True + relation_counts.items(), key = lambda x: x[1], reverse = True )[:5] ] @@ -1379,12 +1379,12 @@ class AIManager: "explanation": f"基于{len(relation_history)}个历史快照分析关系演变趋势", } - def get_prediction_results(self, model_id: str, limit: int = 100) -> list[PredictionResult]: + def get_prediction_results(self, model_id: str, limit: int = 100) -> list[PredictionResult]: """获取预测结果历史""" with self._get_db() as conn: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM prediction_results - WHERE model_id = ? + WHERE model_id = ? ORDER BY created_at DESC LIMIT ?""", (model_id, limit), @@ -1399,8 +1399,8 @@ class AIManager: with self._get_db() as conn: conn.execute( """UPDATE prediction_results - SET actual_value = ?, is_correct = ? - WHERE id = ?""", + SET actual_value = ?, is_correct = ? + WHERE id = ?""", (actual_value, is_correct, prediction_id), ) conn.commit() @@ -1410,106 +1410,106 @@ class AIManager: def _row_to_custom_model(self, row) -> CustomModel: """将数据库行转换为 CustomModel""" return CustomModel( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - description=row["description"], - model_type=ModelType(row["model_type"]), - status=ModelStatus(row["status"]), - training_data=json.loads(row["training_data"]), - hyperparameters=json.loads(row["hyperparameters"]), - metrics=json.loads(row["metrics"]), - model_path=row["model_path"], - created_at=row["created_at"], - updated_at=row["updated_at"], - trained_at=row["trained_at"], - created_by=row["created_by"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + description = row["description"], + model_type = ModelType(row["model_type"]), + status = ModelStatus(row["status"]), + training_data = json.loads(row["training_data"]), + hyperparameters = json.loads(row["hyperparameters"]), + metrics = json.loads(row["metrics"]), + model_path = row["model_path"], + created_at = row["created_at"], + updated_at = row["updated_at"], + trained_at = row["trained_at"], + created_by = row["created_by"], ) def _row_to_training_sample(self, row) -> TrainingSample: """将数据库行转换为 TrainingSample""" return TrainingSample( - id=row["id"], - model_id=row["model_id"], - text=row["text"], - entities=json.loads(row["entities"]), - metadata=json.loads(row["metadata"]), - created_at=row["created_at"], + id = row["id"], + model_id = row["model_id"], + text = row["text"], + entities = json.loads(row["entities"]), + metadata = json.loads(row["metadata"]), + created_at = row["created_at"], ) def _row_to_multimodal_analysis(self, row) -> MultimodalAnalysis: """将数据库行转换为 MultimodalAnalysis""" return MultimodalAnalysis( - id=row["id"], - tenant_id=row["tenant_id"], - project_id=row["project_id"], - provider=MultimodalProvider(row["provider"]), - input_type=row["input_type"], - input_urls=json.loads(row["input_urls"]), - prompt=row["prompt"], - result=json.loads(row["result"]), - tokens_used=row["tokens_used"], - cost=row["cost"], - created_at=row["created_at"], + id = row["id"], + tenant_id = row["tenant_id"], + project_id = row["project_id"], + provider = MultimodalProvider(row["provider"]), + input_type = row["input_type"], + input_urls = json.loads(row["input_urls"]), + prompt = row["prompt"], + result = json.loads(row["result"]), + tokens_used = row["tokens_used"], + cost = row["cost"], + created_at = row["created_at"], ) def _row_to_kg_rag(self, row) -> KnowledgeGraphRAG: """将数据库行转换为 KnowledgeGraphRAG""" return KnowledgeGraphRAG( - id=row["id"], - tenant_id=row["tenant_id"], - project_id=row["project_id"], - name=row["name"], - description=row["description"], - kg_config=json.loads(row["kg_config"]), - retrieval_config=json.loads(row["retrieval_config"]), - generation_config=json.loads(row["generation_config"]), - is_active=bool(row["is_active"]), - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + tenant_id = row["tenant_id"], + project_id = row["project_id"], + name = row["name"], + description = row["description"], + kg_config = json.loads(row["kg_config"]), + retrieval_config = json.loads(row["retrieval_config"]), + generation_config = json.loads(row["generation_config"]), + is_active = bool(row["is_active"]), + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_prediction_model(self, row) -> PredictionModel: """将数据库行转换为 PredictionModel""" return PredictionModel( - id=row["id"], - tenant_id=row["tenant_id"], - project_id=row["project_id"], - name=row["name"], - prediction_type=PredictionType(row["prediction_type"]), - target_entity_type=row["target_entity_type"], - features=json.loads(row["features"]), - model_config=json.loads(row["model_config"]), - accuracy=row["accuracy"], - last_trained_at=row["last_trained_at"], - prediction_count=row["prediction_count"], - is_active=bool(row["is_active"]), - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + tenant_id = row["tenant_id"], + project_id = row["project_id"], + name = row["name"], + prediction_type = PredictionType(row["prediction_type"]), + target_entity_type = row["target_entity_type"], + features = json.loads(row["features"]), + model_config = json.loads(row["model_config"]), + accuracy = row["accuracy"], + last_trained_at = row["last_trained_at"], + prediction_count = row["prediction_count"], + is_active = bool(row["is_active"]), + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_prediction_result(self, row) -> PredictionResult: """将数据库行转换为 PredictionResult""" return PredictionResult( - id=row["id"], - model_id=row["model_id"], - prediction_type=PredictionType(row["prediction_type"]), - target_id=row["target_id"], - prediction_data=json.loads(row["prediction_data"]), - confidence=row["confidence"], - explanation=row["explanation"], - actual_value=row["actual_value"], - is_correct=row["is_correct"], - created_at=row["created_at"], + id = row["id"], + model_id = row["model_id"], + prediction_type = PredictionType(row["prediction_type"]), + target_id = row["target_id"], + prediction_data = json.loads(row["prediction_data"]), + confidence = row["confidence"], + explanation = row["explanation"], + actual_value = row["actual_value"], + is_correct = row["is_correct"], + created_at = row["created_at"], ) # Singleton instance -_ai_manager = None +_ai_manager = None def get_ai_manager() -> AIManager: global _ai_manager if _ai_manager is None: - _ai_manager = AIManager() + _ai_manager = AIManager() return _ai_manager diff --git a/backend/api_key_manager.py b/backend/api_key_manager.py index 04ee2cf..6a81461 100644 --- a/backend/api_key_manager.py +++ b/backend/api_key_manager.py @@ -13,13 +13,13 @@ from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum -DB_PATH = os.getenv("DB_PATH", "/app/data/insightflow.db") +DB_PATH = os.getenv("DB_PATH", "/app/data/insightflow.db") class ApiKeyStatus(Enum): - ACTIVE = "active" - REVOKED = "revoked" - EXPIRED = "expired" + ACTIVE = "active" + REVOKED = "revoked" + EXPIRED = "expired" @dataclass @@ -37,18 +37,18 @@ class ApiKey: last_used_at: str | None revoked_at: str | None revoked_reason: str | None - total_calls: int = 0 + total_calls: int = 0 class ApiKeyManager: """API Key 管理器""" # Key 前缀 - KEY_PREFIX = "ak_live_" - KEY_LENGTH = 48 # 总长度: 前缀(8) + 随机部分(40) + KEY_PREFIX = "ak_live_" + KEY_LENGTH = 48 # 总长度: 前缀(8) + 随机部分(40) - def __init__(self, db_path: str = DB_PATH) -> None: - self.db_path = db_path + def __init__(self, db_path: str = DB_PATH) -> None: + self.db_path = db_path self._init_db() def _init_db(self) -> None: @@ -117,7 +117,7 @@ class ApiKeyManager: def _generate_key(self) -> str: """生成新的 API Key""" # 生成 40 字符的随机字符串 - random_part = secrets.token_urlsafe(30)[:40] + random_part = secrets.token_urlsafe(30)[:40] return f"{self.KEY_PREFIX}{random_part}" def _hash_key(self, key: str) -> str: @@ -131,10 +131,10 @@ class ApiKeyManager: def create_key( self, name: str, - owner_id: str | None = None, - permissions: list[str] = None, - rate_limit: int = 60, - expires_days: int | None = None, + owner_id: str | None = None, + permissions: list[str] = None, + rate_limit: int = 60, + expires_days: int | None = None, ) -> tuple[str, ApiKey]: """ 创建新的 API Key @@ -143,32 +143,32 @@ class ApiKeyManager: tuple: (原始key(仅返回一次), ApiKey对象) """ if permissions is None: - permissions = ["read"] + permissions = ["read"] - key_id = secrets.token_hex(16) - raw_key = self._generate_key() - key_hash = self._hash_key(raw_key) - key_preview = self._get_preview(raw_key) + key_id = secrets.token_hex(16) + raw_key = self._generate_key() + key_hash = self._hash_key(raw_key) + key_preview = self._get_preview(raw_key) - expires_at = None + expires_at = None if expires_days: - expires_at = (datetime.now() + timedelta(days=expires_days)).isoformat() + expires_at = (datetime.now() + timedelta(days = expires_days)).isoformat() - api_key = ApiKey( - id=key_id, - key_hash=key_hash, - key_preview=key_preview, - name=name, - owner_id=owner_id, - permissions=permissions, - rate_limit=rate_limit, - status=ApiKeyStatus.ACTIVE.value, - created_at=datetime.now().isoformat(), - expires_at=expires_at, - last_used_at=None, - revoked_at=None, - revoked_reason=None, - total_calls=0, + api_key = ApiKey( + id = key_id, + key_hash = key_hash, + key_preview = key_preview, + name = name, + owner_id = owner_id, + permissions = permissions, + rate_limit = rate_limit, + status = ApiKeyStatus.ACTIVE.value, + created_at = datetime.now().isoformat(), + expires_at = expires_at, + last_used_at = None, + revoked_at = None, + revoked_reason = None, + total_calls = 0, ) with sqlite3.connect(self.db_path) as conn: @@ -203,16 +203,16 @@ class ApiKeyManager: Returns: ApiKey if valid, None otherwise """ - key_hash = self._hash_key(key) + key_hash = self._hash_key(key) with sqlite3.connect(self.db_path) as conn: - conn.row_factory = sqlite3.Row - row = conn.execute("SELECT * FROM api_keys WHERE key_hash = ?", (key_hash,)).fetchone() + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT * FROM api_keys WHERE key_hash = ?", (key_hash, )).fetchone() if not row: return None - api_key = self._row_to_api_key(row) + api_key = self._row_to_api_key(row) # 检查状态 if api_key.status != ApiKeyStatus.ACTIVE.value: @@ -220,11 +220,11 @@ class ApiKeyManager: # 检查是否过期 if api_key.expires_at: - expires = datetime.fromisoformat(api_key.expires_at) + expires = datetime.fromisoformat(api_key.expires_at) if datetime.now() > expires: # 更新状态为过期 conn.execute( - "UPDATE api_keys SET status = ? WHERE id = ?", + "UPDATE api_keys SET status = ? WHERE id = ?", (ApiKeyStatus.EXPIRED.value, api_key.id), ) conn.commit() @@ -232,22 +232,22 @@ class ApiKeyManager: return api_key - def revoke_key(self, key_id: str, reason: str = "", owner_id: str | None = None) -> bool: + def revoke_key(self, key_id: str, reason: str = "", owner_id: str | None = None) -> bool: """撤销 API Key""" with sqlite3.connect(self.db_path) as conn: # 验证所有权(如果提供了 owner_id) if owner_id: - row = conn.execute( - "SELECT owner_id FROM api_keys WHERE id = ?", (key_id,) + row = conn.execute( + "SELECT owner_id FROM api_keys WHERE id = ?", (key_id, ) ).fetchone() if not row or row[0] != owner_id: return False - cursor = conn.execute( + cursor = conn.execute( """ UPDATE api_keys - SET status = ?, revoked_at = ?, revoked_reason = ? - WHERE id = ? AND status = ? + SET status = ?, revoked_at = ?, revoked_reason = ? + WHERE id = ? AND status = ? """, ( ApiKeyStatus.REVOKED.value, @@ -260,17 +260,17 @@ class ApiKeyManager: conn.commit() return cursor.rowcount > 0 - def get_key_by_id(self, key_id: str, owner_id: str | None = None) -> ApiKey | None: + def get_key_by_id(self, key_id: str, owner_id: str | None = None) -> ApiKey | None: """通过 ID 获取 API Key(不包含敏感信息)""" with sqlite3.connect(self.db_path) as conn: - conn.row_factory = sqlite3.Row + conn.row_factory = sqlite3.Row if owner_id: - row = conn.execute( - "SELECT * FROM api_keys WHERE id = ? AND owner_id = ?", (key_id, owner_id) + row = conn.execute( + "SELECT * FROM api_keys WHERE id = ? AND owner_id = ?", (key_id, owner_id) ).fetchone() else: - row = conn.execute("SELECT * FROM api_keys WHERE id = ?", (key_id,)).fetchone() + row = conn.execute("SELECT * FROM api_keys WHERE id = ?", (key_id, )).fetchone() if row: return self._row_to_api_key(row) @@ -278,54 +278,54 @@ class ApiKeyManager: def list_keys( self, - owner_id: str | None = None, - status: str | None = None, - limit: int = 100, - offset: int = 0, + owner_id: str | None = None, + status: str | None = None, + limit: int = 100, + offset: int = 0, ) -> list[ApiKey]: """列出 API Keys""" with sqlite3.connect(self.db_path) as conn: - conn.row_factory = sqlite3.Row + conn.row_factory = sqlite3.Row - query = "SELECT * FROM api_keys WHERE 1=1" - params = [] + query = "SELECT * FROM api_keys WHERE 1 = 1" + params = [] if owner_id: - query += " AND owner_id = ?" + query += " AND owner_id = ?" params.append(owner_id) if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status) query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_api_key(row) for row in rows] def update_key( self, key_id: str, - name: str | None = None, - permissions: list[str] | None = None, - rate_limit: int | None = None, - owner_id: str | None = None, + name: str | None = None, + permissions: list[str] | None = None, + rate_limit: int | None = None, + owner_id: str | None = None, ) -> bool: """更新 API Key 信息""" - updates = [] - params = [] + updates = [] + params = [] if name is not None: - updates.append("name = ?") + updates.append("name = ?") params.append(name) if permissions is not None: - updates.append("permissions = ?") + updates.append("permissions = ?") params.append(json.dumps(permissions)) if rate_limit is not None: - updates.append("rate_limit = ?") + updates.append("rate_limit = ?") params.append(rate_limit) if not updates: @@ -336,14 +336,14 @@ class ApiKeyManager: with sqlite3.connect(self.db_path) as conn: # 验证所有权 if owner_id: - row = conn.execute( - "SELECT owner_id FROM api_keys WHERE id = ?", (key_id,) + row = conn.execute( + "SELECT owner_id FROM api_keys WHERE id = ?", (key_id, ) ).fetchone() if not row or row[0] != owner_id: return False - query = f"UPDATE api_keys SET {', '.join(updates)} WHERE id = ?" - cursor = conn.execute(query, params) + query = f"UPDATE api_keys SET {', '.join(updates)} WHERE id = ?" + cursor = conn.execute(query, params) conn.commit() return cursor.rowcount > 0 @@ -353,8 +353,8 @@ class ApiKeyManager: conn.execute( """ UPDATE api_keys - SET last_used_at = ?, total_calls = total_calls + 1 - WHERE id = ? + SET last_used_at = ?, total_calls = total_calls + 1 + WHERE id = ? """, (datetime.now().isoformat(), key_id), ) @@ -365,12 +365,12 @@ class ApiKeyManager: api_key_id: str, endpoint: str, method: str, - status_code: int = 200, - response_time_ms: int = 0, - ip_address: str = "", - user_agent: str = "", - error_message: str = "", - ): + status_code: int = 200, + response_time_ms: int = 0, + ip_address: str = "", + user_agent: str = "", + error_message: str = "", + ) -> None: """记录 API 调用日志""" with sqlite3.connect(self.db_path) as conn: conn.execute( @@ -395,21 +395,21 @@ class ApiKeyManager: def get_call_logs( self, - api_key_id: str | None = None, - start_date: str | None = None, - end_date: str | None = None, - limit: int = 100, - offset: int = 0, + api_key_id: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + limit: int = 100, + offset: int = 0, ) -> list[dict]: """获取 API 调用日志""" with sqlite3.connect(self.db_path) as conn: - conn.row_factory = sqlite3.Row + conn.row_factory = sqlite3.Row - query = "SELECT * FROM api_call_logs WHERE 1=1" - params = [] + query = "SELECT * FROM api_call_logs WHERE 1 = 1" + params = [] if api_key_id: - query += " AND api_key_id = ?" + query += " AND api_key_id = ?" params.append(api_key_id) if start_date: @@ -423,16 +423,16 @@ class ApiKeyManager: query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [dict(row) for row in rows] - def get_call_stats(self, api_key_id: str | None = None, days: int = 30) -> dict: + def get_call_stats(self, api_key_id: str | None = None, days: int = 30) -> dict: """获取 API 调用统计""" with sqlite3.connect(self.db_path) as conn: - conn.row_factory = sqlite3.Row + conn.row_factory = sqlite3.Row # 总体统计 - query = f""" + query = f""" SELECT COUNT(*) as total_calls, COUNT(CASE WHEN status_code < 400 THEN 1 END) as success_calls, @@ -444,15 +444,15 @@ class ApiKeyManager: WHERE created_at >= date('now', '-{days} days') """ - params = [] + params = [] if api_key_id: - query = query.replace("WHERE created_at", "WHERE api_key_id = ? AND created_at") + query = query.replace("WHERE created_at", "WHERE api_key_id = ? AND created_at") params.insert(0, api_key_id) - row = conn.execute(query, params).fetchone() + row = conn.execute(query, params).fetchone() # 按端点统计 - endpoint_query = f""" + endpoint_query = f""" SELECT endpoint, method, @@ -462,19 +462,19 @@ class ApiKeyManager: WHERE created_at >= date('now', '-{days} days') """ - endpoint_params = [] + endpoint_params = [] if api_key_id: - endpoint_query = endpoint_query.replace( - "WHERE created_at", "WHERE api_key_id = ? AND created_at" + endpoint_query = endpoint_query.replace( + "WHERE created_at", "WHERE api_key_id = ? AND created_at" ) endpoint_params.insert(0, api_key_id) endpoint_query += " GROUP BY endpoint, method ORDER BY calls DESC" - endpoint_rows = conn.execute(endpoint_query, endpoint_params).fetchall() + endpoint_rows = conn.execute(endpoint_query, endpoint_params).fetchall() # 按天统计 - daily_query = f""" + daily_query = f""" SELECT date(created_at) as date, COUNT(*) as calls, @@ -483,16 +483,16 @@ class ApiKeyManager: WHERE created_at >= date('now', '-{days} days') """ - daily_params = [] + daily_params = [] if api_key_id: - daily_query = daily_query.replace( - "WHERE created_at", "WHERE api_key_id = ? AND created_at" + daily_query = daily_query.replace( + "WHERE created_at", "WHERE api_key_id = ? AND created_at" ) daily_params.insert(0, api_key_id) daily_query += " GROUP BY date(created_at) ORDER BY date" - daily_rows = conn.execute(daily_query, daily_params).fetchall() + daily_rows = conn.execute(daily_query, daily_params).fetchall() return { "summary": { @@ -510,30 +510,30 @@ class ApiKeyManager: def _row_to_api_key(self, row: sqlite3.Row) -> ApiKey: """将数据库行转换为 ApiKey 对象""" return ApiKey( - id=row["id"], - key_hash=row["key_hash"], - key_preview=row["key_preview"], - name=row["name"], - owner_id=row["owner_id"], - permissions=json.loads(row["permissions"]), - rate_limit=row["rate_limit"], - status=row["status"], - created_at=row["created_at"], - expires_at=row["expires_at"], - last_used_at=row["last_used_at"], - revoked_at=row["revoked_at"], - revoked_reason=row["revoked_reason"], - total_calls=row["total_calls"], + id = row["id"], + key_hash = row["key_hash"], + key_preview = row["key_preview"], + name = row["name"], + owner_id = row["owner_id"], + permissions = json.loads(row["permissions"]), + rate_limit = row["rate_limit"], + status = row["status"], + created_at = row["created_at"], + expires_at = row["expires_at"], + last_used_at = row["last_used_at"], + revoked_at = row["revoked_at"], + revoked_reason = row["revoked_reason"], + total_calls = row["total_calls"], ) # 全局实例 -_api_key_manager: ApiKeyManager | None = None +_api_key_manager: ApiKeyManager | None = None def get_api_key_manager() -> ApiKeyManager: """获取 API Key 管理器实例""" global _api_key_manager if _api_key_manager is None: - _api_key_manager = ApiKeyManager() + _api_key_manager = ApiKeyManager() return _api_key_manager diff --git a/backend/collaboration_manager.py b/backend/collaboration_manager.py index 40f99a4..0c33ad5 100644 --- a/backend/collaboration_manager.py +++ b/backend/collaboration_manager.py @@ -15,29 +15,29 @@ from typing import Any class SharePermission(Enum): """分享权限级别""" - READ_ONLY = "read_only" # 只读 - COMMENT = "comment" # 可评论 - EDIT = "edit" # 可编辑 - ADMIN = "admin" # 管理员 + READ_ONLY = "read_only" # 只读 + COMMENT = "comment" # 可评论 + EDIT = "edit" # 可编辑 + ADMIN = "admin" # 管理员 class CommentTargetType(Enum): """评论目标类型""" - ENTITY = "entity" # 实体评论 - RELATION = "relation" # 关系评论 - TRANSCRIPT = "transcript" # 转录文本评论 - PROJECT = "project" # 项目级评论 + ENTITY = "entity" # 实体评论 + RELATION = "relation" # 关系评论 + TRANSCRIPT = "transcript" # 转录文本评论 + PROJECT = "project" # 项目级评论 class ChangeType(Enum): """变更类型""" - CREATE = "create" # 创建 - UPDATE = "update" # 更新 - DELETE = "delete" # 删除 - MERGE = "merge" # 合并 - SPLIT = "split" # 拆分 + CREATE = "create" # 创建 + UPDATE = "update" # 更新 + DELETE = "delete" # 删除 + MERGE = "merge" # 合并 + SPLIT = "split" # 拆分 @dataclass @@ -136,10 +136,10 @@ class TeamSpace: class CollaborationManager: """协作管理主类""" - def __init__(self, db_manager=None): - self.db = db_manager - self._shares_cache: dict[str, ProjectShare] = {} - self._comments_cache: dict[str, list[Comment]] = {} + def __init__(self, db_manager = None) -> None: + self.db = db_manager + self._shares_cache: dict[str, ProjectShare] = {} + self._comments_cache: dict[str, list[Comment]] = {} # ============ 项目分享 ============ @@ -147,57 +147,57 @@ class CollaborationManager: self, project_id: str, created_by: str, - permission: str = "read_only", - expires_in_days: int | None = None, - max_uses: int | None = None, - password: str | None = None, - allow_download: bool = False, - allow_export: bool = False, + permission: str = "read_only", + expires_in_days: int | None = None, + max_uses: int | None = None, + password: str | None = None, + allow_download: bool = False, + allow_export: bool = False, ) -> ProjectShare: """创建项目分享链接""" - share_id = str(uuid.uuid4()) - token = self._generate_share_token(project_id) + share_id = str(uuid.uuid4()) + token = self._generate_share_token(project_id) - now = datetime.now().isoformat() - expires_at = None + now = datetime.now().isoformat() + expires_at = None if expires_in_days: - expires_at = (datetime.now() + timedelta(days=expires_in_days)).isoformat() + expires_at = (datetime.now() + timedelta(days = expires_in_days)).isoformat() - password_hash = None + password_hash = None if password: - password_hash = hashlib.sha256(password.encode()).hexdigest() + password_hash = hashlib.sha256(password.encode()).hexdigest() - share = ProjectShare( - id=share_id, - project_id=project_id, - token=token, - permission=permission, - created_by=created_by, - created_at=now, - expires_at=expires_at, - max_uses=max_uses, - use_count=0, - password_hash=password_hash, - is_active=True, - allow_download=allow_download, - allow_export=allow_export, + share = ProjectShare( + id = share_id, + project_id = project_id, + token = token, + permission = permission, + created_by = created_by, + created_at = now, + expires_at = expires_at, + max_uses = max_uses, + use_count = 0, + password_hash = password_hash, + is_active = True, + allow_download = allow_download, + allow_export = allow_export, ) # 保存到数据库 if self.db: self._save_share_to_db(share) - self._shares_cache[token] = share + self._shares_cache[token] = share return share def _generate_share_token(self, project_id: str) -> str: """生成分享令牌""" - data = f"{project_id}:{datetime.now().timestamp()}:{uuid.uuid4()}" + data = f"{project_id}:{datetime.now().timestamp()}:{uuid.uuid4()}" return hashlib.sha256(data.encode()).hexdigest()[:32] def _save_share_to_db(self, share: ProjectShare) -> None: """保存分享记录到数据库""" - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ INSERT INTO project_shares @@ -224,12 +224,12 @@ class CollaborationManager: ) self.db.conn.commit() - def validate_share_token(self, token: str, password: str | None = None) -> ProjectShare | None: + def validate_share_token(self, token: str, password: str | None = None) -> ProjectShare | None: """验证分享令牌""" # 从缓存或数据库获取 - share = self._shares_cache.get(token) + share = self._shares_cache.get(token) if not share and self.db: - share = self._get_share_from_db(token) + share = self._get_share_from_db(token) if not share: return None @@ -250,7 +250,7 @@ class CollaborationManager: if share.password_hash: if not password: return None - password_hash = hashlib.sha256(password.encode()).hexdigest() + password_hash = hashlib.sha256(password.encode()).hexdigest() if password_hash != share.password_hash: return None @@ -258,63 +258,63 @@ class CollaborationManager: def _get_share_from_db(self, token: str) -> ProjectShare | None: """从数据库获取分享记录""" - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ - SELECT * FROM project_shares WHERE token = ? + SELECT * FROM project_shares WHERE token = ? """, - (token,), + (token, ), ) - row = cursor.fetchone() + row = cursor.fetchone() if not row: return None return ProjectShare( - id=row[0], - project_id=row[1], - token=row[2], - permission=row[3], - created_by=row[4], - created_at=row[5], - expires_at=row[6], - max_uses=row[7], - use_count=row[8], - password_hash=row[9], - is_active=bool(row[10]), - allow_download=bool(row[11]), - allow_export=bool(row[12]), + id = row[0], + project_id = row[1], + token = row[2], + permission = row[3], + created_by = row[4], + created_at = row[5], + expires_at = row[6], + max_uses = row[7], + use_count = row[8], + password_hash = row[9], + is_active = bool(row[10]), + allow_download = bool(row[11]), + allow_export = bool(row[12]), ) def increment_share_usage(self, token: str) -> None: """增加分享链接使用次数""" - share = self._shares_cache.get(token) + share = self._shares_cache.get(token) if share: share.use_count += 1 if self.db: - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ UPDATE project_shares - SET use_count = use_count + 1 - WHERE token = ? + SET use_count = use_count + 1 + WHERE token = ? """, - (token,), + (token, ), ) self.db.conn.commit() def revoke_share_link(self, share_id: str, revoked_by: str) -> bool: """撤销分享链接""" if self.db: - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ UPDATE project_shares - SET is_active = 0 - WHERE id = ? + SET is_active = 0 + WHERE id = ? """, - (share_id,), + (share_id, ), ) self.db.conn.commit() return cursor.rowcount > 0 @@ -325,33 +325,33 @@ class CollaborationManager: if not self.db: return [] - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ SELECT * FROM project_shares - WHERE project_id = ? + WHERE project_id = ? ORDER BY created_at DESC """, - (project_id,), + (project_id, ), ) - shares = [] + shares = [] for row in cursor.fetchall(): shares.append( ProjectShare( - id=row[0], - project_id=row[1], - token=row[2], - permission=row[3], - created_by=row[4], - created_at=row[5], - expires_at=row[6], - max_uses=row[7], - use_count=row[8], - password_hash=row[9], - is_active=bool(row[10]), - allow_download=bool(row[11]), - allow_export=bool(row[12]), + id = row[0], + project_id = row[1], + token = row[2], + permission = row[3], + created_by = row[4], + created_at = row[5], + expires_at = row[6], + max_uses = row[7], + use_count = row[8], + password_hash = row[9], + is_active = bool(row[10]), + allow_download = bool(row[11]), + allow_export = bool(row[12]), ) ) return shares @@ -366,46 +366,46 @@ class CollaborationManager: author: str, author_name: str, content: str, - parent_id: str | None = None, - mentions: list[str] | None = None, - attachments: list[dict] | None = None, + parent_id: str | None = None, + mentions: list[str] | None = None, + attachments: list[dict] | None = None, ) -> Comment: """添加评论""" - comment_id = str(uuid.uuid4()) - now = datetime.now().isoformat() + comment_id = str(uuid.uuid4()) + now = datetime.now().isoformat() - comment = Comment( - id=comment_id, - project_id=project_id, - target_type=target_type, - target_id=target_id, - parent_id=parent_id, - author=author, - author_name=author_name, - content=content, - created_at=now, - updated_at=now, - resolved=False, - resolved_by=None, - resolved_at=None, - mentions=mentions or [], - attachments=attachments or [], + comment = Comment( + id = comment_id, + project_id = project_id, + target_type = target_type, + target_id = target_id, + parent_id = parent_id, + author = author, + author_name = author_name, + content = content, + created_at = now, + updated_at = now, + resolved = False, + resolved_by = None, + resolved_at = None, + mentions = mentions or [], + attachments = attachments or [], ) if self.db: self._save_comment_to_db(comment) # 更新缓存 - key = f"{target_type}:{target_id}" + key = f"{target_type}:{target_id}" if key not in self._comments_cache: - self._comments_cache[key] = [] + self._comments_cache[key] = [] self._comments_cache[key].append(comment) return comment def _save_comment_to_db(self, comment: Comment) -> None: """保存评论到数据库""" - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ INSERT INTO comments @@ -435,18 +435,18 @@ class CollaborationManager: self.db.conn.commit() def get_comments( - self, target_type: str, target_id: str, include_resolved: bool = True + self, target_type: str, target_id: str, include_resolved: bool = True ) -> list[Comment]: """获取评论列表""" if not self.db: return [] - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() if include_resolved: cursor.execute( """ SELECT * FROM comments - WHERE target_type = ? AND target_id = ? + WHERE target_type = ? AND target_id = ? ORDER BY created_at ASC """, (target_type, target_id), @@ -455,13 +455,13 @@ class CollaborationManager: cursor.execute( """ SELECT * FROM comments - WHERE target_type = ? AND target_id = ? AND resolved = 0 + WHERE target_type = ? AND target_id = ? AND resolved = 0 ORDER BY created_at ASC """, (target_type, target_id), ) - comments = [] + comments = [] for row in cursor.fetchall(): comments.append(self._row_to_comment(row)) return comments @@ -469,21 +469,21 @@ class CollaborationManager: def _row_to_comment(self, row) -> Comment: """将数据库行转换为Comment对象""" return Comment( - id=row[0], - project_id=row[1], - target_type=row[2], - target_id=row[3], - parent_id=row[4], - author=row[5], - author_name=row[6], - content=row[7], - created_at=row[8], - updated_at=row[9], - resolved=bool(row[10]), - resolved_by=row[11], - resolved_at=row[12], - mentions=json.loads(row[13]) if row[13] else [], - attachments=json.loads(row[14]) if row[14] else [], + id = row[0], + project_id = row[1], + target_type = row[2], + target_id = row[3], + parent_id = row[4], + author = row[5], + author_name = row[6], + content = row[7], + created_at = row[8], + updated_at = row[9], + resolved = bool(row[10]), + resolved_by = row[11], + resolved_at = row[12], + mentions = json.loads(row[13]) if row[13] else [], + attachments = json.loads(row[14]) if row[14] else [], ) def update_comment(self, comment_id: str, content: str, updated_by: str) -> Comment | None: @@ -491,13 +491,13 @@ class CollaborationManager: if not self.db: return None - now = datetime.now().isoformat() - cursor = self.db.conn.cursor() + now = datetime.now().isoformat() + cursor = self.db.conn.cursor() cursor.execute( """ UPDATE comments - SET content = ?, updated_at = ? - WHERE id = ? AND author = ? + SET content = ?, updated_at = ? + WHERE id = ? AND author = ? """, (content, now, comment_id, updated_by), ) @@ -509,9 +509,9 @@ class CollaborationManager: def _get_comment_by_id(self, comment_id: str) -> Comment | None: """根据ID获取评论""" - cursor = self.db.conn.cursor() - cursor.execute("SELECT * FROM comments WHERE id = ?", (comment_id,)) - row = cursor.fetchone() + cursor = self.db.conn.cursor() + cursor.execute("SELECT * FROM comments WHERE id = ?", (comment_id, )) + row = cursor.fetchone() if row: return self._row_to_comment(row) return None @@ -521,13 +521,13 @@ class CollaborationManager: if not self.db: return False - now = datetime.now().isoformat() - cursor = self.db.conn.cursor() + now = datetime.now().isoformat() + cursor = self.db.conn.cursor() cursor.execute( """ UPDATE comments - SET resolved = 1, resolved_by = ?, resolved_at = ? - WHERE id = ? + SET resolved = 1, resolved_by = ?, resolved_at = ? + WHERE id = ? """, (resolved_by, now, comment_id), ) @@ -539,13 +539,13 @@ class CollaborationManager: if not self.db: return False - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() # 只允许作者或管理员删除 cursor.execute( """ DELETE FROM comments - WHERE id = ? AND (author = ? OR ? IN ( - SELECT created_by FROM projects WHERE id = comments.project_id + WHERE id = ? AND (author = ? OR ? IN ( + SELECT created_by FROM projects WHERE id = comments.project_id )) """, (comment_id, deleted_by, deleted_by), @@ -554,24 +554,24 @@ class CollaborationManager: return cursor.rowcount > 0 def get_project_comments( - self, project_id: str, limit: int = 50, offset: int = 0 + self, project_id: str, limit: int = 50, offset: int = 0 ) -> list[Comment]: """获取项目下的所有评论""" if not self.db: return [] - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ SELECT * FROM comments - WHERE project_id = ? + WHERE project_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ? """, (project_id, limit, offset), ) - comments = [] + comments = [] for row in cursor.fetchall(): comments.append(self._row_to_comment(row)) return comments @@ -587,32 +587,32 @@ class CollaborationManager: entity_name: str, changed_by: str, changed_by_name: str, - old_value: dict | None = None, - new_value: dict | None = None, - description: str = "", - session_id: str | None = None, + old_value: dict | None = None, + new_value: dict | None = None, + description: str = "", + session_id: str | None = None, ) -> ChangeRecord: """记录变更""" - record_id = str(uuid.uuid4()) - now = datetime.now().isoformat() + record_id = str(uuid.uuid4()) + now = datetime.now().isoformat() - record = ChangeRecord( - id=record_id, - project_id=project_id, - change_type=change_type, - entity_type=entity_type, - entity_id=entity_id, - entity_name=entity_name, - changed_by=changed_by, - changed_by_name=changed_by_name, - changed_at=now, - old_value=old_value, - new_value=new_value, - description=description, - session_id=session_id, - reverted=False, - reverted_at=None, - reverted_by=None, + record = ChangeRecord( + id = record_id, + project_id = project_id, + change_type = change_type, + entity_type = entity_type, + entity_id = entity_id, + entity_name = entity_name, + changed_by = changed_by, + changed_by_name = changed_by_name, + changed_at = now, + old_value = old_value, + new_value = new_value, + description = description, + session_id = session_id, + reverted = False, + reverted_at = None, + reverted_by = None, ) if self.db: @@ -622,7 +622,7 @@ class CollaborationManager: def _save_change_to_db(self, record: ChangeRecord) -> None: """保存变更记录到数据库""" - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ INSERT INTO change_history @@ -655,22 +655,22 @@ class CollaborationManager: def get_change_history( self, project_id: str, - entity_type: str | None = None, - entity_id: str | None = None, - limit: int = 50, - offset: int = 0, + entity_type: str | None = None, + entity_id: str | None = None, + limit: int = 50, + offset: int = 0, ) -> list[ChangeRecord]: """获取变更历史""" if not self.db: return [] - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() if entity_type and entity_id: cursor.execute( """ SELECT * FROM change_history - WHERE project_id = ? AND entity_type = ? AND entity_id = ? + WHERE project_id = ? AND entity_type = ? AND entity_id = ? ORDER BY changed_at DESC LIMIT ? OFFSET ? """, @@ -680,7 +680,7 @@ class CollaborationManager: cursor.execute( """ SELECT * FROM change_history - WHERE project_id = ? AND entity_type = ? + WHERE project_id = ? AND entity_type = ? ORDER BY changed_at DESC LIMIT ? OFFSET ? """, @@ -690,14 +690,14 @@ class CollaborationManager: cursor.execute( """ SELECT * FROM change_history - WHERE project_id = ? + WHERE project_id = ? ORDER BY changed_at DESC LIMIT ? OFFSET ? """, (project_id, limit, offset), ) - records = [] + records = [] for row in cursor.fetchall(): records.append(self._row_to_change_record(row)) return records @@ -705,22 +705,22 @@ class CollaborationManager: def _row_to_change_record(self, row) -> ChangeRecord: """将数据库行转换为ChangeRecord对象""" return ChangeRecord( - id=row[0], - project_id=row[1], - change_type=row[2], - entity_type=row[3], - entity_id=row[4], - entity_name=row[5], - changed_by=row[6], - changed_by_name=row[7], - changed_at=row[8], - old_value=json.loads(row[9]) if row[9] else None, - new_value=json.loads(row[10]) if row[10] else None, - description=row[11], - session_id=row[12], - reverted=bool(row[13]), - reverted_at=row[14], - reverted_by=row[15], + id = row[0], + project_id = row[1], + change_type = row[2], + entity_type = row[3], + entity_id = row[4], + entity_name = row[5], + changed_by = row[6], + changed_by_name = row[7], + changed_at = row[8], + old_value = json.loads(row[9]) if row[9] else None, + new_value = json.loads(row[10]) if row[10] else None, + description = row[11], + session_id = row[12], + reverted = bool(row[13]), + reverted_at = row[14], + reverted_by = row[15], ) def get_entity_version_history(self, entity_type: str, entity_id: str) -> list[ChangeRecord]: @@ -728,17 +728,17 @@ class CollaborationManager: if not self.db: return [] - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ SELECT * FROM change_history - WHERE entity_type = ? AND entity_id = ? + WHERE entity_type = ? AND entity_id = ? ORDER BY changed_at ASC """, (entity_type, entity_id), ) - records = [] + records = [] for row in cursor.fetchall(): records.append(self._row_to_change_record(row)) return records @@ -748,13 +748,13 @@ class CollaborationManager: if not self.db: return False - now = datetime.now().isoformat() - cursor = self.db.conn.cursor() + now = datetime.now().isoformat() + cursor = self.db.conn.cursor() cursor.execute( """ UPDATE change_history - SET reverted = 1, reverted_at = ?, reverted_by = ? - WHERE id = ? AND reverted = 0 + SET reverted = 1, reverted_at = ?, reverted_by = ? + WHERE id = ? AND reverted = 0 """, (now, reverted_by, record_id), ) @@ -766,49 +766,49 @@ class CollaborationManager: if not self.db: return {} - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() # 总变更数 cursor.execute( """ - SELECT COUNT(*) FROM change_history WHERE project_id = ? + SELECT COUNT(*) FROM change_history WHERE project_id = ? """, - (project_id,), + (project_id, ), ) - total_changes = cursor.fetchone()[0] + total_changes = cursor.fetchone()[0] # 按类型统计 cursor.execute( """ SELECT change_type, COUNT(*) FROM change_history - WHERE project_id = ? GROUP BY change_type + WHERE project_id = ? GROUP BY change_type """, - (project_id,), + (project_id, ), ) - type_counts = {row[0]: row[1] for row in cursor.fetchall()} + type_counts = {row[0]: row[1] for row in cursor.fetchall()} # 按实体类型统计 cursor.execute( """ SELECT entity_type, COUNT(*) FROM change_history - WHERE project_id = ? GROUP BY entity_type + WHERE project_id = ? GROUP BY entity_type """, - (project_id,), + (project_id, ), ) - entity_type_counts = {row[0]: row[1] for row in cursor.fetchall()} + entity_type_counts = {row[0]: row[1] for row in cursor.fetchall()} # 最近活跃的用户 cursor.execute( """ SELECT changed_by_name, COUNT(*) as count FROM change_history - WHERE project_id = ? + WHERE project_id = ? GROUP BY changed_by_name ORDER BY count DESC LIMIT 5 """, - (project_id,), + (project_id, ), ) - top_contributors = [{"name": row[0], "changes": row[1]} for row in cursor.fetchall()] + top_contributors = [{"name": row[0], "changes": row[1]} for row in cursor.fetchall()] return { "total_changes": total_changes, @@ -827,27 +827,27 @@ class CollaborationManager: user_email: str, role: str, invited_by: str, - permissions: list[str] | None = None, + permissions: list[str] | None = None, ) -> TeamMember: """添加团队成员""" - member_id = str(uuid.uuid4()) - now = datetime.now().isoformat() + member_id = str(uuid.uuid4()) + now = datetime.now().isoformat() # 根据角色设置默认权限 if permissions is None: - permissions = self._get_default_permissions(role) + permissions = self._get_default_permissions(role) - member = TeamMember( - id=member_id, - project_id=project_id, - user_id=user_id, - user_name=user_name, - user_email=user_email, - role=role, - joined_at=now, - invited_by=invited_by, - last_active_at=None, - permissions=permissions, + member = TeamMember( + id = member_id, + project_id = project_id, + user_id = user_id, + user_name = user_name, + user_email = user_email, + role = role, + joined_at = now, + invited_by = invited_by, + last_active_at = None, + permissions = permissions, ) if self.db: @@ -857,7 +857,7 @@ class CollaborationManager: def _get_default_permissions(self, role: str) -> list[str]: """获取角色的默认权限""" - permissions_map = { + permissions_map = { "owner": ["read", "write", "delete", "share", "admin", "export"], "admin": ["read", "write", "delete", "share", "export"], "editor": ["read", "write", "export"], @@ -868,7 +868,7 @@ class CollaborationManager: def _save_member_to_db(self, member: TeamMember) -> None: """保存成员到数据库""" - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ INSERT INTO team_members @@ -896,16 +896,16 @@ class CollaborationManager: if not self.db: return [] - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ - SELECT * FROM team_members WHERE project_id = ? + SELECT * FROM team_members WHERE project_id = ? ORDER BY joined_at ASC """, - (project_id,), + (project_id, ), ) - members = [] + members = [] for row in cursor.fetchall(): members.append(self._row_to_team_member(row)) return members @@ -913,16 +913,16 @@ class CollaborationManager: def _row_to_team_member(self, row) -> TeamMember: """将数据库行转换为TeamMember对象""" return TeamMember( - id=row[0], - project_id=row[1], - user_id=row[2], - user_name=row[3], - user_email=row[4], - role=row[5], - joined_at=row[6], - invited_by=row[7], - last_active_at=row[8], - permissions=json.loads(row[9]) if row[9] else [], + id = row[0], + project_id = row[1], + user_id = row[2], + user_name = row[3], + user_email = row[4], + role = row[5], + joined_at = row[6], + invited_by = row[7], + last_active_at = row[8], + permissions = json.loads(row[9]) if row[9] else [], ) def update_member_role(self, member_id: str, new_role: str, updated_by: str) -> bool: @@ -930,13 +930,13 @@ class CollaborationManager: if not self.db: return False - permissions = self._get_default_permissions(new_role) - cursor = self.db.conn.cursor() + permissions = self._get_default_permissions(new_role) + cursor = self.db.conn.cursor() cursor.execute( """ UPDATE team_members - SET role = ?, permissions = ? - WHERE id = ? + SET role = ?, permissions = ? + WHERE id = ? """, (new_role, json.dumps(permissions), member_id), ) @@ -948,8 +948,8 @@ class CollaborationManager: if not self.db: return False - cursor = self.db.conn.cursor() - cursor.execute("DELETE FROM team_members WHERE id = ?", (member_id,)) + cursor = self.db.conn.cursor() + cursor.execute("DELETE FROM team_members WHERE id = ?", (member_id, )) self.db.conn.commit() return cursor.rowcount > 0 @@ -958,20 +958,20 @@ class CollaborationManager: if not self.db: return False - cursor = self.db.conn.cursor() + cursor = self.db.conn.cursor() cursor.execute( """ SELECT permissions FROM team_members - WHERE project_id = ? AND user_id = ? + WHERE project_id = ? AND user_id = ? """, (project_id, user_id), ) - row = cursor.fetchone() + row = cursor.fetchone() if not row: return False - permissions = json.loads(row[0]) if row[0] else [] + permissions = json.loads(row[0]) if row[0] else [] return permission in permissions or "admin" in permissions def update_last_active(self, project_id: str, user_id: str) -> None: @@ -979,13 +979,13 @@ class CollaborationManager: if not self.db: return - now = datetime.now().isoformat() - cursor = self.db.conn.cursor() + now = datetime.now().isoformat() + cursor = self.db.conn.cursor() cursor.execute( """ UPDATE team_members - SET last_active_at = ? - WHERE project_id = ? AND user_id = ? + SET last_active_at = ? + WHERE project_id = ? AND user_id = ? """, (now, project_id, user_id), ) @@ -993,12 +993,12 @@ class CollaborationManager: # 全局协作管理器实例 -_collaboration_manager = None +_collaboration_manager = None -def get_collaboration_manager(db_manager=None) -> None: +def get_collaboration_manager(db_manager = None) -> None: """获取协作管理器单例""" global _collaboration_manager if _collaboration_manager is None: - _collaboration_manager = CollaborationManager(db_manager) + _collaboration_manager = CollaborationManager(db_manager) return _collaboration_manager diff --git a/backend/db_manager.py b/backend/db_manager.py index 763bd14..37c1e0f 100644 --- a/backend/db_manager.py +++ b/backend/db_manager.py @@ -12,19 +12,19 @@ import uuid from dataclasses import dataclass from datetime import datetime -DB_PATH = os.getenv("DB_PATH", "/app/data/insightflow.db") +DB_PATH = os.getenv("DB_PATH", "/app/data/insightflow.db") # Constants -UUID_LENGTH = 8 # UUID 截断长度 +UUID_LENGTH = 8 # UUID 截断长度 @dataclass class Project: id: str name: str - description: str = "" - created_at: str = "" - updated_at: str = "" + description: str = "" + created_at: str = "" + updated_at: str = "" @dataclass @@ -33,19 +33,19 @@ class Entity: project_id: str name: str type: str - definition: str = "" - canonical_name: str = "" - aliases: list[str] = None - embedding: str = "" # Phase 3: 实体嵌入向量 - attributes: dict = None # Phase 5: 实体属性 - created_at: str = "" - updated_at: str = "" + definition: str = "" + canonical_name: str = "" + aliases: list[str] = None + embedding: str = "" # Phase 3: 实体嵌入向量 + attributes: dict = None # Phase 5: 实体属性 + created_at: str = "" + updated_at: str = "" - def __post_init__(self): + def __post_init__(self) -> None: if self.aliases is None: - self.aliases = [] + self.aliases = [] if self.attributes is None: - self.attributes = {} + self.attributes = {} @dataclass @@ -56,17 +56,17 @@ class AttributeTemplate: project_id: str name: str type: str # text, number, date, select, multiselect, boolean - options: list[str] = None # 用于 select/multiselect - default_value: str = "" - description: str = "" - is_required: bool = False - sort_order: int = 0 - created_at: str = "" - updated_at: str = "" + options: list[str] = None # 用于 select/multiselect + default_value: str = "" + description: str = "" + is_required: bool = False + sort_order: int = 0 + created_at: str = "" + updated_at: str = "" - def __post_init__(self): + def __post_init__(self) -> None: if self.options is None: - self.options = [] + self.options = [] @dataclass @@ -75,19 +75,19 @@ class EntityAttribute: id: str entity_id: str - template_id: str | None = None - name: str = "" # 属性名称 - type: str = "text" # 属性类型 - value: str = "" - options: list[str] = None # 选项列表 - template_name: str = "" # 关联查询时填充 - template_type: str = "" # 关联查询时填充 - created_at: str = "" - updated_at: str = "" + template_id: str | None = None + name: str = "" # 属性名称 + type: str = "text" # 属性类型 + value: str = "" + options: list[str] = None # 选项列表 + template_name: str = "" # 关联查询时填充 + template_type: str = "" # 关联查询时填充 + created_at: str = "" + updated_at: str = "" - def __post_init__(self): + def __post_init__(self) -> None: if self.options is None: - self.options = [] + self.options = [] @dataclass @@ -96,12 +96,12 @@ class AttributeHistory: id: str entity_id: str - attribute_name: str = "" # 属性名称 - old_value: str = "" - new_value: str = "" - changed_by: str = "" - changed_at: str = "" - change_reason: str = "" + attribute_name: str = "" # 属性名称 + old_value: str = "" + new_value: str = "" + changed_by: str = "" + changed_at: str = "" + change_reason: str = "" @dataclass @@ -112,35 +112,35 @@ class EntityMention: start_pos: int end_pos: int text_snippet: str - confidence: float = 1.0 + confidence: float = 1.0 class DatabaseManager: - def __init__(self, db_path: str = DB_PATH): - self.db_path = db_path - os.makedirs(os.path.dirname(db_path), exist_ok=True) + def __init__(self, db_path: str = DB_PATH) -> None: + self.db_path = db_path + os.makedirs(os.path.dirname(db_path), exist_ok = True) self.init_db() - def get_conn(self): - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + def get_conn(self) -> None: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn def init_db(self) -> None: """初始化数据库表""" with open(os.path.join(os.path.dirname(__file__), "schema.sql")) as f: - schema = f.read() + schema = f.read() - conn = self.get_conn() + conn = self.get_conn() conn.executescript(schema) conn.commit() conn.close() # ==================== Project Operations ==================== - def create_project(self, project_id: str, name: str, description: str = "") -> Project: - conn = self.get_conn() - now = datetime.now().isoformat() + def create_project(self, project_id: str, name: str, description: str = "") -> Project: + conn = self.get_conn() + now = datetime.now().isoformat() conn.execute( """INSERT INTO projects (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)""", @@ -149,27 +149,27 @@ class DatabaseManager: conn.commit() conn.close() return Project( - id=project_id, name=name, description=description, created_at=now, updated_at=now + id = project_id, name = name, description = description, created_at = now, updated_at = now ) def get_project(self, project_id: str) -> Project | None: - conn = self.get_conn() - row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() + conn = self.get_conn() + row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id, )).fetchone() conn.close() if row: return Project(**dict(row)) return None def list_projects(self) -> list[Project]: - conn = self.get_conn() - rows = conn.execute("SELECT * FROM projects ORDER BY updated_at DESC").fetchall() + conn = self.get_conn() + rows = conn.execute("SELECT * FROM projects ORDER BY updated_at DESC").fetchall() conn.close() return [Project(**dict(r)) for r in rows] # ==================== Entity Operations ==================== def create_entity(self, entity: Entity) -> Entity: - conn = self.get_conn() + conn = self.get_conn() conn.execute( """INSERT INTO entities (id, project_id, name, canonical_name, type, definition, aliases, created_at, updated_at) @@ -192,122 +192,122 @@ class DatabaseManager: def get_entity_by_name(self, project_id: str, name: str) -> Entity | None: """通过名称查找实体(用于对齐)""" - conn = self.get_conn() - row = conn.execute( - """SELECT * FROM entities WHERE project_id = ? - AND (name = ? OR canonical_name = ? OR aliases LIKE ?)""", + conn = self.get_conn() + row = conn.execute( + """SELECT * FROM entities WHERE project_id = ? + AND (name = ? OR canonical_name = ? OR aliases LIKE ?)""", (project_id, name, name, f'%"{name}"%'), ).fetchone() conn.close() if row: - data = dict(row) - data["aliases"] = json.loads(data["aliases"]) if data["aliases"] else [] + data = dict(row) + data["aliases"] = json.loads(data["aliases"]) if data["aliases"] else [] return Entity(**data) return None def find_similar_entities( - self, project_id: str, name: str, threshold: float = 0.8 + self, project_id: str, name: str, threshold: float = 0.8 ) -> list[Entity]: """查找相似实体""" - conn = self.get_conn() - rows = conn.execute( - "SELECT * FROM entities WHERE project_id = ? AND name LIKE ?", (project_id, f"%{name}%") + conn = self.get_conn() + rows = conn.execute( + "SELECT * FROM entities WHERE project_id = ? AND name LIKE ?", (project_id, f"%{name}%") ).fetchall() conn.close() - entities = [] + entities = [] for row in rows: - data = dict(row) - data["aliases"] = json.loads(data["aliases"]) if data["aliases"] else [] + data = dict(row) + data["aliases"] = json.loads(data["aliases"]) if data["aliases"] else [] entities.append(Entity(**data)) return entities def merge_entities(self, target_id: str, source_id: str) -> Entity: """合并两个实体""" - conn = self.get_conn() + conn = self.get_conn() - target = conn.execute("SELECT * FROM entities WHERE id = ?", (target_id,)).fetchone() - source = conn.execute("SELECT * FROM entities WHERE id = ?", (source_id,)).fetchone() + target = conn.execute("SELECT * FROM entities WHERE id = ?", (target_id, )).fetchone() + source = conn.execute("SELECT * FROM entities WHERE id = ?", (source_id, )).fetchone() if not target or not source: conn.close() raise ValueError("Entity not found") - target_aliases = set(json.loads(target["aliases"]) if target["aliases"] else []) + target_aliases = set(json.loads(target["aliases"]) if target["aliases"] else []) target_aliases.add(source["name"]) target_aliases.update(json.loads(source["aliases"]) if source["aliases"] else []) conn.execute( - "UPDATE entities SET aliases = ?, updated_at = ? WHERE id = ?", + "UPDATE entities SET aliases = ?, updated_at = ? WHERE id = ?", (json.dumps(list(target_aliases)), datetime.now().isoformat(), target_id), ) conn.execute( - "UPDATE entity_mentions SET entity_id = ? WHERE entity_id = ?", (target_id, source_id) + "UPDATE entity_mentions SET entity_id = ? WHERE entity_id = ?", (target_id, source_id) ) conn.execute( - "UPDATE entity_relations SET source_entity_id = ? WHERE source_entity_id = ?", + "UPDATE entity_relations SET source_entity_id = ? WHERE source_entity_id = ?", (target_id, source_id), ) conn.execute( - "UPDATE entity_relations SET target_entity_id = ? WHERE target_entity_id = ?", + "UPDATE entity_relations SET target_entity_id = ? WHERE target_entity_id = ?", (target_id, source_id), ) - conn.execute("DELETE FROM entities WHERE id = ?", (source_id,)) + conn.execute("DELETE FROM entities WHERE id = ?", (source_id, )) conn.commit() conn.close() return self.get_entity(target_id) def get_entity(self, entity_id: str) -> Entity | None: - conn = self.get_conn() - row = conn.execute("SELECT * FROM entities WHERE id = ?", (entity_id,)).fetchone() + conn = self.get_conn() + row = conn.execute("SELECT * FROM entities WHERE id = ?", (entity_id, )).fetchone() conn.close() if row: - data = dict(row) - data["aliases"] = json.loads(data["aliases"]) if data["aliases"] else [] + data = dict(row) + data["aliases"] = json.loads(data["aliases"]) if data["aliases"] else [] return Entity(**data) return None def list_project_entities(self, project_id: str) -> list[Entity]: - conn = self.get_conn() - rows = conn.execute( - "SELECT * FROM entities WHERE project_id = ? ORDER BY updated_at DESC", (project_id,) + conn = self.get_conn() + rows = conn.execute( + "SELECT * FROM entities WHERE project_id = ? ORDER BY updated_at DESC", (project_id, ) ).fetchall() conn.close() - entities = [] + entities = [] for row in rows: - data = dict(row) - data["aliases"] = json.loads(data["aliases"]) if data["aliases"] else [] + data = dict(row) + data["aliases"] = json.loads(data["aliases"]) if data["aliases"] else [] entities.append(Entity(**data)) return entities def update_entity(self, entity_id: str, **kwargs) -> Entity: """更新实体信息""" - conn = self.get_conn() + conn = self.get_conn() - allowed_fields = ["name", "type", "definition", "canonical_name"] - updates = [] - values = [] + allowed_fields = ["name", "type", "definition", "canonical_name"] + updates = [] + values = [] for field in allowed_fields: if field in kwargs: - updates.append(f"{field} = ?") + updates.append(f"{field} = ?") values.append(kwargs[field]) if "aliases" in kwargs: - updates.append("aliases = ?") + updates.append("aliases = ?") values.append(json.dumps(kwargs["aliases"])) if not updates: conn.close() return self.get_entity(entity_id) - updates.append("updated_at = ?") + updates.append("updated_at = ?") values.append(datetime.now().isoformat()) values.append(entity_id) - query = f"UPDATE entities SET {', '.join(updates)} WHERE id = ?" + query = f"UPDATE entities SET {', '.join(updates)} WHERE id = ?" conn.execute(query, values) conn.commit() conn.close() @@ -315,21 +315,21 @@ class DatabaseManager: def delete_entity(self, entity_id: str) -> None: """删除实体及其关联数据""" - conn = self.get_conn() - conn.execute("DELETE FROM entity_mentions WHERE entity_id = ?", (entity_id,)) + conn = self.get_conn() + conn.execute("DELETE FROM entity_mentions WHERE entity_id = ?", (entity_id, )) conn.execute( - "DELETE FROM entity_relations WHERE source_entity_id = ? OR target_entity_id = ?", + "DELETE FROM entity_relations WHERE source_entity_id = ? OR target_entity_id = ?", (entity_id, entity_id), ) - conn.execute("DELETE FROM entity_attributes WHERE entity_id = ?", (entity_id,)) - conn.execute("DELETE FROM entities WHERE id = ?", (entity_id,)) + conn.execute("DELETE FROM entity_attributes WHERE entity_id = ?", (entity_id, )) + conn.execute("DELETE FROM entities WHERE id = ?", (entity_id, )) conn.commit() conn.close() # ==================== Mention Operations ==================== def add_mention(self, mention: EntityMention) -> EntityMention: - conn = self.get_conn() + conn = self.get_conn() conn.execute( """INSERT INTO entity_mentions (id, entity_id, transcript_id, start_pos, end_pos, text_snippet, confidence) @@ -349,10 +349,10 @@ class DatabaseManager: return mention def get_entity_mentions(self, entity_id: str) -> list[EntityMention]: - conn = self.get_conn() - rows = conn.execute( - "SELECT * FROM entity_mentions WHERE entity_id = ? ORDER BY transcript_id, start_pos", - (entity_id,), + conn = self.get_conn() + rows = conn.execute( + "SELECT * FROM entity_mentions WHERE entity_id = ? ORDER BY transcript_id, start_pos", + (entity_id, ), ).fetchall() conn.close() return [EntityMention(**dict(r)) for r in rows] @@ -365,10 +365,10 @@ class DatabaseManager: project_id: str, filename: str, full_text: str, - transcript_type: str = "audio", - ): - conn = self.get_conn() - now = datetime.now().isoformat() + transcript_type: str = "audio", + ) -> None: + conn = self.get_conn() + now = datetime.now().isoformat() conn.execute( """INSERT INTO transcripts (id, project_id, filename, full_text, type, created_at) @@ -379,28 +379,28 @@ class DatabaseManager: conn.close() def get_transcript(self, transcript_id: str) -> dict | None: - conn = self.get_conn() - row = conn.execute("SELECT * FROM transcripts WHERE id = ?", (transcript_id,)).fetchone() + conn = self.get_conn() + row = conn.execute("SELECT * FROM transcripts WHERE id = ?", (transcript_id, )).fetchone() conn.close() return dict(row) if row else None def list_project_transcripts(self, project_id: str) -> list[dict]: - conn = self.get_conn() - rows = conn.execute( - "SELECT * FROM transcripts WHERE project_id = ? ORDER BY created_at DESC", (project_id,) + conn = self.get_conn() + rows = conn.execute( + "SELECT * FROM transcripts WHERE project_id = ? ORDER BY created_at DESC", (project_id, ) ).fetchall() conn.close() return [dict(r) for r in rows] def update_transcript(self, transcript_id: str, full_text: str) -> dict: - conn = self.get_conn() - now = datetime.now().isoformat() + conn = self.get_conn() + now = datetime.now().isoformat() conn.execute( - "UPDATE transcripts SET full_text = ?, updated_at = ? WHERE id = ?", + "UPDATE transcripts SET full_text = ?, updated_at = ? WHERE id = ?", (full_text, now, transcript_id), ) conn.commit() - row = conn.execute("SELECT * FROM transcripts WHERE id = ?", (transcript_id,)).fetchone() + row = conn.execute("SELECT * FROM transcripts WHERE id = ?", (transcript_id, )).fetchone() conn.close() return dict(row) if row else None @@ -411,13 +411,13 @@ class DatabaseManager: project_id: str, source_entity_id: str, target_entity_id: str, - relation_type: str = "related", - evidence: str = "", - transcript_id: str = "", - ): - conn = self.get_conn() - relation_id = str(uuid.uuid4())[:UUID_LENGTH] - now = datetime.now().isoformat() + relation_type: str = "related", + evidence: str = "", + transcript_id: str = "", + ) -> None: + conn = self.get_conn() + relation_id = str(uuid.uuid4())[:UUID_LENGTH] + now = datetime.now().isoformat() conn.execute( """INSERT INTO entity_relations (id, project_id, source_entity_id, target_entity_id, relation_type, @@ -439,10 +439,10 @@ class DatabaseManager: return relation_id def get_entity_relations(self, entity_id: str) -> list[dict]: - conn = self.get_conn() - rows = conn.execute( + conn = self.get_conn() + rows = conn.execute( """SELECT * FROM entity_relations - WHERE source_entity_id = ? OR target_entity_id = ? + WHERE source_entity_id = ? OR target_entity_id = ? ORDER BY created_at DESC""", (entity_id, entity_id), ).fetchall() @@ -450,58 +450,58 @@ class DatabaseManager: return [dict(r) for r in rows] def list_project_relations(self, project_id: str) -> list[dict]: - conn = self.get_conn() - rows = conn.execute( - "SELECT * FROM entity_relations WHERE project_id = ? ORDER BY created_at DESC", - (project_id,), + conn = self.get_conn() + rows = conn.execute( + "SELECT * FROM entity_relations WHERE project_id = ? ORDER BY created_at DESC", + (project_id, ), ).fetchall() conn.close() return [dict(r) for r in rows] def update_relation(self, relation_id: str, **kwargs) -> dict: - conn = self.get_conn() - allowed_fields = ["relation_type", "evidence"] - updates = [] - values = [] + conn = self.get_conn() + allowed_fields = ["relation_type", "evidence"] + updates = [] + values = [] for field in allowed_fields: if field in kwargs: - updates.append(f"{field} = ?") + updates.append(f"{field} = ?") values.append(kwargs[field]) if updates: - query = f"UPDATE entity_relations SET {', '.join(updates)} WHERE id = ?" + query = f"UPDATE entity_relations SET {', '.join(updates)} WHERE id = ?" values.append(relation_id) conn.execute(query, values) conn.commit() - row = conn.execute("SELECT * FROM entity_relations WHERE id = ?", (relation_id,)).fetchone() + row = conn.execute("SELECT * FROM entity_relations WHERE id = ?", (relation_id, )).fetchone() conn.close() return dict(row) if row else None - def delete_relation(self, relation_id: str): - conn = self.get_conn() - conn.execute("DELETE FROM entity_relations WHERE id = ?", (relation_id,)) + def delete_relation(self, relation_id: str) -> None: + conn = self.get_conn() + conn.execute("DELETE FROM entity_relations WHERE id = ?", (relation_id, )) conn.commit() conn.close() # ==================== Glossary Operations ==================== - def add_glossary_term(self, project_id: str, term: str, pronunciation: str = "") -> str: - conn = self.get_conn() - existing = conn.execute( - "SELECT * FROM glossary WHERE project_id = ? AND term = ?", (project_id, term) + def add_glossary_term(self, project_id: str, term: str, pronunciation: str = "") -> str: + conn = self.get_conn() + existing = conn.execute( + "SELECT * FROM glossary WHERE project_id = ? AND term = ?", (project_id, term) ).fetchone() if existing: conn.execute( - "UPDATE glossary SET frequency = frequency + 1 WHERE id = ?", (existing["id"],) + "UPDATE glossary SET frequency = frequency + 1 WHERE id = ?", (existing["id"], ) ) conn.commit() conn.close() return existing["id"] - term_id = str(uuid.uuid4())[:UUID_LENGTH] + term_id = str(uuid.uuid4())[:UUID_LENGTH] conn.execute( """INSERT INTO glossary (id, project_id, term, pronunciation, frequency) @@ -513,118 +513,118 @@ class DatabaseManager: return term_id def list_glossary(self, project_id: str) -> list[dict]: - conn = self.get_conn() - rows = conn.execute( - "SELECT * FROM glossary WHERE project_id = ? ORDER BY frequency DESC", (project_id,) + conn = self.get_conn() + rows = conn.execute( + "SELECT * FROM glossary WHERE project_id = ? ORDER BY frequency DESC", (project_id, ) ).fetchall() conn.close() return [dict(r) for r in rows] - def delete_glossary_term(self, term_id: str): - conn = self.get_conn() - conn.execute("DELETE FROM glossary WHERE id = ?", (term_id,)) + def delete_glossary_term(self, term_id: str) -> None: + conn = self.get_conn() + conn.execute("DELETE FROM glossary WHERE id = ?", (term_id, )) conn.commit() conn.close() # ==================== Phase 4: Agent & Provenance ==================== def get_relation_with_details(self, relation_id: str) -> dict | None: - conn = self.get_conn() - row = conn.execute( + conn = self.get_conn() + row = conn.execute( """SELECT r.*, s.name as source_name, t.name as target_name, tr.filename as transcript_filename, tr.full_text as transcript_text FROM entity_relations r - JOIN entities s ON r.source_entity_id = s.id - JOIN entities t ON r.target_entity_id = t.id - LEFT JOIN transcripts tr ON r.transcript_id = tr.id - WHERE r.id = ?""", - (relation_id,), + JOIN entities s ON r.source_entity_id = s.id + JOIN entities t ON r.target_entity_id = t.id + LEFT JOIN transcripts tr ON r.transcript_id = tr.id + WHERE r.id = ?""", + (relation_id, ), ).fetchone() conn.close() return dict(row) if row else None def get_entity_with_mentions(self, entity_id: str) -> dict | None: - conn = self.get_conn() - entity_row = conn.execute("SELECT * FROM entities WHERE id = ?", (entity_id,)).fetchone() + conn = self.get_conn() + entity_row = conn.execute("SELECT * FROM entities WHERE id = ?", (entity_id, )).fetchone() if not entity_row: conn.close() return None - entity = dict(entity_row) - entity["aliases"] = json.loads(entity["aliases"]) if entity["aliases"] else [] + entity = dict(entity_row) + entity["aliases"] = json.loads(entity["aliases"]) if entity["aliases"] else [] - mentions = conn.execute( + mentions = conn.execute( """SELECT m.*, t.filename, t.created_at as transcript_date FROM entity_mentions m - JOIN transcripts t ON m.transcript_id = t.id - WHERE m.entity_id = ? ORDER BY t.created_at, m.start_pos""", - (entity_id,), + JOIN transcripts t ON m.transcript_id = t.id + WHERE m.entity_id = ? ORDER BY t.created_at, m.start_pos""", + (entity_id, ), ).fetchall() - entity["mentions"] = [dict(m) for m in mentions] - entity["mention_count"] = len(mentions) + entity["mentions"] = [dict(m) for m in mentions] + entity["mention_count"] = len(mentions) - relations = conn.execute( + relations = conn.execute( """SELECT r.*, s.name as source_name, t.name as target_name FROM entity_relations r - JOIN entities s ON r.source_entity_id = s.id - JOIN entities t ON r.target_entity_id = t.id - WHERE r.source_entity_id = ? OR r.target_entity_id = ? + JOIN entities s ON r.source_entity_id = s.id + JOIN entities t ON r.target_entity_id = t.id + WHERE r.source_entity_id = ? OR r.target_entity_id = ? ORDER BY r.created_at DESC""", (entity_id, entity_id), ).fetchall() - entity["relations"] = [dict(r) for r in relations] + entity["relations"] = [dict(r) for r in relations] conn.close() return entity def search_entities(self, project_id: str, query: str) -> list[Entity]: - conn = self.get_conn() - rows = conn.execute( + conn = self.get_conn() + rows = conn.execute( """SELECT * FROM entities - WHERE project_id = ? AND + WHERE project_id = ? AND (name LIKE ? OR definition LIKE ? OR aliases LIKE ?) ORDER BY name""", (project_id, f"%{query}%", f"%{query}%", f"%{query}%"), ).fetchall() conn.close() - entities = [] + entities = [] for row in rows: - data = dict(row) - data["aliases"] = json.loads(data["aliases"]) if data["aliases"] else [] + data = dict(row) + data["aliases"] = json.loads(data["aliases"]) if data["aliases"] else [] entities.append(Entity(**data)) return entities def get_project_summary(self, project_id: str) -> dict: - conn = self.get_conn() - project = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() + conn = self.get_conn() + project = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id, )).fetchone() - entity_count = conn.execute( - "SELECT COUNT(*) as count FROM entities WHERE project_id = ?", (project_id,) + entity_count = conn.execute( + "SELECT COUNT(*) as count FROM entities WHERE project_id = ?", (project_id, ) ).fetchone()["count"] - transcript_count = conn.execute( - "SELECT COUNT(*) as count FROM transcripts WHERE project_id = ?", (project_id,) + transcript_count = conn.execute( + "SELECT COUNT(*) as count FROM transcripts WHERE project_id = ?", (project_id, ) ).fetchone()["count"] - relation_count = conn.execute( - "SELECT COUNT(*) as count FROM entity_relations WHERE project_id = ?", (project_id,) + relation_count = conn.execute( + "SELECT COUNT(*) as count FROM entity_relations WHERE project_id = ?", (project_id, ) ).fetchone()["count"] - recent_transcripts = conn.execute( + recent_transcripts = conn.execute( """SELECT filename, full_text, created_at FROM transcripts - WHERE project_id = ? ORDER BY created_at DESC LIMIT 5""", - (project_id,), + WHERE project_id = ? ORDER BY created_at DESC LIMIT 5""", + (project_id, ), ).fetchall() - top_entities = conn.execute( + top_entities = conn.execute( """SELECT e.name, e.type, e.definition, COUNT(m.id) as mention_count FROM entities e - LEFT JOIN entity_mentions m ON e.id = m.entity_id - WHERE e.project_id = ? + LEFT JOIN entity_mentions m ON e.id = m.entity_id + WHERE e.project_id = ? GROUP BY e.id ORDER BY mention_count DESC LIMIT 10""", - (project_id,), + (project_id, ), ).fetchall() conn.close() @@ -641,32 +641,32 @@ class DatabaseManager: } def get_transcript_context( - self, transcript_id: str, position: int, context_chars: int = 200 + self, transcript_id: str, position: int, context_chars: int = 200 ) -> str: - conn = self.get_conn() - row = conn.execute( - "SELECT full_text FROM transcripts WHERE id = ?", (transcript_id,) + conn = self.get_conn() + row = conn.execute( + "SELECT full_text FROM transcripts WHERE id = ?", (transcript_id, ) ).fetchone() conn.close() if not row: return "" - text = row["full_text"] - start = max(0, position - context_chars) - end = min(len(text), position + context_chars) + text = row["full_text"] + start = max(0, position - context_chars) + end = min(len(text), position + context_chars) return text[start:end] # ==================== Phase 5: Timeline Operations ==================== def get_project_timeline( - self, project_id: str, entity_id: str = None, start_date: str = None, end_date: str = None + self, project_id: str, entity_id: str = None, start_date: str = None, end_date: str = None ) -> list[dict]: - conn = self.get_conn() + conn = self.get_conn() - conditions = ["t.project_id = ?"] - params = [project_id] + conditions = ["t.project_id = ?"] + params = [project_id] if entity_id: - conditions.append("m.entity_id = ?") + conditions.append("m.entity_id = ?") params.append(entity_id) if start_date: conditions.append("t.created_at >= ?") @@ -675,19 +675,19 @@ class DatabaseManager: conditions.append("t.created_at <= ?") params.append(end_date) - where_clause = " AND ".join(conditions) + where_clause = " AND ".join(conditions) - mentions = conn.execute( + mentions = conn.execute( f"""SELECT m.*, e.name as entity_name, e.type as entity_type, e.definition, t.filename, t.created_at as event_date, t.type as source_type FROM entity_mentions m - JOIN entities e ON m.entity_id = e.id - JOIN transcripts t ON m.transcript_id = t.id + JOIN entities e ON m.entity_id = e.id + JOIN transcripts t ON m.transcript_id = t.id WHERE {where_clause} ORDER BY t.created_at, m.start_pos""", params, ).fetchall() - timeline_events = [] + timeline_events = [] for m in mentions: timeline_events.append( { @@ -708,30 +708,30 @@ class DatabaseManager: ) conn.close() - timeline_events.sort(key=lambda x: x["event_date"]) + timeline_events.sort(key = lambda x: x["event_date"]) return timeline_events def get_entity_timeline_summary(self, project_id: str) -> dict: - conn = self.get_conn() + conn = self.get_conn() - daily_stats = conn.execute( + daily_stats = conn.execute( """SELECT DATE(t.created_at) as date, COUNT(*) as count FROM entity_mentions m - JOIN transcripts t ON m.transcript_id = t.id - WHERE t.project_id = ? GROUP BY DATE(t.created_at) ORDER BY date""", - (project_id,), + JOIN transcripts t ON m.transcript_id = t.id + WHERE t.project_id = ? GROUP BY DATE(t.created_at) ORDER BY date""", + (project_id, ), ).fetchall() - entity_stats = conn.execute( + entity_stats = conn.execute( """SELECT e.name, e.type, COUNT(m.id) as mention_count, MIN(t.created_at) as first_mentioned, MAX(t.created_at) as last_mentioned FROM entities e - LEFT JOIN entity_mentions m ON e.id = m.entity_id - LEFT JOIN transcripts t ON m.transcript_id = t.id - WHERE e.project_id = ? + LEFT JOIN entity_mentions m ON e.id = m.entity_id + LEFT JOIN transcripts t ON m.transcript_id = t.id + WHERE e.project_id = ? GROUP BY e.id ORDER BY mention_count DESC LIMIT 20""", - (project_id,), + (project_id, ), ).fetchall() conn.close() @@ -744,8 +744,8 @@ class DatabaseManager: # ==================== Phase 5: Entity Attributes ==================== def create_attribute_template(self, template: AttributeTemplate) -> AttributeTemplate: - conn = self.get_conn() - now = datetime.now().isoformat() + conn = self.get_conn() + now = datetime.now().isoformat() conn.execute( """INSERT INTO attribute_templates (id, project_id, name, type, options, default_value, description, @@ -770,36 +770,36 @@ class DatabaseManager: return template def get_attribute_template(self, template_id: str) -> AttributeTemplate | None: - conn = self.get_conn() - row = conn.execute( - "SELECT * FROM attribute_templates WHERE id = ?", (template_id,) + conn = self.get_conn() + row = conn.execute( + "SELECT * FROM attribute_templates WHERE id = ?", (template_id, ) ).fetchone() conn.close() if row: - data = dict(row) - data["options"] = json.loads(data["options"]) if data["options"] else [] + data = dict(row) + data["options"] = json.loads(data["options"]) if data["options"] else [] return AttributeTemplate(**data) return None def list_attribute_templates(self, project_id: str) -> list[AttributeTemplate]: - conn = self.get_conn() - rows = conn.execute( - """SELECT * FROM attribute_templates WHERE project_id = ? + conn = self.get_conn() + rows = conn.execute( + """SELECT * FROM attribute_templates WHERE project_id = ? ORDER BY sort_order, created_at""", - (project_id,), + (project_id, ), ).fetchall() conn.close() - templates = [] + templates = [] for row in rows: - data = dict(row) - data["options"] = json.loads(data["options"]) if data["options"] else [] + data = dict(row) + data["options"] = json.loads(data["options"]) if data["options"] else [] templates.append(AttributeTemplate(**data)) return templates def update_attribute_template(self, template_id: str, **kwargs) -> AttributeTemplate | None: - conn = self.get_conn() - allowed_fields = [ + conn = self.get_conn() + allowed_fields = [ "name", "type", "options", @@ -808,45 +808,45 @@ class DatabaseManager: "is_required", "sort_order", ] - updates = [] - values = [] + updates = [] + values = [] for field in allowed_fields: if field in kwargs: - updates.append(f"{field} = ?") + updates.append(f"{field} = ?") if field == "options": values.append(json.dumps(kwargs[field]) if kwargs[field] else None) else: values.append(kwargs[field]) if updates: - updates.append("updated_at = ?") + updates.append("updated_at = ?") values.append(datetime.now().isoformat()) values.append(template_id) - query = f"UPDATE attribute_templates SET {', '.join(updates)} WHERE id = ?" + query = f"UPDATE attribute_templates SET {', '.join(updates)} WHERE id = ?" conn.execute(query, values) conn.commit() conn.close() return self.get_attribute_template(template_id) - def delete_attribute_template(self, template_id: str): - conn = self.get_conn() - conn.execute("DELETE FROM attribute_templates WHERE id = ?", (template_id,)) + def delete_attribute_template(self, template_id: str) -> None: + conn = self.get_conn() + conn.execute("DELETE FROM attribute_templates WHERE id = ?", (template_id, )) conn.commit() conn.close() def set_entity_attribute( - self, attr: EntityAttribute, changed_by: str = "system", change_reason: str = "" + self, attr: EntityAttribute, changed_by: str = "system", change_reason: str = "" ) -> EntityAttribute: - conn = self.get_conn() - now = datetime.now().isoformat() + conn = self.get_conn() + now = datetime.now().isoformat() - old_row = conn.execute( - "SELECT value FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + old_row = conn.execute( + "SELECT value FROM entity_attributes WHERE entity_id = ? AND template_id = ?", (attr.entity_id, attr.template_id), ).fetchone() - old_value = old_row["value"] if old_row else None + old_value = old_row["value"] if old_row else None if old_value != attr.value: conn.execute( @@ -872,12 +872,12 @@ class DatabaseManager: VALUES ( COALESCE( (SELECT id FROM entity_attributes - WHERE entity_id = ? AND template_id = ?), ? + WHERE entity_id = ? AND template_id = ?), ? ), ?, ?, ?, COALESCE( (SELECT created_at FROM entity_attributes - WHERE entity_id = ? AND template_id = ?), ? + WHERE entity_id = ? AND template_id = ?), ? ), ?)""", ( @@ -899,23 +899,23 @@ class DatabaseManager: return attr def get_entity_attributes(self, entity_id: str) -> list[EntityAttribute]: - conn = self.get_conn() - rows = conn.execute( + conn = self.get_conn() + rows = conn.execute( """SELECT ea.*, at.name as template_name, at.type as template_type FROM entity_attributes ea - LEFT JOIN attribute_templates at ON ea.template_id = at.id - WHERE ea.entity_id = ? ORDER BY ea.created_at""", - (entity_id,), + LEFT JOIN attribute_templates at ON ea.template_id = at.id + WHERE ea.entity_id = ? ORDER BY ea.created_at""", + (entity_id, ), ).fetchall() conn.close() return [EntityAttribute(**dict(r)) for r in rows] def get_entity_with_attributes(self, entity_id: str) -> Entity | None: - entity = self.get_entity(entity_id) + entity = self.get_entity(entity_id) if not entity: return None - attrs = self.get_entity_attributes(entity_id) - entity.attributes = { + attrs = self.get_entity_attributes(entity_id) + entity.attributes = { attr.template_name: { "value": attr.value, "type": attr.template_type, @@ -926,12 +926,12 @@ class DatabaseManager: return entity def delete_entity_attribute( - self, entity_id: str, template_id: str, changed_by: str = "system", change_reason: str = "" - ): - conn = self.get_conn() - old_row = conn.execute( + self, entity_id: str, template_id: str, changed_by: str = "system", change_reason: str = "" + ) -> None: + conn = self.get_conn() + old_row = conn.execute( """SELECT value FROM entity_attributes - WHERE entity_id = ? AND template_id = ?""", + WHERE entity_id = ? AND template_id = ?""", (entity_id, template_id), ).fetchone() @@ -953,29 +953,29 @@ class DatabaseManager: ), ) conn.execute( - "DELETE FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + "DELETE FROM entity_attributes WHERE entity_id = ? AND template_id = ?", (entity_id, template_id), ) conn.commit() conn.close() def get_attribute_history( - self, entity_id: str = None, template_id: str = None, limit: int = 50 + self, entity_id: str = None, template_id: str = None, limit: int = 50 ) -> list[AttributeHistory]: - conn = self.get_conn() - conditions = [] - params = [] + conn = self.get_conn() + conditions = [] + params = [] if entity_id: - conditions.append("ah.entity_id = ?") + conditions.append("ah.entity_id = ?") params.append(entity_id) if template_id: - conditions.append("ah.template_id = ?") + conditions.append("ah.template_id = ?") params.append(template_id) - where_clause = " AND ".join(conditions) if conditions else "1=1" + where_clause = " AND ".join(conditions) if conditions else "1 = 1" - rows = conn.execute( + rows = conn.execute( f"""SELECT ah.* FROM attribute_history ah WHERE {where_clause} @@ -988,42 +988,42 @@ class DatabaseManager: def search_entities_by_attributes( self, project_id: str, attribute_filters: dict[str, str] ) -> list[Entity]: - entities = self.list_project_entities(project_id) + entities = self.list_project_entities(project_id) if not attribute_filters: return entities - entity_ids = [e.id for e in entities] + entity_ids = [e.id for e in entities] if not entity_ids: return [] - conn = self.get_conn() - placeholders = ",".join(["?" for _ in entity_ids]) - rows = conn.execute( + conn = self.get_conn() + placeholders = ", ".join(["?" for _ in entity_ids]) + rows = conn.execute( f"""SELECT ea.*, at.name as template_name FROM entity_attributes ea - JOIN attribute_templates at ON ea.template_id = at.id + JOIN attribute_templates at ON ea.template_id = at.id WHERE ea.entity_id IN ({placeholders})""", entity_ids, ).fetchall() conn.close() - entity_attrs = {} + entity_attrs = {} for row in rows: - eid = row["entity_id"] + eid = row["entity_id"] if eid not in entity_attrs: - entity_attrs[eid] = {} - entity_attrs[eid][row["template_name"]] = row["value"] + entity_attrs[eid] = {} + entity_attrs[eid][row["template_name"]] = row["value"] - filtered = [] + filtered = [] for entity in entities: - attrs = entity_attrs.get(entity.id, {}) - match = True + attrs = entity_attrs.get(entity.id, {}) + match = True for attr_name, attr_value in attribute_filters.items(): if attrs.get(attr_name) != attr_value: - match = False + match = False break if match: - entity.attributes = attrs + entity.attributes = attrs filtered.append(entity) return filtered @@ -1034,17 +1034,17 @@ class DatabaseManager: video_id: str, project_id: str, filename: str, - duration: float = 0, - fps: float = 0, - resolution: dict = None, - audio_transcript_id: str = None, - full_ocr_text: str = "", - extracted_entities: list[dict] = None, - extracted_relations: list[dict] = None, + duration: float = 0, + fps: float = 0, + resolution: dict = None, + audio_transcript_id: str = None, + full_ocr_text: str = "", + extracted_entities: list[dict] = None, + extracted_relations: list[dict] = None, ) -> str: """创建视频记录""" - conn = self.get_conn() - now = datetime.now().isoformat() + conn = self.get_conn() + now = datetime.now().isoformat() conn.execute( """INSERT INTO videos @@ -1074,17 +1074,17 @@ class DatabaseManager: def get_video(self, video_id: str) -> dict | None: """获取视频信息""" - conn = self.get_conn() - row = conn.execute("SELECT * FROM videos WHERE id = ?", (video_id,)).fetchone() + conn = self.get_conn() + row = conn.execute("SELECT * FROM videos WHERE id = ?", (video_id, )).fetchone() conn.close() if row: - data = dict(row) - data["resolution"] = json.loads(data["resolution"]) if data["resolution"] else None - data["extracted_entities"] = ( + data = dict(row) + data["resolution"] = json.loads(data["resolution"]) if data["resolution"] else None + data["extracted_entities"] = ( json.loads(data["extracted_entities"]) if data["extracted_entities"] else [] ) - data["extracted_relations"] = ( + data["extracted_relations"] = ( json.loads(data["extracted_relations"]) if data["extracted_relations"] else [] ) return data @@ -1092,20 +1092,20 @@ class DatabaseManager: def list_project_videos(self, project_id: str) -> list[dict]: """获取项目的所有视频""" - conn = self.get_conn() - rows = conn.execute( - "SELECT * FROM videos WHERE project_id = ? ORDER BY created_at DESC", (project_id,) + conn = self.get_conn() + rows = conn.execute( + "SELECT * FROM videos WHERE project_id = ? ORDER BY created_at DESC", (project_id, ) ).fetchall() conn.close() - videos = [] + videos = [] for row in rows: - data = dict(row) - data["resolution"] = json.loads(data["resolution"]) if data["resolution"] else None - data["extracted_entities"] = ( + data = dict(row) + data["resolution"] = json.loads(data["resolution"]) if data["resolution"] else None + data["extracted_entities"] = ( json.loads(data["extracted_entities"]) if data["extracted_entities"] else [] ) - data["extracted_relations"] = ( + data["extracted_relations"] = ( json.loads(data["extracted_relations"]) if data["extracted_relations"] else [] ) videos.append(data) @@ -1117,13 +1117,13 @@ class DatabaseManager: video_id: str, frame_number: int, timestamp: float, - image_url: str = None, - ocr_text: str = None, - extracted_entities: list[dict] = None, + image_url: str = None, + ocr_text: str = None, + extracted_entities: list[dict] = None, ) -> str: """创建视频帧记录""" - conn = self.get_conn() - now = datetime.now().isoformat() + conn = self.get_conn() + now = datetime.now().isoformat() conn.execute( """INSERT INTO video_frames @@ -1147,16 +1147,16 @@ class DatabaseManager: def get_video_frames(self, video_id: str) -> list[dict]: """获取视频的所有帧""" - conn = self.get_conn() - rows = conn.execute( - """SELECT * FROM video_frames WHERE video_id = ? ORDER BY timestamp""", (video_id,) + conn = self.get_conn() + rows = conn.execute( + """SELECT * FROM video_frames WHERE video_id = ? ORDER BY timestamp""", (video_id, ) ).fetchall() conn.close() - frames = [] + frames = [] for row in rows: - data = dict(row) - data["extracted_entities"] = ( + data = dict(row) + data["extracted_entities"] = ( json.loads(data["extracted_entities"]) if data["extracted_entities"] else [] ) frames.append(data) @@ -1167,14 +1167,14 @@ class DatabaseManager: image_id: str, project_id: str, filename: str, - ocr_text: str = "", - description: str = "", - extracted_entities: list[dict] = None, - extracted_relations: list[dict] = None, + ocr_text: str = "", + description: str = "", + extracted_entities: list[dict] = None, + extracted_relations: list[dict] = None, ) -> str: """创建图片记录""" - conn = self.get_conn() - now = datetime.now().isoformat() + conn = self.get_conn() + now = datetime.now().isoformat() conn.execute( """INSERT INTO images @@ -1200,16 +1200,16 @@ class DatabaseManager: def get_image(self, image_id: str) -> dict | None: """获取图片信息""" - conn = self.get_conn() - row = conn.execute("SELECT * FROM images WHERE id = ?", (image_id,)).fetchone() + conn = self.get_conn() + row = conn.execute("SELECT * FROM images WHERE id = ?", (image_id, )).fetchone() conn.close() if row: - data = dict(row) - data["extracted_entities"] = ( + data = dict(row) + data["extracted_entities"] = ( json.loads(data["extracted_entities"]) if data["extracted_entities"] else [] ) - data["extracted_relations"] = ( + data["extracted_relations"] = ( json.loads(data["extracted_relations"]) if data["extracted_relations"] else [] ) return data @@ -1217,19 +1217,19 @@ class DatabaseManager: def list_project_images(self, project_id: str) -> list[dict]: """获取项目的所有图片""" - conn = self.get_conn() - rows = conn.execute( - "SELECT * FROM images WHERE project_id = ? ORDER BY created_at DESC", (project_id,) + conn = self.get_conn() + rows = conn.execute( + "SELECT * FROM images WHERE project_id = ? ORDER BY created_at DESC", (project_id, ) ).fetchall() conn.close() - images = [] + images = [] for row in rows: - data = dict(row) - data["extracted_entities"] = ( + data = dict(row) + data["extracted_entities"] = ( json.loads(data["extracted_entities"]) if data["extracted_entities"] else [] ) - data["extracted_relations"] = ( + data["extracted_relations"] = ( json.loads(data["extracted_relations"]) if data["extracted_relations"] else [] ) images.append(data) @@ -1243,12 +1243,12 @@ class DatabaseManager: modality: str, source_id: str, source_type: str, - text_snippet: str = "", - confidence: float = 1.0, + text_snippet: str = "", + confidence: float = 1.0, ) -> str: """创建多模态实体提及记录""" - conn = self.get_conn() - now = datetime.now().isoformat() + conn = self.get_conn() + now = datetime.now().isoformat() conn.execute( """INSERT OR REPLACE INTO multimodal_mentions @@ -1273,37 +1273,37 @@ class DatabaseManager: def get_entity_multimodal_mentions(self, entity_id: str) -> list[dict]: """获取实体的多模态提及""" - conn = self.get_conn() - rows = conn.execute( + conn = self.get_conn() + rows = conn.execute( """SELECT m.*, e.name as entity_name FROM multimodal_mentions m - JOIN entities e ON m.entity_id = e.id - WHERE m.entity_id = ? ORDER BY m.created_at DESC""", - (entity_id,), + JOIN entities e ON m.entity_id = e.id + WHERE m.entity_id = ? ORDER BY m.created_at DESC""", + (entity_id, ), ).fetchall() conn.close() return [dict(r) for r in rows] - def get_project_multimodal_mentions(self, project_id: str, modality: str = None) -> list[dict]: + def get_project_multimodal_mentions(self, project_id: str, modality: str = None) -> list[dict]: """获取项目的多模态提及""" - conn = self.get_conn() + conn = self.get_conn() if modality: - rows = conn.execute( + rows = conn.execute( """SELECT m.*, e.name as entity_name FROM multimodal_mentions m - JOIN entities e ON m.entity_id = e.id - WHERE m.project_id = ? AND m.modality = ? + JOIN entities e ON m.entity_id = e.id + WHERE m.project_id = ? AND m.modality = ? ORDER BY m.created_at DESC""", (project_id, modality), ).fetchall() else: - rows = conn.execute( + rows = conn.execute( """SELECT m.*, e.name as entity_name FROM multimodal_mentions m - JOIN entities e ON m.entity_id = e.id - WHERE m.project_id = ? ORDER BY m.created_at DESC""", - (project_id,), + JOIN entities e ON m.entity_id = e.id + WHERE m.project_id = ? ORDER BY m.created_at DESC""", + (project_id, ), ).fetchall() conn.close() @@ -1315,13 +1315,13 @@ class DatabaseManager: entity_id: str, linked_entity_id: str, link_type: str, - confidence: float = 1.0, - evidence: str = "", - modalities: list[str] = None, + confidence: float = 1.0, + evidence: str = "", + modalities: list[str] = None, ) -> str: """创建多模态实体关联""" - conn = self.get_conn() - now = datetime.now().isoformat() + conn = self.get_conn() + now = datetime.now().isoformat() conn.execute( """INSERT OR REPLACE INTO multimodal_entity_links @@ -1345,29 +1345,29 @@ class DatabaseManager: def get_entity_multimodal_links(self, entity_id: str) -> list[dict]: """获取实体的多模态关联""" - conn = self.get_conn() - rows = conn.execute( + conn = self.get_conn() + rows = conn.execute( """SELECT l.*, e1.name as entity_name, e2.name as linked_entity_name FROM multimodal_entity_links l - JOIN entities e1 ON l.entity_id = e1.id - JOIN entities e2 ON l.linked_entity_id = e2.id - WHERE l.entity_id = ? OR l.linked_entity_id = ?""", + JOIN entities e1 ON l.entity_id = e1.id + JOIN entities e2 ON l.linked_entity_id = e2.id + WHERE l.entity_id = ? OR l.linked_entity_id = ?""", (entity_id, entity_id), ).fetchall() conn.close() - links = [] + links = [] for row in rows: - data = dict(row) - data["modalities"] = json.loads(data["modalities"]) if data["modalities"] else [] + data = dict(row) + data["modalities"] = json.loads(data["modalities"]) if data["modalities"] else [] links.append(data) return links def get_project_multimodal_stats(self, project_id: str) -> dict: """获取项目多模态统计信息""" - conn = self.get_conn() + conn = self.get_conn() - stats = { + stats = { "video_count": 0, "image_count": 0, "multimodal_entity_count": 0, @@ -1376,52 +1376,52 @@ class DatabaseManager: } # 视频数量 - row = conn.execute( - "SELECT COUNT(*) as count FROM videos WHERE project_id = ?", (project_id,) + row = conn.execute( + "SELECT COUNT(*) as count FROM videos WHERE project_id = ?", (project_id, ) ).fetchone() - stats["video_count"] = row["count"] + stats["video_count"] = row["count"] # 图片数量 - row = conn.execute( - "SELECT COUNT(*) as count FROM images WHERE project_id = ?", (project_id,) + row = conn.execute( + "SELECT COUNT(*) as count FROM images WHERE project_id = ?", (project_id, ) ).fetchone() - stats["image_count"] = row["count"] + stats["image_count"] = row["count"] # 多模态实体数量 - row = conn.execute( + row = conn.execute( """SELECT COUNT(DISTINCT entity_id) as count - FROM multimodal_mentions WHERE project_id = ?""", - (project_id,), + FROM multimodal_mentions WHERE project_id = ?""", + (project_id, ), ).fetchone() - stats["multimodal_entity_count"] = row["count"] + stats["multimodal_entity_count"] = row["count"] # 跨模态关联数量 - row = conn.execute( + row = conn.execute( """SELECT COUNT(*) as count FROM multimodal_entity_links - WHERE entity_id IN (SELECT id FROM entities WHERE project_id = ?)""", - (project_id,), + WHERE entity_id IN (SELECT id FROM entities WHERE project_id = ?)""", + (project_id, ), ).fetchone() - stats["cross_modal_links"] = row["count"] + stats["cross_modal_links"] = row["count"] # 模态分布 for modality in ["audio", "video", "image", "document"]: - row = conn.execute( + row = conn.execute( """SELECT COUNT(*) as count FROM multimodal_mentions - WHERE project_id = ? AND modality = ?""", + WHERE project_id = ? AND modality = ?""", (project_id, modality), ).fetchone() - stats["modality_distribution"][modality] = row["count"] + stats["modality_distribution"][modality] = row["count"] conn.close() return stats # Singleton instance -_db_manager = None +_db_manager = None def get_db_manager() -> DatabaseManager: global _db_manager if _db_manager is None: - _db_manager = DatabaseManager() + _db_manager = DatabaseManager() return _db_manager diff --git a/backend/developer_ecosystem_manager.py b/backend/developer_ecosystem_manager.py index 55c31a7..fc48c95 100644 --- a/backend/developer_ecosystem_manager.py +++ b/backend/developer_ecosystem_manager.py @@ -19,81 +19,81 @@ from datetime import datetime from enum import StrEnum # Database path -DB_PATH = os.path.join(os.path.dirname(__file__), "insightflow.db") +DB_PATH = os.path.join(os.path.dirname(__file__), "insightflow.db") class SDKLanguage(StrEnum): """SDK 语言类型""" - PYTHON = "python" - JAVASCRIPT = "javascript" - TYPESCRIPT = "typescript" - GO = "go" - JAVA = "java" - RUST = "rust" + PYTHON = "python" + JAVASCRIPT = "javascript" + TYPESCRIPT = "typescript" + GO = "go" + JAVA = "java" + RUST = "rust" class SDKStatus(StrEnum): """SDK 状态""" - DRAFT = "draft" # 草稿 - BETA = "beta" # 测试版 - STABLE = "stable" # 稳定版 - DEPRECATED = "deprecated" # 已弃用 - ARCHIVED = "archived" # 已归档 + DRAFT = "draft" # 草稿 + BETA = "beta" # 测试版 + STABLE = "stable" # 稳定版 + DEPRECATED = "deprecated" # 已弃用 + ARCHIVED = "archived" # 已归档 class TemplateCategory(StrEnum): """模板分类""" - MEDICAL = "medical" # 医疗 - LEGAL = "legal" # 法律 - FINANCE = "finance" # 金融 - EDUCATION = "education" # 教育 - TECH = "tech" # 科技 - GENERAL = "general" # 通用 + MEDICAL = "medical" # 医疗 + LEGAL = "legal" # 法律 + FINANCE = "finance" # 金融 + EDUCATION = "education" # 教育 + TECH = "tech" # 科技 + GENERAL = "general" # 通用 class TemplateStatus(StrEnum): """模板状态""" - PENDING = "pending" # 待审核 - APPROVED = "approved" # 已通过 - REJECTED = "rejected" # 已拒绝 - PUBLISHED = "published" # 已发布 - UNLISTED = "unlisted" # 未列出 + PENDING = "pending" # 待审核 + APPROVED = "approved" # 已通过 + REJECTED = "rejected" # 已拒绝 + PUBLISHED = "published" # 已发布 + UNLISTED = "unlisted" # 未列出 class PluginStatus(StrEnum): """插件状态""" - PENDING = "pending" # 待审核 - REVIEWING = "reviewing" # 审核中 - APPROVED = "approved" # 已通过 - REJECTED = "rejected" # 已拒绝 - PUBLISHED = "published" # 已发布 - SUSPENDED = "suspended" # 已暂停 + PENDING = "pending" # 待审核 + REVIEWING = "reviewing" # 审核中 + APPROVED = "approved" # 已通过 + REJECTED = "rejected" # 已拒绝 + PUBLISHED = "published" # 已发布 + SUSPENDED = "suspended" # 已暂停 class PluginCategory(StrEnum): """插件分类""" - INTEGRATION = "integration" # 集成 - ANALYSIS = "analysis" # 分析 - VISUALIZATION = "visualization" # 可视化 - AUTOMATION = "automation" # 自动化 - SECURITY = "security" # 安全 - CUSTOM = "custom" # 自定义 + INTEGRATION = "integration" # 集成 + ANALYSIS = "analysis" # 分析 + VISUALIZATION = "visualization" # 可视化 + AUTOMATION = "automation" # 自动化 + SECURITY = "security" # 安全 + CUSTOM = "custom" # 自定义 class DeveloperStatus(StrEnum): """开发者认证状态""" - UNVERIFIED = "unverified" # 未认证 - PENDING = "pending" # 审核中 - VERIFIED = "verified" # 已认证 - CERTIFIED = "certified" # 已认证(高级) - SUSPENDED = "suspended" # 已暂停 + UNVERIFIED = "unverified" # 未认证 + PENDING = "pending" # 审核中 + VERIFIED = "verified" # 已认证 + CERTIFIED = "certified" # 已认证(高级) + SUSPENDED = "suspended" # 已暂停 @dataclass @@ -112,7 +112,7 @@ class SDKRelease: package_name: str # pip/npm/go module name status: SDKStatus min_platform_version: str - dependencies: list[dict] # [{"name": "requests", "version": ">=2.0"}] + dependencies: list[dict] # [{"name": "requests", "version": ">= 2.0"}] file_size: int checksum: str download_count: int @@ -152,7 +152,7 @@ class TemplateMarketItem: author_id: str author_name: str status: TemplateStatus - price: float # 0 = 免费 + price: float # 0 = 免费 currency: str preview_image_url: str | None demo_url: str | None @@ -348,14 +348,14 @@ class DeveloperPortalConfig: class DeveloperEcosystemManager: """开发者生态系统管理主类""" - def __init__(self, db_path: str = DB_PATH): - self.db_path = db_path - self.platform_fee_rate = 0.30 # 平台抽成比例 30% + def __init__(self, db_path: str = DB_PATH) -> None: + self.db_path = db_path + self.platform_fee_rate = 0.30 # 平台抽成比例 30% def _get_db(self) -> None: """获取数据库连接""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn # ==================== SDK 发布与管理 ==================== @@ -378,30 +378,30 @@ class DeveloperEcosystemManager: created_by: str, ) -> SDKRelease: """创建 SDK 发布""" - sdk_id = f"sdk_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + sdk_id = f"sdk_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - sdk = SDKRelease( - id=sdk_id, - name=name, - language=language, - version=version, - description=description, - changelog=changelog, - download_url=download_url, - documentation_url=documentation_url, - repository_url=repository_url, - package_name=package_name, - status=SDKStatus.DRAFT, - min_platform_version=min_platform_version, - dependencies=dependencies, - file_size=file_size, - checksum=checksum, - download_count=0, - created_at=now, - updated_at=now, - published_at=None, - created_by=created_by, + sdk = SDKRelease( + id = sdk_id, + name = name, + language = language, + version = version, + description = description, + changelog = changelog, + download_url = download_url, + documentation_url = documentation_url, + repository_url = repository_url, + package_name = package_name, + status = SDKStatus.DRAFT, + min_platform_version = min_platform_version, + dependencies = dependencies, + file_size = file_size, + checksum = checksum, + download_count = 0, + created_at = now, + updated_at = now, + published_at = None, + created_by = created_by, ) with self._get_db() as conn: @@ -444,7 +444,7 @@ class DeveloperEcosystemManager: def get_sdk_release(self, sdk_id: str) -> SDKRelease | None: """获取 SDK 发布详情""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM sdk_releases WHERE id = ?", (sdk_id,)).fetchone() + row = conn.execute("SELECT * FROM sdk_releases WHERE id = ?", (sdk_id, )).fetchone() if row: return self._row_to_sdk_release(row) @@ -452,19 +452,19 @@ class DeveloperEcosystemManager: def list_sdk_releases( self, - language: SDKLanguage | None = None, - status: SDKStatus | None = None, - search: str | None = None, + language: SDKLanguage | None = None, + status: SDKStatus | None = None, + search: str | None = None, ) -> list[SDKRelease]: """列出 SDK 发布""" - query = "SELECT * FROM sdk_releases WHERE 1=1" - params = [] + query = "SELECT * FROM sdk_releases WHERE 1 = 1" + params = [] if language: - query += " AND language = ?" + query += " AND language = ?" params.append(language.value) if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status.value) if search: query += " AND (name LIKE ? OR description LIKE ? OR package_name LIKE ?)" @@ -473,12 +473,12 @@ class DeveloperEcosystemManager: query += " ORDER BY created_at DESC" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_sdk_release(row) for row in rows] def update_sdk_release(self, sdk_id: str, **kwargs) -> SDKRelease | None: """更新 SDK 发布""" - allowed_fields = [ + allowed_fields = [ "name", "description", "changelog", @@ -488,16 +488,16 @@ class DeveloperEcosystemManager: "status", ] - updates = {k: v for k, v in kwargs.items() if k in allowed_fields} + updates = {k: v for k, v in kwargs.items() if k in allowed_fields} if not updates: return self.get_sdk_release(sdk_id) - updates["updated_at"] = datetime.now().isoformat() + updates["updated_at"] = datetime.now().isoformat() with self._get_db() as conn: - set_clause = ", ".join([f"{k} = ?" for k in updates.keys()]) + set_clause = ", ".join([f"{k} = ?" for k in updates.keys()]) conn.execute( - f"UPDATE sdk_releases SET {set_clause} WHERE id = ?", + f"UPDATE sdk_releases SET {set_clause} WHERE id = ?", list(updates.values()) + [sdk_id], ) conn.commit() @@ -506,14 +506,14 @@ class DeveloperEcosystemManager: def publish_sdk_release(self, sdk_id: str) -> SDKRelease | None: """发布 SDK""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE sdk_releases - SET status = ?, published_at = ?, updated_at = ? - WHERE id = ? + SET status = ?, published_at = ?, updated_at = ? + WHERE id = ? """, (SDKStatus.STABLE.value, now, now, sdk_id), ) @@ -527,18 +527,18 @@ class DeveloperEcosystemManager: conn.execute( """ UPDATE sdk_releases - SET download_count = download_count + 1 - WHERE id = ? + SET download_count = download_count + 1 + WHERE id = ? """, - (sdk_id,), + (sdk_id, ), ) conn.commit() def get_sdk_versions(self, sdk_id: str) -> list[SDKVersion]: """获取 SDK 版本历史""" with self._get_db() as conn: - rows = conn.execute( - "SELECT * FROM sdk_versions WHERE sdk_id = ? ORDER BY created_at DESC", (sdk_id,) + rows = conn.execute( + "SELECT * FROM sdk_versions WHERE sdk_id = ? ORDER BY created_at DESC", (sdk_id, ) ).fetchall() return [self._row_to_sdk_version(row) for row in rows] @@ -553,13 +553,13 @@ class DeveloperEcosystemManager: file_size: int, ) -> SDKVersion: """添加 SDK 版本""" - version_id = f"sv_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + version_id = f"sv_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() with self._get_db() as conn: # 如果设置为最新版本,取消其他版本的最新标记 if True: # 默认新版本为最新 - conn.execute("UPDATE sdk_versions SET is_latest = 0 WHERE sdk_id = ?", (sdk_id,)) + conn.execute("UPDATE sdk_versions SET is_latest = 0 WHERE sdk_id = ?", (sdk_id, )) conn.execute( """ @@ -585,17 +585,17 @@ class DeveloperEcosystemManager: conn.commit() return SDKVersion( - id=version_id, - sdk_id=sdk_id, - version=version, - is_latest=True, - is_lts=is_lts, - release_notes=release_notes, - download_url=download_url, - checksum=checksum, - file_size=file_size, - download_count=0, - created_at=now, + id = version_id, + sdk_id = sdk_id, + version = version, + is_latest = True, + is_lts = is_lts, + release_notes = release_notes, + download_url = download_url, + checksum = checksum, + file_size = file_size, + download_count = 0, + created_at = now, ) # ==================== 模板市场 ==================== @@ -609,48 +609,48 @@ class DeveloperEcosystemManager: tags: list[str], author_id: str, author_name: str, - price: float = 0.0, - currency: str = "CNY", - preview_image_url: str | None = None, - demo_url: str | None = None, - documentation_url: str | None = None, - download_url: str | None = None, - version: str = "1.0.0", - min_platform_version: str = "1.0.0", - file_size: int = 0, - checksum: str = "", + price: float = 0.0, + currency: str = "CNY", + preview_image_url: str | None = None, + demo_url: str | None = None, + documentation_url: str | None = None, + download_url: str | None = None, + version: str = "1.0.0", + min_platform_version: str = "1.0.0", + file_size: int = 0, + checksum: str = "", ) -> TemplateMarketItem: """创建模板""" - template_id = f"tpl_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + template_id = f"tpl_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - template = TemplateMarketItem( - id=template_id, - name=name, - description=description, - category=category, - subcategory=subcategory, - tags=tags, - author_id=author_id, - author_name=author_name, - status=TemplateStatus.PENDING, - price=price, - currency=currency, - preview_image_url=preview_image_url, - demo_url=demo_url, - documentation_url=documentation_url, - download_url=download_url, - install_count=0, - rating=0.0, - rating_count=0, - review_count=0, - version=version, - min_platform_version=min_platform_version, - file_size=file_size, - checksum=checksum, - created_at=now, - updated_at=now, - published_at=None, + template = TemplateMarketItem( + id = template_id, + name = name, + description = description, + category = category, + subcategory = subcategory, + tags = tags, + author_id = author_id, + author_name = author_name, + status = TemplateStatus.PENDING, + price = price, + currency = currency, + preview_image_url = preview_image_url, + demo_url = demo_url, + documentation_url = documentation_url, + download_url = download_url, + install_count = 0, + rating = 0.0, + rating_count = 0, + review_count = 0, + version = version, + min_platform_version = min_platform_version, + file_size = file_size, + checksum = checksum, + created_at = now, + updated_at = now, + published_at = None, ) with self._get_db() as conn: @@ -699,8 +699,8 @@ class DeveloperEcosystemManager: def get_template(self, template_id: str) -> TemplateMarketItem | None: """获取模板详情""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM template_market WHERE id = ?", (template_id,) + row = conn.execute( + "SELECT * FROM template_market WHERE id = ?", (template_id, ) ).fetchone() if row: @@ -709,26 +709,26 @@ class DeveloperEcosystemManager: def list_templates( self, - category: TemplateCategory | None = None, - status: TemplateStatus | None = None, - search: str | None = None, - author_id: str | None = None, - min_price: float | None = None, - max_price: float | None = None, - sort_by: str = "created_at", + category: TemplateCategory | None = None, + status: TemplateStatus | None = None, + search: str | None = None, + author_id: str | None = None, + min_price: float | None = None, + max_price: float | None = None, + sort_by: str = "created_at", ) -> list[TemplateMarketItem]: """列出模板""" - query = "SELECT * FROM template_market WHERE 1=1" - params = [] + query = "SELECT * FROM template_market WHERE 1 = 1" + params = [] if category: - query += " AND category = ?" + query += " AND category = ?" params.append(category.value) if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status.value) if author_id: - query += " AND author_id = ?" + query += " AND author_id = ?" params.append(author_id) if search: query += " AND (name LIKE ? OR description LIKE ? OR tags LIKE ?)" @@ -741,7 +741,7 @@ class DeveloperEcosystemManager: params.append(max_price) # 排序 - sort_mapping = { + sort_mapping = { "created_at": "created_at DESC", "rating": "rating DESC", "install_count": "install_count DESC", @@ -751,19 +751,19 @@ class DeveloperEcosystemManager: query += f" ORDER BY {sort_mapping.get(sort_by, 'created_at DESC')}" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_template(row) for row in rows] def approve_template(self, template_id: str, reviewed_by: str) -> TemplateMarketItem | None: """审核通过模板""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE template_market - SET status = ?, updated_at = ? - WHERE id = ? + SET status = ?, updated_at = ? + WHERE id = ? """, (TemplateStatus.APPROVED.value, now, template_id), ) @@ -773,14 +773,14 @@ class DeveloperEcosystemManager: def publish_template(self, template_id: str) -> TemplateMarketItem | None: """发布模板""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE template_market - SET status = ?, published_at = ?, updated_at = ? - WHERE id = ? + SET status = ?, published_at = ?, updated_at = ? + WHERE id = ? """, (TemplateStatus.PUBLISHED.value, now, now, template_id), ) @@ -790,14 +790,14 @@ class DeveloperEcosystemManager: def reject_template(self, template_id: str, reason: str) -> TemplateMarketItem | None: """拒绝模板""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE template_market - SET status = ?, updated_at = ? - WHERE id = ? + SET status = ?, updated_at = ? + WHERE id = ? """, (TemplateStatus.REJECTED.value, now, template_id), ) @@ -811,10 +811,10 @@ class DeveloperEcosystemManager: conn.execute( """ UPDATE template_market - SET install_count = install_count + 1 - WHERE id = ? + SET install_count = install_count + 1 + WHERE id = ? """, - (template_id,), + (template_id, ), ) conn.commit() @@ -825,23 +825,23 @@ class DeveloperEcosystemManager: user_name: str, rating: int, comment: str, - is_verified_purchase: bool = False, + is_verified_purchase: bool = False, ) -> TemplateReview: """添加模板评价""" - review_id = f"tr_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + review_id = f"tr_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - review = TemplateReview( - id=review_id, - template_id=template_id, - user_id=user_id, - user_name=user_name, - rating=rating, - comment=comment, - is_verified_purchase=is_verified_purchase, - helpful_count=0, - created_at=now, - updated_at=now, + review = TemplateReview( + id = review_id, + template_id = template_id, + user_id = user_id, + user_name = user_name, + rating = rating, + comment = comment, + is_verified_purchase = is_verified_purchase, + helpful_count = 0, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -874,21 +874,21 @@ class DeveloperEcosystemManager: def _update_template_rating(self, conn, template_id: str) -> None: """更新模板评分""" - row = conn.execute( + row = conn.execute( """ SELECT AVG(rating) as avg_rating, COUNT(*) as count FROM template_reviews - WHERE template_id = ? + WHERE template_id = ? """, - (template_id,), + (template_id, ), ).fetchone() if row: conn.execute( """ UPDATE template_market - SET rating = ?, rating_count = ?, review_count = ? - WHERE id = ? + SET rating = ?, rating_count = ?, review_count = ? + WHERE id = ? """, ( round(row["avg_rating"], 2) if row["avg_rating"] else 0, @@ -898,12 +898,12 @@ class DeveloperEcosystemManager: ), ) - def get_template_reviews(self, template_id: str, limit: int = 50) -> list[TemplateReview]: + def get_template_reviews(self, template_id: str, limit: int = 50) -> list[TemplateReview]: """获取模板评价""" with self._get_db() as conn: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM template_reviews - WHERE template_id = ? + WHERE template_id = ? ORDER BY created_at DESC LIMIT ?""", (template_id, limit), @@ -920,59 +920,59 @@ class DeveloperEcosystemManager: tags: list[str], author_id: str, author_name: str, - price: float = 0.0, - currency: str = "CNY", - pricing_model: str = "free", - preview_image_url: str | None = None, - demo_url: str | None = None, - documentation_url: str | None = None, - repository_url: str | None = None, - download_url: str | None = None, - webhook_url: str | None = None, - permissions: list[str] = None, - version: str = "1.0.0", - min_platform_version: str = "1.0.0", - file_size: int = 0, - checksum: str = "", + price: float = 0.0, + currency: str = "CNY", + pricing_model: str = "free", + preview_image_url: str | None = None, + demo_url: str | None = None, + documentation_url: str | None = None, + repository_url: str | None = None, + download_url: str | None = None, + webhook_url: str | None = None, + permissions: list[str] = None, + version: str = "1.0.0", + min_platform_version: str = "1.0.0", + file_size: int = 0, + checksum: str = "", ) -> PluginMarketItem: """创建插件""" - plugin_id = f"plg_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + plugin_id = f"plg_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - plugin = PluginMarketItem( - id=plugin_id, - name=name, - description=description, - category=category, - tags=tags, - author_id=author_id, - author_name=author_name, - status=PluginStatus.PENDING, - price=price, - currency=currency, - pricing_model=pricing_model, - preview_image_url=preview_image_url, - demo_url=demo_url, - documentation_url=documentation_url, - repository_url=repository_url, - download_url=download_url, - webhook_url=webhook_url, - permissions=permissions or [], - install_count=0, - active_install_count=0, - rating=0.0, - rating_count=0, - review_count=0, - version=version, - min_platform_version=min_platform_version, - file_size=file_size, - checksum=checksum, - created_at=now, - updated_at=now, - published_at=None, - reviewed_by=None, - reviewed_at=None, - review_notes=None, + plugin = PluginMarketItem( + id = plugin_id, + name = name, + description = description, + category = category, + tags = tags, + author_id = author_id, + author_name = author_name, + status = PluginStatus.PENDING, + price = price, + currency = currency, + pricing_model = pricing_model, + preview_image_url = preview_image_url, + demo_url = demo_url, + documentation_url = documentation_url, + repository_url = repository_url, + download_url = download_url, + webhook_url = webhook_url, + permissions = permissions or [], + install_count = 0, + active_install_count = 0, + rating = 0.0, + rating_count = 0, + review_count = 0, + version = version, + min_platform_version = min_platform_version, + file_size = file_size, + checksum = checksum, + created_at = now, + updated_at = now, + published_at = None, + reviewed_by = None, + reviewed_at = None, + review_notes = None, ) with self._get_db() as conn: @@ -1032,7 +1032,7 @@ class DeveloperEcosystemManager: def get_plugin(self, plugin_id: str) -> PluginMarketItem | None: """获取插件详情""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM plugin_market WHERE id = ?", (plugin_id,)).fetchone() + row = conn.execute("SELECT * FROM plugin_market WHERE id = ?", (plugin_id, )).fetchone() if row: return self._row_to_plugin(row) @@ -1040,30 +1040,30 @@ class DeveloperEcosystemManager: def list_plugins( self, - category: PluginCategory | None = None, - status: PluginStatus | None = None, - search: str | None = None, - author_id: str | None = None, - sort_by: str = "created_at", + category: PluginCategory | None = None, + status: PluginStatus | None = None, + search: str | None = None, + author_id: str | None = None, + sort_by: str = "created_at", ) -> list[PluginMarketItem]: """列出插件""" - query = "SELECT * FROM plugin_market WHERE 1=1" - params = [] + query = "SELECT * FROM plugin_market WHERE 1 = 1" + params = [] if category: - query += " AND category = ?" + query += " AND category = ?" params.append(category.value) if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status.value) if author_id: - query += " AND author_id = ?" + query += " AND author_id = ?" params.append(author_id) if search: query += " AND (name LIKE ? OR description LIKE ? OR tags LIKE ?)" params.extend([f"%{search}%", f"%{search}%", f"%{search}%"]) - sort_mapping = { + sort_mapping = { "created_at": "created_at DESC", "rating": "rating DESC", "install_count": "install_count DESC", @@ -1072,21 +1072,21 @@ class DeveloperEcosystemManager: query += f" ORDER BY {sort_mapping.get(sort_by, 'created_at DESC')}" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_plugin(row) for row in rows] def review_plugin( - self, plugin_id: str, reviewed_by: str, status: PluginStatus, notes: str = "" + self, plugin_id: str, reviewed_by: str, status: PluginStatus, notes: str = "" ) -> PluginMarketItem | None: """审核插件""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE plugin_market - SET status = ?, reviewed_by = ?, reviewed_at = ?, review_notes = ?, updated_at = ? - WHERE id = ? + SET status = ?, reviewed_by = ?, reviewed_at = ?, review_notes = ?, updated_at = ? + WHERE id = ? """, (status.value, reviewed_by, now, notes, now, plugin_id), ) @@ -1096,14 +1096,14 @@ class DeveloperEcosystemManager: def publish_plugin(self, plugin_id: str) -> PluginMarketItem | None: """发布插件""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE plugin_market - SET status = ?, published_at = ?, updated_at = ? - WHERE id = ? + SET status = ?, published_at = ?, updated_at = ? + WHERE id = ? """, (PluginStatus.PUBLISHED.value, now, now, plugin_id), ) @@ -1111,26 +1111,26 @@ class DeveloperEcosystemManager: return self.get_plugin(plugin_id) - def increment_plugin_install(self, plugin_id: str, active: bool = True) -> None: + def increment_plugin_install(self, plugin_id: str, active: bool = True) -> None: """增加插件安装计数""" with self._get_db() as conn: conn.execute( """ UPDATE plugin_market - SET install_count = install_count + 1 - WHERE id = ? + SET install_count = install_count + 1 + WHERE id = ? """, - (plugin_id,), + (plugin_id, ), ) if active: conn.execute( """ UPDATE plugin_market - SET active_install_count = active_install_count + 1 - WHERE id = ? + SET active_install_count = active_install_count + 1 + WHERE id = ? """, - (plugin_id,), + (plugin_id, ), ) conn.commit() @@ -1141,23 +1141,23 @@ class DeveloperEcosystemManager: user_name: str, rating: int, comment: str, - is_verified_purchase: bool = False, + is_verified_purchase: bool = False, ) -> PluginReview: """添加插件评价""" - review_id = f"pr_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + review_id = f"pr_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - review = PluginReview( - id=review_id, - plugin_id=plugin_id, - user_id=user_id, - user_name=user_name, - rating=rating, - comment=comment, - is_verified_purchase=is_verified_purchase, - helpful_count=0, - created_at=now, - updated_at=now, + review = PluginReview( + id = review_id, + plugin_id = plugin_id, + user_id = user_id, + user_name = user_name, + rating = rating, + comment = comment, + is_verified_purchase = is_verified_purchase, + helpful_count = 0, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -1189,21 +1189,21 @@ class DeveloperEcosystemManager: def _update_plugin_rating(self, conn, plugin_id: str) -> None: """更新插件评分""" - row = conn.execute( + row = conn.execute( """ SELECT AVG(rating) as avg_rating, COUNT(*) as count FROM plugin_reviews - WHERE plugin_id = ? + WHERE plugin_id = ? """, - (plugin_id,), + (plugin_id, ), ).fetchone() if row: conn.execute( """ UPDATE plugin_market - SET rating = ?, rating_count = ?, review_count = ? - WHERE id = ? + SET rating = ?, rating_count = ?, review_count = ? + WHERE id = ? """, ( round(row["avg_rating"], 2) if row["avg_rating"] else 0, @@ -1213,12 +1213,12 @@ class DeveloperEcosystemManager: ), ) - def get_plugin_reviews(self, plugin_id: str, limit: int = 50) -> list[PluginReview]: + def get_plugin_reviews(self, plugin_id: str, limit: int = 50) -> list[PluginReview]: """获取插件评价""" with self._get_db() as conn: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM plugin_reviews - WHERE plugin_id = ? + WHERE plugin_id = ? ORDER BY created_at DESC LIMIT ?""", (plugin_id, limit), @@ -1239,25 +1239,25 @@ class DeveloperEcosystemManager: transaction_id: str, ) -> DeveloperRevenue: """记录开发者收益""" - revenue_id = f"rev_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + revenue_id = f"rev_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - platform_fee = sale_amount * self.platform_fee_rate - developer_earnings = sale_amount - platform_fee + platform_fee = sale_amount * self.platform_fee_rate + developer_earnings = sale_amount - platform_fee - revenue = DeveloperRevenue( - id=revenue_id, - developer_id=developer_id, - item_type=item_type, - item_id=item_id, - item_name=item_name, - sale_amount=sale_amount, - platform_fee=platform_fee, - developer_earnings=developer_earnings, - currency=currency, - buyer_id=buyer_id, - transaction_id=transaction_id, - created_at=now, + revenue = DeveloperRevenue( + id = revenue_id, + developer_id = developer_id, + item_type = item_type, + item_id = item_id, + item_name = item_name, + sale_amount = sale_amount, + platform_fee = platform_fee, + developer_earnings = developer_earnings, + currency = currency, + buyer_id = buyer_id, + transaction_id = transaction_id, + created_at = now, ) with self._get_db() as conn: @@ -1288,8 +1288,8 @@ class DeveloperEcosystemManager: conn.execute( """ UPDATE developer_profiles - SET total_sales = total_sales + ? - WHERE id = ? + SET total_sales = total_sales + ? + WHERE id = ? """, (sale_amount, developer_id), ) @@ -1301,12 +1301,12 @@ class DeveloperEcosystemManager: def get_developer_revenues( self, developer_id: str, - start_date: datetime | None = None, - end_date: datetime | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, ) -> list[DeveloperRevenue]: """获取开发者收益记录""" - query = "SELECT * FROM developer_revenues WHERE developer_id = ?" - params = [developer_id] + query = "SELECT * FROM developer_revenues WHERE developer_id = ?" + params = [developer_id] if start_date: query += " AND created_at >= ?" @@ -1318,13 +1318,13 @@ class DeveloperEcosystemManager: query += " ORDER BY created_at DESC" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_developer_revenue(row) for row in rows] def get_developer_revenue_summary(self, developer_id: str) -> dict: """获取开发者收益汇总""" with self._get_db() as conn: - row = conn.execute( + row = conn.execute( """ SELECT SUM(sale_amount) as total_sales, @@ -1332,9 +1332,9 @@ class DeveloperEcosystemManager: SUM(developer_earnings) as total_earnings, COUNT(*) as transaction_count FROM developer_revenues - WHERE developer_id = ? + WHERE developer_id = ? """, - (developer_id,), + (developer_id, ), ).fetchone() return { @@ -1352,34 +1352,34 @@ class DeveloperEcosystemManager: user_id: str, display_name: str, email: str, - bio: str | None = None, - website: str | None = None, - github_url: str | None = None, - avatar_url: str | None = None, + bio: str | None = None, + website: str | None = None, + github_url: str | None = None, + avatar_url: str | None = None, ) -> DeveloperProfile: """创建开发者档案""" - profile_id = f"dev_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + profile_id = f"dev_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - profile = DeveloperProfile( - id=profile_id, - user_id=user_id, - display_name=display_name, - email=email, - bio=bio, - website=website, - github_url=github_url, - avatar_url=avatar_url, - status=DeveloperStatus.UNVERIFIED, - verification_documents={}, - total_sales=0.0, - total_downloads=0, - plugin_count=0, - template_count=0, - rating_average=0.0, - created_at=now, - updated_at=now, - verified_at=None, + profile = DeveloperProfile( + id = profile_id, + user_id = user_id, + display_name = display_name, + email = email, + bio = bio, + website = website, + github_url = github_url, + avatar_url = avatar_url, + status = DeveloperStatus.UNVERIFIED, + verification_documents = {}, + total_sales = 0.0, + total_downloads = 0, + plugin_count = 0, + template_count = 0, + rating_average = 0.0, + created_at = now, + updated_at = now, + verified_at = None, ) with self._get_db() as conn: @@ -1419,8 +1419,8 @@ class DeveloperEcosystemManager: def get_developer_profile(self, developer_id: str) -> DeveloperProfile | None: """获取开发者档案""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM developer_profiles WHERE id = ?", (developer_id,) + row = conn.execute( + "SELECT * FROM developer_profiles WHERE id = ?", (developer_id, ) ).fetchone() if row: @@ -1430,8 +1430,8 @@ class DeveloperEcosystemManager: def get_developer_profile_by_user(self, user_id: str) -> DeveloperProfile | None: """通过用户 ID 获取开发者档案""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM developer_profiles WHERE user_id = ?", (user_id,) + row = conn.execute( + "SELECT * FROM developer_profiles WHERE user_id = ?", (user_id, ) ).fetchone() if row: @@ -1442,14 +1442,14 @@ class DeveloperEcosystemManager: self, developer_id: str, status: DeveloperStatus ) -> DeveloperProfile | None: """验证开发者""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE developer_profiles - SET status = ?, verified_at = ?, updated_at = ? - WHERE id = ? + SET status = ?, verified_at = ?, updated_at = ? + WHERE id = ? """, ( status.value, @@ -1468,22 +1468,22 @@ class DeveloperEcosystemManager: """更新开发者统计信息""" with self._get_db() as conn: # 统计插件数量 - plugin_row = conn.execute( - "SELECT COUNT(*) as count FROM plugin_market WHERE author_id = ?", (developer_id,) + plugin_row = conn.execute( + "SELECT COUNT(*) as count FROM plugin_market WHERE author_id = ?", (developer_id, ) ).fetchone() # 统计模板数量 - template_row = conn.execute( - "SELECT COUNT(*) as count FROM template_market WHERE author_id = ?", (developer_id,) + template_row = conn.execute( + "SELECT COUNT(*) as count FROM template_market WHERE author_id = ?", (developer_id, ) ).fetchone() # 统计总下载量 - download_row = conn.execute( + download_row = conn.execute( """ SELECT SUM(install_count) as total FROM ( - SELECT install_count FROM plugin_market WHERE author_id = ? + SELECT install_count FROM plugin_market WHERE author_id = ? UNION ALL - SELECT install_count FROM template_market WHERE author_id = ? + SELECT install_count FROM template_market WHERE author_id = ? ) """, (developer_id, developer_id), @@ -1492,8 +1492,8 @@ class DeveloperEcosystemManager: conn.execute( """ UPDATE developer_profiles - SET plugin_count = ?, template_count = ?, total_downloads = ?, updated_at = ? - WHERE id = ? + SET plugin_count = ?, template_count = ?, total_downloads = ?, updated_at = ? + WHERE id = ? """, ( plugin_row["count"], @@ -1518,31 +1518,31 @@ class DeveloperEcosystemManager: tags: list[str], author_id: str, author_name: str, - sdk_id: str | None = None, - api_endpoints: list[str] = None, + sdk_id: str | None = None, + api_endpoints: list[str] = None, ) -> CodeExample: """创建代码示例""" - example_id = f"ex_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + example_id = f"ex_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - example = CodeExample( - id=example_id, - title=title, - description=description, - language=language, - category=category, - code=code, - explanation=explanation, - tags=tags, - author_id=author_id, - author_name=author_name, - sdk_id=sdk_id, - api_endpoints=api_endpoints or [], - view_count=0, - copy_count=0, - rating=0.0, - created_at=now, - updated_at=now, + example = CodeExample( + id = example_id, + title = title, + description = description, + language = language, + category = category, + code = code, + explanation = explanation, + tags = tags, + author_id = author_id, + author_name = author_name, + sdk_id = sdk_id, + api_endpoints = api_endpoints or [], + view_count = 0, + copy_count = 0, + rating = 0.0, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -1581,7 +1581,7 @@ class DeveloperEcosystemManager: def get_code_example(self, example_id: str) -> CodeExample | None: """获取代码示例""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM code_examples WHERE id = ?", (example_id,)).fetchone() + row = conn.execute("SELECT * FROM code_examples WHERE id = ?", (example_id, )).fetchone() if row: return self._row_to_code_example(row) @@ -1589,23 +1589,23 @@ class DeveloperEcosystemManager: def list_code_examples( self, - language: str | None = None, - category: str | None = None, - sdk_id: str | None = None, - search: str | None = None, + language: str | None = None, + category: str | None = None, + sdk_id: str | None = None, + search: str | None = None, ) -> list[CodeExample]: """列出代码示例""" - query = "SELECT * FROM code_examples WHERE 1=1" - params = [] + query = "SELECT * FROM code_examples WHERE 1 = 1" + params = [] if language: - query += " AND language = ?" + query += " AND language = ?" params.append(language) if category: - query += " AND category = ?" + query += " AND category = ?" params.append(category) if sdk_id: - query += " AND sdk_id = ?" + query += " AND sdk_id = ?" params.append(sdk_id) if search: query += " AND (title LIKE ? OR description LIKE ? OR tags LIKE ?)" @@ -1614,7 +1614,7 @@ class DeveloperEcosystemManager: query += " ORDER BY created_at DESC" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_code_example(row) for row in rows] def increment_example_view(self, example_id: str) -> None: @@ -1623,10 +1623,10 @@ class DeveloperEcosystemManager: conn.execute( """ UPDATE code_examples - SET view_count = view_count + 1 - WHERE id = ? + SET view_count = view_count + 1 + WHERE id = ? """, - (example_id,), + (example_id, ), ) conn.commit() @@ -1636,10 +1636,10 @@ class DeveloperEcosystemManager: conn.execute( """ UPDATE code_examples - SET copy_count = copy_count + 1 - WHERE id = ? + SET copy_count = copy_count + 1 + WHERE id = ? """, - (example_id,), + (example_id, ), ) conn.commit() @@ -1655,18 +1655,18 @@ class DeveloperEcosystemManager: generated_by: str, ) -> APIDocumentation: """创建 API 文档""" - doc_id = f"api_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + doc_id = f"api_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - doc = APIDocumentation( - id=doc_id, - version=version, - openapi_spec=openapi_spec, - markdown_content=markdown_content, - html_content=html_content, - changelog=changelog, - generated_at=now, - generated_by=generated_by, + doc = APIDocumentation( + id = doc_id, + version = version, + openapi_spec = openapi_spec, + markdown_content = markdown_content, + html_content = html_content, + changelog = changelog, + generated_at = now, + generated_by = generated_by, ) with self._get_db() as conn: @@ -1695,7 +1695,7 @@ class DeveloperEcosystemManager: def get_api_documentation(self, doc_id: str) -> APIDocumentation | None: """获取 API 文档""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM api_documentation WHERE id = ?", (doc_id,)).fetchone() + row = conn.execute("SELECT * FROM api_documentation WHERE id = ?", (doc_id, )).fetchone() if row: return self._row_to_api_documentation(row) @@ -1704,7 +1704,7 @@ class DeveloperEcosystemManager: def get_latest_api_documentation(self) -> APIDocumentation | None: """获取最新 API 文档""" with self._get_db() as conn: - row = conn.execute( + row = conn.execute( "SELECT * FROM api_documentation ORDER BY generated_at DESC LIMIT 1" ).fetchone() @@ -1718,42 +1718,42 @@ class DeveloperEcosystemManager: self, name: str, description: str, - theme: str = "default", - custom_css: str | None = None, - custom_js: str | None = None, - logo_url: str | None = None, - favicon_url: str | None = None, - primary_color: str = "#1890ff", - secondary_color: str = "#52c41a", - support_email: str = "support@insightflow.io", - support_url: str | None = None, - github_url: str | None = None, - discord_url: str | None = None, - api_base_url: str = "https://api.insightflow.io", + theme: str = "default", + custom_css: str | None = None, + custom_js: str | None = None, + logo_url: str | None = None, + favicon_url: str | None = None, + primary_color: str = "#1890ff", + secondary_color: str = "#52c41a", + support_email: str = "support@insightflow.io", + support_url: str | None = None, + github_url: str | None = None, + discord_url: str | None = None, + api_base_url: str = "https://api.insightflow.io", ) -> DeveloperPortalConfig: """创建开发者门户配置""" - config_id = f"portal_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + config_id = f"portal_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - config = DeveloperPortalConfig( - id=config_id, - name=name, - description=description, - theme=theme, - custom_css=custom_css, - custom_js=custom_js, - logo_url=logo_url, - favicon_url=favicon_url, - primary_color=primary_color, - secondary_color=secondary_color, - support_email=support_email, - support_url=support_url, - github_url=github_url, - discord_url=discord_url, - api_base_url=api_base_url, - is_active=True, - created_at=now, - updated_at=now, + config = DeveloperPortalConfig( + id = config_id, + name = name, + description = description, + theme = theme, + custom_css = custom_css, + custom_js = custom_js, + logo_url = logo_url, + favicon_url = favicon_url, + primary_color = primary_color, + secondary_color = secondary_color, + support_email = support_email, + support_url = support_url, + github_url = github_url, + discord_url = discord_url, + api_base_url = api_base_url, + is_active = True, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -1793,8 +1793,8 @@ class DeveloperEcosystemManager: def get_portal_config(self, config_id: str) -> DeveloperPortalConfig | None: """获取开发者门户配置""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM developer_portal_configs WHERE id = ?", (config_id,) + row = conn.execute( + "SELECT * FROM developer_portal_configs WHERE id = ?", (config_id, ) ).fetchone() if row: @@ -1804,8 +1804,8 @@ class DeveloperEcosystemManager: def get_active_portal_config(self) -> DeveloperPortalConfig | None: """获取活跃的开发者门户配置""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM developer_portal_configs WHERE is_active = 1 LIMIT 1" + row = conn.execute( + "SELECT * FROM developer_portal_configs WHERE is_active = 1 LIMIT 1" ).fetchone() if row: @@ -1817,249 +1817,249 @@ class DeveloperEcosystemManager: def _row_to_sdk_release(self, row) -> SDKRelease: """将数据库行转换为 SDKRelease""" return SDKRelease( - id=row["id"], - name=row["name"], - language=SDKLanguage(row["language"]), - version=row["version"], - description=row["description"], - changelog=row["changelog"], - download_url=row["download_url"], - documentation_url=row["documentation_url"], - repository_url=row["repository_url"], - package_name=row["package_name"], - status=SDKStatus(row["status"]), - min_platform_version=row["min_platform_version"], - dependencies=json.loads(row["dependencies"]), - file_size=row["file_size"], - checksum=row["checksum"], - download_count=row["download_count"], - created_at=row["created_at"], - updated_at=row["updated_at"], - published_at=row["published_at"], - created_by=row["created_by"], + id = row["id"], + name = row["name"], + language = SDKLanguage(row["language"]), + version = row["version"], + description = row["description"], + changelog = row["changelog"], + download_url = row["download_url"], + documentation_url = row["documentation_url"], + repository_url = row["repository_url"], + package_name = row["package_name"], + status = SDKStatus(row["status"]), + min_platform_version = row["min_platform_version"], + dependencies = json.loads(row["dependencies"]), + file_size = row["file_size"], + checksum = row["checksum"], + download_count = row["download_count"], + created_at = row["created_at"], + updated_at = row["updated_at"], + published_at = row["published_at"], + created_by = row["created_by"], ) def _row_to_sdk_version(self, row) -> SDKVersion: """将数据库行转换为 SDKVersion""" return SDKVersion( - id=row["id"], - sdk_id=row["sdk_id"], - version=row["version"], - is_latest=bool(row["is_latest"]), - is_lts=bool(row["is_lts"]), - release_notes=row["release_notes"], - download_url=row["download_url"], - checksum=row["checksum"], - file_size=row["file_size"], - download_count=row["download_count"], - created_at=row["created_at"], + id = row["id"], + sdk_id = row["sdk_id"], + version = row["version"], + is_latest = bool(row["is_latest"]), + is_lts = bool(row["is_lts"]), + release_notes = row["release_notes"], + download_url = row["download_url"], + checksum = row["checksum"], + file_size = row["file_size"], + download_count = row["download_count"], + created_at = row["created_at"], ) def _row_to_template(self, row) -> TemplateMarketItem: """将数据库行转换为 TemplateMarketItem""" return TemplateMarketItem( - id=row["id"], - name=row["name"], - description=row["description"], - category=TemplateCategory(row["category"]), - subcategory=row["subcategory"], - tags=json.loads(row["tags"]), - author_id=row["author_id"], - author_name=row["author_name"], - status=TemplateStatus(row["status"]), - price=row["price"], - currency=row["currency"], - preview_image_url=row["preview_image_url"], - demo_url=row["demo_url"], - documentation_url=row["documentation_url"], - download_url=row["download_url"], - install_count=row["install_count"], - rating=row["rating"], - rating_count=row["rating_count"], - review_count=row["review_count"], - version=row["version"], - min_platform_version=row["min_platform_version"], - file_size=row["file_size"], - checksum=row["checksum"], - created_at=row["created_at"], - updated_at=row["updated_at"], - published_at=row["published_at"], + id = row["id"], + name = row["name"], + description = row["description"], + category = TemplateCategory(row["category"]), + subcategory = row["subcategory"], + tags = json.loads(row["tags"]), + author_id = row["author_id"], + author_name = row["author_name"], + status = TemplateStatus(row["status"]), + price = row["price"], + currency = row["currency"], + preview_image_url = row["preview_image_url"], + demo_url = row["demo_url"], + documentation_url = row["documentation_url"], + download_url = row["download_url"], + install_count = row["install_count"], + rating = row["rating"], + rating_count = row["rating_count"], + review_count = row["review_count"], + version = row["version"], + min_platform_version = row["min_platform_version"], + file_size = row["file_size"], + checksum = row["checksum"], + created_at = row["created_at"], + updated_at = row["updated_at"], + published_at = row["published_at"], ) def _row_to_template_review(self, row) -> TemplateReview: """将数据库行转换为 TemplateReview""" return TemplateReview( - id=row["id"], - template_id=row["template_id"], - user_id=row["user_id"], - user_name=row["user_name"], - rating=row["rating"], - comment=row["comment"], - is_verified_purchase=bool(row["is_verified_purchase"]), - helpful_count=row["helpful_count"], - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + template_id = row["template_id"], + user_id = row["user_id"], + user_name = row["user_name"], + rating = row["rating"], + comment = row["comment"], + is_verified_purchase = bool(row["is_verified_purchase"]), + helpful_count = row["helpful_count"], + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_plugin(self, row) -> PluginMarketItem: """将数据库行转换为 PluginMarketItem""" return PluginMarketItem( - id=row["id"], - name=row["name"], - description=row["description"], - category=PluginCategory(row["category"]), - tags=json.loads(row["tags"]), - author_id=row["author_id"], - author_name=row["author_name"], - status=PluginStatus(row["status"]), - price=row["price"], - currency=row["currency"], - pricing_model=row["pricing_model"], - preview_image_url=row["preview_image_url"], - demo_url=row["demo_url"], - documentation_url=row["documentation_url"], - repository_url=row["repository_url"], - download_url=row["download_url"], - webhook_url=row["webhook_url"], - permissions=json.loads(row["permissions"]), - install_count=row["install_count"], - active_install_count=row["active_install_count"], - rating=row["rating"], - rating_count=row["rating_count"], - review_count=row["review_count"], - version=row["version"], - min_platform_version=row["min_platform_version"], - file_size=row["file_size"], - checksum=row["checksum"], - created_at=row["created_at"], - updated_at=row["updated_at"], - published_at=row["published_at"], - reviewed_by=row["reviewed_by"], - reviewed_at=row["reviewed_at"], - review_notes=row["review_notes"], + id = row["id"], + name = row["name"], + description = row["description"], + category = PluginCategory(row["category"]), + tags = json.loads(row["tags"]), + author_id = row["author_id"], + author_name = row["author_name"], + status = PluginStatus(row["status"]), + price = row["price"], + currency = row["currency"], + pricing_model = row["pricing_model"], + preview_image_url = row["preview_image_url"], + demo_url = row["demo_url"], + documentation_url = row["documentation_url"], + repository_url = row["repository_url"], + download_url = row["download_url"], + webhook_url = row["webhook_url"], + permissions = json.loads(row["permissions"]), + install_count = row["install_count"], + active_install_count = row["active_install_count"], + rating = row["rating"], + rating_count = row["rating_count"], + review_count = row["review_count"], + version = row["version"], + min_platform_version = row["min_platform_version"], + file_size = row["file_size"], + checksum = row["checksum"], + created_at = row["created_at"], + updated_at = row["updated_at"], + published_at = row["published_at"], + reviewed_by = row["reviewed_by"], + reviewed_at = row["reviewed_at"], + review_notes = row["review_notes"], ) def _row_to_plugin_review(self, row) -> PluginReview: """将数据库行转换为 PluginReview""" return PluginReview( - id=row["id"], - plugin_id=row["plugin_id"], - user_id=row["user_id"], - user_name=row["user_name"], - rating=row["rating"], - comment=row["comment"], - is_verified_purchase=bool(row["is_verified_purchase"]), - helpful_count=row["helpful_count"], - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + plugin_id = row["plugin_id"], + user_id = row["user_id"], + user_name = row["user_name"], + rating = row["rating"], + comment = row["comment"], + is_verified_purchase = bool(row["is_verified_purchase"]), + helpful_count = row["helpful_count"], + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_developer_profile(self, row) -> DeveloperProfile: """将数据库行转换为 DeveloperProfile""" return DeveloperProfile( - id=row["id"], - user_id=row["user_id"], - display_name=row["display_name"], - email=row["email"], - bio=row["bio"], - website=row["website"], - github_url=row["github_url"], - avatar_url=row["avatar_url"], - status=DeveloperStatus(row["status"]), - verification_documents=json.loads(row["verification_documents"]), - total_sales=row["total_sales"], - total_downloads=row["total_downloads"], - plugin_count=row["plugin_count"], - template_count=row["template_count"], - rating_average=row["rating_average"], - created_at=row["created_at"], - updated_at=row["updated_at"], - verified_at=row["verified_at"], + id = row["id"], + user_id = row["user_id"], + display_name = row["display_name"], + email = row["email"], + bio = row["bio"], + website = row["website"], + github_url = row["github_url"], + avatar_url = row["avatar_url"], + status = DeveloperStatus(row["status"]), + verification_documents = json.loads(row["verification_documents"]), + total_sales = row["total_sales"], + total_downloads = row["total_downloads"], + plugin_count = row["plugin_count"], + template_count = row["template_count"], + rating_average = row["rating_average"], + created_at = row["created_at"], + updated_at = row["updated_at"], + verified_at = row["verified_at"], ) def _row_to_developer_revenue(self, row) -> DeveloperRevenue: """将数据库行转换为 DeveloperRevenue""" return DeveloperRevenue( - id=row["id"], - developer_id=row["developer_id"], - item_type=row["item_type"], - item_id=row["item_id"], - item_name=row["item_name"], - sale_amount=row["sale_amount"], - platform_fee=row["platform_fee"], - developer_earnings=row["developer_earnings"], - currency=row["currency"], - buyer_id=row["buyer_id"], - transaction_id=row["transaction_id"], - created_at=row["created_at"], + id = row["id"], + developer_id = row["developer_id"], + item_type = row["item_type"], + item_id = row["item_id"], + item_name = row["item_name"], + sale_amount = row["sale_amount"], + platform_fee = row["platform_fee"], + developer_earnings = row["developer_earnings"], + currency = row["currency"], + buyer_id = row["buyer_id"], + transaction_id = row["transaction_id"], + created_at = row["created_at"], ) def _row_to_code_example(self, row) -> CodeExample: """将数据库行转换为 CodeExample""" return CodeExample( - id=row["id"], - title=row["title"], - description=row["description"], - language=row["language"], - category=row["category"], - code=row["code"], - explanation=row["explanation"], - tags=json.loads(row["tags"]), - author_id=row["author_id"], - author_name=row["author_name"], - sdk_id=row["sdk_id"], - api_endpoints=json.loads(row["api_endpoints"]), - view_count=row["view_count"], - copy_count=row["copy_count"], - rating=row["rating"], - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + title = row["title"], + description = row["description"], + language = row["language"], + category = row["category"], + code = row["code"], + explanation = row["explanation"], + tags = json.loads(row["tags"]), + author_id = row["author_id"], + author_name = row["author_name"], + sdk_id = row["sdk_id"], + api_endpoints = json.loads(row["api_endpoints"]), + view_count = row["view_count"], + copy_count = row["copy_count"], + rating = row["rating"], + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_api_documentation(self, row) -> APIDocumentation: """将数据库行转换为 APIDocumentation""" return APIDocumentation( - id=row["id"], - version=row["version"], - openapi_spec=row["openapi_spec"], - markdown_content=row["markdown_content"], - html_content=row["html_content"], - changelog=row["changelog"], - generated_at=row["generated_at"], - generated_by=row["generated_by"], + id = row["id"], + version = row["version"], + openapi_spec = row["openapi_spec"], + markdown_content = row["markdown_content"], + html_content = row["html_content"], + changelog = row["changelog"], + generated_at = row["generated_at"], + generated_by = row["generated_by"], ) def _row_to_portal_config(self, row) -> DeveloperPortalConfig: """将数据库行转换为 DeveloperPortalConfig""" return DeveloperPortalConfig( - id=row["id"], - name=row["name"], - description=row["description"], - theme=row["theme"], - custom_css=row["custom_css"], - custom_js=row["custom_js"], - logo_url=row["logo_url"], - favicon_url=row["favicon_url"], - primary_color=row["primary_color"], - secondary_color=row["secondary_color"], - support_email=row["support_email"], - support_url=row["support_url"], - github_url=row["github_url"], - discord_url=row["discord_url"], - api_base_url=row["api_base_url"], - is_active=bool(row["is_active"]), - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + name = row["name"], + description = row["description"], + theme = row["theme"], + custom_css = row["custom_css"], + custom_js = row["custom_js"], + logo_url = row["logo_url"], + favicon_url = row["favicon_url"], + primary_color = row["primary_color"], + secondary_color = row["secondary_color"], + support_email = row["support_email"], + support_url = row["support_url"], + github_url = row["github_url"], + discord_url = row["discord_url"], + api_base_url = row["api_base_url"], + is_active = bool(row["is_active"]), + created_at = row["created_at"], + updated_at = row["updated_at"], ) # Singleton instance -_developer_ecosystem_manager = None +_developer_ecosystem_manager = None def get_developer_ecosystem_manager() -> DeveloperEcosystemManager: """获取开发者生态系统管理器单例""" global _developer_ecosystem_manager if _developer_ecosystem_manager is None: - _developer_ecosystem_manager = DeveloperEcosystemManager() + _developer_ecosystem_manager = DeveloperEcosystemManager() return _developer_ecosystem_manager diff --git a/backend/document_processor.py b/backend/document_processor.py index 164a4ec..a73913b 100644 --- a/backend/document_processor.py +++ b/backend/document_processor.py @@ -11,8 +11,8 @@ import os class DocumentProcessor: """文档处理器 - 提取 PDF/DOCX 文本""" - def __init__(self): - self.supported_formats = { + def __init__(self) -> None: + self.supported_formats = { ".pdf": self._extract_pdf, ".docx": self._extract_docx, ".doc": self._extract_docx, @@ -31,18 +31,18 @@ class DocumentProcessor: Returns: {"text": "提取的文本内容", "format": "文件格式"} """ - ext = os.path.splitext(filename.lower())[1] + ext = os.path.splitext(filename.lower())[1] if ext not in self.supported_formats: raise ValueError( f"Unsupported file format: {ext}. Supported: {list(self.supported_formats.keys())}" ) - extractor = self.supported_formats[ext] - text = extractor(content) + extractor = self.supported_formats[ext] + text = extractor(content) # 清理文本 - text = self._clean_text(text) + text = self._clean_text(text) return {"text": text, "format": ext, "filename": filename} @@ -51,12 +51,12 @@ class DocumentProcessor: try: import PyPDF2 - pdf_file = io.BytesIO(content) - reader = PyPDF2.PdfReader(pdf_file) + pdf_file = io.BytesIO(content) + reader = PyPDF2.PdfReader(pdf_file) - text_parts = [] + text_parts = [] for page in reader.pages: - page_text = page.extract_text() + page_text = page.extract_text() if page_text: text_parts.append(page_text) @@ -66,10 +66,10 @@ class DocumentProcessor: try: import pdfplumber - text_parts = [] + text_parts = [] with pdfplumber.open(io.BytesIO(content)) as pdf: for page in pdf.pages: - page_text = page.extract_text() + page_text = page.extract_text() if page_text: text_parts.append(page_text) return "\n\n".join(text_parts) @@ -85,10 +85,10 @@ class DocumentProcessor: try: import docx - doc_file = io.BytesIO(content) - doc = docx.Document(doc_file) + doc_file = io.BytesIO(content) + doc = docx.Document(doc_file) - text_parts = [] + text_parts = [] for para in doc.paragraphs: if para.text.strip(): text_parts.append(para.text) @@ -96,7 +96,7 @@ class DocumentProcessor: # 提取表格中的文本 for table in doc.tables: for row in table.rows: - row_text = [] + row_text = [] for cell in row.cells: if cell.text.strip(): row_text.append(cell.text.strip()) @@ -114,7 +114,7 @@ class DocumentProcessor: def _extract_txt(self, content: bytes) -> str: """提取纯文本""" # 尝试多种编码 - encodings = ["utf-8", "gbk", "gb2312", "latin-1"] + encodings = ["utf-8", "gbk", "gb2312", "latin-1"] for encoding in encodings: try: @@ -123,7 +123,7 @@ class DocumentProcessor: continue # 如果都失败了,使用 latin-1 并忽略错误 - return content.decode("latin-1", errors="ignore") + return content.decode("latin-1", errors = "ignore") def _clean_text(self, text: str) -> str: """清理提取的文本""" @@ -131,29 +131,29 @@ class DocumentProcessor: return "" # 移除多余的空白字符 - lines = text.split("\n") - cleaned_lines = [] + lines = text.split("\n") + cleaned_lines = [] for line in lines: - line = line.strip() + line = line.strip() # 移除空行,但保留段落分隔 if line: cleaned_lines.append(line) # 合并行,保留段落结构 - text = "\n\n".join(cleaned_lines) + text = "\n\n".join(cleaned_lines) # 移除多余的空格 - text = " ".join(text.split()) + text = " ".join(text.split()) # 移除控制字符 - text = "".join(char for char in text if ord(char) >= 32 or char in "\n\r\t") + text = "".join(char for char in text if ord(char) >= 32 or char in "\n\r\t") return text.strip() def is_supported(self, filename: str) -> bool: """检查文件格式是否支持""" - ext = os.path.splitext(filename.lower())[1] + ext = os.path.splitext(filename.lower())[1] return ext in self.supported_formats @@ -165,7 +165,7 @@ class SimpleTextExtractor: def extract(self, content: bytes, filename: str) -> str: """尝试提取文本""" - encodings = ["utf-8", "gbk", "latin-1"] + encodings = ["utf-8", "gbk", "latin-1"] for encoding in encodings: try: @@ -173,15 +173,15 @@ class SimpleTextExtractor: except UnicodeDecodeError: continue - return content.decode("latin-1", errors="ignore") + return content.decode("latin-1", errors = "ignore") if __name__ == "__main__": # 测试 - processor = DocumentProcessor() + processor = DocumentProcessor() # 测试文本提取 - test_text = "Hello World\n\nThis is a test document.\n\nMultiple paragraphs." - result = processor.process(test_text.encode("utf-8"), "test.txt") + test_text = "Hello World\n\nThis is a test document.\n\nMultiple paragraphs." + result = processor.process(test_text.encode("utf-8"), "test.txt") print(f"Text extraction test: {len(result['text'])} chars") print(result["text"][:100]) diff --git a/backend/enterprise_manager.py b/backend/enterprise_manager.py index fab08f3..44b61e8 100644 --- a/backend/enterprise_manager.py +++ b/backend/enterprise_manager.py @@ -19,64 +19,64 @@ from datetime import datetime, timedelta from enum import StrEnum from typing import Any -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) class SSOProvider(StrEnum): """SSO 提供商类型""" - WECHAT_WORK = "wechat_work" # 企业微信 - DINGTALK = "dingtalk" # 钉钉 - FEISHU = "feishu" # 飞书 - OKTA = "okta" # Okta - AZURE_AD = "azure_ad" # Azure AD - GOOGLE = "google" # Google Workspace - CUSTOM_SAML = "custom_saml" # 自定义 SAML + WECHAT_WORK = "wechat_work" # 企业微信 + DINGTALK = "dingtalk" # 钉钉 + FEISHU = "feishu" # 飞书 + OKTA = "okta" # Okta + AZURE_AD = "azure_ad" # Azure AD + GOOGLE = "google" # Google Workspace + CUSTOM_SAML = "custom_saml" # 自定义 SAML class SSOStatus(StrEnum): """SSO 配置状态""" - DISABLED = "disabled" # 未启用 - PENDING = "pending" # 待配置 - ACTIVE = "active" # 已启用 - ERROR = "error" # 配置错误 + DISABLED = "disabled" # 未启用 + PENDING = "pending" # 待配置 + ACTIVE = "active" # 已启用 + ERROR = "error" # 配置错误 class SCIMSyncStatus(StrEnum): """SCIM 同步状态""" - IDLE = "idle" # 空闲 - SYNCING = "syncing" # 同步中 - SUCCESS = "success" # 同步成功 - FAILED = "failed" # 同步失败 + IDLE = "idle" # 空闲 + SYNCING = "syncing" # 同步中 + SUCCESS = "success" # 同步成功 + FAILED = "failed" # 同步失败 class AuditLogExportFormat(StrEnum): """审计日志导出格式""" - JSON = "json" - CSV = "csv" - PDF = "pdf" - XLSX = "xlsx" + JSON = "json" + CSV = "csv" + PDF = "pdf" + XLSX = "xlsx" class DataRetentionAction(StrEnum): """数据保留策略动作""" - ARCHIVE = "archive" # 归档 - DELETE = "delete" # 删除 - ANONYMIZE = "anonymize" # 匿名化 + ARCHIVE = "archive" # 归档 + DELETE = "delete" # 删除 + ANONYMIZE = "anonymize" # 匿名化 class ComplianceStandard(StrEnum): """合规标准""" - SOC2 = "soc2" - ISO27001 = "iso27001" - GDPR = "gdpr" - HIPAA = "hipaa" - PCI_DSS = "pci_dss" + SOC2 = "soc2" + ISO27001 = "iso27001" + GDPR = "gdpr" + HIPAA = "hipaa" + PCI_DSS = "pci_dss" @dataclass @@ -264,7 +264,7 @@ class EnterpriseManager: """企业级功能管理器""" # 默认属性映射 - DEFAULT_ATTRIBUTE_MAPPING = { + DEFAULT_ATTRIBUTE_MAPPING = { SSOProvider.WECHAT_WORK: { "email": "email", "name": "name", @@ -293,7 +293,7 @@ class EnterpriseManager: } # 合规标准字段映射 - COMPLIANCE_FIELDS = { + COMPLIANCE_FIELDS = { ComplianceStandard.SOC2: [ "timestamp", "user_id", @@ -329,21 +329,21 @@ class EnterpriseManager: ], } - def __init__(self, db_path: str = "insightflow.db"): - self.db_path = db_path + def __init__(self, db_path: str = "insightflow.db") -> None: + self.db_path = db_path self._init_db() def _get_connection(self) -> sqlite3.Connection: """获取数据库连接""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn def _init_db(self) -> None: """初始化数据库表""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() # SSO 配置表 cursor.execute(""" @@ -582,61 +582,61 @@ class EnterpriseManager: self, tenant_id: str, provider: str, - entity_id: str | None = None, - sso_url: str | None = None, - slo_url: str | None = None, - certificate: str | None = None, - metadata_url: str | None = None, - metadata_xml: str | None = None, - client_id: str | None = None, - client_secret: str | None = None, - authorization_url: str | None = None, - token_url: str | None = None, - userinfo_url: str | None = None, - scopes: list[str] | None = None, - attribute_mapping: dict[str, str] | None = None, - auto_provision: bool = True, - default_role: str = "member", - domain_restriction: list[str] | None = None, + entity_id: str | None = None, + sso_url: str | None = None, + slo_url: str | None = None, + certificate: str | None = None, + metadata_url: str | None = None, + metadata_xml: str | None = None, + client_id: str | None = None, + client_secret: str | None = None, + authorization_url: str | None = None, + token_url: str | None = None, + userinfo_url: str | None = None, + scopes: list[str] | None = None, + attribute_mapping: dict[str, str] | None = None, + auto_provision: bool = True, + default_role: str = "member", + domain_restriction: list[str] | None = None, ) -> SSOConfig: """创建 SSO 配置""" - conn = self._get_connection() + conn = self._get_connection() try: - config_id = str(uuid.uuid4()) - now = datetime.now() + config_id = str(uuid.uuid4()) + now = datetime.now() # 使用默认属性映射 if attribute_mapping is None and provider in self.DEFAULT_ATTRIBUTE_MAPPING: - attribute_mapping = self.DEFAULT_ATTRIBUTE_MAPPING[SSOProvider(provider)] + attribute_mapping = self.DEFAULT_ATTRIBUTE_MAPPING[SSOProvider(provider)] - config = SSOConfig( - id=config_id, - tenant_id=tenant_id, - provider=provider, - status=SSOStatus.PENDING.value, - entity_id=entity_id, - sso_url=sso_url, - slo_url=slo_url, - certificate=certificate, - metadata_url=metadata_url, - metadata_xml=metadata_xml, - client_id=client_id, - client_secret=client_secret, - authorization_url=authorization_url, - token_url=token_url, - userinfo_url=userinfo_url, - scopes=scopes or ["openid", "email", "profile"], - attribute_mapping=attribute_mapping or {}, - auto_provision=auto_provision, - default_role=default_role, - domain_restriction=domain_restriction or [], - created_at=now, - updated_at=now, - last_tested_at=None, - last_error=None, + config = SSOConfig( + id = config_id, + tenant_id = tenant_id, + provider = provider, + status = SSOStatus.PENDING.value, + entity_id = entity_id, + sso_url = sso_url, + slo_url = slo_url, + certificate = certificate, + metadata_url = metadata_url, + metadata_xml = metadata_xml, + client_id = client_id, + client_secret = client_secret, + authorization_url = authorization_url, + token_url = token_url, + userinfo_url = userinfo_url, + scopes = scopes or ["openid", "email", "profile"], + attribute_mapping = attribute_mapping or {}, + auto_provision = auto_provision, + default_role = default_role, + domain_restriction = domain_restriction or [], + created_at = now, + updated_at = now, + last_tested_at = None, + last_error = None, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO sso_configs @@ -685,11 +685,11 @@ class EnterpriseManager: def get_sso_config(self, config_id: str) -> SSOConfig | None: """获取 SSO 配置""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM sso_configs WHERE id = ?", (config_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM sso_configs WHERE id = ?", (config_id, )) + row = cursor.fetchone() if row: return self._row_to_sso_config(row) @@ -699,18 +699,18 @@ class EnterpriseManager: conn.close() def get_tenant_sso_config( - self, tenant_id: str, provider: str | None = None + self, tenant_id: str, provider: str | None = None ) -> SSOConfig | None: """获取租户的 SSO 配置""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() if provider: cursor.execute( """ SELECT * FROM sso_configs - WHERE tenant_id = ? AND provider = ? + WHERE tenant_id = ? AND provider = ? ORDER BY created_at DESC LIMIT 1 """, (tenant_id, provider), @@ -719,13 +719,13 @@ class EnterpriseManager: cursor.execute( """ SELECT * FROM sso_configs - WHERE tenant_id = ? AND status = 'active' + WHERE tenant_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1 """, - (tenant_id,), + (tenant_id, ), ) - row = cursor.fetchone() + row = cursor.fetchone() if row: return self._row_to_sso_config(row) @@ -736,16 +736,16 @@ class EnterpriseManager: def update_sso_config(self, config_id: str, **kwargs) -> SSOConfig | None: """更新 SSO 配置""" - conn = self._get_connection() + conn = self._get_connection() try: - config = self.get_sso_config(config_id) + config = self.get_sso_config(config_id) if not config: return None - updates = [] - params = [] + updates = [] + params = [] - allowed_fields = [ + allowed_fields = [ "entity_id", "sso_url", "slo_url", @@ -767,7 +767,7 @@ class EnterpriseManager: for key, value in kwargs.items(): if key in allowed_fields: - updates.append(f"{key} = ?") + updates.append(f"{key} = ?") if key in ["scopes", "attribute_mapping", "domain_restriction"]: params.append(json.dumps(value) if value else "[]") elif key == "auto_provision": @@ -778,15 +778,15 @@ class EnterpriseManager: if not updates: return config - updates.append("updated_at = ?") + updates.append("updated_at = ?") params.append(datetime.now()) params.append(config_id) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( f""" UPDATE sso_configs SET {", ".join(updates)} - WHERE id = ? + WHERE id = ? """, params, ) @@ -799,10 +799,10 @@ class EnterpriseManager: def delete_sso_config(self, config_id: str) -> bool: """删除 SSO 配置""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("DELETE FROM sso_configs WHERE id = ?", (config_id,)) + cursor = conn.cursor() + cursor.execute("DELETE FROM sso_configs WHERE id = ?", (config_id, )) conn.commit() return cursor.rowcount > 0 finally: @@ -810,17 +810,17 @@ class EnterpriseManager: def list_sso_configs(self, tenant_id: str) -> list[SSOConfig]: """列出租户的所有 SSO 配置""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ - SELECT * FROM sso_configs WHERE tenant_id = ? + SELECT * FROM sso_configs WHERE tenant_id = ? ORDER BY created_at DESC """, - (tenant_id,), + (tenant_id, ), ) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_sso_config(row) for row in rows] @@ -829,70 +829,70 @@ class EnterpriseManager: def generate_saml_metadata(self, config_id: str, base_url: str) -> str: """生成 SAML Service Provider 元数据""" - config = self.get_sso_config(config_id) + config = self.get_sso_config(config_id) if not config: raise ValueError(f"SSO config {config_id} not found") # 生成 SP 实体 ID - sp_entity_id = f"{base_url}/api/v1/sso/saml/{config.tenant_id}" - acs_url = f"{base_url}/api/v1/sso/saml/{config.tenant_id}/acs" - slo_url = f"{base_url}/api/v1/sso/saml/{config.tenant_id}/slo" + sp_entity_id = f"{base_url}/api/v1/sso/saml/{config.tenant_id}" + acs_url = f"{base_url}/api/v1/sso/saml/{config.tenant_id}/acs" + slo_url = f"{base_url}/api/v1/sso/saml/{config.tenant_id}/slo" # 生成 X.509 证书(简化实现,实际应该生成真实的密钥对) - cert = config.certificate or self._generate_self_signed_cert() + cert = config.certificate or self._generate_self_signed_cert() - metadata = f""" - - - - + metadata = f""" + + + + {cert} - - + + - InsightFlow - InsightFlow - {base_url} + InsightFlow + InsightFlow + {base_url} """ return metadata def create_saml_auth_request( - self, tenant_id: str, config_id: str, relay_state: str | None = None + self, tenant_id: str, config_id: str, relay_state: str | None = None ) -> SAMLAuthRequest: """创建 SAML 认证请求""" - conn = self._get_connection() + conn = self._get_connection() try: - request_id = f"_{uuid.uuid4().hex}" - now = datetime.now() - expires = now + timedelta(minutes=10) + request_id = f"_{uuid.uuid4().hex}" + now = datetime.now() + expires = now + timedelta(minutes = 10) - auth_request = SAMLAuthRequest( - id=str(uuid.uuid4()), - tenant_id=tenant_id, - sso_config_id=config_id, - request_id=request_id, - relay_state=relay_state, - created_at=now, - expires_at=expires, - used=False, - used_at=None, + auth_request = SAMLAuthRequest( + id = str(uuid.uuid4()), + tenant_id = tenant_id, + sso_config_id = config_id, + request_id = request_id, + relay_state = relay_state, + created_at = now, + expires_at = expires, + used = False, + used_at = None, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO saml_auth_requests @@ -919,16 +919,16 @@ class EnterpriseManager: def get_saml_auth_request(self, request_id: str) -> SAMLAuthRequest | None: """获取 SAML 认证请求""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ - SELECT * FROM saml_auth_requests WHERE request_id = ? + SELECT * FROM saml_auth_requests WHERE request_id = ? """, - (request_id,), + (request_id, ), ) - row = cursor.fetchone() + row = cursor.fetchone() if row: return self._row_to_saml_request(row) @@ -942,27 +942,27 @@ class EnterpriseManager: # 这里应该实现实际的 SAML 响应解析 # 简化实现:假设响应已经验证并解析 - conn = self._get_connection() + conn = self._get_connection() try: # 解析 SAML Response(简化) # 实际应该使用 python-saml 或类似库 - attributes = self._parse_saml_response(saml_response) + attributes = self._parse_saml_response(saml_response) - auth_response = SAMLAuthResponse( - id=str(uuid.uuid4()), - request_id=request_id, - tenant_id="", # 从 request 获取 - user_id=None, - email=attributes.get("email"), - name=attributes.get("name"), - attributes=attributes, - session_index=attributes.get("session_index"), - processed=False, - processed_at=None, - created_at=datetime.now(), + auth_response = SAMLAuthResponse( + id = str(uuid.uuid4()), + request_id = request_id, + tenant_id = "", # 从 request 获取 + user_id = None, + email = attributes.get("email"), + name = attributes.get("name"), + attributes = attributes, + session_index = attributes.get("session_index"), + processed = False, + processed_at = None, + created_at = datetime.now(), ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO saml_auth_responses @@ -1017,35 +1017,35 @@ class EnterpriseManager: provider: str, scim_base_url: str, scim_token: str, - sync_interval_minutes: int = 60, - attribute_mapping: dict[str, str] | None = None, - sync_rules: dict[str, Any] | None = None, + sync_interval_minutes: int = 60, + attribute_mapping: dict[str, str] | None = None, + sync_rules: dict[str, Any] | None = None, ) -> SCIMConfig: """创建 SCIM 配置""" - conn = self._get_connection() + conn = self._get_connection() try: - config_id = str(uuid.uuid4()) - now = datetime.now() + config_id = str(uuid.uuid4()) + now = datetime.now() - config = SCIMConfig( - id=config_id, - tenant_id=tenant_id, - provider=provider, - status="disabled", - scim_base_url=scim_base_url, - scim_token=scim_token, - sync_interval_minutes=sync_interval_minutes, - last_sync_at=None, - last_sync_status=None, - last_sync_error=None, - last_sync_users_count=0, - attribute_mapping=attribute_mapping or {}, - sync_rules=sync_rules or {}, - created_at=now, - updated_at=now, + config = SCIMConfig( + id = config_id, + tenant_id = tenant_id, + provider = provider, + status = "disabled", + scim_base_url = scim_base_url, + scim_token = scim_token, + sync_interval_minutes = sync_interval_minutes, + last_sync_at = None, + last_sync_status = None, + last_sync_error = None, + last_sync_users_count = 0, + attribute_mapping = attribute_mapping or {}, + sync_rules = sync_rules or {}, + created_at = now, + updated_at = now, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO scim_configs @@ -1081,11 +1081,11 @@ class EnterpriseManager: def get_scim_config(self, config_id: str) -> SCIMConfig | None: """获取 SCIM 配置""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM scim_configs WHERE id = ?", (config_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM scim_configs WHERE id = ?", (config_id, )) + row = cursor.fetchone() if row: return self._row_to_scim_config(row) @@ -1096,17 +1096,17 @@ class EnterpriseManager: def get_tenant_scim_config(self, tenant_id: str) -> SCIMConfig | None: """获取租户的 SCIM 配置""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ - SELECT * FROM scim_configs WHERE tenant_id = ? + SELECT * FROM scim_configs WHERE tenant_id = ? ORDER BY created_at DESC LIMIT 1 """, - (tenant_id,), + (tenant_id, ), ) - row = cursor.fetchone() + row = cursor.fetchone() if row: return self._row_to_scim_config(row) @@ -1117,16 +1117,16 @@ class EnterpriseManager: def update_scim_config(self, config_id: str, **kwargs) -> SCIMConfig | None: """更新 SCIM 配置""" - conn = self._get_connection() + conn = self._get_connection() try: - config = self.get_scim_config(config_id) + config = self.get_scim_config(config_id) if not config: return None - updates = [] - params = [] + updates = [] + params = [] - allowed_fields = [ + allowed_fields = [ "scim_base_url", "scim_token", "sync_interval_minutes", @@ -1137,7 +1137,7 @@ class EnterpriseManager: for key, value in kwargs.items(): if key in allowed_fields: - updates.append(f"{key} = ?") + updates.append(f"{key} = ?") if key in ["attribute_mapping", "sync_rules"]: params.append(json.dumps(value) if value else "{}") else: @@ -1146,15 +1146,15 @@ class EnterpriseManager: if not updates: return config - updates.append("updated_at = ?") + updates.append("updated_at = ?") params.append(datetime.now()) params.append(config_id) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( f""" UPDATE scim_configs SET {", ".join(updates)} - WHERE id = ? + WHERE id = ? """, params, ) @@ -1167,21 +1167,21 @@ class EnterpriseManager: def sync_scim_users(self, config_id: str) -> dict[str, Any]: """执行 SCIM 用户同步""" - config = self.get_scim_config(config_id) + config = self.get_scim_config(config_id) if not config: raise ValueError(f"SCIM config {config_id} not found") - conn = self._get_connection() + conn = self._get_connection() try: - now = datetime.now() + now = datetime.now() # 更新同步状态 - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE scim_configs - SET status = 'syncing', last_sync_at = ? - WHERE id = ? + SET status = 'syncing', last_sync_at = ? + WHERE id = ? """, (now, config_id), ) @@ -1190,9 +1190,9 @@ class EnterpriseManager: try: # 模拟从 SCIM 服务端获取用户 # 实际应该使用 HTTP 请求获取 - users = self._fetch_scim_users(config) + users = self._fetch_scim_users(config) - synced_count = 0 + synced_count = 0 for user_data in users: self._upsert_scim_user(conn, config.tenant_id, user_data) synced_count += 1 @@ -1201,9 +1201,9 @@ class EnterpriseManager: cursor.execute( """ UPDATE scim_configs - SET status = 'active', last_sync_status = 'success', - last_sync_error = NULL, last_sync_users_count = ? - WHERE id = ? + SET status = 'active', last_sync_status = 'success', + last_sync_error = NULL, last_sync_users_count = ? + WHERE id = ? """, (synced_count, config_id), ) @@ -1215,9 +1215,9 @@ class EnterpriseManager: cursor.execute( """ UPDATE scim_configs - SET status = 'error', last_sync_status = 'failed', - last_sync_error = ? - WHERE id = ? + SET status = 'error', last_sync_status = 'failed', + last_sync_error = ? + WHERE id = ? """, (str(e), config_id), ) @@ -1238,17 +1238,17 @@ class EnterpriseManager: self, conn: sqlite3.Connection, tenant_id: str, user_data: dict[str, Any] ) -> None: """插入或更新 SCIM 用户""" - cursor = conn.cursor() + cursor = conn.cursor() - external_id = user_data.get("id") - user_name = user_data.get("userName", "") - email = user_data.get("emails", [{}])[0].get("value", "") - display_name = user_data.get("displayName") - name = user_data.get("name", {}) - given_name = name.get("givenName") - family_name = name.get("familyName") - active = user_data.get("active", True) - groups = [g.get("value") for g in user_data.get("groups", [])] + external_id = user_data.get("id") + user_name = user_data.get("userName", "") + email = user_data.get("emails", [{}])[0].get("value", "") + display_name = user_data.get("displayName") + name = user_data.get("name", {}) + given_name = name.get("givenName") + family_name = name.get("familyName") + active = user_data.get("active", True) + groups = [g.get("value") for g in user_data.get("groups", [])] cursor.execute( """ @@ -1257,16 +1257,16 @@ class EnterpriseManager: given_name, family_name, active, groups, raw_data, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(tenant_id, external_id) DO UPDATE SET - user_name = excluded.user_name, - email = excluded.email, - display_name = excluded.display_name, - given_name = excluded.given_name, - family_name = excluded.family_name, - active = excluded.active, - groups = excluded.groups, - raw_data = excluded.raw_data, - synced_at = excluded.synced_at, - updated_at = CURRENT_TIMESTAMP + user_name = excluded.user_name, + email = excluded.email, + display_name = excluded.display_name, + given_name = excluded.given_name, + family_name = excluded.family_name, + active = excluded.active, + groups = excluded.groups, + raw_data = excluded.raw_data, + synced_at = excluded.synced_at, + updated_at = CURRENT_TIMESTAMP """, ( str(uuid.uuid4()), @@ -1284,22 +1284,22 @@ class EnterpriseManager: ), ) - def list_scim_users(self, tenant_id: str, active_only: bool = True) -> list[SCIMUser]: + def list_scim_users(self, tenant_id: str, active_only: bool = True) -> list[SCIMUser]: """列出 SCIM 用户""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() - query = "SELECT * FROM scim_users WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM scim_users WHERE tenant_id = ?" + params = [tenant_id] if active_only: - query += " AND active = 1" + query += " AND active = 1" query += " ORDER BY synced_at DESC" cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_scim_user(row) for row in rows] @@ -1315,41 +1315,41 @@ class EnterpriseManager: start_date: datetime, end_date: datetime, created_by: str, - filters: dict[str, Any] | None = None, - compliance_standard: str | None = None, + filters: dict[str, Any] | None = None, + compliance_standard: str | None = None, ) -> AuditLogExport: """创建审计日志导出任务""" - conn = self._get_connection() + conn = self._get_connection() try: - export_id = str(uuid.uuid4()) - now = datetime.now() + export_id = str(uuid.uuid4()) + now = datetime.now() # 默认7天后过期 - expires_at = now + timedelta(days=7) + expires_at = now + timedelta(days = 7) - export = AuditLogExport( - id=export_id, - tenant_id=tenant_id, - export_format=export_format, - start_date=start_date, - end_date=end_date, - filters=filters or {}, - compliance_standard=compliance_standard, - status="pending", - file_path=None, - file_size=None, - record_count=None, - checksum=None, - downloaded_by=None, - downloaded_at=None, - expires_at=expires_at, - created_by=created_by, - created_at=now, - completed_at=None, - error_message=None, + export = AuditLogExport( + id = export_id, + tenant_id = tenant_id, + export_format = export_format, + start_date = start_date, + end_date = end_date, + filters = filters or {}, + compliance_standard = compliance_standard, + status = "pending", + file_path = None, + file_size = None, + record_count = None, + checksum = None, + downloaded_by = None, + downloaded_at = None, + expires_at = expires_at, + created_by = created_by, + created_at = now, + completed_at = None, + error_message = None, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO audit_log_exports @@ -1383,49 +1383,49 @@ class EnterpriseManager: finally: conn.close() - def process_audit_export(self, export_id: str, db_manager=None) -> AuditLogExport | None: + def process_audit_export(self, export_id: str, db_manager = None) -> AuditLogExport | None: """处理审计日志导出任务""" - export = self.get_audit_export(export_id) + export = self.get_audit_export(export_id) if not export: return None - conn = self._get_connection() + conn = self._get_connection() try: # 更新状态为处理中 - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ - UPDATE audit_log_exports SET status = 'processing' - WHERE id = ? + UPDATE audit_log_exports SET status = 'processing' + WHERE id = ? """, - (export_id,), + (export_id, ), ) conn.commit() try: # 获取审计日志数据 - logs = self._fetch_audit_logs( + logs = self._fetch_audit_logs( export.tenant_id, export.start_date, export.end_date, export.filters, db_manager ) # 根据合规标准过滤字段 if export.compliance_standard: - logs = self._apply_compliance_filter(logs, export.compliance_standard) + logs = self._apply_compliance_filter(logs, export.compliance_standard) # 生成导出文件 - file_path, file_size, checksum = self._generate_export_file( + file_path, file_size, checksum = self._generate_export_file( export_id, logs, export.export_format ) - now = datetime.now() + now = datetime.now() # 更新导出记录 cursor.execute( """ UPDATE audit_log_exports - SET status = 'completed', file_path = ?, file_size = ?, - record_count = ?, checksum = ?, completed_at = ? - WHERE id = ? + SET status = 'completed', file_path = ?, file_size = ?, + record_count = ?, checksum = ?, completed_at = ? + WHERE id = ? """, (file_path, file_size, len(logs), checksum, now, export_id), ) @@ -1437,8 +1437,8 @@ class EnterpriseManager: cursor.execute( """ UPDATE audit_log_exports - SET status = 'failed', error_message = ? - WHERE id = ? + SET status = 'failed', error_message = ? + WHERE id = ? """, (str(e), export_id), ) @@ -1454,7 +1454,7 @@ class EnterpriseManager: start_date: datetime, end_date: datetime, filters: dict[str, Any], - db_manager=None, + db_manager = None, ) -> list[dict[str, Any]]: """获取审计日志数据""" if db_manager is None: @@ -1468,14 +1468,14 @@ class EnterpriseManager: self, logs: list[dict[str, Any]], standard: str ) -> list[dict[str, Any]]: """应用合规标准字段过滤""" - fields = self.COMPLIANCE_FIELDS.get(ComplianceStandard(standard), []) + fields = self.COMPLIANCE_FIELDS.get(ComplianceStandard(standard), []) if not fields: return logs - filtered_logs = [] + filtered_logs = [] for log in logs: - filtered_log = {k: v for k, v in log.items() if k in fields} + filtered_log = {k: v for k, v in log.items() if k in fields} filtered_logs.append(filtered_log) return filtered_logs @@ -1487,44 +1487,44 @@ class EnterpriseManager: import hashlib import os - export_dir = "/tmp/insightflow/exports" - os.makedirs(export_dir, exist_ok=True) + export_dir = "/tmp/insightflow/exports" + os.makedirs(export_dir, exist_ok = True) - file_path = f"{export_dir}/audit_export_{export_id}.{format}" + file_path = f"{export_dir}/audit_export_{export_id}.{format}" if format == "json": - content = json.dumps(logs, ensure_ascii=False, indent=2) - with open(file_path, "w", encoding="utf-8") as f: + content = json.dumps(logs, ensure_ascii = False, indent = 2) + with open(file_path, "w", encoding = "utf-8") as f: f.write(content) elif format == "csv": import csv if logs: - with open(file_path, "w", newline="", encoding="utf-8") as f: - writer = csv.DictWriter(f, fieldnames=logs[0].keys()) + with open(file_path, "w", newline = "", encoding = "utf-8") as f: + writer = csv.DictWriter(f, fieldnames = logs[0].keys()) writer.writeheader() writer.writerows(logs) else: # 其他格式暂不支持 - content = json.dumps(logs, ensure_ascii=False) - with open(file_path, "w", encoding="utf-8") as f: + content = json.dumps(logs, ensure_ascii = False) + with open(file_path, "w", encoding = "utf-8") as f: f.write(content) - file_size = os.path.getsize(file_path) + file_size = os.path.getsize(file_path) # 计算校验和 with open(file_path, "rb") as f: - checksum = hashlib.sha256(f.read()).hexdigest() + checksum = hashlib.sha256(f.read()).hexdigest() return file_path, file_size, checksum def get_audit_export(self, export_id: str) -> AuditLogExport | None: """获取审计日志导出记录""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM audit_log_exports WHERE id = ?", (export_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM audit_log_exports WHERE id = ?", (export_id, )) + row = cursor.fetchone() if row: return self._row_to_audit_export(row) @@ -1533,21 +1533,21 @@ class EnterpriseManager: finally: conn.close() - def list_audit_exports(self, tenant_id: str, limit: int = 100) -> list[AuditLogExport]: + def list_audit_exports(self, tenant_id: str, limit: int = 100) -> list[AuditLogExport]: """列出审计日志导出记录""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ SELECT * FROM audit_log_exports - WHERE tenant_id = ? + WHERE tenant_id = ? ORDER BY created_at DESC LIMIT ? """, (tenant_id, limit), ) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_audit_export(row) for row in rows] @@ -1556,14 +1556,14 @@ class EnterpriseManager: def mark_export_downloaded(self, export_id: str, downloaded_by: str) -> bool: """标记导出文件已下载""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE audit_log_exports - SET downloaded_by = ?, downloaded_at = ? - WHERE id = ? + SET downloaded_by = ?, downloaded_at = ? + WHERE id = ? """, (downloaded_by, datetime.now(), export_id), ) @@ -1581,42 +1581,42 @@ class EnterpriseManager: resource_type: str, retention_days: int, action: str, - description: str | None = None, - conditions: dict[str, Any] | None = None, - auto_execute: bool = False, - execute_at: str | None = None, - notify_before_days: int = 7, - archive_location: str | None = None, - archive_encryption: bool = True, + description: str | None = None, + conditions: dict[str, Any] | None = None, + auto_execute: bool = False, + execute_at: str | None = None, + notify_before_days: int = 7, + archive_location: str | None = None, + archive_encryption: bool = True, ) -> DataRetentionPolicy: """创建数据保留策略""" - conn = self._get_connection() + conn = self._get_connection() try: - policy_id = str(uuid.uuid4()) - now = datetime.now() + policy_id = str(uuid.uuid4()) + now = datetime.now() - policy = DataRetentionPolicy( - id=policy_id, - tenant_id=tenant_id, - name=name, - description=description, - resource_type=resource_type, - retention_days=retention_days, - action=action, - conditions=conditions or {}, - auto_execute=auto_execute, - execute_at=execute_at, - notify_before_days=notify_before_days, - archive_location=archive_location, - archive_encryption=archive_encryption, - is_active=True, - last_executed_at=None, - last_execution_result=None, - created_at=now, - updated_at=now, + policy = DataRetentionPolicy( + id = policy_id, + tenant_id = tenant_id, + name = name, + description = description, + resource_type = resource_type, + retention_days = retention_days, + action = action, + conditions = conditions or {}, + auto_execute = auto_execute, + execute_at = execute_at, + notify_before_days = notify_before_days, + archive_location = archive_location, + archive_encryption = archive_encryption, + is_active = True, + last_executed_at = None, + last_execution_result = None, + created_at = now, + updated_at = now, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO data_retention_policies @@ -1658,11 +1658,11 @@ class EnterpriseManager: def get_retention_policy(self, policy_id: str) -> DataRetentionPolicy | None: """获取数据保留策略""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM data_retention_policies WHERE id = ?", (policy_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM data_retention_policies WHERE id = ?", (policy_id, )) + row = cursor.fetchone() if row: return self._row_to_retention_policy(row) @@ -1672,24 +1672,24 @@ class EnterpriseManager: conn.close() def list_retention_policies( - self, tenant_id: str, resource_type: str | None = None + self, tenant_id: str, resource_type: str | None = None ) -> list[DataRetentionPolicy]: """列出数据保留策略""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() - query = "SELECT * FROM data_retention_policies WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM data_retention_policies WHERE tenant_id = ?" + params = [tenant_id] if resource_type: - query += " AND resource_type = ?" + query += " AND resource_type = ?" params.append(resource_type) query += " ORDER BY created_at DESC" cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_retention_policy(row) for row in rows] @@ -1698,16 +1698,16 @@ class EnterpriseManager: def update_retention_policy(self, policy_id: str, **kwargs) -> DataRetentionPolicy | None: """更新数据保留策略""" - conn = self._get_connection() + conn = self._get_connection() try: - policy = self.get_retention_policy(policy_id) + policy = self.get_retention_policy(policy_id) if not policy: return None - updates = [] - params = [] + updates = [] + params = [] - allowed_fields = [ + allowed_fields = [ "name", "description", "retention_days", @@ -1723,7 +1723,7 @@ class EnterpriseManager: for key, value in kwargs.items(): if key in allowed_fields: - updates.append(f"{key} = ?") + updates.append(f"{key} = ?") if key == "conditions": params.append(json.dumps(value) if value else "{}") elif key in ["auto_execute", "archive_encryption", "is_active"]: @@ -1734,15 +1734,15 @@ class EnterpriseManager: if not updates: return policy - updates.append("updated_at = ?") + updates.append("updated_at = ?") params.append(datetime.now()) params.append(policy_id) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( f""" UPDATE data_retention_policies SET {", ".join(updates)} - WHERE id = ? + WHERE id = ? """, params, ) @@ -1755,10 +1755,10 @@ class EnterpriseManager: def delete_retention_policy(self, policy_id: str) -> bool: """删除数据保留策略""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("DELETE FROM data_retention_policies WHERE id = ?", (policy_id,)) + cursor = conn.cursor() + cursor.execute("DELETE FROM data_retention_policies WHERE id = ?", (policy_id, )) conn.commit() return cursor.rowcount > 0 finally: @@ -1766,31 +1766,31 @@ class EnterpriseManager: def execute_retention_policy(self, policy_id: str) -> DataRetentionJob: """执行数据保留策略""" - policy = self.get_retention_policy(policy_id) + policy = self.get_retention_policy(policy_id) if not policy: raise ValueError(f"Retention policy {policy_id} not found") - conn = self._get_connection() + conn = self._get_connection() try: - job_id = str(uuid.uuid4()) - now = datetime.now() + job_id = str(uuid.uuid4()) + now = datetime.now() - job = DataRetentionJob( - id=job_id, - policy_id=policy_id, - tenant_id=policy.tenant_id, - status="running", - started_at=now, - completed_at=None, - affected_records=0, - archived_records=0, - deleted_records=0, - error_count=0, - details={}, - created_at=now, + job = DataRetentionJob( + id = job_id, + policy_id = policy_id, + tenant_id = policy.tenant_id, + status = "running", + started_at = now, + completed_at = None, + affected_records = 0, + archived_records = 0, + deleted_records = 0, + error_count = 0, + details = {}, + created_at = now, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO data_retention_jobs @@ -1804,26 +1804,26 @@ class EnterpriseManager: try: # 计算截止日期 - cutoff_date = now - timedelta(days=policy.retention_days) + cutoff_date = now - timedelta(days = policy.retention_days) # 根据资源类型执行不同的处理 if policy.resource_type == "audit_log": - result = self._retain_audit_logs(conn, policy, cutoff_date) + result = self._retain_audit_logs(conn, policy, cutoff_date) elif policy.resource_type == "project": - result = self._retain_projects(conn, policy, cutoff_date) + result = self._retain_projects(conn, policy, cutoff_date) elif policy.resource_type == "transcript": - result = self._retain_transcripts(conn, policy, cutoff_date) + result = self._retain_transcripts(conn, policy, cutoff_date) else: - result = {"affected": 0, "archived": 0, "deleted": 0, "errors": 0} + result = {"affected": 0, "archived": 0, "deleted": 0, "errors": 0} # 更新任务状态 cursor.execute( """ UPDATE data_retention_jobs - SET status = 'completed', completed_at = ?, - affected_records = ?, archived_records = ?, - deleted_records = ?, error_count = ?, details = ? - WHERE id = ? + SET status = 'completed', completed_at = ?, + affected_records = ?, archived_records = ?, + deleted_records = ?, error_count = ?, details = ? + WHERE id = ? """, ( datetime.now(), @@ -1840,8 +1840,8 @@ class EnterpriseManager: cursor.execute( """ UPDATE data_retention_policies - SET last_executed_at = ?, last_execution_result = 'success' - WHERE id = ? + SET last_executed_at = ?, last_execution_result = 'success' + WHERE id = ? """, (datetime.now(), policy_id), ) @@ -1852,8 +1852,8 @@ class EnterpriseManager: cursor.execute( """ UPDATE data_retention_jobs - SET status = 'failed', completed_at = ?, error_count = 1, details = ? - WHERE id = ? + SET status = 'failed', completed_at = ?, error_count = 1, details = ? + WHERE id = ? """, (datetime.now(), json.dumps({"error": str(e)}), job_id), ) @@ -1861,8 +1861,8 @@ class EnterpriseManager: cursor.execute( """ UPDATE data_retention_policies - SET last_executed_at = ?, last_execution_result = ? - WHERE id = ? + SET last_executed_at = ?, last_execution_result = ? + WHERE id = ? """, (datetime.now(), str(e), policy_id), ) @@ -1879,7 +1879,7 @@ class EnterpriseManager: self, conn: sqlite3.Connection, policy: DataRetentionPolicy, cutoff_date: datetime ) -> dict[str, int]: """保留审计日志""" - cursor = conn.cursor() + cursor = conn.cursor() # 获取符合条件的记录数 cursor.execute( @@ -1887,23 +1887,23 @@ class EnterpriseManager: SELECT COUNT(*) as count FROM audit_logs WHERE created_at < ? """, - (cutoff_date,), + (cutoff_date, ), ) - count = cursor.fetchone()["count"] + count = cursor.fetchone()["count"] if policy.action == DataRetentionAction.DELETE.value: cursor.execute( """ DELETE FROM audit_logs WHERE created_at < ? """, - (cutoff_date,), + (cutoff_date, ), ) - deleted = cursor.rowcount + deleted = cursor.rowcount return {"affected": count, "archived": 0, "deleted": deleted, "errors": 0} elif policy.action == DataRetentionAction.ARCHIVE.value: # 归档逻辑(简化实现) - archived = count + archived = count return {"affected": count, "archived": archived, "deleted": 0, "errors": 0} return {"affected": 0, "archived": 0, "deleted": 0, "errors": 0} @@ -1924,11 +1924,11 @@ class EnterpriseManager: def get_retention_job(self, job_id: str) -> DataRetentionJob | None: """获取数据保留任务""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM data_retention_jobs WHERE id = ?", (job_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM data_retention_jobs WHERE id = ?", (job_id, )) + row = cursor.fetchone() if row: return self._row_to_retention_job(row) @@ -1937,21 +1937,21 @@ class EnterpriseManager: finally: conn.close() - def list_retention_jobs(self, policy_id: str, limit: int = 100) -> list[DataRetentionJob]: + def list_retention_jobs(self, policy_id: str, limit: int = 100) -> list[DataRetentionJob]: """列出数据保留任务""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ SELECT * FROM data_retention_jobs - WHERE policy_id = ? + WHERE policy_id = ? ORDER BY created_at DESC LIMIT ? """, (policy_id, limit), ) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_retention_job(row) for row in rows] @@ -1963,64 +1963,64 @@ class EnterpriseManager: def _row_to_sso_config(self, row: sqlite3.Row) -> SSOConfig: """数据库行转换为 SSOConfig 对象""" return SSOConfig( - id=row["id"], - tenant_id=row["tenant_id"], - provider=row["provider"], - status=row["status"], - entity_id=row["entity_id"], - sso_url=row["sso_url"], - slo_url=row["slo_url"], - certificate=row["certificate"], - metadata_url=row["metadata_url"], - metadata_xml=row["metadata_xml"], - client_id=row["client_id"], - client_secret=row["client_secret"], - authorization_url=row["authorization_url"], - token_url=row["token_url"], - userinfo_url=row["userinfo_url"], - scopes=json.loads(row["scopes"] or '["openid", "email", "profile"]'), - attribute_mapping=json.loads(row["attribute_mapping"] or "{}"), - auto_provision=bool(row["auto_provision"]), - default_role=row["default_role"], - domain_restriction=json.loads(row["domain_restriction"] or "[]"), - created_at=( + id = row["id"], + tenant_id = row["tenant_id"], + provider = row["provider"], + status = row["status"], + entity_id = row["entity_id"], + sso_url = row["sso_url"], + slo_url = row["slo_url"], + certificate = row["certificate"], + metadata_url = row["metadata_url"], + metadata_xml = row["metadata_xml"], + client_id = row["client_id"], + client_secret = row["client_secret"], + authorization_url = row["authorization_url"], + token_url = row["token_url"], + userinfo_url = row["userinfo_url"], + scopes = json.loads(row["scopes"] or '["openid", "email", "profile"]'), + attribute_mapping = json.loads(row["attribute_mapping"] or "{}"), + auto_provision = bool(row["auto_provision"]), + default_role = row["default_role"], + domain_restriction = json.loads(row["domain_restriction"] or "[]"), + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] ), - last_tested_at=( + last_tested_at = ( datetime.fromisoformat(row["last_tested_at"]) if row["last_tested_at"] and isinstance(row["last_tested_at"], str) else row["last_tested_at"] ), - last_error=row["last_error"], + last_error = row["last_error"], ) def _row_to_saml_request(self, row: sqlite3.Row) -> SAMLAuthRequest: """数据库行转换为 SAMLAuthRequest 对象""" return SAMLAuthRequest( - id=row["id"], - tenant_id=row["tenant_id"], - sso_config_id=row["sso_config_id"], - request_id=row["request_id"], - relay_state=row["relay_state"], - created_at=( + id = row["id"], + tenant_id = row["tenant_id"], + sso_config_id = row["sso_config_id"], + request_id = row["request_id"], + relay_state = row["relay_state"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - expires_at=( + expires_at = ( datetime.fromisoformat(row["expires_at"]) if isinstance(row["expires_at"], str) else row["expires_at"] ), - used=bool(row["used"]), - used_at=( + used = bool(row["used"]), + used_at = ( datetime.fromisoformat(row["used_at"]) if row["used_at"] and isinstance(row["used_at"], str) else row["used_at"] @@ -2030,29 +2030,29 @@ class EnterpriseManager: def _row_to_scim_config(self, row: sqlite3.Row) -> SCIMConfig: """数据库行转换为 SCIMConfig 对象""" return SCIMConfig( - id=row["id"], - tenant_id=row["tenant_id"], - provider=row["provider"], - status=row["status"], - scim_base_url=row["scim_base_url"], - scim_token=row["scim_token"], - sync_interval_minutes=row["sync_interval_minutes"], - last_sync_at=( + id = row["id"], + tenant_id = row["tenant_id"], + provider = row["provider"], + status = row["status"], + scim_base_url = row["scim_base_url"], + scim_token = row["scim_token"], + sync_interval_minutes = row["sync_interval_minutes"], + last_sync_at = ( datetime.fromisoformat(row["last_sync_at"]) if row["last_sync_at"] and isinstance(row["last_sync_at"], str) else row["last_sync_at"] ), - last_sync_status=row["last_sync_status"], - last_sync_error=row["last_sync_error"], - last_sync_users_count=row["last_sync_users_count"], - attribute_mapping=json.loads(row["attribute_mapping"] or "{}"), - sync_rules=json.loads(row["sync_rules"] or "{}"), - created_at=( + last_sync_status = row["last_sync_status"], + last_sync_error = row["last_sync_error"], + last_sync_users_count = row["last_sync_users_count"], + attribute_mapping = json.loads(row["attribute_mapping"] or "{}"), + sync_rules = json.loads(row["sync_rules"] or "{}"), + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] @@ -2062,28 +2062,28 @@ class EnterpriseManager: def _row_to_scim_user(self, row: sqlite3.Row) -> SCIMUser: """数据库行转换为 SCIMUser 对象""" return SCIMUser( - id=row["id"], - tenant_id=row["tenant_id"], - external_id=row["external_id"], - user_name=row["user_name"], - email=row["email"], - display_name=row["display_name"], - given_name=row["given_name"], - family_name=row["family_name"], - active=bool(row["active"]), - groups=json.loads(row["groups"] or "[]"), - raw_data=json.loads(row["raw_data"] or "{}"), - synced_at=( + id = row["id"], + tenant_id = row["tenant_id"], + external_id = row["external_id"], + user_name = row["user_name"], + email = row["email"], + display_name = row["display_name"], + given_name = row["given_name"], + family_name = row["family_name"], + active = bool(row["active"]), + groups = json.loads(row["groups"] or "[]"), + raw_data = json.loads(row["raw_data"] or "{}"), + synced_at = ( datetime.fromisoformat(row["synced_at"]) if isinstance(row["synced_at"], str) else row["synced_at"] ), - created_at=( + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] @@ -2093,78 +2093,78 @@ class EnterpriseManager: def _row_to_audit_export(self, row: sqlite3.Row) -> AuditLogExport: """数据库行转换为 AuditLogExport 对象""" return AuditLogExport( - id=row["id"], - tenant_id=row["tenant_id"], - export_format=row["export_format"], - start_date=( + id = row["id"], + tenant_id = row["tenant_id"], + export_format = row["export_format"], + start_date = ( datetime.fromisoformat(row["start_date"]) if isinstance(row["start_date"], str) else row["start_date"] ), - end_date=datetime.fromisoformat(row["end_date"]) + end_date = datetime.fromisoformat(row["end_date"]) if isinstance(row["end_date"], str) else row["end_date"], - filters=json.loads(row["filters"] or "{}"), - compliance_standard=row["compliance_standard"], - status=row["status"], - file_path=row["file_path"], - file_size=row["file_size"], - record_count=row["record_count"], - checksum=row["checksum"], - downloaded_by=row["downloaded_by"], - downloaded_at=( + filters = json.loads(row["filters"] or "{}"), + compliance_standard = row["compliance_standard"], + status = row["status"], + file_path = row["file_path"], + file_size = row["file_size"], + record_count = row["record_count"], + checksum = row["checksum"], + downloaded_by = row["downloaded_by"], + downloaded_at = ( datetime.fromisoformat(row["downloaded_at"]) if row["downloaded_at"] and isinstance(row["downloaded_at"], str) else row["downloaded_at"] ), - expires_at=( + expires_at = ( datetime.fromisoformat(row["expires_at"]) if isinstance(row["expires_at"], str) else row["expires_at"] ), - created_by=row["created_by"], - created_at=( + created_by = row["created_by"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - completed_at=( + completed_at = ( datetime.fromisoformat(row["completed_at"]) if row["completed_at"] and isinstance(row["completed_at"], str) else row["completed_at"] ), - error_message=row["error_message"], + error_message = row["error_message"], ) def _row_to_retention_policy(self, row: sqlite3.Row) -> DataRetentionPolicy: """数据库行转换为 DataRetentionPolicy 对象""" return DataRetentionPolicy( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - description=row["description"], - resource_type=row["resource_type"], - retention_days=row["retention_days"], - action=row["action"], - conditions=json.loads(row["conditions"] or "{}"), - auto_execute=bool(row["auto_execute"]), - execute_at=row["execute_at"], - notify_before_days=row["notify_before_days"], - archive_location=row["archive_location"], - archive_encryption=bool(row["archive_encryption"]), - is_active=bool(row["is_active"]), - last_executed_at=( + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + description = row["description"], + resource_type = row["resource_type"], + retention_days = row["retention_days"], + action = row["action"], + conditions = json.loads(row["conditions"] or "{}"), + auto_execute = bool(row["auto_execute"]), + execute_at = row["execute_at"], + notify_before_days = row["notify_before_days"], + archive_location = row["archive_location"], + archive_encryption = bool(row["archive_encryption"]), + is_active = bool(row["is_active"]), + last_executed_at = ( datetime.fromisoformat(row["last_executed_at"]) if row["last_executed_at"] and isinstance(row["last_executed_at"], str) else row["last_executed_at"] ), - last_execution_result=row["last_execution_result"], - created_at=( + last_execution_result = row["last_execution_result"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] @@ -2174,26 +2174,26 @@ class EnterpriseManager: def _row_to_retention_job(self, row: sqlite3.Row) -> DataRetentionJob: """数据库行转换为 DataRetentionJob 对象""" return DataRetentionJob( - id=row["id"], - policy_id=row["policy_id"], - tenant_id=row["tenant_id"], - status=row["status"], - started_at=( + id = row["id"], + policy_id = row["policy_id"], + tenant_id = row["tenant_id"], + status = row["status"], + started_at = ( datetime.fromisoformat(row["started_at"]) if row["started_at"] and isinstance(row["started_at"], str) else row["started_at"] ), - completed_at=( + completed_at = ( datetime.fromisoformat(row["completed_at"]) if row["completed_at"] and isinstance(row["completed_at"], str) else row["completed_at"] ), - affected_records=row["affected_records"], - archived_records=row["archived_records"], - deleted_records=row["deleted_records"], - error_count=row["error_count"], - details=json.loads(row["details"] or "{}"), - created_at=( + affected_records = row["affected_records"], + archived_records = row["archived_records"], + deleted_records = row["deleted_records"], + error_count = row["error_count"], + details = json.loads(row["details"] or "{}"), + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] @@ -2202,12 +2202,12 @@ class EnterpriseManager: # 全局实例 -_enterprise_manager = None +_enterprise_manager = None -def get_enterprise_manager(db_path: str = "insightflow.db") -> EnterpriseManager: +def get_enterprise_manager(db_path: str = "insightflow.db") -> EnterpriseManager: """获取 EnterpriseManager 单例""" global _enterprise_manager if _enterprise_manager is None: - _enterprise_manager = EnterpriseManager(db_path) + _enterprise_manager = EnterpriseManager(db_path) return _enterprise_manager diff --git a/backend/entity_aligner.py b/backend/entity_aligner.py index 3bad4cf..e1999d1 100644 --- a/backend/entity_aligner.py +++ b/backend/entity_aligner.py @@ -12,8 +12,8 @@ import httpx import numpy as np # API Keys -KIMI_API_KEY = os.getenv("KIMI_API_KEY", "") -KIMI_BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.kimi.com/coding") +KIMI_API_KEY = os.getenv("KIMI_API_KEY", "") +KIMI_BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.kimi.com/coding") @dataclass @@ -27,9 +27,9 @@ class EntityEmbedding: class EntityAligner: """实体对齐器 - 使用 embedding 进行相似度匹配""" - def __init__(self, similarity_threshold: float = 0.85): - self.similarity_threshold = similarity_threshold - self.embedding_cache: dict[str, list[float]] = {} + def __init__(self, similarity_threshold: float = 0.85) -> None: + self.similarity_threshold = similarity_threshold + self.embedding_cache: dict[str, list[float]] = {} def get_embedding(self, text: str) -> list[float] | None: """ @@ -45,25 +45,25 @@ class EntityAligner: return None # 检查缓存 - cache_key = hash(text) + cache_key = hash(text) if cache_key in self.embedding_cache: return self.embedding_cache[cache_key] try: - response = httpx.post( + response = httpx.post( f"{KIMI_BASE_URL}/v1/embeddings", - headers={ + headers = { "Authorization": f"Bearer {KIMI_API_KEY}", "Content-Type": "application/json", }, - json={"model": "k2p5", "input": text[:500]}, # 限制长度 - timeout=30.0, + json = {"model": "k2p5", "input": text[:500]}, # 限制长度 + timeout = 30.0, ) response.raise_for_status() - result = response.json() + result = response.json() - embedding = result["data"][0]["embedding"] - self.embedding_cache[cache_key] = embedding + embedding = result["data"][0]["embedding"] + self.embedding_cache[cache_key] = embedding return embedding except (httpx.HTTPError, json.JSONDecodeError, KeyError) as e: @@ -81,20 +81,20 @@ class EntityAligner: Returns: 相似度分数 (0-1) """ - vec1 = np.array(embedding1) - vec2 = np.array(embedding2) + vec1 = np.array(embedding1) + vec2 = np.array(embedding2) # 余弦相似度 - dot_product = np.dot(vec1, vec2) - norm1 = np.linalg.norm(vec1) - norm2 = np.linalg.norm(vec2) + dot_product = np.dot(vec1, vec2) + norm1 = np.linalg.norm(vec1) + norm2 = np.linalg.norm(vec2) if norm1 == 0 or norm2 == 0: return 0.0 return float(dot_product / (norm1 * norm2)) - def get_entity_text(self, name: str, definition: str = "") -> str: + def get_entity_text(self, name: str, definition: str = "") -> str: """ 构建用于 embedding 的实体文本 @@ -113,9 +113,9 @@ class EntityAligner: self, project_id: str, name: str, - definition: str = "", - exclude_id: str | None = None, - threshold: float | None = None, + definition: str = "", + exclude_id: str | None = None, + threshold: float | None = None, ) -> object | None: """ 查找相似的实体 @@ -131,54 +131,54 @@ class EntityAligner: 相似的实体或 None """ if threshold is None: - threshold = self.similarity_threshold + threshold = self.similarity_threshold try: from db_manager import get_db_manager - db = get_db_manager() + db = get_db_manager() except ImportError: return None # 获取项目的所有实体 - entities = db.get_all_entities_for_embedding(project_id) + entities = db.get_all_entities_for_embedding(project_id) if not entities: return None # 获取查询实体的 embedding - query_text = self.get_entity_text(name, definition) - query_embedding = self.get_embedding(query_text) + query_text = self.get_entity_text(name, definition) + query_embedding = self.get_embedding(query_text) if query_embedding is None: # 如果 embedding API 失败,回退到简单匹配 return self._fallback_similarity_match(entities, name, exclude_id) - best_match = None - best_score = threshold + best_match = None + best_score = threshold for entity in entities: if exclude_id and entity.id == exclude_id: continue # 获取实体的 embedding - entity_text = self.get_entity_text(entity.name, entity.definition) - entity_embedding = self.get_embedding(entity_text) + entity_text = self.get_entity_text(entity.name, entity.definition) + entity_embedding = self.get_embedding(entity_text) if entity_embedding is None: continue # 计算相似度 - similarity = self.compute_similarity(query_embedding, entity_embedding) + similarity = self.compute_similarity(query_embedding, entity_embedding) if similarity > best_score: - best_score = similarity - best_match = entity + best_score = similarity + best_match = entity return best_match def _fallback_similarity_match( - self, entities: list[object], name: str, exclude_id: str | None = None + self, entities: list[object], name: str, exclude_id: str | None = None ) -> object | None: """ 回退到简单的相似度匹配(不使用 embedding) @@ -191,7 +191,7 @@ class EntityAligner: Returns: 最相似的实体或 None """ - name_lower = name.lower() + name_lower = name.lower() # 1. 精确匹配 for entity in entities: @@ -212,7 +212,7 @@ class EntityAligner: return None def batch_align_entities( - self, project_id: str, new_entities: list[dict], threshold: float | None = None + self, project_id: str, new_entities: list[dict], threshold: float | None = None ) -> list[dict]: """ 批量对齐实体 @@ -226,16 +226,16 @@ class EntityAligner: 对齐结果列表 [{"new_entity": {...}, "matched_entity": {...}, "similarity": 0.9}] """ if threshold is None: - threshold = self.similarity_threshold + threshold = self.similarity_threshold - results = [] + results = [] for new_ent in new_entities: - matched = self.find_similar_entity( - project_id, new_ent["name"], new_ent.get("definition", ""), threshold=threshold + matched = self.find_similar_entity( + project_id, new_ent["name"], new_ent.get("definition", ""), threshold = threshold ) - result = { + result = { "new_entity": new_ent, "matched_entity": None, "similarity": 0.0, @@ -244,28 +244,28 @@ class EntityAligner: if matched: # 计算相似度 - query_text = self.get_entity_text(new_ent["name"], new_ent.get("definition", "")) - matched_text = self.get_entity_text(matched.name, matched.definition) + query_text = self.get_entity_text(new_ent["name"], new_ent.get("definition", "")) + matched_text = self.get_entity_text(matched.name, matched.definition) - query_emb = self.get_embedding(query_text) - matched_emb = self.get_embedding(matched_text) + query_emb = self.get_embedding(query_text) + matched_emb = self.get_embedding(matched_text) if query_emb and matched_emb: - similarity = self.compute_similarity(query_emb, matched_emb) - result["matched_entity"] = { + similarity = self.compute_similarity(query_emb, matched_emb) + result["matched_entity"] = { "id": matched.id, "name": matched.name, "type": matched.type, "definition": matched.definition, } - result["similarity"] = similarity - result["should_merge"] = similarity >= threshold + result["similarity"] = similarity + result["should_merge"] = similarity >= threshold results.append(result) return results - def suggest_entity_aliases(self, entity_name: str, entity_definition: str = "") -> list[str]: + def suggest_entity_aliases(self, entity_name: str, entity_definition: str = "") -> list[str]: """ 使用 LLM 建议实体的别名 @@ -279,7 +279,7 @@ class EntityAligner: if not KIMI_API_KEY: return [] - prompt = f"""为以下实体生成可能的别名或简称: + prompt = f"""为以下实体生成可能的别名或简称: 实体名称:{entity_name} 定义:{entity_definition} @@ -290,28 +290,28 @@ class EntityAligner: 只返回 JSON,不要其他内容。""" try: - response = httpx.post( + response = httpx.post( f"{KIMI_BASE_URL}/v1/chat/completions", - headers={ + headers = { "Authorization": f"Bearer {KIMI_API_KEY}", "Content-Type": "application/json", }, - json={ + json = { "model": "k2p5", "messages": [{"role": "user", "content": prompt}], "temperature": 0.3, }, - timeout=30.0, + timeout = 30.0, ) response.raise_for_status() - result = response.json() - content = result["choices"][0]["message"]["content"] + result = response.json() + content = result["choices"][0]["message"]["content"] import re - json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) + json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) if json_match: - data = json.loads(json_match.group()) + data = json.loads(json_match.group()) return data.get("aliases", []) except (httpx.HTTPError, json.JSONDecodeError, KeyError) as e: print(f"Alias suggestion failed: {e}") @@ -340,8 +340,8 @@ def simple_similarity(str1: str, str2: str) -> float: return 0.0 # 转换为小写 - s1 = str1.lower() - s2 = str2.lower() + s1 = str1.lower() + s2 = str2.lower() # 包含关系 if s1 in s2 or s2 in s1: @@ -355,11 +355,11 @@ def simple_similarity(str1: str, str2: str) -> float: if __name__ == "__main__": # 测试 - aligner = EntityAligner() + aligner = EntityAligner() # 测试 embedding - test_text = "Kubernetes 容器编排平台" - embedding = aligner.get_embedding(test_text) + test_text = "Kubernetes 容器编排平台" + embedding = aligner.get_embedding(test_text) if embedding: print(f"Embedding dimension: {len(embedding)}") print(f"First 5 values: {embedding[:5]}") @@ -367,7 +367,7 @@ if __name__ == "__main__": print("Embedding API not available") # 测试相似度计算 - emb1 = [1.0, 0.0, 0.0] - emb2 = [0.9, 0.1, 0.0] - sim = aligner.compute_similarity(emb1, emb2) + emb1 = [1.0, 0.0, 0.0] + emb2 = [0.9, 0.1, 0.0] + sim = aligner.compute_similarity(emb1, emb2) print(f"Similarity: {sim:.4f}") diff --git a/backend/export_manager.py b/backend/export_manager.py index 35e7792..8d1547a 100644 --- a/backend/export_manager.py +++ b/backend/export_manager.py @@ -14,9 +14,9 @@ from typing import Any try: import pandas as pd - PANDAS_AVAILABLE = True + PANDAS_AVAILABLE = True except ImportError: - PANDAS_AVAILABLE = False + PANDAS_AVAILABLE = False try: from reportlab.lib import colors @@ -32,9 +32,9 @@ try: TableStyle, ) - REPORTLAB_AVAILABLE = True + REPORTLAB_AVAILABLE = True except ImportError: - REPORTLAB_AVAILABLE = False + REPORTLAB_AVAILABLE = False @dataclass @@ -71,8 +71,8 @@ class ExportTranscript: class ExportManager: """导出管理器 - 处理各种导出需求""" - def __init__(self, db_manager=None): - self.db = db_manager + def __init__(self, db_manager = None) -> None: + self.db = db_manager def export_knowledge_graph_svg( self, project_id: str, entities: list[ExportEntity], relations: list[ExportRelation] @@ -84,21 +84,21 @@ class ExportManager: SVG 字符串 """ # 计算布局参数 - width = 1200 - height = 800 - center_x = width / 2 - center_y = height / 2 - radius = 300 + width = 1200 + height = 800 + center_x = width / 2 + center_y = height / 2 + radius = 300 # 按类型分组实体 - entities_by_type = {} + entities_by_type = {} for e in entities: if e.type not in entities_by_type: - entities_by_type[e.type] = [] + entities_by_type[e.type] = [] entities_by_type[e.type].append(e) # 颜色映射 - type_colors = { + type_colors = { "PERSON": "#FF6B6B", "ORGANIZATION": "#4ECDC4", "LOCATION": "#45B7D1", @@ -110,110 +110,110 @@ class ExportManager: } # 计算实体位置 - entity_positions = {} - angle_step = 2 * 3.14159 / max(len(entities), 1) + entity_positions = {} + angle_step = 2 * 3.14159 / max(len(entities), 1) for i, entity in enumerate(entities): i * angle_step - x = center_x + radius * 0.8 * (i % 3 - 1) * 150 + (i // 3) * 50 - y = center_y + radius * 0.6 * ((i % 6) - 3) * 80 - entity_positions[entity.id] = (x, y) + x = center_x + radius * 0.8 * (i % 3 - 1) * 150 + (i // 3) * 50 + y = center_y + radius * 0.6 * ((i % 6) - 3) * 80 + entity_positions[entity.id] = (x, y) # 生成 SVG - svg_parts = [ - f'', + svg_parts = [ + f'', "", - ' ', - ' ', + ' ', + ' ', " ", "", - f'', - f'知识图谱 - {project_id}', + f'', + f'知识图谱 - {project_id}', ] # 绘制关系连线 for rel in relations: if rel.source in entity_positions and rel.target in entity_positions: - x1, y1 = entity_positions[rel.source] - x2, y2 = entity_positions[rel.target] + x1, y1 = entity_positions[rel.source] + x2, y2 = entity_positions[rel.target] # 计算箭头终点(避免覆盖节点) - dx = x2 - x1 - dy = y2 - y1 - dist = (dx**2 + dy**2) ** 0.5 + dx = x2 - x1 + dy = y2 - y1 + dist = (dx**2 + dy**2) ** 0.5 if dist > 0: - offset = 40 - x2 = x2 - dx * offset / dist - y2 = y2 - dy * offset / dist + offset = 40 + x2 = x2 - dx * offset / dist + y2 = y2 - dy * offset / dist svg_parts.append( - f'' + f'' ) # 关系标签 - mid_x = (x1 + x2) / 2 - mid_y = (y1 + y2) / 2 + mid_x = (x1 + x2) / 2 + mid_y = (y1 + y2) / 2 svg_parts.append( - f'' + f'' ) svg_parts.append( - f'{rel.relation_type}' + f'{rel.relation_type}' ) # 绘制实体节点 for entity in entities: if entity.id in entity_positions: - x, y = entity_positions[entity.id] - color = type_colors.get(entity.type, type_colors["default"]) + x, y = entity_positions[entity.id] + color = type_colors.get(entity.type, type_colors["default"]) # 节点圆圈 svg_parts.append( - f'' + f'' ) # 实体名称 svg_parts.append( - f'{entity.name[:8]}' + f'{entity.name[:8]}' ) # 实体类型 svg_parts.append( - f'{entity.type}' + f'{entity.type}' ) # 图例 - legend_x = width - 150 - legend_y = 80 - rect_x = legend_x - 10 - rect_y = legend_y - 20 - rect_height = len(type_colors) * 25 + 10 + legend_x = width - 150 + legend_y = 80 + rect_x = legend_x - 10 + rect_y = legend_y - 20 + rect_height = len(type_colors) * 25 + 10 svg_parts.append( - f'' + f'' ) svg_parts.append( - f'实体类型' + f'实体类型' ) for i, (etype, color) in enumerate(type_colors.items()): if etype != "default": - y_pos = legend_y + 25 + i * 20 + y_pos = legend_y + 25 + i * 20 svg_parts.append( - f'' + f'' ) - text_y = y_pos + 4 + text_y = y_pos + 4 svg_parts.append( - f'{etype}' + f'{etype}' ) svg_parts.append("") @@ -231,12 +231,12 @@ class ExportManager: try: import cairosvg - svg_content = self.export_knowledge_graph_svg(project_id, entities, relations) - png_bytes = cairosvg.svg2png(bytestring=svg_content.encode("utf-8")) + svg_content = self.export_knowledge_graph_svg(project_id, entities, relations) + png_bytes = cairosvg.svg2png(bytestring = svg_content.encode("utf-8")) return png_bytes except ImportError: # 如果没有 cairosvg,返回 SVG 的 base64 - svg_content = self.export_knowledge_graph_svg(project_id, entities, relations) + svg_content = self.export_knowledge_graph_svg(project_id, entities, relations) return base64.b64encode(svg_content.encode("utf-8")) def export_entities_excel(self, entities: list[ExportEntity]) -> bytes: @@ -250,9 +250,9 @@ class ExportManager: raise ImportError("pandas is required for Excel export") # 准备数据 - data = [] + data = [] for e in entities: - row = { + row = { "ID": e.id, "名称": e.name, "类型": e.type, @@ -262,29 +262,29 @@ class ExportManager: } # 添加属性 for attr_name, attr_value in e.attributes.items(): - row[f"属性:{attr_name}"] = attr_value + row[f"属性:{attr_name}"] = attr_value data.append(row) - df = pd.DataFrame(data) + df = pd.DataFrame(data) # 写入 Excel - output = io.BytesIO() - with pd.ExcelWriter(output, engine="openpyxl") as writer: - df.to_excel(writer, sheet_name="实体列表", index=False) + output = io.BytesIO() + with pd.ExcelWriter(output, engine = "openpyxl") as writer: + df.to_excel(writer, sheet_name = "实体列表", index = False) # 调整列宽 - worksheet = writer.sheets["实体列表"] + worksheet = writer.sheets["实体列表"] for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter + max_length = 0 + column_letter = column[0].column_letter for cell in column: try: if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) + max_length = len(str(cell.value)) except (AttributeError, TypeError, ValueError): pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width return output.getvalue() @@ -295,24 +295,24 @@ class ExportManager: Returns: CSV 字符串 """ - output = io.StringIO() + output = io.StringIO() # 收集所有可能的属性列 - all_attrs = set() + all_attrs = set() for e in entities: all_attrs.update(e.attributes.keys()) # 表头 - headers = ["ID", "名称", "类型", "定义", "别名", "提及次数"] + [ + headers = ["ID", "名称", "类型", "定义", "别名", "提及次数"] + [ f"属性:{a}" for a in sorted(all_attrs) ] - writer = csv.writer(output) + writer = csv.writer(output) writer.writerow(headers) # 数据行 for e in entities: - row = [e.id, e.name, e.type, e.definition, ", ".join(e.aliases), e.mention_count] + row = [e.id, e.name, e.type, e.definition, ", ".join(e.aliases), e.mention_count] for attr in sorted(all_attrs): row.append(e.attributes.get(attr, "")) writer.writerow(row) @@ -327,8 +327,8 @@ class ExportManager: CSV 字符串 """ - output = io.StringIO() - writer = csv.writer(output) + output = io.StringIO() + writer = csv.writer(output) writer.writerow(["ID", "源实体", "目标实体", "关系类型", "置信度", "证据"]) for r in relations: @@ -345,7 +345,7 @@ class ExportManager: Returns: Markdown 字符串 """ - lines = [ + lines = [ f"# {transcript.name}", "", f"**类型**: {transcript.type}", @@ -369,10 +369,10 @@ class ExportManager: ] ) for seg in transcript.segments: - speaker = seg.get("speaker", "Unknown") - start = seg.get("start", 0) - end = seg.get("end", 0) - text = seg.get("text", "") + speaker = seg.get("speaker", "Unknown") + start = seg.get("start", 0) + end = seg.get("end", 0) + text = seg.get("text", "") lines.append(f"**[{start:.1f}s - {end:.1f}s] {speaker}**: {text}") lines.append("") @@ -387,12 +387,12 @@ class ExportManager: ] ) for mention in transcript.entity_mentions: - entity_id = mention.get("entity_id", "") - entity = entities_map.get(entity_id) - entity_name = entity.name if entity else mention.get("entity_name", "Unknown") - entity_type = entity.type if entity else "Unknown" - position = mention.get("position", "") - context = mention.get("context", "")[:50] + "..." if mention.get("context") else "" + entity_id = mention.get("entity_id", "") + entity = entities_map.get(entity_id) + entity_name = entity.name if entity else mention.get("entity_name", "Unknown") + entity_type = entity.type if entity else "Unknown" + position = mention.get("position", "") + context = mention.get("context", "")[:50] + "..." if mention.get("context") else "" lines.append(f"| {entity_name} | {entity_type} | {position} | {context} |") return "\n".join(lines) @@ -404,7 +404,7 @@ class ExportManager: entities: list[ExportEntity], relations: list[ExportRelation], transcripts: list[ExportTranscript], - summary: str = "", + summary: str = "", ) -> bytes: """ 导出项目报告为 PDF 格式 @@ -415,29 +415,29 @@ class ExportManager: if not REPORTLAB_AVAILABLE: raise ImportError("reportlab is required for PDF export") - output = io.BytesIO() - doc = SimpleDocTemplate( - output, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=18 + output = io.BytesIO() + doc = SimpleDocTemplate( + output, pagesize = A4, rightMargin = 72, leftMargin = 72, topMargin = 72, bottomMargin = 18 ) # 样式 - styles = getSampleStyleSheet() - title_style = ParagraphStyle( + styles = getSampleStyleSheet() + title_style = ParagraphStyle( "CustomTitle", - parent=styles["Heading1"], - fontSize=24, - spaceAfter=30, - textColor=colors.HexColor("#2c3e50"), + parent = styles["Heading1"], + fontSize = 24, + spaceAfter = 30, + textColor = colors.HexColor("#2c3e50"), ) - heading_style = ParagraphStyle( + heading_style = ParagraphStyle( "CustomHeading", - parent=styles["Heading2"], - fontSize=16, - spaceAfter=12, - textColor=colors.HexColor("#34495e"), + parent = styles["Heading2"], + fontSize = 16, + spaceAfter = 12, + textColor = colors.HexColor("#34495e"), ) - story = [] + story = [] # 标题页 story.append(Paragraph("InsightFlow 项目报告", title_style)) @@ -452,7 +452,7 @@ class ExportManager: # 统计概览 story.append(Paragraph("项目概览", heading_style)) - stats_data = [ + stats_data = [ ["指标", "数值"], ["实体数量", str(len(entities))], ["关系数量", str(len(relations))], @@ -460,14 +460,14 @@ class ExportManager: ] # 按类型统计实体 - type_counts = {} + type_counts = {} for e in entities: - type_counts[e.type] = type_counts.get(e.type, 0) + 1 + type_counts[e.type] = type_counts.get(e.type, 0) + 1 for etype, count in sorted(type_counts.items()): stats_data.append([f"{etype} 实体", str(count)]) - stats_table = Table(stats_data, colWidths=[3 * inch, 2 * inch]) + stats_table = Table(stats_data, colWidths = [3 * inch, 2 * inch]) stats_table.setStyle( TableStyle( [ @@ -496,8 +496,8 @@ class ExportManager: story.append(PageBreak()) story.append(Paragraph("实体列表", heading_style)) - entity_data = [["名称", "类型", "提及次数", "定义"]] - for e in sorted(entities, key=lambda x: x.mention_count, reverse=True)[ + entity_data = [["名称", "类型", "提及次数", "定义"]] + for e in sorted(entities, key = lambda x: x.mention_count, reverse = True)[ :50 ]: # 限制前50个 entity_data.append( @@ -509,8 +509,8 @@ class ExportManager: ] ) - entity_table = Table( - entity_data, colWidths=[1.5 * inch, 1 * inch, 1 * inch, 2.5 * inch] + entity_table = Table( + entity_data, colWidths = [1.5 * inch, 1 * inch, 1 * inch, 2.5 * inch] ) entity_table.setStyle( TableStyle( @@ -534,12 +534,12 @@ class ExportManager: story.append(PageBreak()) story.append(Paragraph("关系列表", heading_style)) - relation_data = [["源实体", "关系", "目标实体", "置信度"]] + relation_data = [["源实体", "关系", "目标实体", "置信度"]] for r in relations[:100]: # 限制前100个 relation_data.append([r.source, r.relation_type, r.target, f"{r.confidence:.2f}"]) - relation_table = Table( - relation_data, colWidths=[2 * inch, 1.5 * inch, 2 * inch, 1 * inch] + relation_table = Table( + relation_data, colWidths = [2 * inch, 1.5 * inch, 2 * inch, 1 * inch] ) relation_table.setStyle( TableStyle( @@ -574,7 +574,7 @@ class ExportManager: Returns: JSON 字符串 """ - data = { + data = { "project_id": project_id, "project_name": project_name, "export_time": datetime.now().isoformat(), @@ -613,16 +613,16 @@ class ExportManager: ], } - return json.dumps(data, ensure_ascii=False, indent=2) + return json.dumps(data, ensure_ascii = False, indent = 2) # 全局导出管理器实例 -_export_manager = None +_export_manager = None -def get_export_manager(db_manager=None) -> None: +def get_export_manager(db_manager = None) -> None: """获取导出管理器实例""" global _export_manager if _export_manager is None: - _export_manager = ExportManager(db_manager) + _export_manager = ExportManager(db_manager) return _export_manager diff --git a/backend/growth_manager.py b/backend/growth_manager.py index 0d71ab3..ffcae8f 100644 --- a/backend/growth_manager.py +++ b/backend/growth_manager.py @@ -26,88 +26,88 @@ from typing import Any import httpx # Database path -DB_PATH = os.path.join(os.path.dirname(__file__), "insightflow.db") +DB_PATH = os.path.join(os.path.dirname(__file__), "insightflow.db") class EventType(StrEnum): """事件类型""" - PAGE_VIEW = "page_view" # 页面浏览 - FEATURE_USE = "feature_use" # 功能使用 - CONVERSION = "conversion" # 转化 - SIGNUP = "signup" # 注册 - LOGIN = "login" # 登录 - UPGRADE = "upgrade" # 升级 - DOWNGRADE = "downgrade" # 降级 - CANCEL = "cancel" # 取消订阅 - INVITE_SENT = "invite_sent" # 发送邀请 - INVITE_ACCEPTED = "invite_accepted" # 接受邀请 - REFERRAL_REWARD = "referral_reward" # 推荐奖励 + PAGE_VIEW = "page_view" # 页面浏览 + FEATURE_USE = "feature_use" # 功能使用 + CONVERSION = "conversion" # 转化 + SIGNUP = "signup" # 注册 + LOGIN = "login" # 登录 + UPGRADE = "upgrade" # 升级 + DOWNGRADE = "downgrade" # 降级 + CANCEL = "cancel" # 取消订阅 + INVITE_SENT = "invite_sent" # 发送邀请 + INVITE_ACCEPTED = "invite_accepted" # 接受邀请 + REFERRAL_REWARD = "referral_reward" # 推荐奖励 class ExperimentStatus(StrEnum): """实验状态""" - DRAFT = "draft" # 草稿 - RUNNING = "running" # 运行中 - PAUSED = "paused" # 暂停 - COMPLETED = "completed" # 已完成 - ARCHIVED = "archived" # 已归档 + DRAFT = "draft" # 草稿 + RUNNING = "running" # 运行中 + PAUSED = "paused" # 暂停 + COMPLETED = "completed" # 已完成 + ARCHIVED = "archived" # 已归档 class TrafficAllocationType(StrEnum): """流量分配类型""" - RANDOM = "random" # 随机分配 - STRATIFIED = "stratified" # 分层分配 - TARGETED = "targeted" # 定向分配 + RANDOM = "random" # 随机分配 + STRATIFIED = "stratified" # 分层分配 + TARGETED = "targeted" # 定向分配 class EmailTemplateType(StrEnum): """邮件模板类型""" - WELCOME = "welcome" # 欢迎邮件 - ONBOARDING = "onboarding" # 引导邮件 - FEATURE_ANNOUNCEMENT = "feature_announcement" # 功能公告 - CHURN_RECOVERY = "churn_recovery" # 流失挽回 - UPGRADE_PROMPT = "upgrade_prompt" # 升级提示 - REFERRAL = "referral" # 推荐邀请 - NEWSLETTER = "newsletter" # 新闻通讯 + WELCOME = "welcome" # 欢迎邮件 + ONBOARDING = "onboarding" # 引导邮件 + FEATURE_ANNOUNCEMENT = "feature_announcement" # 功能公告 + CHURN_RECOVERY = "churn_recovery" # 流失挽回 + UPGRADE_PROMPT = "upgrade_prompt" # 升级提示 + REFERRAL = "referral" # 推荐邀请 + NEWSLETTER = "newsletter" # 新闻通讯 class EmailStatus(StrEnum): """邮件状态""" - DRAFT = "draft" # 草稿 - SCHEDULED = "scheduled" # 已计划 - SENDING = "sending" # 发送中 - SENT = "sent" # 已发送 - DELIVERED = "delivered" # 已送达 - OPENED = "opened" # 已打开 - CLICKED = "clicked" # 已点击 - BOUNCED = "bounced" # 退信 - FAILED = "failed" # 失败 + DRAFT = "draft" # 草稿 + SCHEDULED = "scheduled" # 已计划 + SENDING = "sending" # 发送中 + SENT = "sent" # 已发送 + DELIVERED = "delivered" # 已送达 + OPENED = "opened" # 已打开 + CLICKED = "clicked" # 已点击 + BOUNCED = "bounced" # 退信 + FAILED = "failed" # 失败 class WorkflowTriggerType(StrEnum): """工作流触发类型""" - USER_SIGNUP = "user_signup" # 用户注册 - USER_LOGIN = "user_login" # 用户登录 - SUBSCRIPTION_CREATED = "subscription_created" # 创建订阅 - SUBSCRIPTION_CANCELLED = "subscription_cancelled" # 取消订阅 - INACTIVITY = "inactivity" # 不活跃 - MILESTONE = "milestone" # 里程碑 - CUSTOM_EVENT = "custom_event" # 自定义事件 + USER_SIGNUP = "user_signup" # 用户注册 + USER_LOGIN = "user_login" # 用户登录 + SUBSCRIPTION_CREATED = "subscription_created" # 创建订阅 + SUBSCRIPTION_CANCELLED = "subscription_cancelled" # 取消订阅 + INACTIVITY = "inactivity" # 不活跃 + MILESTONE = "milestone" # 里程碑 + CUSTOM_EVENT = "custom_event" # 自定义事件 class ReferralStatus(StrEnum): """推荐状态""" - PENDING = "pending" # 待处理 - CONVERTED = "converted" # 已转化 - REWARDED = "rewarded" # 已奖励 - EXPIRED = "expired" # 已过期 + PENDING = "pending" # 待处理 + CONVERTED = "converted" # 已转化 + REWARDED = "rewarded" # 已奖励 + EXPIRED = "expired" # 已过期 @dataclass @@ -362,17 +362,17 @@ class TeamIncentive: class GrowthManager: """运营与增长管理主类""" - def __init__(self, db_path: str = DB_PATH): - self.db_path = db_path - self.mixpanel_token = os.getenv("MIXPANEL_TOKEN", "") - self.amplitude_api_key = os.getenv("AMPLITUDE_API_KEY", "") - self.segment_write_key = os.getenv("SEGMENT_WRITE_KEY", "") - self.sendgrid_api_key = os.getenv("SENDGRID_API_KEY", "") + def __init__(self, db_path: str = DB_PATH) -> None: + self.db_path = db_path + self.mixpanel_token = os.getenv("MIXPANEL_TOKEN", "") + self.amplitude_api_key = os.getenv("AMPLITUDE_API_KEY", "") + self.segment_write_key = os.getenv("SEGMENT_WRITE_KEY", "") + self.sendgrid_api_key = os.getenv("SENDGRID_API_KEY", "") def _get_db(self) -> None: """获取数据库连接""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn # ==================== 用户行为分析 ==================== @@ -383,30 +383,30 @@ class GrowthManager: user_id: str, event_type: EventType, event_name: str, - properties: dict = None, - session_id: str = None, - device_info: dict = None, - referrer: str = None, - utm_params: dict = None, + properties: dict = None, + session_id: str = None, + device_info: dict = None, + referrer: str = None, + utm_params: dict = None, ) -> AnalyticsEvent: """追踪事件""" - event_id = f"evt_{uuid.uuid4().hex[:16]}" - now = datetime.now() + event_id = f"evt_{uuid.uuid4().hex[:16]}" + now = datetime.now() - event = AnalyticsEvent( - id=event_id, - tenant_id=tenant_id, - user_id=user_id, - event_type=event_type, - event_name=event_name, - properties=properties or {}, - timestamp=now, - session_id=session_id, - device_info=device_info or {}, - referrer=referrer, - utm_source=utm_params.get("source") if utm_params else None, - utm_medium=utm_params.get("medium") if utm_params else None, - utm_campaign=utm_params.get("campaign") if utm_params else None, + event = AnalyticsEvent( + id = event_id, + tenant_id = tenant_id, + user_id = user_id, + event_type = event_type, + event_name = event_name, + properties = properties or {}, + timestamp = now, + session_id = session_id, + device_info = device_info or {}, + referrer = referrer, + utm_source = utm_params.get("source") if utm_params else None, + utm_medium = utm_params.get("medium") if utm_params else None, + utm_campaign = utm_params.get("campaign") if utm_params else None, ) with self._get_db() as conn: @@ -443,9 +443,9 @@ class GrowthManager: return event - async def _send_to_analytics_platforms(self, event: AnalyticsEvent): + async def _send_to_analytics_platforms(self, event: AnalyticsEvent) -> None: """发送事件到第三方分析平台""" - tasks = [] + tasks = [] if self.mixpanel_token: tasks.append(self._send_to_mixpanel(event)) @@ -453,17 +453,17 @@ class GrowthManager: tasks.append(self._send_to_amplitude(event)) if tasks: - await asyncio.gather(*tasks, return_exceptions=True) + await asyncio.gather(*tasks, return_exceptions = True) - async def _send_to_mixpanel(self, event: AnalyticsEvent): + async def _send_to_mixpanel(self, event: AnalyticsEvent) -> None: """发送事件到 Mixpanel""" try: - headers = { + headers = { "Content-Type": "application/json", "Authorization": f"Basic {self.mixpanel_token}", } - payload = { + payload = { "event": event.event_name, "properties": { "distinct_id": event.user_id, @@ -475,17 +475,17 @@ class GrowthManager: async with httpx.AsyncClient() as client: await client.post( - "https://api.mixpanel.com/track", headers=headers, json=[payload], timeout=10.0 + "https://api.mixpanel.com/track", headers = headers, json = [payload], timeout = 10.0 ) except (RuntimeError, ValueError, TypeError) as e: print(f"Failed to send to Mixpanel: {e}") - async def _send_to_amplitude(self, event: AnalyticsEvent): + async def _send_to_amplitude(self, event: AnalyticsEvent) -> None: """发送事件到 Amplitude""" try: - headers = {"Content-Type": "application/json"} + headers = {"Content-Type": "application/json"} - payload = { + payload = { "api_key": self.amplitude_api_key, "events": [ { @@ -501,45 +501,45 @@ class GrowthManager: async with httpx.AsyncClient() as client: await client.post( "https://api.amplitude.com/2/httpapi", - headers=headers, - json=payload, - timeout=10.0, + headers = headers, + json = payload, + timeout = 10.0, ) except (RuntimeError, ValueError, TypeError) as e: print(f"Failed to send to Amplitude: {e}") async def _update_user_profile( self, tenant_id: str, user_id: str, event_type: EventType, event_name: str - ): + ) -> None: """更新用户画像""" with self._get_db() as conn: # 检查用户画像是否存在 - row = conn.execute( - "SELECT * FROM user_profiles WHERE tenant_id = ? AND user_id = ?", + row = conn.execute( + "SELECT * FROM user_profiles WHERE tenant_id = ? AND user_id = ?", (tenant_id, user_id), ).fetchone() - now = datetime.now().isoformat() + now = datetime.now().isoformat() if row: # 更新现有画像 - feature_usage = json.loads(row["feature_usage"]) + feature_usage = json.loads(row["feature_usage"]) if event_name not in feature_usage: - feature_usage[event_name] = 0 + feature_usage[event_name] = 0 feature_usage[event_name] += 1 conn.execute( """ UPDATE user_profiles - SET last_seen = ?, total_events = total_events + 1, - feature_usage = ?, updated_at = ? - WHERE id = ? + SET last_seen = ?, total_events = total_events + 1, + feature_usage = ?, updated_at = ? + WHERE id = ? """, (now, json.dumps(feature_usage), now, row["id"]), ) else: # 创建新画像 - profile_id = f"up_{uuid.uuid4().hex[:16]}" + profile_id = f"up_{uuid.uuid4().hex[:16]}" conn.execute( """ INSERT INTO user_profiles @@ -571,8 +571,8 @@ class GrowthManager: def get_user_profile(self, tenant_id: str, user_id: str) -> UserProfile | None: """获取用户画像""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM user_profiles WHERE tenant_id = ? AND user_id = ?", + row = conn.execute( + "SELECT * FROM user_profiles WHERE tenant_id = ? AND user_id = ?", (tenant_id, user_id), ).fetchone() @@ -581,20 +581,20 @@ class GrowthManager: return None def get_user_analytics_summary( - self, tenant_id: str, start_date: datetime = None, end_date: datetime = None + self, tenant_id: str, start_date: datetime = None, end_date: datetime = None ) -> dict: """获取用户分析汇总""" with self._get_db() as conn: - query = """ + query = """ SELECT COUNT(DISTINCT user_id) as unique_users, COUNT(*) as total_events, COUNT(DISTINCT session_id) as total_sessions, COUNT(DISTINCT date(timestamp)) as active_days FROM analytics_events - WHERE tenant_id = ? + WHERE tenant_id = ? """ - params = [tenant_id] + params = [tenant_id] if start_date: query += " AND timestamp >= ?" @@ -603,15 +603,15 @@ class GrowthManager: query += " AND timestamp <= ?" params.append(end_date.isoformat()) - row = conn.execute(query, params).fetchone() + row = conn.execute(query, params).fetchone() # 获取事件类型分布 - type_query = """ + type_query = """ SELECT event_type, COUNT(*) as count FROM analytics_events - WHERE tenant_id = ? + WHERE tenant_id = ? """ - type_params = [tenant_id] + type_params = [tenant_id] if start_date: type_query += " AND timestamp >= ?" @@ -622,7 +622,7 @@ class GrowthManager: type_query += " GROUP BY event_type" - type_rows = conn.execute(type_query, type_params).fetchall() + type_rows = conn.execute(type_query, type_params).fetchall() return { "unique_users": row["unique_users"], @@ -638,17 +638,17 @@ class GrowthManager: self, tenant_id: str, name: str, description: str, steps: list[dict], created_by: str ) -> Funnel: """创建转化漏斗""" - funnel_id = f"fnl_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + funnel_id = f"fnl_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - funnel = Funnel( - id=funnel_id, - tenant_id=tenant_id, - name=name, - description=description, - steps=steps, - created_at=now, - updated_at=now, + funnel = Funnel( + id = funnel_id, + tenant_id = tenant_id, + name = name, + description = description, + steps = steps, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -673,46 +673,46 @@ class GrowthManager: return funnel def analyze_funnel( - self, funnel_id: str, period_start: datetime = None, period_end: datetime = None + self, funnel_id: str, period_start: datetime = None, period_end: datetime = None ) -> FunnelAnalysis | None: """分析漏斗转化率""" with self._get_db() as conn: - funnel_row = conn.execute("SELECT * FROM funnels WHERE id = ?", (funnel_id,)).fetchone() + funnel_row = conn.execute("SELECT * FROM funnels WHERE id = ?", (funnel_id, )).fetchone() if not funnel_row: return None - steps = json.loads(funnel_row["steps"]) + steps = json.loads(funnel_row["steps"]) if not period_start: - period_start = datetime.now() - timedelta(days=30) + period_start = datetime.now() - timedelta(days = 30) if not period_end: - period_end = datetime.now() + period_end = datetime.now() # 计算每步转化 - step_conversions = [] - previous_count = None + step_conversions = [] + previous_count = None for step in steps: - event_name = step.get("event_name") + event_name = step.get("event_name") - query = """ + query = """ SELECT COUNT(DISTINCT user_id) as user_count FROM analytics_events - WHERE event_name = ? AND timestamp >= ? AND timestamp <= ? + WHERE event_name = ? AND timestamp >= ? AND timestamp <= ? """ - row = conn.execute( + row = conn.execute( query, (event_name, period_start.isoformat(), period_end.isoformat()) ).fetchone() - user_count = row["user_count"] if row else 0 + user_count = row["user_count"] if row else 0 - conversion_rate = 0.0 - drop_off_rate = 0.0 + conversion_rate = 0.0 + drop_off_rate = 0.0 if previous_count and previous_count > 0: - conversion_rate = user_count / previous_count - drop_off_rate = 1 - conversion_rate + conversion_rate = user_count / previous_count + drop_off_rate = 1 - conversion_rate step_conversions.append( { @@ -724,79 +724,79 @@ class GrowthManager: } ) - previous_count = user_count + previous_count = user_count # 计算总体转化率 if steps and step_conversions: - first_step_count = step_conversions[0]["user_count"] - last_step_count = step_conversions[-1]["user_count"] - overall_conversion = last_step_count / max(first_step_count, 1) + first_step_count = step_conversions[0]["user_count"] + last_step_count = step_conversions[-1]["user_count"] + overall_conversion = last_step_count / max(first_step_count, 1) else: - overall_conversion = 0.0 + overall_conversion = 0.0 # 找出主要流失点 - drop_off_points = [ + drop_off_points = [ s for s in step_conversions if s["drop_off_rate"] > 0.2 and s != step_conversions[0] ] return FunnelAnalysis( - funnel_id=funnel_id, - period_start=period_start, - period_end=period_end, - total_users=step_conversions[0]["user_count"] if step_conversions else 0, - step_conversions=step_conversions, - overall_conversion=round(overall_conversion, 4), - drop_off_points=drop_off_points, + funnel_id = funnel_id, + period_start = period_start, + period_end = period_end, + total_users = step_conversions[0]["user_count"] if step_conversions else 0, + step_conversions = step_conversions, + overall_conversion = round(overall_conversion, 4), + drop_off_points = drop_off_points, ) def calculate_retention( - self, tenant_id: str, cohort_date: datetime, periods: list[int] = None + self, tenant_id: str, cohort_date: datetime, periods: list[int] = None ) -> dict: """计算留存率""" if periods is None: - periods = [1, 3, 7, 14, 30] + periods = [1, 3, 7, 14, 30] with self._get_db() as conn: # 获取同期群用户(在 cohort_date 当天首次活跃的用户) - cohort_query = """ + cohort_query = """ SELECT DISTINCT user_id FROM analytics_events - WHERE tenant_id = ? AND date(timestamp) = date(?) + WHERE tenant_id = ? AND date(timestamp) = date(?) AND user_id IN ( SELECT user_id FROM user_profiles - WHERE tenant_id = ? AND date(first_seen) = date(?) + WHERE tenant_id = ? AND date(first_seen) = date(?) ) """ - cohort_rows = conn.execute( + cohort_rows = conn.execute( cohort_query, (tenant_id, cohort_date.isoformat(), tenant_id, cohort_date.isoformat()), ).fetchall() - cohort_users = {r["user_id"] for r in cohort_rows} - cohort_size = len(cohort_users) + cohort_users = {r["user_id"] for r in cohort_rows} + cohort_size = len(cohort_users) if cohort_size == 0: return {"cohort_date": cohort_date.isoformat(), "cohort_size": 0, "retention": {}} - retention_rates = {} + retention_rates = {} for period in periods: - period_date = cohort_date + timedelta(days=period) + period_date = cohort_date + timedelta(days = period) - active_query = """ + active_query = """ SELECT COUNT(DISTINCT user_id) as active_count FROM analytics_events - WHERE tenant_id = ? AND date(timestamp) = date(?) + WHERE tenant_id = ? AND date(timestamp) = date(?) AND user_id IN ({}) - """.format(",".join(["?" for _ in cohort_users])) + """.format(", ".join(["?" for _ in cohort_users])) - params = [tenant_id, period_date.isoformat()] + list(cohort_users) - row = conn.execute(active_query, params).fetchone() + params = [tenant_id, period_date.isoformat()] + list(cohort_users) + row = conn.execute(active_query, params).fetchone() - active_count = row["active_count"] if row else 0 - retention_rate = active_count / cohort_size + active_count = row["active_count"] if row else 0 + retention_rate = active_count / cohort_size - retention_rates[f"day_{period}"] = { + retention_rates[f"day_{period}"] = { "active_users": active_count, "retention_rate": round(retention_rate, 4), } @@ -821,34 +821,34 @@ class GrowthManager: target_audience: dict, primary_metric: str, secondary_metrics: list[str], - min_sample_size: int = 100, - confidence_level: float = 0.95, - created_by: str = None, + min_sample_size: int = 100, + confidence_level: float = 0.95, + created_by: str = None, ) -> Experiment: """创建 A/B 测试实验""" - experiment_id = f"exp_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + experiment_id = f"exp_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - experiment = Experiment( - id=experiment_id, - tenant_id=tenant_id, - name=name, - description=description, - hypothesis=hypothesis, - status=ExperimentStatus.DRAFT, - variants=variants, - traffic_allocation=traffic_allocation, - traffic_split=traffic_split, - target_audience=target_audience, - primary_metric=primary_metric, - secondary_metrics=secondary_metrics, - start_date=None, - end_date=None, - min_sample_size=min_sample_size, - confidence_level=confidence_level, - created_at=now, - updated_at=now, - created_by=created_by or "system", + experiment = Experiment( + id = experiment_id, + tenant_id = tenant_id, + name = name, + description = description, + hypothesis = hypothesis, + status = ExperimentStatus.DRAFT, + variants = variants, + traffic_allocation = traffic_allocation, + traffic_split = traffic_split, + target_audience = target_audience, + primary_metric = primary_metric, + secondary_metrics = secondary_metrics, + start_date = None, + end_date = None, + min_sample_size = min_sample_size, + confidence_level = confidence_level, + created_at = now, + updated_at = now, + created_by = created_by or "system", ) with self._get_db() as conn: @@ -890,42 +890,42 @@ class GrowthManager: def get_experiment(self, experiment_id: str) -> Experiment | None: """获取实验详情""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM experiments WHERE id = ?", (experiment_id,) + row = conn.execute( + "SELECT * FROM experiments WHERE id = ?", (experiment_id, ) ).fetchone() if row: return self._row_to_experiment(row) return None - def list_experiments(self, tenant_id: str, status: ExperimentStatus = None) -> list[Experiment]: + def list_experiments(self, tenant_id: str, status: ExperimentStatus = None) -> list[Experiment]: """列出实验""" - query = "SELECT * FROM experiments WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM experiments WHERE tenant_id = ?" + params = [tenant_id] if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status.value) query += " ORDER BY created_at DESC" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_experiment(row) for row in rows] def assign_variant( - self, experiment_id: str, user_id: str, user_attributes: dict = None + self, experiment_id: str, user_id: str, user_attributes: dict = None ) -> str | None: """为用户分配实验变体""" - experiment = self.get_experiment(experiment_id) + experiment = self.get_experiment(experiment_id) if not experiment or experiment.status != ExperimentStatus.RUNNING: return None # 检查用户是否已分配 with self._get_db() as conn: - row = conn.execute( + row = conn.execute( """SELECT variant_id FROM experiment_assignments - WHERE experiment_id = ? AND user_id = ?""", + WHERE experiment_id = ? AND user_id = ?""", (experiment_id, user_id), ).fetchone() @@ -934,18 +934,18 @@ class GrowthManager: # 根据分配策略选择变体 if experiment.traffic_allocation == TrafficAllocationType.RANDOM: - variant_id = self._random_allocation(experiment.variants, experiment.traffic_split) + variant_id = self._random_allocation(experiment.variants, experiment.traffic_split) elif experiment.traffic_allocation == TrafficAllocationType.STRATIFIED: - variant_id = self._stratified_allocation( + variant_id = self._stratified_allocation( experiment.variants, experiment.traffic_split, user_attributes ) else: # TARGETED - variant_id = self._targeted_allocation( + variant_id = self._targeted_allocation( experiment.variants, experiment.target_audience, user_attributes ) if variant_id: - now = datetime.now().isoformat() + now = datetime.now().isoformat() conn.execute( """ INSERT INTO experiment_assignments @@ -967,13 +967,13 @@ class GrowthManager: def _random_allocation(self, variants: list[dict], traffic_split: dict[str, float]) -> str: """随机分配""" - variant_ids = [v["id"] for v in variants] - weights = [traffic_split.get(v_id, 1.0 / len(variants)) for v_id in variant_ids] + variant_ids = [v["id"] for v in variants] + weights = [traffic_split.get(v_id, 1.0 / len(variants)) for v_id in variant_ids] - total = sum(weights) - normalized_weights = [w / total for w in weights] + total = sum(weights) + normalized_weights = [w / total for w in weights] - return random.choices(variant_ids, weights=normalized_weights, k=1)[0] + return random.choices(variant_ids, weights = normalized_weights, k = 1)[0] def _stratified_allocation( self, variants: list[dict], traffic_split: dict[str, float], user_attributes: dict @@ -981,9 +981,9 @@ class GrowthManager: """分层分配(基于用户属性)""" # 简化的分层分配:根据用户 ID 哈希值分配 if user_attributes and "user_id" in user_attributes: - hash_value = int(hashlib.md5(user_attributes["user_id"].encode()).hexdigest(), 16) - variant_ids = [v["id"] for v in variants] - index = hash_value % len(variant_ids) + hash_value = int(hashlib.md5(user_attributes["user_id"].encode()).hexdigest(), 16) + variant_ids = [v["id"] for v in variants] + index = hash_value % len(variant_ids) return variant_ids[index] return self._random_allocation(variants, traffic_split) @@ -993,29 +993,29 @@ class GrowthManager: ) -> str | None: """定向分配(基于目标受众条件)""" # 检查用户是否符合目标受众条件 - conditions = target_audience.get("conditions", []) + conditions = target_audience.get("conditions", []) - matches = True + matches = True for condition in conditions: - attr_name = condition.get("attribute") - operator = condition.get("operator") - value = condition.get("value") + attr_name = condition.get("attribute") + operator = condition.get("operator") + value = condition.get("value") - user_value = user_attributes.get(attr_name) if user_attributes else None + user_value = user_attributes.get(attr_name) if user_attributes else None if operator == "equals" and user_value != value: - matches = False + matches = False break elif operator == "not_equals" and user_value == value: - matches = False + matches = False break elif operator == "in" and user_value not in value: - matches = False + matches = False break if not matches: # 用户不符合条件,返回对照组 - control_variant = next((v for v in variants if v.get("is_control")), variants[0]) + control_variant = next((v for v in variants if v.get("is_control")), variants[0]) return control_variant["id"] if control_variant else None return self._random_allocation(variants, target_audience.get("traffic_split", {})) @@ -1027,7 +1027,7 @@ class GrowthManager: user_id: str, metric_name: str, metric_value: float, - ): + ) -> None: """记录实验指标""" with self._get_db() as conn: conn.execute( @@ -1050,46 +1050,46 @@ class GrowthManager: def analyze_experiment(self, experiment_id: str) -> dict: """分析实验结果""" - experiment = self.get_experiment(experiment_id) + experiment = self.get_experiment(experiment_id) if not experiment: return {"error": "Experiment not found"} with self._get_db() as conn: - results = {} + results = {} for variant in experiment.variants: - variant_id = variant["id"] + variant_id = variant["id"] # 获取样本量 - sample_row = conn.execute( + sample_row = conn.execute( """ SELECT COUNT(DISTINCT user_id) as sample_size FROM experiment_assignments - WHERE experiment_id = ? AND variant_id = ? + WHERE experiment_id = ? AND variant_id = ? """, (experiment_id, variant_id), ).fetchone() - sample_size = sample_row["sample_size"] if sample_row else 0 + sample_size = sample_row["sample_size"] if sample_row else 0 # 获取主要指标统计 - metric_row = conn.execute( + metric_row = conn.execute( """ SELECT AVG(metric_value) as mean_value, COUNT(*) as metric_count, SUM(metric_value) as total_value FROM experiment_metrics - WHERE experiment_id = ? AND variant_id = ? AND metric_name = ? + WHERE experiment_id = ? AND variant_id = ? AND metric_name = ? """, (experiment_id, variant_id, experiment.primary_metric), ).fetchone() - mean_value = ( + mean_value = ( metric_row["mean_value"] if metric_row and metric_row["mean_value"] else 0 ) - results[variant_id] = { + results[variant_id] = { "variant_name": variant.get("name", variant_id), "is_control": variant.get("is_control", False), "sample_size": sample_size, @@ -1098,27 +1098,27 @@ class GrowthManager: } # 计算统计显著性(简化版) - control_variant = next((v for v in experiment.variants if v.get("is_control")), None) + control_variant = next((v for v in experiment.variants if v.get("is_control")), None) if control_variant: - control_id = control_variant["id"] - control_result = results.get(control_id, {}) + control_id = control_variant["id"] + control_result = results.get(control_id, {}) for variant_id, result in results.items(): if variant_id != control_id: - control_mean = control_result.get("mean_value", 0) - variant_mean = result.get("mean_value", 0) + control_mean = control_result.get("mean_value", 0) + variant_mean = result.get("mean_value", 0) if control_mean > 0: - uplift = (variant_mean - control_mean) / control_mean + uplift = (variant_mean - control_mean) / control_mean else: - uplift = 0 + uplift = 0 # 简化的显著性判断 - is_significant = abs(uplift) > 0.05 and result["sample_size"] > 100 + is_significant = abs(uplift) > 0.05 and result["sample_size"] > 100 - result["uplift"] = round(uplift, 4) - result["is_significant"] = is_significant - result["p_value"] = 0.05 if is_significant else 0.5 + result["uplift"] = round(uplift, 4) + result["is_significant"] = is_significant + result["p_value"] = 0.05 if is_significant else 0.5 return { "experiment_id": experiment_id, @@ -1131,12 +1131,12 @@ class GrowthManager: def start_experiment(self, experiment_id: str) -> Experiment | None: """启动实验""" with self._get_db() as conn: - now = datetime.now().isoformat() + now = datetime.now().isoformat() conn.execute( """ UPDATE experiments - SET status = ?, start_date = ?, updated_at = ? - WHERE id = ? AND status = ? + SET status = ?, start_date = ?, updated_at = ? + WHERE id = ? AND status = ? """, ( ExperimentStatus.RUNNING.value, @@ -1153,12 +1153,12 @@ class GrowthManager: def stop_experiment(self, experiment_id: str) -> Experiment | None: """停止实验""" with self._get_db() as conn: - now = datetime.now().isoformat() + now = datetime.now().isoformat() conn.execute( """ UPDATE experiments - SET status = ?, end_date = ?, updated_at = ? - WHERE id = ? AND status = ? + SET status = ?, end_date = ?, updated_at = ? + WHERE id = ? AND status = ? """, ( ExperimentStatus.COMPLETED.value, @@ -1181,36 +1181,36 @@ class GrowthManager: template_type: EmailTemplateType, subject: str, html_content: str, - text_content: str = None, - variables: list[str] = None, - from_name: str = None, - from_email: str = None, - reply_to: str = None, + text_content: str = None, + variables: list[str] = None, + from_name: str = None, + from_email: str = None, + reply_to: str = None, ) -> EmailTemplate: """创建邮件模板""" - template_id = f"et_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + template_id = f"et_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() # 自动提取变量 if variables is None: - variables = re.findall(r"\{\{(\w+)\}\}", html_content) + variables = re.findall(r"\{\{(\w+)\}\}", html_content) - template = EmailTemplate( - id=template_id, - tenant_id=tenant_id, - name=name, - template_type=template_type, - subject=subject, - html_content=html_content, - text_content=text_content or re.sub(r"<[^>]+>", "", html_content), - variables=variables, - preview_text=None, - from_name=from_name or "InsightFlow", - from_email=from_email or "noreply@insightflow.io", - reply_to=reply_to, - is_active=True, - created_at=now, - updated_at=now, + template = EmailTemplate( + id = template_id, + tenant_id = tenant_id, + name = name, + template_type = template_type, + subject = subject, + html_content = html_content, + text_content = text_content or re.sub(r"<[^>]+>", "", html_content), + variables = variables, + preview_text = None, + from_name = from_name or "InsightFlow", + from_email = from_email or "noreply@insightflow.io", + reply_to = reply_to, + is_active = True, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -1245,8 +1245,8 @@ class GrowthManager: def get_email_template(self, template_id: str) -> EmailTemplate | None: """获取邮件模板""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM email_templates WHERE id = ?", (template_id,) + row = conn.execute( + "SELECT * FROM email_templates WHERE id = ?", (template_id, ) ).fetchone() if row: @@ -1254,37 +1254,37 @@ class GrowthManager: return None def list_email_templates( - self, tenant_id: str, template_type: EmailTemplateType = None + self, tenant_id: str, template_type: EmailTemplateType = None ) -> list[EmailTemplate]: """列出邮件模板""" - query = "SELECT * FROM email_templates WHERE tenant_id = ? AND is_active = 1" - params = [tenant_id] + query = "SELECT * FROM email_templates WHERE tenant_id = ? AND is_active = 1" + params = [tenant_id] if template_type: - query += " AND template_type = ?" + query += " AND template_type = ?" params.append(template_type.value) query += " ORDER BY created_at DESC" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_email_template(row) for row in rows] def render_template(self, template_id: str, variables: dict) -> dict[str, str]: """渲染邮件模板""" - template = self.get_email_template(template_id) + template = self.get_email_template(template_id) if not template: return None - subject = template.subject - html_content = template.html_content - text_content = template.text_content + subject = template.subject + html_content = template.html_content + text_content = template.text_content for key, value in variables.items(): - placeholder = f"{{{{{key}}}}}" - subject = subject.replace(placeholder, str(value)) - html_content = html_content.replace(placeholder, str(value)) - text_content = text_content.replace(placeholder, str(value)) + placeholder = f"{{{{{key}}}}}" + subject = subject.replace(placeholder, str(value)) + html_content = html_content.replace(placeholder, str(value)) + text_content = text_content.replace(placeholder, str(value)) return { "subject": subject, @@ -1301,29 +1301,29 @@ class GrowthManager: name: str, template_id: str, recipient_list: list[dict], - scheduled_at: datetime = None, + scheduled_at: datetime = None, ) -> EmailCampaign: """创建邮件营销活动""" - campaign_id = f"ec_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + campaign_id = f"ec_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - campaign = EmailCampaign( - id=campaign_id, - tenant_id=tenant_id, - name=name, - template_id=template_id, - status="draft", - recipient_count=len(recipient_list), - sent_count=0, - delivered_count=0, - opened_count=0, - clicked_count=0, - bounced_count=0, - failed_count=0, - scheduled_at=scheduled_at.isoformat() if scheduled_at else None, - started_at=None, - completed_at=None, - created_at=now, + campaign = EmailCampaign( + id = campaign_id, + tenant_id = tenant_id, + name = name, + template_id = template_id, + status = "draft", + recipient_count = len(recipient_list), + sent_count = 0, + delivered_count = 0, + opened_count = 0, + clicked_count = 0, + bounced_count = 0, + failed_count = 0, + scheduled_at = scheduled_at.isoformat() if scheduled_at else None, + started_at = None, + completed_at = None, + created_at = now, ) with self._get_db() as conn: @@ -1384,20 +1384,20 @@ class GrowthManager: self, campaign_id: str, user_id: str, email: str, template_id: str, variables: dict ) -> bool: """发送单封邮件""" - template = self.get_email_template(template_id) + template = self.get_email_template(template_id) if not template: return False - rendered = self.render_template(template_id, variables) + rendered = self.render_template(template_id, variables) # 更新状态为发送中 with self._get_db() as conn: - now = datetime.now().isoformat() + now = datetime.now().isoformat() conn.execute( """ UPDATE email_logs - SET status = ?, sent_at = ?, subject = ? - WHERE campaign_id = ? AND user_id = ? + SET status = ?, sent_at = ?, subject = ? + WHERE campaign_id = ? AND user_id = ? """, (EmailStatus.SENDING.value, now, rendered["subject"], campaign_id, user_id), ) @@ -1408,17 +1408,17 @@ class GrowthManager: # 目前使用模拟发送 await asyncio.sleep(0.1) - success = True # 模拟成功 + success = True # 模拟成功 # 更新状态 with self._get_db() as conn: - now = datetime.now().isoformat() + now = datetime.now().isoformat() if success: conn.execute( """ UPDATE email_logs - SET status = ?, delivered_at = ? - WHERE campaign_id = ? AND user_id = ? + SET status = ?, delivered_at = ? + WHERE campaign_id = ? AND user_id = ? """, (EmailStatus.DELIVERED.value, now, campaign_id, user_id), ) @@ -1426,8 +1426,8 @@ class GrowthManager: conn.execute( """ UPDATE email_logs - SET status = ?, error_message = ? - WHERE campaign_id = ? AND user_id = ? + SET status = ?, error_message = ? + WHERE campaign_id = ? AND user_id = ? """, (EmailStatus.FAILED.value, "Send failed", campaign_id, user_id), ) @@ -1440,8 +1440,8 @@ class GrowthManager: conn.execute( """ UPDATE email_logs - SET status = ?, error_message = ? - WHERE campaign_id = ? AND user_id = ? + SET status = ?, error_message = ? + WHERE campaign_id = ? AND user_id = ? """, (EmailStatus.FAILED.value, str(e), campaign_id, user_id), ) @@ -1451,37 +1451,37 @@ class GrowthManager: async def send_campaign(self, campaign_id: str) -> dict: """发送整个营销活动""" with self._get_db() as conn: - campaign_row = conn.execute( - "SELECT * FROM email_campaigns WHERE id = ?", (campaign_id,) + campaign_row = conn.execute( + "SELECT * FROM email_campaigns WHERE id = ?", (campaign_id, ) ).fetchone() if not campaign_row: return {"error": "Campaign not found"} # 获取待发送的邮件 - logs = conn.execute( + logs = conn.execute( """SELECT * FROM email_logs - WHERE campaign_id = ? AND status IN (?, ?)""", + WHERE campaign_id = ? AND status IN (?, ?)""", (campaign_id, EmailStatus.DRAFT.value, EmailStatus.SCHEDULED.value), ).fetchall() # 更新活动状态 - now = datetime.now().isoformat() + now = datetime.now().isoformat() conn.execute( - "UPDATE email_campaigns SET status = ?, started_at = ? WHERE id = ?", + "UPDATE email_campaigns SET status = ?, started_at = ? WHERE id = ?", ("sending", now, campaign_id), ) conn.commit() # 批量发送 - success_count = 0 - failed_count = 0 + success_count = 0 + failed_count = 0 for log in logs: # 获取用户变量 - variables = self._get_user_variables(log["tenant_id"], log["user_id"]) + variables = self._get_user_variables(log["tenant_id"], log["user_id"]) - success = await self.send_email( + success = await self.send_email( campaign_id, log["user_id"], log["email"], log["template_id"], variables ) @@ -1492,12 +1492,12 @@ class GrowthManager: # 更新活动状态 with self._get_db() as conn: - now = datetime.now().isoformat() + now = datetime.now().isoformat() conn.execute( """ UPDATE email_campaigns - SET status = ?, completed_at = ?, sent_count = ? - WHERE id = ? + SET status = ?, completed_at = ?, sent_count = ? + WHERE id = ? """, ("completed", now, success_count, campaign_id), ) @@ -1526,21 +1526,21 @@ class GrowthManager: actions: list[dict], ) -> AutomationWorkflow: """创建自动化工作流""" - workflow_id = f"aw_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + workflow_id = f"aw_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - workflow = AutomationWorkflow( - id=workflow_id, - tenant_id=tenant_id, - name=name, - description=description, - trigger_type=trigger_type, - trigger_conditions=trigger_conditions, - actions=actions, - is_active=True, - execution_count=0, - created_at=now, - updated_at=now, + workflow = AutomationWorkflow( + id = workflow_id, + tenant_id = tenant_id, + name = name, + description = description, + trigger_type = trigger_type, + trigger_conditions = trigger_conditions, + actions = actions, + is_active = True, + execution_count = 0, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -1569,17 +1569,17 @@ class GrowthManager: return workflow - async def trigger_workflow(self, workflow_id: str, event_data: dict): + async def trigger_workflow(self, workflow_id: str, event_data: dict) -> None: """触发自动化工作流""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM automation_workflows WHERE id = ? AND is_active = 1", (workflow_id,) + row = conn.execute( + "SELECT * FROM automation_workflows WHERE id = ? AND is_active = 1", (workflow_id, ) ).fetchone() if not row: return False - workflow = self._row_to_automation_workflow(row) + workflow = self._row_to_automation_workflow(row) # 检查触发条件 if not self._check_trigger_conditions(workflow.trigger_conditions, event_data): @@ -1591,8 +1591,8 @@ class GrowthManager: # 更新执行计数 conn.execute( - "UPDATE automation_workflows SET execution_count = execution_count + 1 WHERE id = ?", - (workflow_id,), + "UPDATE automation_workflows SET execution_count = execution_count + 1 WHERE id = ?", + (workflow_id, ), ) conn.commit() @@ -1606,9 +1606,9 @@ class GrowthManager: return False return True - async def _execute_action(self, action: dict, event_data: dict): + async def _execute_action(self, action: dict, event_data: dict) -> None: """执行工作流动作""" - action_type = action.get("type") + action_type = action.get("type") if action_type == "send_email": action.get("template_id") @@ -1631,29 +1631,29 @@ class GrowthManager: referrer_reward_value: float, referee_reward_type: str, referee_reward_value: float, - max_referrals_per_user: int = 10, - referral_code_length: int = 8, - expiry_days: int = 30, + max_referrals_per_user: int = 10, + referral_code_length: int = 8, + expiry_days: int = 30, ) -> ReferralProgram: """创建推荐计划""" - program_id = f"rp_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + program_id = f"rp_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - program = ReferralProgram( - id=program_id, - tenant_id=tenant_id, - name=name, - description=description, - referrer_reward_type=referrer_reward_type, - referrer_reward_value=referrer_reward_value, - referee_reward_type=referee_reward_type, - referee_reward_value=referee_reward_value, - max_referrals_per_user=max_referrals_per_user, - referral_code_length=referral_code_length, - expiry_days=expiry_days, - is_active=True, - created_at=now, - updated_at=now, + program = ReferralProgram( + id = program_id, + tenant_id = tenant_id, + name = name, + description = description, + referrer_reward_type = referrer_reward_type, + referrer_reward_value = referrer_reward_value, + referee_reward_type = referee_reward_type, + referee_reward_value = referee_reward_value, + max_referrals_per_user = max_referrals_per_user, + referral_code_length = referral_code_length, + expiry_days = expiry_days, + is_active = True, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -1688,15 +1688,15 @@ class GrowthManager: def generate_referral_code(self, program_id: str, referrer_id: str) -> Referral: """生成推荐码""" - program = self._get_referral_program(program_id) + program = self._get_referral_program(program_id) if not program: return None # 检查推荐次数限制 with self._get_db() as conn: - count_row = conn.execute( + count_row = conn.execute( """SELECT COUNT(*) as count FROM referrals - WHERE program_id = ? AND referrer_id = ? AND status != ?""", + WHERE program_id = ? AND referrer_id = ? AND status != ?""", (program_id, referrer_id, ReferralStatus.EXPIRED.value), ).fetchone() @@ -1704,28 +1704,28 @@ class GrowthManager: return None # 生成推荐码 - referral_code = self._generate_unique_code(program.referral_code_length) + referral_code = self._generate_unique_code(program.referral_code_length) - referral_id = f"ref_{uuid.uuid4().hex[:16]}" - now = datetime.now() - expires_at = now + timedelta(days=program.expiry_days) + referral_id = f"ref_{uuid.uuid4().hex[:16]}" + now = datetime.now() + expires_at = now + timedelta(days = program.expiry_days) - referral = Referral( - id=referral_id, - program_id=program_id, - tenant_id=program.tenant_id, - referrer_id=referrer_id, - referee_id=None, - referral_code=referral_code, - status=ReferralStatus.PENDING, - referrer_rewarded=False, - referee_rewarded=False, - referrer_reward_value=program.referrer_reward_value, - referee_reward_value=program.referee_reward_value, - converted_at=None, - rewarded_at=None, - expires_at=expires_at, - created_at=now, + referral = Referral( + id = referral_id, + program_id = program_id, + tenant_id = program.tenant_id, + referrer_id = referrer_id, + referee_id = None, + referral_code = referral_code, + status = ReferralStatus.PENDING, + referrer_rewarded = False, + referee_rewarded = False, + referrer_reward_value = program.referrer_reward_value, + referee_reward_value = program.referee_reward_value, + converted_at = None, + rewarded_at = None, + expires_at = expires_at, + created_at = now, ) conn.execute( @@ -1760,13 +1760,13 @@ class GrowthManager: def _generate_unique_code(self, length: int) -> str: """生成唯一推荐码""" - chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # 排除易混淆字符 + chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # 排除易混淆字符 while True: - code = "".join(random.choices(chars, k=length)) + code = "".join(random.choices(chars, k = length)) with self._get_db() as conn: - row = conn.execute( - "SELECT 1 FROM referrals WHERE referral_code = ?", (code,) + row = conn.execute( + "SELECT 1 FROM referrals WHERE referral_code = ?", (code, ) ).fetchone() if not row: @@ -1775,8 +1775,8 @@ class GrowthManager: def _get_referral_program(self, program_id: str) -> ReferralProgram | None: """获取推荐计划""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM referral_programs WHERE id = ?", (program_id,) + row = conn.execute( + "SELECT * FROM referral_programs WHERE id = ?", (program_id, ) ).fetchone() if row: @@ -1786,21 +1786,21 @@ class GrowthManager: def apply_referral_code(self, referral_code: str, referee_id: str) -> bool: """应用推荐码""" with self._get_db() as conn: - row = conn.execute( + row = conn.execute( """SELECT * FROM referrals - WHERE referral_code = ? AND status = ? AND expires_at > ?""", + WHERE referral_code = ? AND status = ? AND expires_at > ?""", (referral_code, ReferralStatus.PENDING.value, datetime.now().isoformat()), ).fetchone() if not row: return False - now = datetime.now().isoformat() + now = datetime.now().isoformat() conn.execute( """ UPDATE referrals - SET referee_id = ?, status = ?, converted_at = ? - WHERE id = ? + SET referee_id = ?, status = ?, converted_at = ? + WHERE id = ? """, (referee_id, ReferralStatus.CONVERTED.value, now, row["id"]), ) @@ -1811,17 +1811,17 @@ class GrowthManager: def reward_referral(self, referral_id: str) -> bool: """发放推荐奖励""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM referrals WHERE id = ?", (referral_id,)).fetchone() + row = conn.execute("SELECT * FROM referrals WHERE id = ?", (referral_id, )).fetchone() if not row or row["status"] != ReferralStatus.CONVERTED.value: return False - now = datetime.now().isoformat() + now = datetime.now().isoformat() conn.execute( """ UPDATE referrals - SET status = ?, referrer_rewarded = 1, referee_rewarded = 1, rewarded_at = ? - WHERE id = ? + SET status = ?, referrer_rewarded = 1, referee_rewarded = 1, rewarded_at = ? + WHERE id = ? """, (ReferralStatus.REWARDED.value, now, referral_id), ) @@ -1832,17 +1832,17 @@ class GrowthManager: def get_referral_stats(self, program_id: str) -> dict: """获取推荐统计""" with self._get_db() as conn: - stats = conn.execute( + stats = conn.execute( """ SELECT COUNT(*) as total_referrals, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as converted, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as rewarded, - SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as expired, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as converted, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as rewarded, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as expired, COUNT(DISTINCT referrer_id) as unique_referrers FROM referrals - WHERE program_id = ? + WHERE program_id = ? """, ( ReferralStatus.PENDING.value, @@ -1879,22 +1879,22 @@ class GrowthManager: valid_until: datetime, ) -> TeamIncentive: """创建团队升级激励""" - incentive_id = f"ti_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + incentive_id = f"ti_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - incentive = TeamIncentive( - id=incentive_id, - tenant_id=tenant_id, - name=name, - description=description, - target_tier=target_tier, - min_team_size=min_team_size, - incentive_type=incentive_type, - incentive_value=incentive_value, - valid_from=valid_from.isoformat(), - valid_until=valid_until.isoformat(), - is_active=True, - created_at=now, + incentive = TeamIncentive( + id = incentive_id, + tenant_id = tenant_id, + name = name, + description = description, + target_tier = target_tier, + min_team_size = min_team_size, + incentive_type = incentive_type, + incentive_value = incentive_value, + valid_from = valid_from.isoformat(), + valid_until = valid_until.isoformat(), + is_active = True, + created_at = now, ) with self._get_db() as conn: @@ -1929,12 +1929,12 @@ class GrowthManager: ) -> list[TeamIncentive]: """检查团队激励资格""" with self._get_db() as conn: - now = datetime.now().isoformat() - rows = conn.execute( + now = datetime.now().isoformat() + rows = conn.execute( """ SELECT * FROM team_incentives - WHERE tenant_id = ? AND is_active = 1 - AND target_tier = ? AND min_team_size <= ? + WHERE tenant_id = ? AND is_active = 1 + AND target_tier = ? AND min_team_size <= ? AND valid_from <= ? AND valid_until >= ? """, (tenant_id, current_tier, team_size, now, now), @@ -1946,41 +1946,41 @@ class GrowthManager: def get_realtime_dashboard(self, tenant_id: str) -> dict: """获取实时分析仪表板数据""" - now = datetime.now() - today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + now = datetime.now() + today_start = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0) with self._get_db() as conn: # 今日统计 - today_stats = conn.execute( + today_stats = conn.execute( """ SELECT COUNT(DISTINCT user_id) as active_users, COUNT(*) as total_events, COUNT(DISTINCT session_id) as sessions FROM analytics_events - WHERE tenant_id = ? AND timestamp >= ? + WHERE tenant_id = ? AND timestamp >= ? """, (tenant_id, today_start.isoformat()), ).fetchone() # 最近事件 - recent_events = conn.execute( + recent_events = conn.execute( """ SELECT event_name, event_type, timestamp, user_id FROM analytics_events - WHERE tenant_id = ? + WHERE tenant_id = ? ORDER BY timestamp DESC LIMIT 20 """, - (tenant_id,), + (tenant_id, ), ).fetchall() # 热门功能 - top_features = conn.execute( + top_features = conn.execute( """ SELECT event_name, COUNT(*) as count FROM analytics_events - WHERE tenant_id = ? AND timestamp >= ? AND event_type = ? + WHERE tenant_id = ? AND timestamp >= ? AND event_type = ? GROUP BY event_name ORDER BY count DESC LIMIT 10 @@ -1989,16 +1989,16 @@ class GrowthManager: ).fetchall() # 活跃用户趋势(最近24小时,每小时) - hourly_trend = [] + hourly_trend = [] for i in range(24): - hour_start = now - timedelta(hours=i + 1) - hour_end = now - timedelta(hours=i) + hour_start = now - timedelta(hours = i + 1) + hour_end = now - timedelta(hours = i) - row = conn.execute( + row = conn.execute( """ SELECT COUNT(DISTINCT user_id) as count FROM analytics_events - WHERE tenant_id = ? AND timestamp >= ? AND timestamp < ? + WHERE tenant_id = ? AND timestamp >= ? AND timestamp < ? """, (tenant_id, hour_start.isoformat(), hour_end.isoformat()), ).fetchone() @@ -2035,125 +2035,125 @@ class GrowthManager: def _row_to_user_profile(self, row) -> UserProfile: """将数据库行转换为 UserProfile""" return UserProfile( - id=row["id"], - tenant_id=row["tenant_id"], - user_id=row["user_id"], - first_seen=datetime.fromisoformat(row["first_seen"]), - last_seen=datetime.fromisoformat(row["last_seen"]), - total_sessions=row["total_sessions"], - total_events=row["total_events"], - feature_usage=json.loads(row["feature_usage"]), - subscription_history=json.loads(row["subscription_history"]), - ltv=row["ltv"], - churn_risk_score=row["churn_risk_score"], - engagement_score=row["engagement_score"], - created_at=datetime.fromisoformat(row["created_at"]), - updated_at=datetime.fromisoformat(row["updated_at"]), + id = row["id"], + tenant_id = row["tenant_id"], + user_id = row["user_id"], + first_seen = datetime.fromisoformat(row["first_seen"]), + last_seen = datetime.fromisoformat(row["last_seen"]), + total_sessions = row["total_sessions"], + total_events = row["total_events"], + feature_usage = json.loads(row["feature_usage"]), + subscription_history = json.loads(row["subscription_history"]), + ltv = row["ltv"], + churn_risk_score = row["churn_risk_score"], + engagement_score = row["engagement_score"], + created_at = datetime.fromisoformat(row["created_at"]), + updated_at = datetime.fromisoformat(row["updated_at"]), ) def _row_to_experiment(self, row) -> Experiment: """将数据库行转换为 Experiment""" return Experiment( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - description=row["description"], - hypothesis=row["hypothesis"], - status=ExperimentStatus(row["status"]), - variants=json.loads(row["variants"]), - traffic_allocation=TrafficAllocationType(row["traffic_allocation"]), - traffic_split=json.loads(row["traffic_split"]), - target_audience=json.loads(row["target_audience"]), - primary_metric=row["primary_metric"], - secondary_metrics=json.loads(row["secondary_metrics"]), - start_date=datetime.fromisoformat(row["start_date"]) if row["start_date"] else None, - end_date=datetime.fromisoformat(row["end_date"]) if row["end_date"] else None, - min_sample_size=row["min_sample_size"], - confidence_level=row["confidence_level"], - created_at=row["created_at"], - updated_at=row["updated_at"], - created_by=row["created_by"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + description = row["description"], + hypothesis = row["hypothesis"], + status = ExperimentStatus(row["status"]), + variants = json.loads(row["variants"]), + traffic_allocation = TrafficAllocationType(row["traffic_allocation"]), + traffic_split = json.loads(row["traffic_split"]), + target_audience = json.loads(row["target_audience"]), + primary_metric = row["primary_metric"], + secondary_metrics = json.loads(row["secondary_metrics"]), + start_date = datetime.fromisoformat(row["start_date"]) if row["start_date"] else None, + end_date = datetime.fromisoformat(row["end_date"]) if row["end_date"] else None, + min_sample_size = row["min_sample_size"], + confidence_level = row["confidence_level"], + created_at = row["created_at"], + updated_at = row["updated_at"], + created_by = row["created_by"], ) def _row_to_email_template(self, row) -> EmailTemplate: """将数据库行转换为 EmailTemplate""" return EmailTemplate( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - template_type=EmailTemplateType(row["template_type"]), - subject=row["subject"], - html_content=row["html_content"], - text_content=row["text_content"], - variables=json.loads(row["variables"]), - preview_text=row["preview_text"], - from_name=row["from_name"], - from_email=row["from_email"], - reply_to=row["reply_to"], - is_active=bool(row["is_active"]), - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + template_type = EmailTemplateType(row["template_type"]), + subject = row["subject"], + html_content = row["html_content"], + text_content = row["text_content"], + variables = json.loads(row["variables"]), + preview_text = row["preview_text"], + from_name = row["from_name"], + from_email = row["from_email"], + reply_to = row["reply_to"], + is_active = bool(row["is_active"]), + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_automation_workflow(self, row) -> AutomationWorkflow: """将数据库行转换为 AutomationWorkflow""" return AutomationWorkflow( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - description=row["description"], - trigger_type=WorkflowTriggerType(row["trigger_type"]), - trigger_conditions=json.loads(row["trigger_conditions"]), - actions=json.loads(row["actions"]), - is_active=bool(row["is_active"]), - execution_count=row["execution_count"], - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + description = row["description"], + trigger_type = WorkflowTriggerType(row["trigger_type"]), + trigger_conditions = json.loads(row["trigger_conditions"]), + actions = json.loads(row["actions"]), + is_active = bool(row["is_active"]), + execution_count = row["execution_count"], + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_referral_program(self, row) -> ReferralProgram: """将数据库行转换为 ReferralProgram""" return ReferralProgram( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - description=row["description"], - referrer_reward_type=row["referrer_reward_type"], - referrer_reward_value=row["referrer_reward_value"], - referee_reward_type=row["referee_reward_type"], - referee_reward_value=row["referee_reward_value"], - max_referrals_per_user=row["max_referrals_per_user"], - referral_code_length=row["referral_code_length"], - expiry_days=row["expiry_days"], - is_active=bool(row["is_active"]), - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + description = row["description"], + referrer_reward_type = row["referrer_reward_type"], + referrer_reward_value = row["referrer_reward_value"], + referee_reward_type = row["referee_reward_type"], + referee_reward_value = row["referee_reward_value"], + max_referrals_per_user = row["max_referrals_per_user"], + referral_code_length = row["referral_code_length"], + expiry_days = row["expiry_days"], + is_active = bool(row["is_active"]), + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_team_incentive(self, row) -> TeamIncentive: """将数据库行转换为 TeamIncentive""" return TeamIncentive( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - description=row["description"], - target_tier=row["target_tier"], - min_team_size=row["min_team_size"], - incentive_type=row["incentive_type"], - incentive_value=row["incentive_value"], - valid_from=datetime.fromisoformat(row["valid_from"]), - valid_until=datetime.fromisoformat(row["valid_until"]), - is_active=bool(row["is_active"]), - created_at=row["created_at"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + description = row["description"], + target_tier = row["target_tier"], + min_team_size = row["min_team_size"], + incentive_type = row["incentive_type"], + incentive_value = row["incentive_value"], + valid_from = datetime.fromisoformat(row["valid_from"]), + valid_until = datetime.fromisoformat(row["valid_until"]), + is_active = bool(row["is_active"]), + created_at = row["created_at"], ) # Singleton instance -_growth_manager = None +_growth_manager = None def get_growth_manager() -> GrowthManager: global _growth_manager if _growth_manager is None: - _growth_manager = GrowthManager() + _growth_manager = GrowthManager() return _growth_manager diff --git a/backend/image_processor.py b/backend/image_processor.py index e34c59b..c14950f 100644 --- a/backend/image_processor.py +++ b/backend/image_processor.py @@ -11,30 +11,30 @@ import uuid from dataclasses import dataclass # Constants -UUID_LENGTH = 8 # UUID 截断长度 +UUID_LENGTH = 8 # UUID 截断长度 # 尝试导入图像处理库 try: from PIL import Image, ImageEnhance, ImageFilter - PIL_AVAILABLE = True + PIL_AVAILABLE = True except ImportError: - PIL_AVAILABLE = False + PIL_AVAILABLE = False try: import cv2 import numpy as np - CV2_AVAILABLE = True + CV2_AVAILABLE = True except ImportError: - CV2_AVAILABLE = False + CV2_AVAILABLE = False try: import pytesseract - PYTESSERACT_AVAILABLE = True + PYTESSERACT_AVAILABLE = True except ImportError: - PYTESSERACT_AVAILABLE = False + PYTESSERACT_AVAILABLE = False @dataclass @@ -44,7 +44,7 @@ class ImageEntity: name: str type: str confidence: float - bbox: tuple[int, int, int, int] | None = None # (x, y, width, height) + bbox: tuple[int, int, int, int] | None = None # (x, y, width, height) @dataclass @@ -70,7 +70,7 @@ class ImageProcessingResult: width: int height: int success: bool - error_message: str = "" + error_message: str = "" @dataclass @@ -87,7 +87,7 @@ class ImageProcessor: """图片处理器 - 处理各种类型图片""" # 图片类型定义 - IMAGE_TYPES = { + IMAGE_TYPES = { "whiteboard": "白板", "ppt": "PPT/演示文稿", "handwritten": "手写笔记", @@ -96,17 +96,17 @@ class ImageProcessor: "other": "其他", } - def __init__(self, temp_dir: str = None) -> None: + def __init__(self, temp_dir: str = None) -> None: """ 初始化图片处理器 Args: temp_dir: 临时文件目录 """ - self.temp_dir = temp_dir or os.path.join(os.getcwd(), "temp", "images") - os.makedirs(self.temp_dir, exist_ok=True) + self.temp_dir = temp_dir or os.path.join(os.getcwd(), "temp", "images") + os.makedirs(self.temp_dir, exist_ok = True) - def preprocess_image(self, image, image_type: str = None) -> None: + def preprocess_image(self, image, image_type: str = None) -> None: """ 预处理图片以提高OCR质量 @@ -123,25 +123,25 @@ class ImageProcessor: try: # 转换为RGB(如果是RGBA) if image.mode == "RGBA": - image = image.convert("RGB") + image = image.convert("RGB") # 根据图片类型进行针对性处理 if image_type == "whiteboard": # 白板:增强对比度,去除背景 - image = self._enhance_whiteboard(image) + image = self._enhance_whiteboard(image) elif image_type == "handwritten": # 手写笔记:降噪,增强对比度 - image = self._enhance_handwritten(image) + image = self._enhance_handwritten(image) elif image_type == "screenshot": # 截图:轻微锐化 - image = image.filter(ImageFilter.SHARPEN) + image = image.filter(ImageFilter.SHARPEN) # 通用处理:调整大小(如果太大) - max_size = 4096 + max_size = 4096 if max(image.size) > max_size: - ratio = max_size / max(image.size) - new_size = (int(image.size[0] * ratio), int(image.size[1] * ratio)) - image = image.resize(new_size, Image.Resampling.LANCZOS) + ratio = max_size / max(image.size) + new_size = (int(image.size[0] * ratio), int(image.size[1] * ratio)) + image = image.resize(new_size, Image.Resampling.LANCZOS) return image except Exception as e: @@ -151,33 +151,33 @@ class ImageProcessor: def _enhance_whiteboard(self, image) -> None: """增强白板图片""" # 转换为灰度 - gray = image.convert("L") + gray = image.convert("L") # 增强对比度 - enhancer = ImageEnhance.Contrast(gray) - enhanced = enhancer.enhance(2.0) + enhancer = ImageEnhance.Contrast(gray) + enhanced = enhancer.enhance(2.0) # 二值化 - threshold = 128 - binary = enhanced.point(lambda x: 0 if x < threshold else 255, "1") + threshold = 128 + binary = enhanced.point(lambda x: 0 if x < threshold else 255, "1") return binary.convert("L") def _enhance_handwritten(self, image) -> None: """增强手写笔记图片""" # 转换为灰度 - gray = image.convert("L") + gray = image.convert("L") # 轻微降噪 - blurred = gray.filter(ImageFilter.GaussianBlur(radius=1)) + blurred = gray.filter(ImageFilter.GaussianBlur(radius = 1)) # 增强对比度 - enhancer = ImageEnhance.Contrast(blurred) - enhanced = enhancer.enhance(1.5) + enhancer = ImageEnhance.Contrast(blurred) + enhanced = enhancer.enhance(1.5) return enhanced - def detect_image_type(self, image, ocr_text: str = "") -> str: + def detect_image_type(self, image, ocr_text: str = "") -> str: """ 自动检测图片类型 @@ -193,8 +193,8 @@ class ImageProcessor: try: # 基于图片特征和OCR内容判断类型 - width, height = image.size - aspect_ratio = width / height + width, height = image.size + aspect_ratio = width / height # 检测是否为PPT(通常是16:9或4:3) if 1.3 <= aspect_ratio <= 1.8: @@ -204,12 +204,12 @@ class ImageProcessor: # 检测是否为白板(大量手写文字,可能有箭头、框等) if CV2_AVAILABLE: - img_array = np.array(image.convert("RGB")) - gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY) + img_array = np.array(image.convert("RGB")) + gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY) # 检测边缘(白板通常有很多线条) - edges = cv2.Canny(gray, 50, 150) - edge_ratio = np.sum(edges > 0) / edges.size + edges = cv2.Canny(gray, 50, 150) + edge_ratio = np.sum(edges > 0) / edges.size # 如果边缘比例高,可能是白板 if edge_ratio > 0.05 and len(ocr_text) > 50: @@ -236,7 +236,7 @@ class ImageProcessor: print(f"Image type detection error: {e}") return "other" - def perform_ocr(self, image, lang: str = "chi_sim+eng") -> tuple[str, float]: + def perform_ocr(self, image, lang: str = "chi_sim+eng") -> tuple[str, float]: """ 对图片进行OCR识别 @@ -252,15 +252,15 @@ class ImageProcessor: try: # 预处理图片 - processed_image = self.preprocess_image(image) + processed_image = self.preprocess_image(image) # 执行OCR - text = pytesseract.image_to_string(processed_image, lang=lang) + text = pytesseract.image_to_string(processed_image, lang = lang) # 获取置信度 - data = pytesseract.image_to_data(processed_image, output_type=pytesseract.Output.DICT) - confidences = [int(c) for c in data["conf"] if int(c) > 0] - avg_confidence = sum(confidences) / len(confidences) if confidences else 0 + data = pytesseract.image_to_data(processed_image, output_type = pytesseract.Output.DICT) + confidences = [int(c) for c in data["conf"] if int(c) > 0] + avg_confidence = sum(confidences) / len(confidences) if confidences else 0 return text.strip(), avg_confidence / 100.0 except Exception as e: @@ -277,26 +277,26 @@ class ImageProcessor: Returns: 实体列表 """ - entities = [] + entities = [] # 简单的实体提取规则(可以替换为LLM调用) # 提取大写字母开头的词组(可能是专有名词) import re # 项目名称(通常是大写或带引号) - project_pattern = r'["\']([^"\']+)["\']|([A-Z][a-zA-Z0-9]*(?:\s+[A-Z][a-zA-Z0-9]*)+)' + project_pattern = r'["\']([^"\']+)["\']|([A-Z][a-zA-Z0-9]*(?:\s+[A-Z][a-zA-Z0-9]*)+)' for match in re.finditer(project_pattern, text): - name = match.group(1) or match.group(2) + name = match.group(1) or match.group(2) if name and len(name) > 2: - entities.append(ImageEntity(name=name.strip(), type="PROJECT", confidence=0.7)) + entities.append(ImageEntity(name = name.strip(), type = "PROJECT", confidence = 0.7)) # 人名(中文) - name_pattern = r"([\u4e00-\u9fa5]{2,4})(?:先生|女士|总|经理|工程师|老师)" + name_pattern = r"([\u4e00-\u9fa5]{2, 4})(?:先生|女士|总|经理|工程师|老师)" for match in re.finditer(name_pattern, text): - entities.append(ImageEntity(name=match.group(1), type="PERSON", confidence=0.8)) + entities.append(ImageEntity(name = match.group(1), type = "PERSON", confidence = 0.8)) # 技术术语 - tech_keywords = [ + tech_keywords = [ "K8s", "Kubernetes", "Docker", @@ -314,13 +314,13 @@ class ImageProcessor: ] for keyword in tech_keywords: if keyword in text: - entities.append(ImageEntity(name=keyword, type="TECH", confidence=0.9)) + entities.append(ImageEntity(name = keyword, type = "TECH", confidence = 0.9)) # 去重 - seen = set() - unique_entities = [] + seen = set() + unique_entities = [] for e in entities: - key = (e.name.lower(), e.type) + key = (e.name.lower(), e.type) if key not in seen: seen.add(key) unique_entities.append(e) @@ -341,19 +341,19 @@ class ImageProcessor: Returns: 图片描述 """ - type_name = self.IMAGE_TYPES.get(image_type, "图片") + type_name = self.IMAGE_TYPES.get(image_type, "图片") - description_parts = [f"这是一张{type_name}图片。"] + description_parts = [f"这是一张{type_name}图片。"] if ocr_text: # 提取前200字符作为摘要 - text_preview = ocr_text[:200].replace("\n", " ") + text_preview = ocr_text[:200].replace("\n", " ") if len(ocr_text) > 200: text_preview += "..." description_parts.append(f"内容摘要:{text_preview}") if entities: - entity_names = [e.name for e in entities[:5]] # 最多显示5个实体 + entity_names = [e.name for e in entities[:5]] # 最多显示5个实体 description_parts.append(f"识别到的关键实体:{', '.join(entity_names)}") return " ".join(description_parts) @@ -361,9 +361,9 @@ class ImageProcessor: def process_image( self, image_data: bytes, - filename: str = None, - image_id: str = None, - detect_type: bool = True, + filename: str = None, + image_id: str = None, + detect_type: bool = True, ) -> ImageProcessingResult: """ 处理单张图片 @@ -377,73 +377,73 @@ class ImageProcessor: Returns: 图片处理结果 """ - image_id = image_id or str(uuid.uuid4())[:UUID_LENGTH] + image_id = image_id or str(uuid.uuid4())[:UUID_LENGTH] if not PIL_AVAILABLE: return ImageProcessingResult( - image_id=image_id, - image_type="other", - ocr_text="", - description="PIL not available", - entities=[], - relations=[], - width=0, - height=0, - success=False, - error_message="PIL library not available", + image_id = image_id, + image_type = "other", + ocr_text = "", + description = "PIL not available", + entities = [], + relations = [], + width = 0, + height = 0, + success = False, + error_message = "PIL library not available", ) try: # 加载图片 - image = Image.open(io.BytesIO(image_data)) - width, height = image.size + image = Image.open(io.BytesIO(image_data)) + width, height = image.size # 执行OCR - ocr_text, ocr_confidence = self.perform_ocr(image) + ocr_text, ocr_confidence = self.perform_ocr(image) # 检测图片类型 - image_type = "other" + image_type = "other" if detect_type: - image_type = self.detect_image_type(image, ocr_text) + image_type = self.detect_image_type(image, ocr_text) # 提取实体 - entities = self.extract_entities_from_text(ocr_text) + entities = self.extract_entities_from_text(ocr_text) # 生成描述 - description = self.generate_description(image_type, ocr_text, entities) + description = self.generate_description(image_type, ocr_text, entities) # 提取关系(基于实体共现) - relations = self._extract_relations(entities, ocr_text) + relations = self._extract_relations(entities, ocr_text) # 保存图片文件(可选) if filename: - save_path = os.path.join(self.temp_dir, f"{image_id}_{filename}") + save_path = os.path.join(self.temp_dir, f"{image_id}_{filename}") image.save(save_path) return ImageProcessingResult( - image_id=image_id, - image_type=image_type, - ocr_text=ocr_text, - description=description, - entities=entities, - relations=relations, - width=width, - height=height, - success=True, + image_id = image_id, + image_type = image_type, + ocr_text = ocr_text, + description = description, + entities = entities, + relations = relations, + width = width, + height = height, + success = True, ) except Exception as e: return ImageProcessingResult( - image_id=image_id, - image_type="other", - ocr_text="", - description="", - entities=[], - relations=[], - width=0, - height=0, - success=False, - error_message=str(e), + image_id = image_id, + image_type = "other", + ocr_text = "", + description = "", + entities = [], + relations = [], + width = 0, + height = 0, + success = False, + error_message = str(e), ) def _extract_relations(self, entities: list[ImageEntity], text: str) -> list[ImageRelation]: @@ -457,16 +457,16 @@ class ImageProcessor: Returns: 关系列表 """ - relations = [] + relations = [] if len(entities) < 2: return relations # 简单的关系提取:如果两个实体在同一句子中出现,则认为它们相关 - sentences = text.replace("。", ".").replace("!", "!").replace("?", "?").split(".") + sentences = text.replace("。", ".").replace("!", "!").replace("?", "?").split(".") for sentence in sentences: - sentence_entities = [] + sentence_entities = [] for entity in entities: if entity.name in sentence: sentence_entities.append(entity) @@ -477,17 +477,17 @@ class ImageProcessor: for j in range(i + 1, len(sentence_entities)): relations.append( ImageRelation( - source=sentence_entities[i].name, - target=sentence_entities[j].name, - relation_type="related", - confidence=0.5, + source = sentence_entities[i].name, + target = sentence_entities[j].name, + relation_type = "related", + confidence = 0.5, ) ) return relations def process_batch( - self, images_data: list[tuple[bytes, str]], project_id: str = None + self, images_data: list[tuple[bytes, str]], project_id: str = None ) -> BatchProcessingResult: """ 批量处理图片 @@ -499,12 +499,12 @@ class ImageProcessor: Returns: 批量处理结果 """ - results = [] - success_count = 0 - failed_count = 0 + results = [] + success_count = 0 + failed_count = 0 for image_data, filename in images_data: - result = self.process_image(image_data, filename) + result = self.process_image(image_data, filename) results.append(result) if result.success: @@ -513,10 +513,10 @@ class ImageProcessor: failed_count += 1 return BatchProcessingResult( - results=results, - total_count=len(results), - success_count=success_count, - failed_count=failed_count, + results = results, + total_count = len(results), + success_count = success_count, + failed_count = failed_count, ) def image_to_base64(self, image_data: bytes) -> str: @@ -531,7 +531,7 @@ class ImageProcessor: """ return base64.b64encode(image_data).decode("utf-8") - def get_image_thumbnail(self, image_data: bytes, size: tuple[int, int] = (200, 200)) -> bytes: + def get_image_thumbnail(self, image_data: bytes, size: tuple[int, int] = (200, 200)) -> bytes: """ 生成图片缩略图 @@ -546,11 +546,11 @@ class ImageProcessor: return image_data try: - image = Image.open(io.BytesIO(image_data)) + image = Image.open(io.BytesIO(image_data)) image.thumbnail(size, Image.Resampling.LANCZOS) - buffer = io.BytesIO() - image.save(buffer, format="JPEG") + buffer = io.BytesIO() + image.save(buffer, format = "JPEG") return buffer.getvalue() except Exception as e: print(f"Thumbnail generation error: {e}") @@ -558,12 +558,12 @@ class ImageProcessor: # Singleton instance -_image_processor = None +_image_processor = None -def get_image_processor(temp_dir: str = None) -> ImageProcessor: +def get_image_processor(temp_dir: str = None) -> ImageProcessor: """获取图片处理器单例""" global _image_processor if _image_processor is None: - _image_processor = ImageProcessor(temp_dir) + _image_processor = ImageProcessor(temp_dir) return _image_processor diff --git a/backend/init_db.py b/backend/init_db.py index 7cd7778..db80146 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -4,27 +4,27 @@ import os import sqlite3 -db_path = os.path.join(os.path.dirname(__file__), "insightflow.db") -schema_path = os.path.join(os.path.dirname(__file__), "schema.sql") +db_path = os.path.join(os.path.dirname(__file__), "insightflow.db") +schema_path = os.path.join(os.path.dirname(__file__), "schema.sql") print(f"Database path: {db_path}") print(f"Schema path: {schema_path}") # Read schema with open(schema_path) as f: - schema = f.read() + schema = f.read() # Execute schema -conn = sqlite3.connect(db_path) -cursor = conn.cursor() +conn = sqlite3.connect(db_path) +cursor = conn.cursor() # Split schema by semicolons and execute each statement -statements = schema.split(";") -success_count = 0 -error_count = 0 +statements = schema.split(";") +success_count = 0 +error_count = 0 for stmt in statements: - stmt = stmt.strip() + stmt = stmt.strip() if stmt: try: cursor.execute(stmt) diff --git a/backend/knowledge_reasoner.py b/backend/knowledge_reasoner.py index 7924d08..beb7e2c 100644 --- a/backend/knowledge_reasoner.py +++ b/backend/knowledge_reasoner.py @@ -12,18 +12,18 @@ from enum import Enum import httpx -KIMI_API_KEY = os.getenv("KIMI_API_KEY", "") -KIMI_BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.kimi.com/coding") +KIMI_API_KEY = os.getenv("KIMI_API_KEY", "") +KIMI_BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.kimi.com/coding") class ReasoningType(Enum): """推理类型""" - CAUSAL = "causal" # 因果推理 - ASSOCIATIVE = "associative" # 关联推理 - TEMPORAL = "temporal" # 时序推理 - COMPARATIVE = "comparative" # 对比推理 - SUMMARY = "summary" # 总结推理 + CAUSAL = "causal" # 因果推理 + ASSOCIATIVE = "associative" # 关联推理 + TEMPORAL = "temporal" # 时序推理 + COMPARATIVE = "comparative" # 对比推理 + SUMMARY = "summary" # 总结推理 @dataclass @@ -51,38 +51,38 @@ class InferencePath: class KnowledgeReasoner: """知识推理引擎""" - def __init__(self, api_key: str = None, base_url: str = None): - self.api_key = api_key or KIMI_API_KEY - self.base_url = base_url or KIMI_BASE_URL - self.headers = { + def __init__(self, api_key: str = None, base_url: str = None) -> None: + self.api_key = api_key or KIMI_API_KEY + self.base_url = base_url or KIMI_BASE_URL + self.headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", } - async def _call_llm(self, prompt: str, temperature: float = 0.3) -> str: + async def _call_llm(self, prompt: str, temperature: float = 0.3) -> str: """调用 LLM""" if not self.api_key: raise ValueError("KIMI_API_KEY not set") - payload = { + payload = { "model": "k2p5", "messages": [{"role": "user", "content": prompt}], "temperature": temperature, } async with httpx.AsyncClient() as client: - response = await client.post( + response = await client.post( f"{self.base_url}/v1/chat/completions", - headers=self.headers, - json=payload, - timeout=120.0, + headers = self.headers, + json = payload, + timeout = 120.0, ) response.raise_for_status() - result = response.json() + result = response.json() return result["choices"][0]["message"]["content"] async def enhanced_qa( - self, query: str, project_context: dict, graph_data: dict, reasoning_depth: str = "medium" + self, query: str, project_context: dict, graph_data: dict, reasoning_depth: str = "medium" ) -> ReasoningResult: """ 增强问答 - 结合图谱推理的问答 @@ -94,7 +94,7 @@ class KnowledgeReasoner: reasoning_depth: 推理深度 (shallow/medium/deep) """ # 1. 分析问题类型 - analysis = await self._analyze_question(query) + analysis = await self._analyze_question(query) # 2. 根据问题类型选择推理策略 if analysis["type"] == "causal": @@ -108,7 +108,7 @@ class KnowledgeReasoner: async def _analyze_question(self, query: str) -> dict: """分析问题类型和意图""" - prompt = f"""分析以下问题的类型和意图: + prompt = f"""分析以下问题的类型和意图: 问题:{query} @@ -127,9 +127,9 @@ class KnowledgeReasoner: - factual: 事实类问题(是什么、有哪些) - opinion: 观点类问题(怎么看、态度、评价)""" - content = await self._call_llm(prompt, temperature=0.1) + content = await self._call_llm(prompt, temperature = 0.1) - json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) + json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) if json_match: try: return json.loads(json_match.group()) @@ -144,10 +144,10 @@ class KnowledgeReasoner: """因果推理 - 分析原因和影响""" # 构建因果分析提示 - entities_str = json.dumps(graph_data.get("entities", []), ensure_ascii=False, indent=2) - relations_str = json.dumps(graph_data.get("relations", []), ensure_ascii=False, indent=2) + entities_str = json.dumps(graph_data.get("entities", []), ensure_ascii = False, indent = 2) + relations_str = json.dumps(graph_data.get("relations", []), ensure_ascii = False, indent = 2) - prompt = f"""基于以下知识图谱进行因果推理分析: + prompt = f"""基于以下知识图谱进行因果推理分析: ## 问题 {query} @@ -159,7 +159,7 @@ class KnowledgeReasoner: {relations_str[:2000]} ## 项目上下文 -{json.dumps(project_context, ensure_ascii=False, indent=2)[:1500]} +{json.dumps(project_context, ensure_ascii = False, indent = 2)[:1500]} 请进行因果分析,返回 JSON 格式: {{ @@ -172,31 +172,31 @@ class KnowledgeReasoner: "knowledge_gaps": ["缺失信息1"] }}""" - content = await self._call_llm(prompt, temperature=0.3) + content = await self._call_llm(prompt, temperature = 0.3) - json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) + json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) if json_match: try: - data = json.loads(json_match.group()) + data = json.loads(json_match.group()) return ReasoningResult( - answer=data.get("answer", ""), - reasoning_type=ReasoningType.CAUSAL, - confidence=data.get("confidence", 0.7), - evidence=[{"text": e} for e in data.get("evidence", [])], - related_entities=[], - gaps=data.get("knowledge_gaps", []), + answer = data.get("answer", ""), + reasoning_type = ReasoningType.CAUSAL, + confidence = data.get("confidence", 0.7), + evidence = [{"text": e} for e in data.get("evidence", [])], + related_entities = [], + gaps = data.get("knowledge_gaps", []), ) except (json.JSONDecodeError, KeyError): pass return ReasoningResult( - answer=content, - reasoning_type=ReasoningType.CAUSAL, - confidence=0.5, - evidence=[], - related_entities=[], - gaps=["无法完成因果推理"], + answer = content, + reasoning_type = ReasoningType.CAUSAL, + confidence = 0.5, + evidence = [], + related_entities = [], + gaps = ["无法完成因果推理"], ) async def _comparative_reasoning( @@ -204,16 +204,16 @@ class KnowledgeReasoner: ) -> ReasoningResult: """对比推理 - 比较实体间的异同""" - prompt = f"""基于以下知识图谱进行对比分析: + prompt = f"""基于以下知识图谱进行对比分析: ## 问题 {query} ## 实体 -{json.dumps(graph_data.get("entities", []), ensure_ascii=False, indent=2)[:2000]} +{json.dumps(graph_data.get("entities", []), ensure_ascii = False, indent = 2)[:2000]} ## 关系 -{json.dumps(graph_data.get("relations", []), ensure_ascii=False, indent=2)[:1500]} +{json.dumps(graph_data.get("relations", []), ensure_ascii = False, indent = 2)[:1500]} 请进行对比分析,返回 JSON 格式: {{ @@ -226,31 +226,31 @@ class KnowledgeReasoner: "knowledge_gaps": [] }}""" - content = await self._call_llm(prompt, temperature=0.3) + content = await self._call_llm(prompt, temperature = 0.3) - json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) + json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) if json_match: try: - data = json.loads(json_match.group()) + data = json.loads(json_match.group()) return ReasoningResult( - answer=data.get("answer", ""), - reasoning_type=ReasoningType.COMPARATIVE, - confidence=data.get("confidence", 0.7), - evidence=[{"text": e} for e in data.get("evidence", [])], - related_entities=[], - gaps=data.get("knowledge_gaps", []), + answer = data.get("answer", ""), + reasoning_type = ReasoningType.COMPARATIVE, + confidence = data.get("confidence", 0.7), + evidence = [{"text": e} for e in data.get("evidence", [])], + related_entities = [], + gaps = data.get("knowledge_gaps", []), ) except (json.JSONDecodeError, KeyError): pass return ReasoningResult( - answer=content, - reasoning_type=ReasoningType.COMPARATIVE, - confidence=0.5, - evidence=[], - related_entities=[], - gaps=[], + answer = content, + reasoning_type = ReasoningType.COMPARATIVE, + confidence = 0.5, + evidence = [], + related_entities = [], + gaps = [], ) async def _temporal_reasoning( @@ -258,16 +258,16 @@ class KnowledgeReasoner: ) -> ReasoningResult: """时序推理 - 分析时间线和演变""" - prompt = f"""基于以下知识图谱进行时序分析: + prompt = f"""基于以下知识图谱进行时序分析: ## 问题 {query} ## 项目时间线 -{json.dumps(project_context.get("timeline", []), ensure_ascii=False, indent=2)[:2000]} +{json.dumps(project_context.get("timeline", []), ensure_ascii = False, indent = 2)[:2000]} ## 实体提及历史 -{json.dumps(graph_data.get("entities", []), ensure_ascii=False, indent=2)[:1500]} +{json.dumps(graph_data.get("entities", []), ensure_ascii = False, indent = 2)[:1500]} 请进行时序分析,返回 JSON 格式: {{ @@ -280,31 +280,31 @@ class KnowledgeReasoner: "knowledge_gaps": [] }}""" - content = await self._call_llm(prompt, temperature=0.3) + content = await self._call_llm(prompt, temperature = 0.3) - json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) + json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) if json_match: try: - data = json.loads(json_match.group()) + data = json.loads(json_match.group()) return ReasoningResult( - answer=data.get("answer", ""), - reasoning_type=ReasoningType.TEMPORAL, - confidence=data.get("confidence", 0.7), - evidence=[{"text": e} for e in data.get("evidence", [])], - related_entities=[], - gaps=data.get("knowledge_gaps", []), + answer = data.get("answer", ""), + reasoning_type = ReasoningType.TEMPORAL, + confidence = data.get("confidence", 0.7), + evidence = [{"text": e} for e in data.get("evidence", [])], + related_entities = [], + gaps = data.get("knowledge_gaps", []), ) except (json.JSONDecodeError, KeyError): pass return ReasoningResult( - answer=content, - reasoning_type=ReasoningType.TEMPORAL, - confidence=0.5, - evidence=[], - related_entities=[], - gaps=[], + answer = content, + reasoning_type = ReasoningType.TEMPORAL, + confidence = 0.5, + evidence = [], + related_entities = [], + gaps = [], ) async def _associative_reasoning( @@ -312,16 +312,16 @@ class KnowledgeReasoner: ) -> ReasoningResult: """关联推理 - 发现实体间的隐含关联""" - prompt = f"""基于以下知识图谱进行关联分析: + prompt = f"""基于以下知识图谱进行关联分析: ## 问题 {query} ## 实体 -{json.dumps(graph_data.get("entities", [])[:20], ensure_ascii=False, indent=2)} +{json.dumps(graph_data.get("entities", [])[:20], ensure_ascii = False, indent = 2)} ## 关系 -{json.dumps(graph_data.get("relations", [])[:30], ensure_ascii=False, indent=2)} +{json.dumps(graph_data.get("relations", [])[:30], ensure_ascii = False, indent = 2)} 请进行关联推理,发现隐含联系,返回 JSON 格式: {{ @@ -334,52 +334,52 @@ class KnowledgeReasoner: "knowledge_gaps": [] }}""" - content = await self._call_llm(prompt, temperature=0.4) + content = await self._call_llm(prompt, temperature = 0.4) - json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) + json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) if json_match: try: - data = json.loads(json_match.group()) + data = json.loads(json_match.group()) return ReasoningResult( - answer=data.get("answer", ""), - reasoning_type=ReasoningType.ASSOCIATIVE, - confidence=data.get("confidence", 0.7), - evidence=[{"text": e} for e in data.get("evidence", [])], - related_entities=[], - gaps=data.get("knowledge_gaps", []), + answer = data.get("answer", ""), + reasoning_type = ReasoningType.ASSOCIATIVE, + confidence = data.get("confidence", 0.7), + evidence = [{"text": e} for e in data.get("evidence", [])], + related_entities = [], + gaps = data.get("knowledge_gaps", []), ) except (json.JSONDecodeError, KeyError): pass return ReasoningResult( - answer=content, - reasoning_type=ReasoningType.ASSOCIATIVE, - confidence=0.5, - evidence=[], - related_entities=[], - gaps=[], + answer = content, + reasoning_type = ReasoningType.ASSOCIATIVE, + confidence = 0.5, + evidence = [], + related_entities = [], + gaps = [], ) def find_inference_paths( - self, start_entity: str, end_entity: str, graph_data: dict, max_depth: int = 3 + self, start_entity: str, end_entity: str, graph_data: dict, max_depth: int = 3 ) -> list[InferencePath]: """ 发现两个实体之间的推理路径 使用 BFS 在关系图中搜索路径 """ - relations = graph_data.get("relations", []) + relations = graph_data.get("relations", []) # 构建邻接表 - adj = {} + adj = {} for r in relations: - src = r.get("source_id") or r.get("source") - tgt = r.get("target_id") or r.get("target") + src = r.get("source_id") or r.get("source") + tgt = r.get("target_id") or r.get("target") if src not in adj: - adj[src] = [] + adj[src] = [] if tgt not in adj: - adj[tgt] = [] + adj[tgt] = [] adj[src].append({"target": tgt, "relation": r.get("type", "related"), "data": r}) # 无向图也添加反向 adj[tgt].append( @@ -389,21 +389,21 @@ class KnowledgeReasoner: # BFS 搜索路径 from collections import deque - paths = [] - queue = deque([(start_entity, [{"entity": start_entity, "relation": None}])]) + paths = [] + queue = deque([(start_entity, [{"entity": start_entity, "relation": None}])]) {start_entity} while queue and len(paths) < 5: - current, path = queue.popleft() + current, path = queue.popleft() if current == end_entity and len(path) > 1: # 找到一条路径 paths.append( InferencePath( - start_entity=start_entity, - end_entity=end_entity, - path=path, - strength=self._calculate_path_strength(path), + start_entity = start_entity, + end_entity = end_entity, + path = path, + strength = self._calculate_path_strength(path), ) ) continue @@ -412,9 +412,9 @@ class KnowledgeReasoner: continue for neighbor in adj.get(current, []): - next_entity = neighbor["target"] + next_entity = neighbor["target"] if next_entity not in [p["entity"] for p in path]: # 避免循环 - new_path = path + [ + new_path = path + [ { "entity": next_entity, "relation": neighbor["relation"], @@ -424,7 +424,7 @@ class KnowledgeReasoner: queue.append((next_entity, new_path)) # 按强度排序 - paths.sort(key=lambda p: p.strength, reverse=True) + paths.sort(key = lambda p: p.strength, reverse = True) return paths def _calculate_path_strength(self, path: list[dict]) -> float: @@ -433,23 +433,23 @@ class KnowledgeReasoner: return 0.0 # 路径越短越强 - length_factor = 1.0 / len(path) + length_factor = 1.0 / len(path) # 关系置信度 - confidence_sum = 0 - confidence_count = 0 + confidence_sum = 0 + confidence_count = 0 for node in path[1:]: # 跳过第一个节点 - rel_data = node.get("relation_data", {}) + rel_data = node.get("relation_data", {}) if "confidence" in rel_data: confidence_sum += rel_data["confidence"] confidence_count += 1 - confidence_factor = (confidence_sum / confidence_count) if confidence_count > 0 else 0.5 + confidence_factor = (confidence_sum / confidence_count) if confidence_count > 0 else 0.5 return length_factor * confidence_factor async def summarize_project( - self, project_context: dict, graph_data: dict, summary_type: str = "comprehensive" + self, project_context: dict, graph_data: dict, summary_type: str = "comprehensive" ) -> dict: """ 项目智能总结 @@ -457,17 +457,17 @@ class KnowledgeReasoner: Args: summary_type: comprehensive/executive/technical/risk """ - type_prompts = { + type_prompts = { "comprehensive": "全面总结项目的所有方面", "executive": "高管摘要,关注关键决策和风险", "technical": "技术总结,关注架构和技术栈", "risk": "风险分析,关注潜在问题和依赖", } - prompt = f"""请对以下项目进行{type_prompts.get(summary_type, "全面总结")}: + prompt = f"""请对以下项目进行{type_prompts.get(summary_type, "全面总结")}: ## 项目信息 -{json.dumps(project_context, ensure_ascii=False, indent=2)[:3000]} +{json.dumps(project_context, ensure_ascii = False, indent = 2)[:3000]} ## 知识图谱 实体数: {len(graph_data.get("entities", []))} @@ -483,9 +483,9 @@ class KnowledgeReasoner: "confidence": 0.85 }}""" - content = await self._call_llm(prompt, temperature=0.3) + content = await self._call_llm(prompt, temperature = 0.3) - json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) + json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) if json_match: try: @@ -504,11 +504,11 @@ class KnowledgeReasoner: # Singleton instance -_reasoner = None +_reasoner = None def get_knowledge_reasoner() -> KnowledgeReasoner: global _reasoner if _reasoner is None: - _reasoner = KnowledgeReasoner() + _reasoner = KnowledgeReasoner() return _reasoner diff --git a/backend/llm_client.py b/backend/llm_client.py index 82a2991..368ffed 100644 --- a/backend/llm_client.py +++ b/backend/llm_client.py @@ -12,8 +12,8 @@ from dataclasses import dataclass import httpx -KIMI_API_KEY = os.getenv("KIMI_API_KEY", "") -KIMI_BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.kimi.com/coding") +KIMI_API_KEY = os.getenv("KIMI_API_KEY", "") +KIMI_BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.kimi.com/coding") @dataclass @@ -41,22 +41,22 @@ class RelationExtractionResult: class LLMClient: """Kimi API 客户端""" - def __init__(self, api_key: str = None, base_url: str = None): - self.api_key = api_key or KIMI_API_KEY - self.base_url = base_url or KIMI_BASE_URL - self.headers = { + def __init__(self, api_key: str = None, base_url: str = None) -> None: + self.api_key = api_key or KIMI_API_KEY + self.base_url = base_url or KIMI_BASE_URL + self.headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", } async def chat( - self, messages: list[ChatMessage], temperature: float = 0.3, stream: bool = False + self, messages: list[ChatMessage], temperature: float = 0.3, stream: bool = False ) -> str: """发送聊天请求""" if not self.api_key: raise ValueError("KIMI_API_KEY not set") - payload = { + payload = { "model": "k2p5", "messages": [{"role": m.role, "content": m.content} for m in messages], "temperature": temperature, @@ -64,24 +64,24 @@ class LLMClient: } async with httpx.AsyncClient() as client: - response = await client.post( + response = await client.post( f"{self.base_url}/v1/chat/completions", - headers=self.headers, - json=payload, - timeout=120.0, + headers = self.headers, + json = payload, + timeout = 120.0, ) response.raise_for_status() - result = response.json() + result = response.json() return result["choices"][0]["message"]["content"] async def chat_stream( - self, messages: list[ChatMessage], temperature: float = 0.3 + self, messages: list[ChatMessage], temperature: float = 0.3 ) -> AsyncGenerator[str, None]: """流式聊天请求""" if not self.api_key: raise ValueError("KIMI_API_KEY not set") - payload = { + payload = { "model": "k2p5", "messages": [{"role": m.role, "content": m.content} for m in messages], "temperature": temperature, @@ -92,19 +92,19 @@ class LLMClient: async with client.stream( "POST", f"{self.base_url}/v1/chat/completions", - headers=self.headers, - json=payload, - timeout=120.0, + headers = self.headers, + json = payload, + timeout = 120.0, ) as response: response.raise_for_status() async for line in response.aiter_lines(): if line.startswith("data: "): - data = line[6:] + data = line[6:] if data == "[DONE]": break try: - chunk = json.loads(data) - delta = chunk["choices"][0]["delta"] + chunk = json.loads(data) + delta = chunk["choices"][0]["delta"] if "content" in delta: yield delta["content"] except (json.JSONDecodeError, KeyError, IndexError): @@ -114,7 +114,7 @@ class LLMClient: self, text: str ) -> tuple[list[EntityExtractionResult], list[RelationExtractionResult]]: """提取实体和关系,带置信度分数""" - prompt = f"""从以下会议文本中提取关键实体和它们之间的关系,以 JSON 格式返回: + prompt = f"""从以下会议文本中提取关键实体和它们之间的关系,以 JSON 格式返回: 文本:{text[:3000]} @@ -139,30 +139,30 @@ class LLMClient: ] }}""" - messages = [ChatMessage(role="user", content=prompt)] - content = await self.chat(messages, temperature=0.1) + messages = [ChatMessage(role = "user", content = prompt)] + content = await self.chat(messages, temperature = 0.1) - json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) + json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) if not json_match: return [], [] try: - data = json.loads(json_match.group()) - entities = [ + data = json.loads(json_match.group()) + entities = [ EntityExtractionResult( - name=e["name"], - type=e.get("type", "OTHER"), - definition=e.get("definition", ""), - confidence=e.get("confidence", 0.8), + name = e["name"], + type = e.get("type", "OTHER"), + definition = e.get("definition", ""), + confidence = e.get("confidence", 0.8), ) for e in data.get("entities", []) ] - relations = [ + relations = [ RelationExtractionResult( - source=r["source"], - target=r["target"], - type=r.get("type", "related"), - confidence=r.get("confidence", 0.8), + source = r["source"], + target = r["target"], + type = r.get("type", "related"), + confidence = r.get("confidence", 0.8), ) for r in data.get("relations", []) ] @@ -173,10 +173,10 @@ class LLMClient: async def rag_query(self, query: str, context: str, project_context: dict) -> str: """RAG 问答 - 基于项目上下文回答问题""" - prompt = f"""你是一个专业的项目分析助手。基于以下项目信息回答问题: + prompt = f"""你是一个专业的项目分析助手。基于以下项目信息回答问题: ## 项目信息 -{json.dumps(project_context, ensure_ascii=False, indent=2)} +{json.dumps(project_context, ensure_ascii = False, indent = 2)} ## 相关上下文 {context[:4000]} @@ -186,21 +186,21 @@ class LLMClient: 请用中文回答,保持简洁专业。如果信息不足,请明确说明。""" - messages = [ + messages = [ ChatMessage( - role="system", content="你是一个专业的项目分析助手,擅长从会议记录中提取洞察。" + role = "system", content = "你是一个专业的项目分析助手,擅长从会议记录中提取洞察。" ), - ChatMessage(role="user", content=prompt), + ChatMessage(role = "user", content = prompt), ] - return await self.chat(messages, temperature=0.3) + return await self.chat(messages, temperature = 0.3) async def agent_command(self, command: str, project_context: dict) -> dict: """Agent 指令解析 - 将自然语言指令转换为结构化操作""" - prompt = f"""解析以下用户指令,转换为结构化操作: + prompt = f"""解析以下用户指令,转换为结构化操作: ## 项目信息 -{json.dumps(project_context, ensure_ascii=False, indent=2)} +{json.dumps(project_context, ensure_ascii = False, indent = 2)} ## 用户指令 {command} @@ -221,10 +221,10 @@ class LLMClient: - create_relation: 创建关系,params 包含 source(源实体), target(目标实体), relation_type(关系类型) """ - messages = [ChatMessage(role="user", content=prompt)] - content = await self.chat(messages, temperature=0.1) + messages = [ChatMessage(role = "user", content = prompt)] + content = await self.chat(messages, temperature = 0.1) - json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) + json_match = re.search(r"\{{.*?\}}", content, re.DOTALL) if not json_match: return {"intent": "unknown", "explanation": "无法解析指令"} @@ -235,14 +235,14 @@ class LLMClient: async def analyze_entity_evolution(self, entity_name: str, mentions: list[dict]) -> str: """分析实体在项目中的演变/态度变化""" - mentions_text = "\n".join( + mentions_text = "\n".join( [ f"[{m.get('created_at', '未知时间')}] {m.get('text_snippet', '')}" for m in mentions[:20] ] # 限制数量 ) - prompt = f"""分析实体 "{entity_name}" 在项目中的演变和态度变化: + prompt = f"""分析实体 "{entity_name}" 在项目中的演变和态度变化: ## 提及记录 {mentions_text} @@ -255,16 +255,16 @@ class LLMClient: 用中文回答,结构清晰。""" - messages = [ChatMessage(role="user", content=prompt)] - return await self.chat(messages, temperature=0.3) + messages = [ChatMessage(role = "user", content = prompt)] + return await self.chat(messages, temperature = 0.3) # Singleton instance -_llm_client = None +_llm_client = None def get_llm_client() -> LLMClient: global _llm_client if _llm_client is None: - _llm_client = LLMClient() + _llm_client = LLMClient() return _llm_client diff --git a/backend/localization_manager.py b/backend/localization_manager.py index 6325c31..344a95f 100644 --- a/backend/localization_manager.py +++ b/backend/localization_manager.py @@ -22,90 +22,90 @@ from typing import Any try: import pytz - PYTZ_AVAILABLE = True + PYTZ_AVAILABLE = True except ImportError: - PYTZ_AVAILABLE = False + PYTZ_AVAILABLE = False try: from babel import Locale, dates, numbers - BABEL_AVAILABLE = True + BABEL_AVAILABLE = True except ImportError: - BABEL_AVAILABLE = False + BABEL_AVAILABLE = False -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) class LanguageCode(StrEnum): """支持的语言代码""" - EN = "en" - ZH_CN = "zh_CN" - ZH_TW = "zh_TW" - JA = "ja" - KO = "ko" - DE = "de" - FR = "fr" - ES = "es" - PT = "pt" - RU = "ru" - AR = "ar" - HI = "hi" + EN = "en" + ZH_CN = "zh_CN" + ZH_TW = "zh_TW" + JA = "ja" + KO = "ko" + DE = "de" + FR = "fr" + ES = "es" + PT = "pt" + RU = "ru" + AR = "ar" + HI = "hi" class RegionCode(StrEnum): """区域代码""" - GLOBAL = "global" - NORTH_AMERICA = "na" - EUROPE = "eu" - ASIA_PACIFIC = "apac" - CHINA = "cn" - LATIN_AMERICA = "latam" - MIDDLE_EAST = "me" + GLOBAL = "global" + NORTH_AMERICA = "na" + EUROPE = "eu" + ASIA_PACIFIC = "apac" + CHINA = "cn" + LATIN_AMERICA = "latam" + MIDDLE_EAST = "me" class DataCenterRegion(StrEnum): """数据中心区域""" - US_EAST = "us-east" - US_WEST = "us-west" - EU_WEST = "eu-west" - EU_CENTRAL = "eu-central" - AP_SOUTHEAST = "ap-southeast" - AP_NORTHEAST = "ap-northeast" - AP_SOUTH = "ap-south" - CN_NORTH = "cn-north" - CN_EAST = "cn-east" + US_EAST = "us-east" + US_WEST = "us-west" + EU_WEST = "eu-west" + EU_CENTRAL = "eu-central" + AP_SOUTHEAST = "ap-southeast" + AP_NORTHEAST = "ap-northeast" + AP_SOUTH = "ap-south" + CN_NORTH = "cn-north" + CN_EAST = "cn-east" class PaymentProvider(StrEnum): """支付提供商""" - STRIPE = "stripe" - ALIPAY = "alipay" - WECHAT_PAY = "wechat_pay" - PAYPAL = "paypal" - APPLE_PAY = "apple_pay" - GOOGLE_PAY = "google_pay" - KLARNA = "klarna" - IDEAL = "ideal" - BANCONTACT = "bancontact" - GIROPAY = "giropay" - SEPA = "sepa" - UNIONPAY = "unionpay" + STRIPE = "stripe" + ALIPAY = "alipay" + WECHAT_PAY = "wechat_pay" + PAYPAL = "paypal" + APPLE_PAY = "apple_pay" + GOOGLE_PAY = "google_pay" + KLARNA = "klarna" + IDEAL = "ideal" + BANCONTACT = "bancontact" + GIROPAY = "giropay" + SEPA = "sepa" + UNIONPAY = "unionpay" class CalendarType(StrEnum): """日历类型""" - GREGORIAN = "gregorian" - CHINESE_LUNAR = "chinese_lunar" - ISLAMIC = "islamic" - HEBREW = "hebrew" - INDIAN = "indian" - PERSIAN = "persian" - BUDDHIST = "buddhist" + GREGORIAN = "gregorian" + CHINESE_LUNAR = "chinese_lunar" + ISLAMIC = "islamic" + HEBREW = "hebrew" + INDIAN = "indian" + PERSIAN = "persian" + BUDDHIST = "buddhist" @dataclass @@ -252,7 +252,7 @@ class LocalizationSettings: class LocalizationManager: - DEFAULT_LANGUAGES = { + DEFAULT_LANGUAGES = { LanguageCode.EN: { "name": "English", "name_local": "English", @@ -260,8 +260,8 @@ class LocalizationManager: "date_format": "MM/dd/yyyy", "time_format": "h:mm a", "datetime_format": "MM/dd/yyyy h:mm a", - "number_format": "#,##0.##", - "currency_format": "$#,##0.00", + "number_format": "#, ##0.##", + "currency_format": "$#, ##0.00", "first_day_of_week": 0, "calendar_type": CalendarType.GREGORIAN.value, }, @@ -272,8 +272,8 @@ class LocalizationManager: "date_format": "yyyy-MM-dd", "time_format": "HH:mm", "datetime_format": "yyyy-MM-dd HH:mm", - "number_format": "#,##0.##", - "currency_format": "¥#,##0.00", + "number_format": "#, ##0.##", + "currency_format": "¥#, ##0.00", "first_day_of_week": 1, "calendar_type": CalendarType.GREGORIAN.value, }, @@ -284,8 +284,8 @@ class LocalizationManager: "date_format": "yyyy/MM/dd", "time_format": "HH:mm", "datetime_format": "yyyy/MM/dd HH:mm", - "number_format": "#,##0.##", - "currency_format": "NT$#,##0.00", + "number_format": "#, ##0.##", + "currency_format": "NT$#, ##0.00", "first_day_of_week": 0, "calendar_type": CalendarType.GREGORIAN.value, }, @@ -296,8 +296,8 @@ class LocalizationManager: "date_format": "yyyy/MM/dd", "time_format": "HH:mm", "datetime_format": "yyyy/MM/dd HH:mm", - "number_format": "#,##0.##", - "currency_format": "¥#,##0", + "number_format": "#, ##0.##", + "currency_format": "¥#, ##0", "first_day_of_week": 0, "calendar_type": CalendarType.GREGORIAN.value, }, @@ -308,8 +308,8 @@ class LocalizationManager: "date_format": "yyyy. MM. dd", "time_format": "HH:mm", "datetime_format": "yyyy. MM. dd HH:mm", - "number_format": "#,##0.##", - "currency_format": "₩#,##0", + "number_format": "#, ##0.##", + "currency_format": "₩#, ##0", "first_day_of_week": 0, "calendar_type": CalendarType.GREGORIAN.value, }, @@ -320,8 +320,8 @@ class LocalizationManager: "date_format": "dd.MM.yyyy", "time_format": "HH:mm", "datetime_format": "dd.MM.yyyy HH:mm", - "number_format": "#,##0.##", - "currency_format": "#,##0.00 €", + "number_format": "#, ##0.##", + "currency_format": "#, ##0.00 €", "first_day_of_week": 1, "calendar_type": CalendarType.GREGORIAN.value, }, @@ -332,8 +332,8 @@ class LocalizationManager: "date_format": "dd/MM/yyyy", "time_format": "HH:mm", "datetime_format": "dd/MM/yyyy HH:mm", - "number_format": "#,##0.##", - "currency_format": "#,##0.00 €", + "number_format": "#, ##0.##", + "currency_format": "#, ##0.00 €", "first_day_of_week": 1, "calendar_type": CalendarType.GREGORIAN.value, }, @@ -344,8 +344,8 @@ class LocalizationManager: "date_format": "dd/MM/yyyy", "time_format": "HH:mm", "datetime_format": "dd/MM/yyyy HH:mm", - "number_format": "#,##0.##", - "currency_format": "#,##0.00 €", + "number_format": "#, ##0.##", + "currency_format": "#, ##0.00 €", "first_day_of_week": 1, "calendar_type": CalendarType.GREGORIAN.value, }, @@ -356,8 +356,8 @@ class LocalizationManager: "date_format": "dd/MM/yyyy", "time_format": "HH:mm", "datetime_format": "dd/MM/yyyy HH:mm", - "number_format": "#,##0.##", - "currency_format": "R$#,##0.00", + "number_format": "#, ##0.##", + "currency_format": "R$#, ##0.00", "first_day_of_week": 0, "calendar_type": CalendarType.GREGORIAN.value, }, @@ -368,8 +368,8 @@ class LocalizationManager: "date_format": "dd.MM.yyyy", "time_format": "HH:mm", "datetime_format": "dd.MM.yyyy HH:mm", - "number_format": "#,##0.##", - "currency_format": "#,##0.00 ₽", + "number_format": "#, ##0.##", + "currency_format": "#, ##0.00 ₽", "first_day_of_week": 1, "calendar_type": CalendarType.GREGORIAN.value, }, @@ -380,8 +380,8 @@ class LocalizationManager: "date_format": "dd/MM/yyyy", "time_format": "hh:mm a", "datetime_format": "dd/MM/yyyy hh:mm a", - "number_format": "#,##0.##", - "currency_format": "#,##0.00 ر.س", + "number_format": "#, ##0.##", + "currency_format": "#, ##0.00 ر.س", "first_day_of_week": 6, "calendar_type": CalendarType.ISLAMIC.value, }, @@ -392,14 +392,14 @@ class LocalizationManager: "date_format": "dd/MM/yyyy", "time_format": "hh:mm a", "datetime_format": "dd/MM/yyyy hh:mm a", - "number_format": "#,##0.##", - "currency_format": "₹#,##0.00", + "number_format": "#, ##0.##", + "currency_format": "₹#, ##0.00", "first_day_of_week": 0, "calendar_type": CalendarType.INDIAN.value, }, } - DEFAULT_DATA_CENTERS = { + DEFAULT_DATA_CENTERS = { DataCenterRegion.US_EAST: { "name": "US East (Virginia)", "location": "Virginia, USA", @@ -474,7 +474,7 @@ class LocalizationManager: }, } - DEFAULT_PAYMENT_METHODS = { + DEFAULT_PAYMENT_METHODS = { PaymentProvider.STRIPE: { "name": "Credit Card", "name_local": { @@ -572,7 +572,7 @@ class LocalizationManager: }, } - DEFAULT_COUNTRIES = { + DEFAULT_COUNTRIES = { "US": { "name": "United States", "name_local": {"en": "United States"}, @@ -719,31 +719,31 @@ class LocalizationManager: }, } - def __init__(self, db_path: str = "insightflow.db"): - self.db_path = db_path - self._is_memory_db = db_path == ":memory:" - self._conn = None + def __init__(self, db_path: str = "insightflow.db") -> None: + self.db_path = db_path + self._is_memory_db = db_path == ":memory:" + self._conn = None self._init_db() self._init_default_data() def _get_connection(self) -> sqlite3.Connection: if self._is_memory_db: if self._conn is None: - self._conn = sqlite3.connect(self.db_path) - self._conn.row_factory = sqlite3.Row + self._conn = sqlite3.connect(self.db_path) + self._conn.row_factory = sqlite3.Row return self._conn - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn - def _close_if_file_db(self, conn): + def _close_if_file_db(self, conn) -> None: if not self._is_memory_db: conn.close() - def _init_db(self): - conn = self._get_connection() + def _init_db(self) -> None: + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS translations ( id TEXT PRIMARY KEY, key TEXT NOT NULL, language TEXT NOT NULL, @@ -813,7 +813,7 @@ class LocalizationManager: CREATE TABLE IF NOT EXISTS currency_configs ( code TEXT PRIMARY KEY, name TEXT NOT NULL, name_local TEXT DEFAULT '{}', symbol TEXT NOT NULL, decimal_places INTEGER DEFAULT 2, decimal_separator TEXT DEFAULT '.', - thousands_separator TEXT DEFAULT ',', is_active INTEGER DEFAULT 1 + thousands_separator TEXT DEFAULT ', ', is_active INTEGER DEFAULT 1 ) """) cursor.execute(""" @@ -863,10 +863,10 @@ class LocalizationManager: finally: self._close_if_file_db(conn) - def _init_default_data(self): - conn = self._get_connection() + def _init_default_data(self) -> None: + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() for code, config in self.DEFAULT_LANGUAGES.items(): cursor.execute( """ @@ -894,7 +894,7 @@ class LocalizationManager: ), ) for region_code, config in self.DEFAULT_DATA_CENTERS.items(): - dc_id = str(uuid.uuid4()) + dc_id = str(uuid.uuid4()) cursor.execute( """ INSERT OR IGNORE INTO data_centers @@ -913,7 +913,7 @@ class LocalizationManager: ), ) for provider, config in self.DEFAULT_PAYMENT_METHODS.items(): - pm_id = str(uuid.uuid4()) + pm_id = str(uuid.uuid4()) cursor.execute( """ INSERT OR IGNORE INTO localized_payment_methods @@ -963,20 +963,20 @@ class LocalizationManager: self._close_if_file_db(conn) def get_translation( - self, key: str, language: str, namespace: str = "common", fallback: bool = True + self, key: str, language: str, namespace: str = "common", fallback: bool = True ) -> str | None: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( - "SELECT value FROM translations WHERE key = ? AND language = ? AND namespace = ?", + "SELECT value FROM translations WHERE key = ? AND language = ? AND namespace = ?", (key, language, namespace), ) - row = cursor.fetchone() + row = cursor.fetchone() if row: return row["value"] if fallback: - lang_config = self.get_language_config(language) + lang_config = self.get_language_config(language) if lang_config and lang_config.fallback_language: return self.get_translation( key, lang_config.fallback_language, namespace, False @@ -992,24 +992,24 @@ class LocalizationManager: key: str, language: str, value: str, - namespace: str = "common", - context: str | None = None, + namespace: str = "common", + context: str | None = None, ) -> Translation: - conn = self._get_connection() + conn = self._get_connection() try: - translation_id = str(uuid.uuid4()) - now = datetime.now() - cursor = conn.cursor() + translation_id = str(uuid.uuid4()) + now = datetime.now() + cursor = conn.cursor() cursor.execute( """ INSERT INTO translations (id, key, language, value, namespace, context, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(key, language, namespace) DO UPDATE SET - value = excluded.value, - context = excluded.context, - updated_at = excluded.updated_at, - is_reviewed = 0 + value = excluded.value, + context = excluded.context, + updated_at = excluded.updated_at, + is_reviewed = 0 """, (translation_id, key, language, value, namespace, context, now, now), ) @@ -1021,22 +1021,22 @@ class LocalizationManager: def _get_translation_internal( self, conn: sqlite3.Connection, key: str, language: str, namespace: str ) -> Translation | None: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( - "SELECT * FROM translations WHERE key = ? AND language = ? AND namespace = ?", + "SELECT * FROM translations WHERE key = ? AND language = ? AND namespace = ?", (key, language, namespace), ) - row = cursor.fetchone() + row = cursor.fetchone() if row: return self._row_to_translation(row) return None - def delete_translation(self, key: str, language: str, namespace: str = "common") -> bool: - conn = self._get_connection() + def delete_translation(self, key: str, language: str, namespace: str = "common") -> bool: + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( - "DELETE FROM translations WHERE key = ? AND language = ? AND namespace = ?", + "DELETE FROM translations WHERE key = ? AND language = ? AND namespace = ?", (key, language, namespace), ) conn.commit() @@ -1046,62 +1046,62 @@ class LocalizationManager: def list_translations( self, - language: str | None = None, - namespace: str | None = None, - limit: int = 1000, - offset: int = 0, + language: str | None = None, + namespace: str | None = None, + limit: int = 1000, + offset: int = 0, ) -> list[Translation]: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - query = "SELECT * FROM translations WHERE 1=1" - params = [] + cursor = conn.cursor() + query = "SELECT * FROM translations WHERE 1 = 1" + params = [] if language: - query += " AND language = ?" + query += " AND language = ?" params.append(language) if namespace: - query += " AND namespace = ?" + query += " AND namespace = ?" params.append(namespace) query += " ORDER BY namespace, key LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_translation(row) for row in rows] finally: self._close_if_file_db(conn) def get_language_config(self, code: str) -> LanguageConfig | None: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM language_configs WHERE code = ?", (code,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM language_configs WHERE code = ?", (code, )) + row = cursor.fetchone() if row: return self._row_to_language_config(row) return None finally: self._close_if_file_db(conn) - def list_language_configs(self, active_only: bool = True) -> list[LanguageConfig]: - conn = self._get_connection() + def list_language_configs(self, active_only: bool = True) -> list[LanguageConfig]: + conn = self._get_connection() try: - cursor = conn.cursor() - query = "SELECT * FROM language_configs" + cursor = conn.cursor() + query = "SELECT * FROM language_configs" if active_only: - query += " WHERE is_active = 1" + query += " WHERE is_active = 1" query += " ORDER BY name" cursor.execute(query) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_language_config(row) for row in rows] finally: self._close_if_file_db(conn) def get_data_center(self, dc_id: str) -> DataCenter | None: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM data_centers WHERE id = ?", (dc_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM data_centers WHERE id = ?", (dc_id, )) + row = cursor.fetchone() if row: return self._row_to_data_center(row) return None @@ -1109,11 +1109,11 @@ class LocalizationManager: self._close_if_file_db(conn) def get_data_center_by_region(self, region_code: str) -> DataCenter | None: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM data_centers WHERE region_code = ?", (region_code,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM data_centers WHERE region_code = ?", (region_code, )) + row = cursor.fetchone() if row: return self._row_to_data_center(row) return None @@ -1121,34 +1121,34 @@ class LocalizationManager: self._close_if_file_db(conn) def list_data_centers( - self, status: str | None = None, region: str | None = None + self, status: str | None = None, region: str | None = None ) -> list[DataCenter]: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - query = "SELECT * FROM data_centers WHERE 1=1" - params = [] + cursor = conn.cursor() + query = "SELECT * FROM data_centers WHERE 1 = 1" + params = [] if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status) if region: query += " AND supported_regions LIKE ?" params.append(f'%"{region}"%') query += " ORDER BY priority" cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_data_center(row) for row in rows] finally: self._close_if_file_db(conn) def get_tenant_data_center(self, tenant_id: str) -> TenantDataCenterMapping | None: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( - "SELECT * FROM tenant_data_center_mappings WHERE tenant_id = ?", (tenant_id,) + "SELECT * FROM tenant_data_center_mappings WHERE tenant_id = ?", (tenant_id, ) ) - row = cursor.fetchone() + row = cursor.fetchone() if row: return self._row_to_tenant_dc_mapping(row) return None @@ -1156,38 +1156,38 @@ class LocalizationManager: self._close_if_file_db(conn) def set_tenant_data_center( - self, tenant_id: str, region_code: str, data_residency: str = "regional" + self, tenant_id: str, region_code: str, data_residency: str = "regional" ) -> TenantDataCenterMapping: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ - SELECT * FROM data_centers WHERE supported_regions LIKE ? AND status = 'active' + SELECT * FROM data_centers WHERE supported_regions LIKE ? AND status = 'active' ORDER BY priority LIMIT 1 """, - (f'%"{region_code}"%',), + (f'%"{region_code}"%', ), ) - row = cursor.fetchone() + row = cursor.fetchone() if not row: cursor.execute(""" - SELECT * FROM data_centers WHERE supported_regions LIKE '%"global"%' AND status = 'active' + SELECT * FROM data_centers WHERE supported_regions LIKE '%"global"%' AND status = 'active' ORDER BY priority LIMIT 1 """) - row = cursor.fetchone() + row = cursor.fetchone() if not row: raise ValueError(f"No data center available for region: {region_code}") - primary_dc_id = row["id"] + primary_dc_id = row["id"] cursor.execute( """ - SELECT * FROM data_centers WHERE id != ? AND status = 'active' ORDER BY priority LIMIT 1 + SELECT * FROM data_centers WHERE id != ? AND status = 'active' ORDER BY priority LIMIT 1 """, - (primary_dc_id,), + (primary_dc_id, ), ) - secondary_row = cursor.fetchone() - secondary_dc_id = secondary_row["id"] if secondary_row else None - mapping_id = str(uuid.uuid4()) - now = datetime.now() + secondary_row = cursor.fetchone() + secondary_dc_id = secondary_row["id"] if secondary_row else None + mapping_id = str(uuid.uuid4()) + now = datetime.now() cursor.execute( """ INSERT INTO tenant_data_center_mappings @@ -1195,11 +1195,11 @@ class LocalizationManager: data_residency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(tenant_id) DO UPDATE SET - primary_dc_id = excluded.primary_dc_id, - secondary_dc_id = excluded.secondary_dc_id, - region_code = excluded.region_code, - data_residency = excluded.data_residency, - updated_at = excluded.updated_at + primary_dc_id = excluded.primary_dc_id, + secondary_dc_id = excluded.secondary_dc_id, + region_code = excluded.region_code, + data_residency = excluded.data_residency, + updated_at = excluded.updated_at """, ( mapping_id, @@ -1218,13 +1218,13 @@ class LocalizationManager: self._close_if_file_db(conn) def get_payment_method(self, provider: str) -> LocalizedPaymentMethod | None: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( - "SELECT * FROM localized_payment_methods WHERE provider = ?", (provider,) + "SELECT * FROM localized_payment_methods WHERE provider = ?", (provider, ) ) - row = cursor.fetchone() + row = cursor.fetchone() if row: return self._row_to_payment_method(row) return None @@ -1232,15 +1232,15 @@ class LocalizationManager: self._close_if_file_db(conn) def list_payment_methods( - self, country_code: str | None = None, currency: str | None = None, active_only: bool = True + self, country_code: str | None = None, currency: str | None = None, active_only: bool = True ) -> list[LocalizedPaymentMethod]: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - query = "SELECT * FROM localized_payment_methods WHERE 1=1" - params = [] + cursor = conn.cursor() + query = "SELECT * FROM localized_payment_methods WHERE 1 = 1" + params = [] if active_only: - query += " AND is_active = 1" + query += " AND is_active = 1" if country_code: query += " AND (supported_countries LIKE ? OR supported_countries LIKE '%\"*\"%')" params.append(f'%"{country_code}"%') @@ -1249,18 +1249,18 @@ class LocalizationManager: params.append(f'%"{currency}"%') query += " ORDER BY display_order" cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_payment_method(row) for row in rows] finally: self._close_if_file_db(conn) def get_localized_payment_methods( - self, country_code: str, language: str = "en" + self, country_code: str, language: str = "en" ) -> list[dict[str, Any]]: - methods = self.list_payment_methods(country_code=country_code) - result = [] + methods = self.list_payment_methods(country_code = country_code) + result = [] for method in methods: - name_local = method.name_local.get(language, method.name) + name_local = method.name_local.get(language, method.name) result.append( { "id": method.id, @@ -1275,11 +1275,11 @@ class LocalizationManager: return result def get_country_config(self, code: str) -> CountryConfig | None: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM country_configs WHERE code = ?", (code,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM country_configs WHERE code = ?", (code, )) + row = cursor.fetchone() if row: return self._row_to_country_config(row) return None @@ -1287,21 +1287,21 @@ class LocalizationManager: self._close_if_file_db(conn) def list_country_configs( - self, region: str | None = None, active_only: bool = True + self, region: str | None = None, active_only: bool = True ) -> list[CountryConfig]: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - query = "SELECT * FROM country_configs WHERE 1=1" - params = [] + cursor = conn.cursor() + query = "SELECT * FROM country_configs WHERE 1 = 1" + params = [] if active_only: - query += " AND is_active = 1" + query += " AND is_active = 1" if region: - query += " AND region = ?" + query += " AND region = ?" params.append(region) query += " ORDER BY name" cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_country_config(row) for row in rows] finally: self._close_if_file_db(conn) @@ -1309,34 +1309,34 @@ class LocalizationManager: def format_datetime( self, dt: datetime, - language: str = "en", - timezone: str | None = None, - format_type: str = "datetime", + language: str = "en", + timezone: str | None = None, + format_type: str = "datetime", ) -> str: try: if timezone and PYTZ_AVAILABLE: - tz = pytz.timezone(timezone) + tz = pytz.timezone(timezone) if dt.tzinfo is None: - dt = pytz.UTC.localize(dt) - dt = dt.astimezone(tz) - lang_config = self.get_language_config(language) + dt = pytz.UTC.localize(dt) + dt = dt.astimezone(tz) + lang_config = self.get_language_config(language) if not lang_config: - lang_config = self.get_language_config("en") + lang_config = self.get_language_config("en") if format_type == "date": - fmt = lang_config.date_format if lang_config else "%Y-%m-%d" + fmt = lang_config.date_format if lang_config else "%Y-%m-%d" elif format_type == "time": - fmt = lang_config.time_format if lang_config else "%H:%M" + fmt = lang_config.time_format if lang_config else "%H:%M" else: - fmt = lang_config.datetime_format if lang_config else "%Y-%m-%d %H:%M" + fmt = lang_config.datetime_format if lang_config else "%Y-%m-%d %H:%M" if BABEL_AVAILABLE: try: - locale = Locale.parse(language.replace("_", "-")) + locale = Locale.parse(language.replace("_", "-")) if format_type == "date": - return dates.format_date(dt, locale=locale) + return dates.format_date(dt, locale = locale) elif format_type == "time": - return dates.format_time(dt, locale=locale) + return dates.format_time(dt, locale = locale) else: - return dates.format_datetime(dt, locale=locale) + return dates.format_datetime(dt, locale = locale) except (ValueError, AttributeError): pass return dt.strftime(fmt) @@ -1345,33 +1345,33 @@ class LocalizationManager: return dt.strftime("%Y-%m-%d %H:%M") def format_number( - self, number: float, language: str = "en", decimal_places: int | None = None + self, number: float, language: str = "en", decimal_places: int | None = None ) -> str: try: if BABEL_AVAILABLE: try: - locale = Locale.parse(language.replace("_", "-")) + locale = Locale.parse(language.replace("_", "-")) return numbers.format_decimal( - number, locale=locale, decimal_quantization=(decimal_places is not None) + number, locale = locale, decimal_quantization = (decimal_places is not None) ) except (ValueError, AttributeError): pass if decimal_places is not None: - return f"{number:,.{decimal_places}f}" - return f"{number:,}" + return f"{number:, .{decimal_places}f}" + return f"{number:, }" except Exception as e: logger.error(f"Error formatting number: {e}") return str(number) - def format_currency(self, amount: float, currency: str, language: str = "en") -> str: + def format_currency(self, amount: float, currency: str, language: str = "en") -> str: try: if BABEL_AVAILABLE: try: - locale = Locale.parse(language.replace("_", "-")) - return numbers.format_currency(amount, currency, locale=locale) + locale = Locale.parse(language.replace("_", "-")) + return numbers.format_currency(amount, currency, locale = locale) except (ValueError, AttributeError): pass - return f"{currency} {amount:,.2f}" + return f"{currency} {amount:, .2f}" except Exception as e: logger.error(f"Error formatting currency: {e}") return f"{currency} {amount:.2f}" @@ -1379,10 +1379,10 @@ class LocalizationManager: def convert_timezone(self, dt: datetime, from_tz: str, to_tz: str) -> datetime: try: if PYTZ_AVAILABLE: - from_zone = pytz.timezone(from_tz) - to_zone = pytz.timezone(to_tz) + from_zone = pytz.timezone(from_tz) + to_zone = pytz.timezone(to_tz) if dt.tzinfo is None: - dt = from_zone.localize(dt) + dt = from_zone.localize(dt) return dt.astimezone(to_zone) return dt except Exception as e: @@ -1392,8 +1392,8 @@ class LocalizationManager: def get_calendar_info(self, calendar_type: str, year: int, month: int) -> dict[str, Any]: import calendar - cal = calendar.Calendar() - month_days = cal.monthdayscalendar(year, month) + cal = calendar.Calendar() + month_days = cal.monthdayscalendar(year, month) return { "calendar_type": calendar_type, "year": year, @@ -1405,11 +1405,11 @@ class LocalizationManager: } def get_localization_settings(self, tenant_id: str) -> LocalizationSettings | None: - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM localization_settings WHERE tenant_id = ?", (tenant_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM localization_settings WHERE tenant_id = ?", (tenant_id, )) + row = cursor.fetchone() if row: return self._row_to_localization_settings(row) return None @@ -1419,22 +1419,22 @@ class LocalizationManager: def create_localization_settings( self, tenant_id: str, - default_language: str = "en", - supported_languages: list[str] | None = None, - default_currency: str = "USD", - supported_currencies: list[str] | None = None, - default_timezone: str = "UTC", - region_code: str = "global", - data_residency: str = "regional", + default_language: str = "en", + supported_languages: list[str] | None = None, + default_currency: str = "USD", + supported_currencies: list[str] | None = None, + default_timezone: str = "UTC", + region_code: str = "global", + data_residency: str = "regional", ) -> LocalizationSettings: - conn = self._get_connection() + conn = self._get_connection() try: - settings_id = str(uuid.uuid4()) - now = datetime.now() - supported_languages = supported_languages or [default_language] - supported_currencies = supported_currencies or [default_currency] - lang_config = self.get_language_config(default_language) - cursor = conn.cursor() + settings_id = str(uuid.uuid4()) + now = datetime.now() + supported_languages = supported_languages or [default_language] + supported_currencies = supported_currencies or [default_currency] + lang_config = self.get_language_config(default_language) + cursor = conn.cursor() cursor.execute( """ INSERT INTO localization_settings @@ -1453,7 +1453,7 @@ class LocalizationManager: default_timezone, lang_config.date_format if lang_config else "%Y-%m-%d", lang_config.time_format if lang_config else "%H:%M", - lang_config.number_format if lang_config else "#,##0.##", + lang_config.number_format if lang_config else "#, ##0.##", lang_config.calendar_type if lang_config else CalendarType.GREGORIAN.value, lang_config.first_day_of_week if lang_config else 1, region_code, @@ -1468,14 +1468,14 @@ class LocalizationManager: self._close_if_file_db(conn) def update_localization_settings(self, tenant_id: str, **kwargs) -> LocalizationSettings | None: - conn = self._get_connection() + conn = self._get_connection() try: - settings = self.get_localization_settings(tenant_id) + settings = self.get_localization_settings(tenant_id) if not settings: return None - updates = [] - params = [] - allowed_fields = [ + updates = [] + params = [] + allowed_fields = [ "default_language", "supported_languages", "default_currency", @@ -1491,7 +1491,7 @@ class LocalizationManager: ] for key, value in kwargs.items(): if key in allowed_fields: - updates.append(f"{key} = ?") + updates.append(f"{key} = ?") if key in ["supported_languages", "supported_currencies"]: params.append(json.dumps(value) if value else "[]") elif key == "first_day_of_week": @@ -1500,12 +1500,12 @@ class LocalizationManager: params.append(value) if not updates: return settings - updates.append("updated_at = ?") + updates.append("updated_at = ?") params.append(datetime.now()) params.append(tenant_id) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( - f"UPDATE localization_settings SET {', '.join(updates)} WHERE tenant_id = ?", params + f"UPDATE localization_settings SET {', '.join(updates)} WHERE tenant_id = ?", params ) conn.commit() return self.get_localization_settings(tenant_id) @@ -1513,48 +1513,48 @@ class LocalizationManager: self._close_if_file_db(conn) def detect_user_preferences( - self, accept_language: str | None = None, ip_country: str | None = None + self, accept_language: str | None = None, ip_country: str | None = None ) -> dict[str, str]: - preferences = {"language": "en", "country": "US", "timezone": "UTC", "currency": "USD"} + preferences = {"language": "en", "country": "US", "timezone": "UTC", "currency": "USD"} if accept_language: - langs = accept_language.split(",") + langs = accept_language.split(", ") for lang in langs: - lang_code = lang.split(";")[0].strip().replace("-", "_") - lang_config = self.get_language_config(lang_code) + lang_code = lang.split(";")[0].strip().replace("-", "_") + lang_config = self.get_language_config(lang_code) if lang_config and lang_config.is_active: - preferences["language"] = lang_code + preferences["language"] = lang_code break if ip_country: - country = self.get_country_config(ip_country) + country = self.get_country_config(ip_country) if country: - preferences["country"] = ip_country - preferences["currency"] = country.default_currency - preferences["timezone"] = country.timezone + preferences["country"] = ip_country + preferences["currency"] = country.default_currency + preferences["timezone"] = country.timezone if country.default_language not in preferences["language"]: - preferences["language"] = country.default_language + preferences["language"] = country.default_language return preferences def _row_to_translation(self, row: sqlite3.Row) -> Translation: return Translation( - id=row["id"], - key=row["key"], - language=row["language"], - value=row["value"], - namespace=row["namespace"], - context=row["context"], - created_at=( + id = row["id"], + key = row["key"], + language = row["language"], + value = row["value"], + namespace = row["namespace"], + context = row["context"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] ), - is_reviewed=bool(row["is_reviewed"]), - reviewed_by=row["reviewed_by"], - reviewed_at=( + is_reviewed = bool(row["is_reviewed"]), + reviewed_by = row["reviewed_by"], + reviewed_at = ( datetime.fromisoformat(row["reviewed_at"]) if row["reviewed_at"] and isinstance(row["reviewed_at"], str) else row["reviewed_at"] @@ -1563,39 +1563,39 @@ class LocalizationManager: def _row_to_language_config(self, row: sqlite3.Row) -> LanguageConfig: return LanguageConfig( - code=row["code"], - name=row["name"], - name_local=row["name_local"], - is_rtl=bool(row["is_rtl"]), - is_active=bool(row["is_active"]), - is_default=bool(row["is_default"]), - fallback_language=row["fallback_language"], - date_format=row["date_format"], - time_format=row["time_format"], - datetime_format=row["datetime_format"], - number_format=row["number_format"], - currency_format=row["currency_format"], - first_day_of_week=row["first_day_of_week"], - calendar_type=row["calendar_type"], + code = row["code"], + name = row["name"], + name_local = row["name_local"], + is_rtl = bool(row["is_rtl"]), + is_active = bool(row["is_active"]), + is_default = bool(row["is_default"]), + fallback_language = row["fallback_language"], + date_format = row["date_format"], + time_format = row["time_format"], + datetime_format = row["datetime_format"], + number_format = row["number_format"], + currency_format = row["currency_format"], + first_day_of_week = row["first_day_of_week"], + calendar_type = row["calendar_type"], ) def _row_to_data_center(self, row: sqlite3.Row) -> DataCenter: return DataCenter( - id=row["id"], - region_code=row["region_code"], - name=row["name"], - location=row["location"], - endpoint=row["endpoint"], - status=row["status"], - priority=row["priority"], - supported_regions=json.loads(row["supported_regions"] or "[]"), - capabilities=json.loads(row["capabilities"] or "{}"), - created_at=( + id = row["id"], + region_code = row["region_code"], + name = row["name"], + location = row["location"], + endpoint = row["endpoint"], + status = row["status"], + priority = row["priority"], + supported_regions = json.loads(row["supported_regions"] or "[]"), + capabilities = json.loads(row["capabilities"] or "{}"), + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] @@ -1604,18 +1604,18 @@ class LocalizationManager: def _row_to_tenant_dc_mapping(self, row: sqlite3.Row) -> TenantDataCenterMapping: return TenantDataCenterMapping( - id=row["id"], - tenant_id=row["tenant_id"], - primary_dc_id=row["primary_dc_id"], - secondary_dc_id=row["secondary_dc_id"], - region_code=row["region_code"], - data_residency=row["data_residency"], - created_at=( + id = row["id"], + tenant_id = row["tenant_id"], + primary_dc_id = row["primary_dc_id"], + secondary_dc_id = row["secondary_dc_id"], + region_code = row["region_code"], + data_residency = row["data_residency"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] @@ -1624,24 +1624,24 @@ class LocalizationManager: def _row_to_payment_method(self, row: sqlite3.Row) -> LocalizedPaymentMethod: return LocalizedPaymentMethod( - id=row["id"], - provider=row["provider"], - name=row["name"], - name_local=json.loads(row["name_local"] or "{}"), - supported_countries=json.loads(row["supported_countries"] or "[]"), - supported_currencies=json.loads(row["supported_currencies"] or "[]"), - is_active=bool(row["is_active"]), - config=json.loads(row["config"] or "{}"), - icon_url=row["icon_url"], - display_order=row["display_order"], - min_amount=row["min_amount"], - max_amount=row["max_amount"], - created_at=( + id = row["id"], + provider = row["provider"], + name = row["name"], + name_local = json.loads(row["name_local"] or "{}"), + supported_countries = json.loads(row["supported_countries"] or "[]"), + supported_currencies = json.loads(row["supported_currencies"] or "[]"), + is_active = bool(row["is_active"]), + config = json.loads(row["config"] or "{}"), + icon_url = row["icon_url"], + display_order = row["display_order"], + min_amount = row["min_amount"], + max_amount = row["max_amount"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] @@ -1650,48 +1650,48 @@ class LocalizationManager: def _row_to_country_config(self, row: sqlite3.Row) -> CountryConfig: return CountryConfig( - code=row["code"], - code3=row["code3"], - name=row["name"], - name_local=json.loads(row["name_local"] or "{}"), - region=row["region"], - default_language=row["default_language"], - supported_languages=json.loads(row["supported_languages"] or "[]"), - default_currency=row["default_currency"], - supported_currencies=json.loads(row["supported_currencies"] or "[]"), - timezone=row["timezone"], - calendar_type=row["calendar_type"], - date_format=row["date_format"], - time_format=row["time_format"], - number_format=row["number_format"], - address_format=row["address_format"], - phone_format=row["phone_format"], - vat_rate=row["vat_rate"], - is_active=bool(row["is_active"]), + code = row["code"], + code3 = row["code3"], + name = row["name"], + name_local = json.loads(row["name_local"] or "{}"), + region = row["region"], + default_language = row["default_language"], + supported_languages = json.loads(row["supported_languages"] or "[]"), + default_currency = row["default_currency"], + supported_currencies = json.loads(row["supported_currencies"] or "[]"), + timezone = row["timezone"], + calendar_type = row["calendar_type"], + date_format = row["date_format"], + time_format = row["time_format"], + number_format = row["number_format"], + address_format = row["address_format"], + phone_format = row["phone_format"], + vat_rate = row["vat_rate"], + is_active = bool(row["is_active"]), ) def _row_to_localization_settings(self, row: sqlite3.Row) -> LocalizationSettings: return LocalizationSettings( - id=row["id"], - tenant_id=row["tenant_id"], - default_language=row["default_language"], - supported_languages=json.loads(row["supported_languages"] or '["en"]'), - default_currency=row["default_currency"], - supported_currencies=json.loads(row["supported_currencies"] or '["USD"]'), - default_timezone=row["default_timezone"], - default_date_format=row["default_date_format"], - default_time_format=row["default_time_format"], - default_number_format=row["default_number_format"], - calendar_type=row["calendar_type"], - first_day_of_week=row["first_day_of_week"], - region_code=row["region_code"], - data_residency=row["data_residency"], - created_at=( + id = row["id"], + tenant_id = row["tenant_id"], + default_language = row["default_language"], + supported_languages = json.loads(row["supported_languages"] or '["en"]'), + default_currency = row["default_currency"], + supported_currencies = json.loads(row["supported_currencies"] or '["USD"]'), + default_timezone = row["default_timezone"], + default_date_format = row["default_date_format"], + default_time_format = row["default_time_format"], + default_number_format = row["default_number_format"], + calendar_type = row["calendar_type"], + first_day_of_week = row["first_day_of_week"], + region_code = row["region_code"], + data_residency = row["data_residency"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] @@ -1699,11 +1699,11 @@ class LocalizationManager: ) -_localization_manager = None +_localization_manager = None -def get_localization_manager(db_path: str = "insightflow.db") -> LocalizationManager: +def get_localization_manager(db_path: str = "insightflow.db") -> LocalizationManager: global _localization_manager if _localization_manager is None: - _localization_manager = LocalizationManager(db_path) + _localization_manager = LocalizationManager(db_path) return _localization_manager diff --git a/backend/multimodal_entity_linker.py b/backend/multimodal_entity_linker.py index ca73030..803f566 100644 --- a/backend/multimodal_entity_linker.py +++ b/backend/multimodal_entity_linker.py @@ -9,13 +9,13 @@ from dataclasses import dataclass from difflib import SequenceMatcher # Constants -UUID_LENGTH = 8 # UUID 截断长度 +UUID_LENGTH = 8 # UUID 截断长度 # 尝试导入embedding库 try: - NUMPY_AVAILABLE = True + NUMPY_AVAILABLE = True except ImportError: - NUMPY_AVAILABLE = False + NUMPY_AVAILABLE = False @dataclass @@ -30,11 +30,11 @@ class MultimodalEntity: source_id: str mention_context: str confidence: float - modality_features: dict = None # 模态特定特征 + modality_features: dict = None # 模态特定特征 - def __post_init__(self): + def __post_init__(self) -> None: if self.modality_features is None: - self.modality_features = {} + self.modality_features = {} @dataclass @@ -78,7 +78,7 @@ class MultimodalEntityLinker: """多模态实体关联器 - 跨模态实体对齐和知识融合""" # 关联类型 - LINK_TYPES = { + LINK_TYPES = { "same_as": "同一实体", "related_to": "相关实体", "part_of": "组成部分", @@ -86,16 +86,16 @@ class MultimodalEntityLinker: } # 模态类型 - MODALITIES = ["audio", "video", "image", "document"] + MODALITIES = ["audio", "video", "image", "document"] - def __init__(self, similarity_threshold: float = 0.85) -> None: + def __init__(self, similarity_threshold: float = 0.85) -> None: """ 初始化多模态实体关联器 Args: similarity_threshold: 相似度阈值 """ - self.similarity_threshold = similarity_threshold + self.similarity_threshold = similarity_threshold def calculate_string_similarity(self, s1: str, s2: str) -> float: """ @@ -111,7 +111,7 @@ class MultimodalEntityLinker: if not s1 or not s2: return 0.0 - s1, s2 = s1.lower().strip(), s2.lower().strip() + s1, s2 = s1.lower().strip(), s2.lower().strip() # 完全匹配 if s1 == s2: @@ -136,7 +136,7 @@ class MultimodalEntityLinker: (相似度, 匹配类型) """ # 名称相似度 - name_sim = self.calculate_string_similarity( + name_sim = self.calculate_string_similarity( entity1.get("name", ""), entity2.get("name", "") ) @@ -145,8 +145,8 @@ class MultimodalEntityLinker: return 1.0, "exact" # 检查别名 - aliases1 = set(a.lower() for a in entity1.get("aliases", [])) - aliases2 = set(a.lower() for a in entity2.get("aliases", [])) + aliases1 = set(a.lower() for a in entity1.get("aliases", [])) + aliases2 = set(a.lower() for a in entity2.get("aliases", [])) if aliases1 & aliases2: # 有共同别名 return 0.95, "alias_match" @@ -157,12 +157,12 @@ class MultimodalEntityLinker: return 0.95, "alias_match" # 定义相似度 - def_sim = self.calculate_string_similarity( + def_sim = self.calculate_string_similarity( entity1.get("definition", ""), entity2.get("definition", "") ) # 综合相似度 - combined_sim = name_sim * 0.7 + def_sim * 0.3 + combined_sim = name_sim * 0.7 + def_sim * 0.3 if combined_sim >= self.similarity_threshold: return combined_sim, "fuzzy" @@ -170,7 +170,7 @@ class MultimodalEntityLinker: return combined_sim, "none" def find_matching_entity( - self, query_entity: dict, candidate_entities: list[dict], exclude_ids: set[str] = None + self, query_entity: dict, candidate_entities: list[dict], exclude_ids: set[str] = None ) -> AlignmentResult | None: """ 在候选实体中查找匹配的实体 @@ -183,28 +183,28 @@ class MultimodalEntityLinker: Returns: 对齐结果 """ - exclude_ids = exclude_ids or set() - best_match = None - best_similarity = 0.0 + exclude_ids = exclude_ids or set() + best_match = None + best_similarity = 0.0 for candidate in candidate_entities: if candidate.get("id") in exclude_ids: continue - similarity, match_type = self.calculate_entity_similarity(query_entity, candidate) + similarity, match_type = self.calculate_entity_similarity(query_entity, candidate) if similarity > best_similarity and similarity >= self.similarity_threshold: - best_similarity = similarity - best_match = candidate - best_match_type = match_type + best_similarity = similarity + best_match = candidate + best_match_type = match_type if best_match: return AlignmentResult( - entity_id=query_entity.get("id"), - matched_entity_id=best_match.get("id"), - similarity=best_similarity, - match_type=best_match_type, - confidence=best_similarity, + entity_id = query_entity.get("id"), + matched_entity_id = best_match.get("id"), + similarity = best_similarity, + match_type = best_match_type, + confidence = best_similarity, ) return None @@ -230,10 +230,10 @@ class MultimodalEntityLinker: Returns: 实体关联列表 """ - links = [] + links = [] # 合并所有实体 - all_entities = { + all_entities = { "audio": audio_entities, "video": video_entities, "image": image_entities, @@ -246,24 +246,24 @@ class MultimodalEntityLinker: if mod1 >= mod2: # 避免重复比较 continue - entities1 = all_entities.get(mod1, []) - entities2 = all_entities.get(mod2, []) + entities1 = all_entities.get(mod1, []) + entities2 = all_entities.get(mod2, []) for ent1 in entities1: # 在另一个模态中查找匹配 - result = self.find_matching_entity(ent1, entities2) + result = self.find_matching_entity(ent1, entities2) if result and result.matched_entity_id: - link = EntityLink( - id=str(uuid.uuid4())[:UUID_LENGTH], - project_id=project_id, - source_entity_id=ent1.get("id"), - target_entity_id=result.matched_entity_id, - link_type="same_as" if result.similarity > 0.95 else "related_to", - source_modality=mod1, - target_modality=mod2, - confidence=result.confidence, - evidence=f"Cross-modal alignment: {result.match_type}", + link = EntityLink( + id = str(uuid.uuid4())[:UUID_LENGTH], + project_id = project_id, + source_entity_id = ent1.get("id"), + target_entity_id = result.matched_entity_id, + link_type = "same_as" if result.similarity > 0.95 else "related_to", + source_modality = mod1, + target_modality = mod2, + confidence = result.confidence, + evidence = f"Cross-modal alignment: {result.match_type}", ) links.append(link) @@ -284,7 +284,7 @@ class MultimodalEntityLinker: 融合结果 """ # 收集所有属性 - fused_properties = { + fused_properties = { "names": set(), "definitions": [], "aliases": set(), @@ -293,7 +293,7 @@ class MultimodalEntityLinker: "contexts": [], } - merged_ids = [] + merged_ids = [] for entity in linked_entities: merged_ids.append(entity.get("id")) @@ -318,21 +318,21 @@ class MultimodalEntityLinker: fused_properties["contexts"].append(mention.get("mention_context")) # 选择最佳定义(最长的那个) - best_definition = ( - max(fused_properties["definitions"], key=len) if fused_properties["definitions"] else "" + best_definition = ( + max(fused_properties["definitions"], key = len) if fused_properties["definitions"] else "" ) # 选择最佳名称(最常见的那个) from collections import Counter - name_counts = Counter(fused_properties["names"]) - best_name = name_counts.most_common(1)[0][0] if name_counts else "" + name_counts = Counter(fused_properties["names"]) + best_name = name_counts.most_common(1)[0][0] if name_counts else "" # 构建融合结果 return FusionResult( - canonical_entity_id=entity_id, - merged_entity_ids=merged_ids, - fused_properties={ + canonical_entity_id = entity_id, + merged_entity_ids = merged_ids, + fused_properties = { "name": best_name, "definition": best_definition, "aliases": list(fused_properties["aliases"]), @@ -340,8 +340,8 @@ class MultimodalEntityLinker: "modalities": list(fused_properties["modalities"]), "contexts": fused_properties["contexts"][:10], # 最多10个上下文 }, - source_modalities=list(fused_properties["modalities"]), - confidence=min(1.0, len(linked_entities) * 0.2 + 0.5), + source_modalities = list(fused_properties["modalities"]), + confidence = min(1.0, len(linked_entities) * 0.2 + 0.5), ) def detect_entity_conflicts(self, entities: list[dict]) -> list[dict]: @@ -354,30 +354,30 @@ class MultimodalEntityLinker: Returns: 冲突列表 """ - conflicts = [] + conflicts = [] # 按名称分组 - name_groups = {} + name_groups = {} for entity in entities: - name = entity.get("name", "").lower() + name = entity.get("name", "").lower() if name: if name not in name_groups: - name_groups[name] = [] + name_groups[name] = [] name_groups[name].append(entity) # 检测同名但定义不同的实体 for name, group in name_groups.items(): if len(group) > 1: # 检查定义是否相似 - definitions = [e.get("definition", "") for e in group if e.get("definition")] + definitions = [e.get("definition", "") for e in group if e.get("definition")] if len(definitions) > 1: # 计算定义之间的相似度 - sim_matrix = [] + sim_matrix = [] for i, d1 in enumerate(definitions): for j, d2 in enumerate(definitions): if i < j: - sim = self.calculate_string_similarity(d1, d2) + sim = self.calculate_string_similarity(d1, d2) sim_matrix.append(sim) # 如果定义相似度都很低,可能是冲突 @@ -394,7 +394,7 @@ class MultimodalEntityLinker: return conflicts def suggest_entity_merges( - self, entities: list[dict], existing_links: list[EntityLink] = None + self, entities: list[dict], existing_links: list[EntityLink] = None ) -> list[dict]: """ 建议实体合并 @@ -406,13 +406,13 @@ class MultimodalEntityLinker: Returns: 合并建议列表 """ - suggestions = [] - existing_pairs = set() + suggestions = [] + existing_pairs = set() # 记录已有的关联 if existing_links: for link in existing_links: - pair = tuple(sorted([link.source_entity_id, link.target_entity_id])) + pair = tuple(sorted([link.source_entity_id, link.target_entity_id])) existing_pairs.add(pair) # 检查所有实体对 @@ -422,12 +422,12 @@ class MultimodalEntityLinker: continue # 检查是否已有关联 - pair = tuple(sorted([ent1.get("id"), ent2.get("id")])) + pair = tuple(sorted([ent1.get("id"), ent2.get("id")])) if pair in existing_pairs: continue # 计算相似度 - similarity, match_type = self.calculate_entity_similarity(ent1, ent2) + similarity, match_type = self.calculate_entity_similarity(ent1, ent2) if similarity >= self.similarity_threshold: suggestions.append( @@ -441,7 +441,7 @@ class MultimodalEntityLinker: ) # 按相似度排序 - suggestions.sort(key=lambda x: x["similarity"], reverse=True) + suggestions.sort(key = lambda x: x["similarity"], reverse = True) return suggestions @@ -451,8 +451,8 @@ class MultimodalEntityLinker: entity_id: str, source_type: str, source_id: str, - mention_context: str = "", - confidence: float = 1.0, + mention_context: str = "", + confidence: float = 1.0, ) -> MultimodalEntity: """ 创建多模态实体记录 @@ -469,14 +469,14 @@ class MultimodalEntityLinker: 多模态实体记录 """ return MultimodalEntity( - id=str(uuid.uuid4())[:UUID_LENGTH], - entity_id=entity_id, - project_id=project_id, - name="", # 将在后续填充 - source_type=source_type, - source_id=source_id, - mention_context=mention_context, - confidence=confidence, + id = str(uuid.uuid4())[:UUID_LENGTH], + entity_id = entity_id, + project_id = project_id, + name = "", # 将在后续填充 + source_type = source_type, + source_id = source_id, + mention_context = mention_context, + confidence = confidence, ) def analyze_modality_distribution(self, multimodal_entities: list[MultimodalEntity]) -> dict: @@ -489,7 +489,7 @@ class MultimodalEntityLinker: Returns: 模态分布统计 """ - distribution = {mod: 0 for mod in self.MODALITIES} + distribution = {mod: 0 for mod in self.MODALITIES} # 统计每个模态的实体数 for me in multimodal_entities: @@ -497,13 +497,13 @@ class MultimodalEntityLinker: distribution[me.source_type] += 1 # 统计跨模态实体 - entity_modalities = {} + entity_modalities = {} for me in multimodal_entities: if me.entity_id not in entity_modalities: - entity_modalities[me.entity_id] = set() + entity_modalities[me.entity_id] = set() entity_modalities[me.entity_id].add(me.source_type) - cross_modal_count = sum(1 for mods in entity_modalities.values() if len(mods) > 1) + cross_modal_count = sum(1 for mods in entity_modalities.values() if len(mods) > 1) return { "modality_distribution": distribution, @@ -517,12 +517,12 @@ class MultimodalEntityLinker: # Singleton instance -_multimodal_entity_linker = None +_multimodal_entity_linker = None -def get_multimodal_entity_linker(similarity_threshold: float = 0.85) -> MultimodalEntityLinker: +def get_multimodal_entity_linker(similarity_threshold: float = 0.85) -> MultimodalEntityLinker: """获取多模态实体关联器单例""" global _multimodal_entity_linker if _multimodal_entity_linker is None: - _multimodal_entity_linker = MultimodalEntityLinker(similarity_threshold) + _multimodal_entity_linker = MultimodalEntityLinker(similarity_threshold) return _multimodal_entity_linker diff --git a/backend/multimodal_processor.py b/backend/multimodal_processor.py index d450811..b13b4d9 100644 --- a/backend/multimodal_processor.py +++ b/backend/multimodal_processor.py @@ -13,30 +13,30 @@ from dataclasses import dataclass from pathlib import Path # Constants -UUID_LENGTH = 8 # UUID 截断长度 +UUID_LENGTH = 8 # UUID 截断长度 # 尝试导入OCR库 try: import pytesseract from PIL import Image - PYTESSERACT_AVAILABLE = True + PYTESSERACT_AVAILABLE = True except ImportError: - PYTESSERACT_AVAILABLE = False + PYTESSERACT_AVAILABLE = False try: import cv2 - CV2_AVAILABLE = True + CV2_AVAILABLE = True except ImportError: - CV2_AVAILABLE = False + CV2_AVAILABLE = False try: import ffmpeg - FFMPEG_AVAILABLE = True + FFMPEG_AVAILABLE = True except ImportError: - FFMPEG_AVAILABLE = False + FFMPEG_AVAILABLE = False @dataclass @@ -48,13 +48,13 @@ class VideoFrame: frame_number: int timestamp: float frame_path: str - ocr_text: str = "" - ocr_confidence: float = 0.0 - entities_detected: list[dict] = None + ocr_text: str = "" + ocr_confidence: float = 0.0 + entities_detected: list[dict] = None - def __post_init__(self): + def __post_init__(self) -> None: if self.entities_detected is None: - self.entities_detected = [] + self.entities_detected = [] @dataclass @@ -65,20 +65,20 @@ class VideoInfo: project_id: str filename: str file_path: str - duration: float = 0.0 - width: int = 0 - height: int = 0 - fps: float = 0.0 - audio_extracted: bool = False - audio_path: str = "" - transcript_id: str = "" - status: str = "pending" - error_message: str = "" - metadata: dict = None + duration: float = 0.0 + width: int = 0 + height: int = 0 + fps: float = 0.0 + audio_extracted: bool = False + audio_path: str = "" + transcript_id: str = "" + status: str = "pending" + error_message: str = "" + metadata: dict = None - def __post_init__(self): + def __post_init__(self) -> None: if self.metadata is None: - self.metadata = {} + self.metadata = {} @dataclass @@ -91,13 +91,13 @@ class VideoProcessingResult: ocr_results: list[dict] full_text: str # 整合的文本(音频转录 + OCR文本) success: bool - error_message: str = "" + error_message: str = "" class MultimodalProcessor: """多模态处理器 - 处理视频文件""" - def __init__(self, temp_dir: str = None, frame_interval: int = 5) -> None: + def __init__(self, temp_dir: str = None, frame_interval: int = 5) -> None: """ 初始化多模态处理器 @@ -105,16 +105,16 @@ class MultimodalProcessor: temp_dir: 临时文件目录 frame_interval: 关键帧提取间隔(秒) """ - self.temp_dir = temp_dir or tempfile.gettempdir() - self.frame_interval = frame_interval - self.video_dir = os.path.join(self.temp_dir, "videos") - self.frames_dir = os.path.join(self.temp_dir, "frames") - self.audio_dir = os.path.join(self.temp_dir, "audio") + self.temp_dir = temp_dir or tempfile.gettempdir() + self.frame_interval = frame_interval + self.video_dir = os.path.join(self.temp_dir, "videos") + self.frames_dir = os.path.join(self.temp_dir, "frames") + self.audio_dir = os.path.join(self.temp_dir, "audio") # 创建目录 - os.makedirs(self.video_dir, exist_ok=True) - os.makedirs(self.frames_dir, exist_ok=True) - os.makedirs(self.audio_dir, exist_ok=True) + os.makedirs(self.video_dir, exist_ok = True) + os.makedirs(self.frames_dir, exist_ok = True) + os.makedirs(self.audio_dir, exist_ok = True) def extract_video_info(self, video_path: str) -> dict: """ @@ -128,11 +128,11 @@ class MultimodalProcessor: """ try: if FFMPEG_AVAILABLE: - probe = ffmpeg.probe(video_path) - video_stream = next( + probe = ffmpeg.probe(video_path) + video_stream = next( (s for s in probe["streams"] if s["codec_type"] == "video"), None ) - audio_stream = next( + audio_stream = next( (s for s in probe["streams"] if s["codec_type"] == "audio"), None ) @@ -147,21 +147,21 @@ class MultimodalProcessor: } else: # 使用 ffprobe 命令行 - cmd = [ + cmd = [ "ffprobe", "-v", "error", "-show_entries", - "format=duration,bit_rate", + "format = duration, bit_rate", "-show_entries", - "stream=width,height,r_frame_rate", + "stream = width, height, r_frame_rate", "-of", "json", video_path, ] - result = subprocess.run(cmd, capture_output=True, text=True) + result = subprocess.run(cmd, capture_output = True, text = True) if result.returncode == 0: - data = json.loads(result.stdout) + data = json.loads(result.stdout) return { "duration": float(data["format"].get("duration", 0)), "width": int(data["streams"][0].get("width", 0)) if data["streams"] else 0, @@ -177,7 +177,7 @@ class MultimodalProcessor: return {"duration": 0, "width": 0, "height": 0, "fps": 0, "has_audio": False, "bitrate": 0} - def extract_audio(self, video_path: str, output_path: str = None) -> str: + def extract_audio(self, video_path: str, output_path: str = None) -> str: """ 从视频中提取音频 @@ -189,20 +189,20 @@ class MultimodalProcessor: 提取的音频文件路径 """ if output_path is None: - video_name = Path(video_path).stem - output_path = os.path.join(self.audio_dir, f"{video_name}.wav") + video_name = Path(video_path).stem + output_path = os.path.join(self.audio_dir, f"{video_name}.wav") try: if FFMPEG_AVAILABLE: ( ffmpeg.input(video_path) - .output(output_path, ac=1, ar=16000, vn=None) + .output(output_path, ac = 1, ar = 16000, vn = None) .overwrite_output() - .run(quiet=True) + .run(quiet = True) ) else: # 使用命令行 ffmpeg - cmd = [ + cmd = [ "ffmpeg", "-i", video_path, @@ -216,14 +216,14 @@ class MultimodalProcessor: "-y", output_path, ] - subprocess.run(cmd, check=True, capture_output=True) + subprocess.run(cmd, check = True, capture_output = True) return output_path except Exception as e: print(f"Error extracting audio: {e}") raise - def extract_keyframes(self, video_path: str, video_id: str, interval: int = None) -> list[str]: + def extract_keyframes(self, video_path: str, video_id: str, interval: int = None) -> list[str]: """ 从视频中提取关键帧 @@ -235,31 +235,31 @@ class MultimodalProcessor: Returns: 提取的帧文件路径列表 """ - interval = interval or self.frame_interval - frame_paths = [] + interval = interval or self.frame_interval + frame_paths = [] # 创建帧存储目录 - video_frames_dir = os.path.join(self.frames_dir, video_id) - os.makedirs(video_frames_dir, exist_ok=True) + video_frames_dir = os.path.join(self.frames_dir, video_id) + os.makedirs(video_frames_dir, exist_ok = True) try: if CV2_AVAILABLE: # 使用 OpenCV 提取帧 - cap = cv2.VideoCapture(video_path) - fps = cap.get(cv2.CAP_PROP_FPS) + cap = cv2.VideoCapture(video_path) + fps = cap.get(cv2.CAP_PROP_FPS) int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) - frame_interval_frames = int(fps * interval) - frame_number = 0 + frame_interval_frames = int(fps * interval) + frame_number = 0 while True: - ret, frame = cap.read() + ret, frame = cap.read() if not ret: break if frame_number % frame_interval_frames == 0: - timestamp = frame_number / fps - frame_path = os.path.join( + timestamp = frame_number / fps + frame_path = os.path.join( video_frames_dir, f"frame_{frame_number:06d}_{timestamp:.2f}.jpg" ) cv2.imwrite(frame_path, frame) @@ -271,23 +271,23 @@ class MultimodalProcessor: else: # 使用 ffmpeg 命令行提取帧 Path(video_path).stem - output_pattern = os.path.join(video_frames_dir, "frame_%06d_%t.jpg") + output_pattern = os.path.join(video_frames_dir, "frame_%06d_%t.jpg") - cmd = [ + cmd = [ "ffmpeg", "-i", video_path, "-vf", - f"fps=1/{interval}", + f"fps = 1/{interval}", "-frame_pts", "1", "-y", output_pattern, ] - subprocess.run(cmd, check=True, capture_output=True) + subprocess.run(cmd, check = True, capture_output = True) # 获取生成的帧文件列表 - frame_paths = sorted( + frame_paths = sorted( [ os.path.join(video_frames_dir, f) for f in os.listdir(video_frames_dir) @@ -313,19 +313,19 @@ class MultimodalProcessor: return "", 0.0 try: - image = Image.open(image_path) + image = Image.open(image_path) # 预处理:转换为灰度图 if image.mode != "L": - image = image.convert("L") + image = image.convert("L") # 使用 pytesseract 进行 OCR - text = pytesseract.image_to_string(image, lang="chi_sim+eng") + text = pytesseract.image_to_string(image, lang = "chi_sim+eng") # 获取置信度数据 - data = pytesseract.image_to_data(image, output_type=pytesseract.Output.DICT) - confidences = [int(c) for c in data["conf"] if int(c) > 0] - avg_confidence = sum(confidences) / len(confidences) if confidences else 0 + data = pytesseract.image_to_data(image, output_type = pytesseract.Output.DICT) + confidences = [int(c) for c in data["conf"] if int(c) > 0] + avg_confidence = sum(confidences) / len(confidences) if confidences else 0 return text.strip(), avg_confidence / 100.0 except Exception as e: @@ -333,7 +333,7 @@ class MultimodalProcessor: return "", 0.0 def process_video( - self, video_data: bytes, filename: str, project_id: str, video_id: str = None + self, video_data: bytes, filename: str, project_id: str, video_id: str = None ) -> VideoProcessingResult: """ 处理视频文件:提取音频、关键帧、OCR @@ -347,48 +347,48 @@ class MultimodalProcessor: Returns: 视频处理结果 """ - video_id = video_id or str(uuid.uuid4())[:UUID_LENGTH] + video_id = video_id or str(uuid.uuid4())[:UUID_LENGTH] try: # 保存视频文件 - video_path = os.path.join(self.video_dir, f"{video_id}_{filename}") + video_path = os.path.join(self.video_dir, f"{video_id}_{filename}") with open(video_path, "wb") as f: f.write(video_data) # 提取视频信息 - video_info = self.extract_video_info(video_path) + video_info = self.extract_video_info(video_path) # 提取音频 - audio_path = "" + audio_path = "" if video_info["has_audio"]: - audio_path = self.extract_audio(video_path) + audio_path = self.extract_audio(video_path) # 提取关键帧 - frame_paths = self.extract_keyframes(video_path, video_id) + frame_paths = self.extract_keyframes(video_path, video_id) # 对关键帧进行 OCR - frames = [] - ocr_results = [] - all_ocr_text = [] + frames = [] + ocr_results = [] + all_ocr_text = [] for i, frame_path in enumerate(frame_paths): # 解析帧信息 - frame_name = os.path.basename(frame_path) - parts = frame_name.replace(".jpg", "").split("_") - frame_number = int(parts[1]) if len(parts) > 1 else i - timestamp = float(parts[2]) if len(parts) > 2 else i * self.frame_interval + frame_name = os.path.basename(frame_path) + parts = frame_name.replace(".jpg", "").split("_") + frame_number = int(parts[1]) if len(parts) > 1 else i + timestamp = float(parts[2]) if len(parts) > 2 else i * self.frame_interval # OCR 识别 - ocr_text, confidence = self.perform_ocr(frame_path) + ocr_text, confidence = self.perform_ocr(frame_path) - frame = VideoFrame( - id=str(uuid.uuid4())[:UUID_LENGTH], - video_id=video_id, - frame_number=frame_number, - timestamp=timestamp, - frame_path=frame_path, - ocr_text=ocr_text, - ocr_confidence=confidence, + frame = VideoFrame( + id = str(uuid.uuid4())[:UUID_LENGTH], + video_id = video_id, + frame_number = frame_number, + timestamp = timestamp, + frame_path = frame_path, + ocr_text = ocr_text, + ocr_confidence = confidence, ) frames.append(frame) @@ -404,29 +404,29 @@ class MultimodalProcessor: all_ocr_text.append(ocr_text) # 整合所有 OCR 文本 - full_ocr_text = "\n\n".join(all_ocr_text) + full_ocr_text = "\n\n".join(all_ocr_text) return VideoProcessingResult( - video_id=video_id, - audio_path=audio_path, - frames=frames, - ocr_results=ocr_results, - full_text=full_ocr_text, - success=True, + video_id = video_id, + audio_path = audio_path, + frames = frames, + ocr_results = ocr_results, + full_text = full_ocr_text, + success = True, ) except Exception as e: return VideoProcessingResult( - video_id=video_id, - audio_path="", - frames=[], - ocr_results=[], - full_text="", - success=False, - error_message=str(e), + video_id = video_id, + audio_path = "", + frames = [], + ocr_results = [], + full_text = "", + success = False, + error_message = str(e), ) - def cleanup(self, video_id: str = None) -> None: + def cleanup(self, video_id: str = None) -> None: """ 清理临时文件 @@ -438,7 +438,7 @@ class MultimodalProcessor: if video_id: # 清理特定视频的文件 for dir_path in [self.video_dir, self.frames_dir, self.audio_dir]: - target_dir = ( + target_dir = ( os.path.join(dir_path, video_id) if dir_path == self.frames_dir else dir_path ) if os.path.exists(target_dir): @@ -450,16 +450,16 @@ class MultimodalProcessor: for dir_path in [self.video_dir, self.frames_dir, self.audio_dir]: if os.path.exists(dir_path): shutil.rmtree(dir_path) - os.makedirs(dir_path, exist_ok=True) + os.makedirs(dir_path, exist_ok = True) # Singleton instance -_multimodal_processor = None +_multimodal_processor = None -def get_multimodal_processor(temp_dir: str = None, frame_interval: int = 5) -> MultimodalProcessor: +def get_multimodal_processor(temp_dir: str = None, frame_interval: int = 5) -> MultimodalProcessor: """获取多模态处理器单例""" global _multimodal_processor if _multimodal_processor is None: - _multimodal_processor = MultimodalProcessor(temp_dir, frame_interval) + _multimodal_processor = MultimodalProcessor(temp_dir, frame_interval) return _multimodal_processor diff --git a/backend/neo4j_manager.py b/backend/neo4j_manager.py index 45b8c57..229175d 100644 --- a/backend/neo4j_manager.py +++ b/backend/neo4j_manager.py @@ -10,20 +10,20 @@ import logging import os from dataclasses import dataclass -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) # Neo4j 连接配置 -NEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687") -NEO4J_USER = os.getenv("NEO4J_USER", "neo4j") -NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "password") +NEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687") +NEO4J_USER = os.getenv("NEO4J_USER", "neo4j") +NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "password") # 延迟导入,避免未安装时出错 try: from neo4j import Driver, GraphDatabase - NEO4J_AVAILABLE = True + NEO4J_AVAILABLE = True except ImportError: - NEO4J_AVAILABLE = False + NEO4J_AVAILABLE = False logger.warning("Neo4j driver not installed. Neo4j features will be disabled.") @@ -35,15 +35,15 @@ class GraphEntity: project_id: str name: str type: str - definition: str = "" - aliases: list[str] = None - properties: dict = None + definition: str = "" + aliases: list[str] = None + properties: dict = None - def __post_init__(self): + def __post_init__(self) -> None: if self.aliases is None: - self.aliases = [] + self.aliases = [] if self.properties is None: - self.properties = {} + self.properties = {} @dataclass @@ -54,12 +54,12 @@ class GraphRelation: source_id: str target_id: str relation_type: str - evidence: str = "" - properties: dict = None + evidence: str = "" + properties: dict = None - def __post_init__(self): + def __post_init__(self) -> None: if self.properties is None: - self.properties = {} + self.properties = {} @dataclass @@ -69,7 +69,7 @@ class PathResult: nodes: list[dict] relationships: list[dict] length: int - total_weight: float = 0.0 + total_weight: float = 0.0 @dataclass @@ -79,7 +79,7 @@ class CommunityResult: community_id: int nodes: list[dict] size: int - density: float = 0.0 + density: float = 0.0 @dataclass @@ -89,17 +89,17 @@ class CentralityResult: entity_id: str entity_name: str score: float - rank: int = 0 + rank: int = 0 class Neo4jManager: """Neo4j 图数据库管理器""" - def __init__(self, uri: str = None, user: str = None, password: str = None): - self.uri = uri or NEO4J_URI - self.user = user or NEO4J_USER - self.password = password or NEO4J_PASSWORD - self._driver: Driver | None = None + def __init__(self, uri: str = None, user: str = None, password: str = None) -> None: + self.uri = uri or NEO4J_URI + self.user = user or NEO4J_USER + self.password = password or NEO4J_PASSWORD + self._driver: Driver | None = None if not NEO4J_AVAILABLE: logger.error("Neo4j driver not available. Please install: pip install neo4j") @@ -113,13 +113,13 @@ class Neo4jManager: return try: - self._driver = GraphDatabase.driver(self.uri, auth=(self.user, self.password)) + self._driver = GraphDatabase.driver(self.uri, auth = (self.user, self.password)) # 验证连接 self._driver.verify_connectivity() logger.info(f"Connected to Neo4j at {self.uri}") except (RuntimeError, ValueError, TypeError) as e: logger.error(f"Failed to connect to Neo4j: {e}") - self._driver = None + self._driver = None def close(self) -> None: """关闭连接""" @@ -179,7 +179,7 @@ class Neo4jManager: # ==================== 数据同步 ==================== def sync_project( - self, project_id: str, project_name: str, project_description: str = "" + self, project_id: str, project_name: str, project_description: str = "" ) -> None: """同步项目节点到 Neo4j""" if not self._driver: @@ -189,13 +189,13 @@ class Neo4jManager: session.run( """ MERGE (p:Project {id: $project_id}) - SET p.name = $name, - p.description = $description, - p.updated_at = datetime() + SET p.name = $name, + p.description = $description, + p.updated_at = datetime() """, - project_id=project_id, - name=project_name, - description=project_description, + project_id = project_id, + name = project_name, + description = project_description, ) def sync_entity(self, entity: GraphEntity) -> None: @@ -208,23 +208,23 @@ class Neo4jManager: session.run( """ MERGE (e:Entity {id: $id}) - SET e.name = $name, - e.type = $type, - e.definition = $definition, - e.aliases = $aliases, - e.properties = $properties, - e.updated_at = datetime() + SET e.name = $name, + e.type = $type, + e.definition = $definition, + e.aliases = $aliases, + e.properties = $properties, + e.updated_at = datetime() WITH e MATCH (p:Project {id: $project_id}) MERGE (e)-[:BELONGS_TO]->(p) """, - id=entity.id, - project_id=entity.project_id, - name=entity.name, - type=entity.type, - definition=entity.definition, - aliases=json.dumps(entity.aliases), - properties=json.dumps(entity.properties), + id = entity.id, + project_id = entity.project_id, + name = entity.name, + type = entity.type, + definition = entity.definition, + aliases = json.dumps(entity.aliases), + properties = json.dumps(entity.properties), ) def sync_entities_batch(self, entities: list[GraphEntity]) -> None: @@ -234,7 +234,7 @@ class Neo4jManager: with self._driver.session() as session: # 使用 UNWIND 批量处理 - entities_data = [ + entities_data = [ { "id": e.id, "project_id": e.project_id, @@ -251,17 +251,17 @@ class Neo4jManager: """ UNWIND $entities AS entity MERGE (e:Entity {id: entity.id}) - SET e.name = entity.name, - e.type = entity.type, - e.definition = entity.definition, - e.aliases = entity.aliases, - e.properties = entity.properties, - e.updated_at = datetime() + SET e.name = entity.name, + e.type = entity.type, + e.definition = entity.definition, + e.aliases = entity.aliases, + e.properties = entity.properties, + e.updated_at = datetime() WITH e, entity MATCH (p:Project {id: entity.project_id}) MERGE (e)-[:BELONGS_TO]->(p) """, - entities=entities_data, + entities = entities_data, ) def sync_relation(self, relation: GraphRelation) -> None: @@ -275,17 +275,17 @@ class Neo4jManager: MATCH (source:Entity {id: $source_id}) MATCH (target:Entity {id: $target_id}) MERGE (source)-[r:RELATES_TO {id: $id}]->(target) - SET r.relation_type = $relation_type, - r.evidence = $evidence, - r.properties = $properties, - r.updated_at = datetime() + SET r.relation_type = $relation_type, + r.evidence = $evidence, + r.properties = $properties, + r.updated_at = datetime() """, - id=relation.id, - source_id=relation.source_id, - target_id=relation.target_id, - relation_type=relation.relation_type, - evidence=relation.evidence, - properties=json.dumps(relation.properties), + id = relation.id, + source_id = relation.source_id, + target_id = relation.target_id, + relation_type = relation.relation_type, + evidence = relation.evidence, + properties = json.dumps(relation.properties), ) def sync_relations_batch(self, relations: list[GraphRelation]) -> None: @@ -294,7 +294,7 @@ class Neo4jManager: return with self._driver.session() as session: - relations_data = [ + relations_data = [ { "id": r.id, "source_id": r.source_id, @@ -312,12 +312,12 @@ class Neo4jManager: MATCH (source:Entity {id: rel.source_id}) MATCH (target:Entity {id: rel.target_id}) MERGE (source)-[r:RELATES_TO {id: rel.id}]->(target) - SET r.relation_type = rel.relation_type, - r.evidence = rel.evidence, - r.properties = rel.properties, - r.updated_at = datetime() + SET r.relation_type = rel.relation_type, + r.evidence = rel.evidence, + r.properties = rel.properties, + r.updated_at = datetime() """, - relations=relations_data, + relations = relations_data, ) def delete_entity(self, entity_id: str) -> None: @@ -331,7 +331,7 @@ class Neo4jManager: MATCH (e:Entity {id: $id}) DETACH DELETE e """, - id=entity_id, + id = entity_id, ) def delete_project(self, project_id: str) -> None: @@ -346,13 +346,13 @@ class Neo4jManager: OPTIONAL MATCH (e:Entity)-[:BELONGS_TO]->(p) DETACH DELETE e, p """, - id=project_id, + id = project_id, ) # ==================== 复杂图查询 ==================== def find_shortest_path( - self, source_id: str, target_id: str, max_depth: int = 10 + self, source_id: str, target_id: str, max_depth: int = 10 ) -> PathResult | None: """ 查找两个实体之间的最短路径 @@ -369,31 +369,31 @@ class Neo4jManager: return None with self._driver.session() as session: - result = session.run( + result = session.run( """ - MATCH path = shortestPath( + MATCH path = shortestPath( (source:Entity {id: $source_id})-[*1..$max_depth]-(target:Entity {id: $target_id}) ) RETURN path """, - source_id=source_id, - target_id=target_id, - max_depth=max_depth, + source_id = source_id, + target_id = target_id, + max_depth = max_depth, ) - record = result.single() + record = result.single() if not record: return None - path = record["path"] + path = record["path"] # 提取节点和关系 - nodes = [ + nodes = [ {"id": node["id"], "name": node["name"], "type": node["type"]} for node in path.nodes ] - relationships = [ + relationships = [ { "source": rel.start_node["id"], "target": rel.end_node["id"], @@ -404,11 +404,11 @@ class Neo4jManager: ] return PathResult( - nodes=nodes, relationships=relationships, length=len(path.relationships) + nodes = nodes, relationships = relationships, length = len(path.relationships) ) def find_all_paths( - self, source_id: str, target_id: str, max_depth: int = 5, limit: int = 10 + self, source_id: str, target_id: str, max_depth: int = 5, limit: int = 10 ) -> list[PathResult]: """ 查找两个实体之间的所有路径 @@ -426,29 +426,29 @@ class Neo4jManager: return [] with self._driver.session() as session: - result = session.run( + result = session.run( """ - MATCH path = (source:Entity {id: $source_id})-[*1..$max_depth]-(target:Entity {id: $target_id}) + MATCH path = (source:Entity {id: $source_id})-[*1..$max_depth]-(target:Entity {id: $target_id}) WHERE source <> target RETURN path LIMIT $limit """, - source_id=source_id, - target_id=target_id, - max_depth=max_depth, - limit=limit, + source_id = source_id, + target_id = target_id, + max_depth = max_depth, + limit = limit, ) - paths = [] + paths = [] for record in result: - path = record["path"] + path = record["path"] - nodes = [ + nodes = [ {"id": node["id"], "name": node["name"], "type": node["type"]} for node in path.nodes ] - relationships = [ + relationships = [ { "source": rel.start_node["id"], "target": rel.end_node["id"], @@ -460,14 +460,14 @@ class Neo4jManager: paths.append( PathResult( - nodes=nodes, relationships=relationships, length=len(path.relationships) + nodes = nodes, relationships = relationships, length = len(path.relationships) ) ) return paths def find_neighbors( - self, entity_id: str, relation_type: str = None, limit: int = 50 + self, entity_id: str, relation_type: str = None, limit: int = 50 ) -> list[dict]: """ 查找实体的邻居节点 @@ -485,30 +485,30 @@ class Neo4jManager: with self._driver.session() as session: if relation_type: - result = session.run( + result = session.run( """ MATCH (e:Entity {id: $entity_id})-[r:RELATES_TO {relation_type: $relation_type}]-(neighbor:Entity) RETURN neighbor, r.relation_type as rel_type, r.evidence as evidence LIMIT $limit """, - entity_id=entity_id, - relation_type=relation_type, - limit=limit, + entity_id = entity_id, + relation_type = relation_type, + limit = limit, ) else: - result = session.run( + result = session.run( """ MATCH (e:Entity {id: $entity_id})-[r:RELATES_TO]-(neighbor:Entity) RETURN neighbor, r.relation_type as rel_type, r.evidence as evidence LIMIT $limit """, - entity_id=entity_id, - limit=limit, + entity_id = entity_id, + limit = limit, ) - neighbors = [] + neighbors = [] for record in result: - node = record["neighbor"] + node = record["neighbor"] neighbors.append( { "id": node["id"], @@ -536,13 +536,13 @@ class Neo4jManager: return [] with self._driver.session() as session: - result = session.run( + result = session.run( """ MATCH (e1:Entity {id: $id1})-[:RELATES_TO]-(common:Entity)-[:RELATES_TO]-(e2:Entity {id: $id2}) RETURN DISTINCT common """, - id1=entity_id1, - id2=entity_id2, + id1 = entity_id1, + id2 = entity_id2, ) return [ @@ -556,7 +556,7 @@ class Neo4jManager: # ==================== 图算法分析 ==================== - def calculate_pagerank(self, project_id: str, top_n: int = 20) -> list[CentralityResult]: + def calculate_pagerank(self, project_id: str, top_n: int = 20) -> list[CentralityResult]: """ 计算 PageRank 中心性 @@ -571,7 +571,7 @@ class Neo4jManager: return [] with self._driver.session() as session: - result = session.run( + result = session.run( """ CALL gds.graph.exists('project-graph-$project_id') YIELD exists WITH exists @@ -581,7 +581,7 @@ class Neo4jManager: {} ) YIELD value RETURN value """, - project_id=project_id, + project_id = project_id, ) # 创建临时图 @@ -601,11 +601,11 @@ class Neo4jManager: } ) """, - project_id=project_id, + project_id = project_id, ) # 运行 PageRank - result = session.run( + result = session.run( """ CALL gds.pageRank.stream('project-graph-$project_id') YIELD nodeId, score @@ -615,19 +615,19 @@ class Neo4jManager: ORDER BY score DESC LIMIT $top_n """, - project_id=project_id, - top_n=top_n, + project_id = project_id, + top_n = top_n, ) - rankings = [] - rank = 1 + rankings = [] + rank = 1 for record in result: rankings.append( CentralityResult( - entity_id=record["entity_id"], - entity_name=record["entity_name"], - score=record["score"], - rank=rank, + entity_id = record["entity_id"], + entity_name = record["entity_name"], + score = record["score"], + rank = rank, ) ) rank += 1 @@ -637,12 +637,12 @@ class Neo4jManager: """ CALL gds.graph.drop('project-graph-$project_id') """, - project_id=project_id, + project_id = project_id, ) return rankings - def calculate_betweenness(self, project_id: str, top_n: int = 20) -> list[CentralityResult]: + def calculate_betweenness(self, project_id: str, top_n: int = 20) -> list[CentralityResult]: """ 计算 Betweenness 中心性(桥梁作用) @@ -658,7 +658,7 @@ class Neo4jManager: with self._driver.session() as session: # 使用 APOC 的 betweenness 计算(如果没有 GDS) - result = session.run( + result = session.run( """ MATCH (e:Entity)-[:BELONGS_TO]->(p:Project {id: $project_id}) OPTIONAL MATCH (e)-[:RELATES_TO]-(other:Entity) @@ -667,19 +667,19 @@ class Neo4jManager: LIMIT $top_n RETURN e.id as entity_id, e.name as entity_name, degree as score """, - project_id=project_id, - top_n=top_n, + project_id = project_id, + top_n = top_n, ) - rankings = [] - rank = 1 + rankings = [] + rank = 1 for record in result: rankings.append( CentralityResult( - entity_id=record["entity_id"], - entity_name=record["entity_name"], - score=float(record["score"]), - rank=rank, + entity_id = record["entity_id"], + entity_name = record["entity_name"], + score = float(record["score"]), + rank = rank, ) ) rank += 1 @@ -701,7 +701,7 @@ class Neo4jManager: with self._driver.session() as session: # 简单的社区检测:基于连通分量 - result = session.run( + result = session.run( """ MATCH (e:Entity)-[:BELONGS_TO]->(p:Project {id: $project_id}) OPTIONAL MATCH (e)-[:RELATES_TO]-(other:Entity)-[:BELONGS_TO]->(p) @@ -710,25 +710,25 @@ class Neo4jManager: connections, size(connections) as connection_count ORDER BY connection_count DESC """, - project_id=project_id, + project_id = project_id, ) # 手动分组(基于连通性) - communities = {} + communities = {} for record in result: - entity_id = record["entity_id"] - connections = record["connections"] + entity_id = record["entity_id"] + connections = record["connections"] # 找到所属的社区 - found_community = None + found_community = None for comm_id, comm_data in communities.items(): if any(conn in comm_data["member_ids"] for conn in connections): - found_community = comm_id + found_community = comm_id break if found_community is None: - found_community = len(communities) - communities[found_community] = {"member_ids": set(), "nodes": []} + found_community = len(communities) + communities[found_community] = {"member_ids": set(), "nodes": []} communities[found_community]["member_ids"].add(entity_id) communities[found_community]["nodes"].append( @@ -741,27 +741,27 @@ class Neo4jManager: ) # 构建结果 - results = [] + results = [] for comm_id, comm_data in communities.items(): - nodes = comm_data["nodes"] - size = len(nodes) + nodes = comm_data["nodes"] + size = len(nodes) # 计算密度(简化版) - max_edges = size * (size - 1) / 2 if size > 1 else 1 - actual_edges = sum(n["connections"] for n in nodes) / 2 - density = actual_edges / max_edges if max_edges > 0 else 0 + max_edges = size * (size - 1) / 2 if size > 1 else 1 + actual_edges = sum(n["connections"] for n in nodes) / 2 + density = actual_edges / max_edges if max_edges > 0 else 0 results.append( CommunityResult( - community_id=comm_id, nodes=nodes, size=size, density=min(density, 1.0) + community_id = comm_id, nodes = nodes, size = size, density = min(density, 1.0) ) ) # 按大小排序 - results.sort(key=lambda x: x.size, reverse=True) + results.sort(key = lambda x: x.size, reverse = True) return results def find_central_entities( - self, project_id: str, metric: str = "degree" + self, project_id: str, metric: str = "degree" ) -> list[CentralityResult]: """ 查找中心实体 @@ -778,7 +778,7 @@ class Neo4jManager: with self._driver.session() as session: if metric == "degree": - result = session.run( + result = session.run( """ MATCH (e:Entity)-[:BELONGS_TO]->(p:Project {id: $project_id}) OPTIONAL MATCH (e)-[:RELATES_TO]-(other:Entity) @@ -787,11 +787,11 @@ class Neo4jManager: ORDER BY degree DESC LIMIT 20 """, - project_id=project_id, + project_id = project_id, ) else: # 默认使用度中心性 - result = session.run( + result = session.run( """ MATCH (e:Entity)-[:BELONGS_TO]->(p:Project {id: $project_id}) OPTIONAL MATCH (e)-[:RELATES_TO]-(other:Entity) @@ -800,18 +800,18 @@ class Neo4jManager: ORDER BY degree DESC LIMIT 20 """, - project_id=project_id, + project_id = project_id, ) - rankings = [] - rank = 1 + rankings = [] + rank = 1 for record in result: rankings.append( CentralityResult( - entity_id=record["entity_id"], - entity_name=record["entity_name"], - score=float(record["score"]), - rank=rank, + entity_id = record["entity_id"], + entity_name = record["entity_name"], + score = float(record["score"]), + rank = rank, ) ) rank += 1 @@ -835,49 +835,49 @@ class Neo4jManager: with self._driver.session() as session: # 实体数量 - entity_count = session.run( + entity_count = session.run( """ MATCH (e:Entity)-[:BELONGS_TO]->(p:Project {id: $project_id}) RETURN count(e) as count """, - project_id=project_id, + project_id = project_id, ).single()["count"] # 关系数量 - relation_count = session.run( + relation_count = session.run( """ MATCH (e:Entity)-[:BELONGS_TO]->(p:Project {id: $project_id}) MATCH (e)-[r:RELATES_TO]-() RETURN count(r) as count """, - project_id=project_id, + project_id = project_id, ).single()["count"] # 实体类型分布 - type_distribution = session.run( + type_distribution = session.run( """ MATCH (e:Entity)-[:BELONGS_TO]->(p:Project {id: $project_id}) RETURN e.type as type, count(e) as count ORDER BY count DESC """, - project_id=project_id, + project_id = project_id, ) - types = {record["type"]: record["count"] for record in type_distribution} + types = {record["type"]: record["count"] for record in type_distribution} # 平均度 - avg_degree = session.run( + avg_degree = session.run( """ MATCH (e:Entity)-[:BELONGS_TO]->(p:Project {id: $project_id}) OPTIONAL MATCH (e)-[:RELATES_TO]-(other) WITH e, count(other) as degree RETURN avg(degree) as avg_degree """, - project_id=project_id, + project_id = project_id, ).single()["avg_degree"] # 关系类型分布 - rel_types = session.run( + rel_types = session.run( """ MATCH (e:Entity)-[:BELONGS_TO]->(p:Project {id: $project_id}) MATCH (e)-[r:RELATES_TO]-() @@ -885,10 +885,10 @@ class Neo4jManager: ORDER BY count DESC LIMIT 10 """, - project_id=project_id, + project_id = project_id, ) - relation_types = {record["type"]: record["count"] for record in rel_types} + relation_types = {record["type"]: record["count"] for record in rel_types} return { "entity_count": entity_count, @@ -901,7 +901,7 @@ class Neo4jManager: else 0, } - def get_subgraph(self, entity_ids: list[str], depth: int = 1) -> dict: + def get_subgraph(self, entity_ids: list[str], depth: int = 1) -> dict: """ 获取指定实体的子图 @@ -916,7 +916,7 @@ class Neo4jManager: return {"nodes": [], "relationships": []} with self._driver.session() as session: - result = session.run( + result = session.run( """ MATCH (e:Entity) WHERE e.id IN $entity_ids @@ -927,14 +927,14 @@ class Neo4jManager: }) YIELD node RETURN DISTINCT node """, - entity_ids=entity_ids, - depth=depth, + entity_ids = entity_ids, + depth = depth, ) - nodes = [] - node_ids = set() + nodes = [] + node_ids = set() for record in result: - node = record["node"] + node = record["node"] node_ids.add(node["id"]) nodes.append( { @@ -946,17 +946,17 @@ class Neo4jManager: ) # 获取这些节点之间的关系 - result = session.run( + result = session.run( """ MATCH (source:Entity)-[r:RELATES_TO]->(target:Entity) WHERE source.id IN $node_ids AND target.id IN $node_ids RETURN source.id as source_id, target.id as target_id, r.relation_type as type, r.evidence as evidence """, - node_ids=list(node_ids), + node_ids = list(node_ids), ) - relationships = [ + relationships = [ { "source": record["source_id"], "target": record["target_id"], @@ -970,14 +970,14 @@ class Neo4jManager: # 全局单例 -_neo4j_manager = None +_neo4j_manager = None def get_neo4j_manager() -> Neo4jManager: """获取 Neo4j 管理器单例""" global _neo4j_manager if _neo4j_manager is None: - _neo4j_manager = Neo4jManager() + _neo4j_manager = Neo4jManager() return _neo4j_manager @@ -986,7 +986,7 @@ def close_neo4j_manager() -> None: global _neo4j_manager if _neo4j_manager: _neo4j_manager.close() - _neo4j_manager = None + _neo4j_manager = None # 便捷函数 @@ -1004,7 +1004,7 @@ def sync_project_to_neo4j( entities: 实体列表(字典格式) relations: 关系列表(字典格式) """ - manager = get_neo4j_manager() + manager = get_neo4j_manager() if not manager.is_connected(): logger.warning("Neo4j not connected, skipping sync") return @@ -1013,29 +1013,29 @@ def sync_project_to_neo4j( manager.sync_project(project_id, project_name) # 同步实体 - graph_entities = [ + graph_entities = [ GraphEntity( - id=e["id"], - project_id=project_id, - name=e["name"], - type=e.get("type", "unknown"), - definition=e.get("definition", ""), - aliases=e.get("aliases", []), - properties=e.get("properties", {}), + id = e["id"], + project_id = project_id, + name = e["name"], + type = e.get("type", "unknown"), + definition = e.get("definition", ""), + aliases = e.get("aliases", []), + properties = e.get("properties", {}), ) for e in entities ] manager.sync_entities_batch(graph_entities) # 同步关系 - graph_relations = [ + graph_relations = [ GraphRelation( - id=r["id"], - source_id=r["source_entity_id"], - target_id=r["target_entity_id"], - relation_type=r["relation_type"], - evidence=r.get("evidence", ""), - properties=r.get("properties", {}), + id = r["id"], + source_id = r["source_entity_id"], + target_id = r["target_entity_id"], + relation_type = r["relation_type"], + evidence = r.get("evidence", ""), + properties = r.get("properties", {}), ) for r in relations ] @@ -1048,9 +1048,9 @@ def sync_project_to_neo4j( if __name__ == "__main__": # 测试代码 - logging.basicConfig(level=logging.INFO) + logging.basicConfig(level = logging.INFO) - manager = Neo4jManager() + manager = Neo4jManager() if manager.is_connected(): print("✅ Connected to Neo4j") @@ -1064,18 +1064,18 @@ if __name__ == "__main__": print("✅ Project synced") # 测试实体 - test_entity = GraphEntity( - id="test-entity-1", - project_id="test-project", - name="Test Entity", - type="Person", - definition="A test entity", + test_entity = GraphEntity( + id = "test-entity-1", + project_id = "test-project", + name = "Test Entity", + type = "Person", + definition = "A test entity", ) manager.sync_entity(test_entity) print("✅ Entity synced") # 获取统计 - stats = manager.get_graph_stats("test-project") + stats = manager.get_graph_stats("test-project") print(f"📊 Graph stats: {stats}") else: diff --git a/backend/ops_manager.py b/backend/ops_manager.py index 5a2ede9..ba80dfc 100644 --- a/backend/ops_manager.py +++ b/backend/ops_manager.py @@ -27,87 +27,87 @@ from enum import StrEnum import httpx # Database path -DB_PATH = os.path.join(os.path.dirname(__file__), "insightflow.db") +DB_PATH = os.path.join(os.path.dirname(__file__), "insightflow.db") class AlertSeverity(StrEnum): """告警严重级别 P0-P3""" - P0 = "p0" # 紧急 - 系统不可用,需要立即处理 - P1 = "p1" # 严重 - 核心功能受损,需要1小时内处理 - P2 = "p2" # 一般 - 部分功能受影响,需要4小时内处理 - P3 = "p3" # 轻微 - 非核心功能问题,24小时内处理 + P0 = "p0" # 紧急 - 系统不可用,需要立即处理 + P1 = "p1" # 严重 - 核心功能受损,需要1小时内处理 + P2 = "p2" # 一般 - 部分功能受影响,需要4小时内处理 + P3 = "p3" # 轻微 - 非核心功能问题,24小时内处理 class AlertStatus(StrEnum): """告警状态""" - FIRING = "firing" # 正在告警 - RESOLVED = "resolved" # 已恢复 - ACKNOWLEDGED = "acknowledged" # 已确认 - SUPPRESSED = "suppressed" # 已抑制 + FIRING = "firing" # 正在告警 + RESOLVED = "resolved" # 已恢复 + ACKNOWLEDGED = "acknowledged" # 已确认 + SUPPRESSED = "suppressed" # 已抑制 class AlertChannelType(StrEnum): """告警渠道类型""" - PAGERDUTY = "pagerduty" - OPSGENIE = "opsgenie" - FEISHU = "feishu" - DINGTALK = "dingtalk" - SLACK = "slack" - EMAIL = "email" - SMS = "sms" - WEBHOOK = "webhook" + PAGERDUTY = "pagerduty" + OPSGENIE = "opsgenie" + FEISHU = "feishu" + DINGTALK = "dingtalk" + SLACK = "slack" + EMAIL = "email" + SMS = "sms" + WEBHOOK = "webhook" class AlertRuleType(StrEnum): """告警规则类型""" - THRESHOLD = "threshold" # 阈值告警 - ANOMALY = "anomaly" # 异常检测 - PREDICTIVE = "predictive" # 预测性告警 - COMPOSITE = "composite" # 复合告警 + THRESHOLD = "threshold" # 阈值告警 + ANOMALY = "anomaly" # 异常检测 + PREDICTIVE = "predictive" # 预测性告警 + COMPOSITE = "composite" # 复合告警 class ResourceType(StrEnum): """资源类型""" - CPU = "cpu" - MEMORY = "memory" - DISK = "disk" - NETWORK = "network" - GPU = "gpu" - DATABASE = "database" - CACHE = "cache" - QUEUE = "queue" + CPU = "cpu" + MEMORY = "memory" + DISK = "disk" + NETWORK = "network" + GPU = "gpu" + DATABASE = "database" + CACHE = "cache" + QUEUE = "queue" class ScalingAction(StrEnum): """扩缩容动作""" - SCALE_UP = "scale_up" # 扩容 - SCALE_DOWN = "scale_down" # 缩容 - MAINTAIN = "maintain" # 保持 + SCALE_UP = "scale_up" # 扩容 + SCALE_DOWN = "scale_down" # 缩容 + MAINTAIN = "maintain" # 保持 class HealthStatus(StrEnum): """健康状态""" - HEALTHY = "healthy" - DEGRADED = "degraded" - UNHEALTHY = "unhealthy" - UNKNOWN = "unknown" + HEALTHY = "healthy" + DEGRADED = "degraded" + UNHEALTHY = "unhealthy" + UNKNOWN = "unknown" class BackupStatus(StrEnum): """备份状态""" - PENDING = "pending" - IN_PROGRESS = "in_progress" - COMPLETED = "completed" - FAILED = "failed" - VERIFIED = "verified" + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + FAILED = "failed" + VERIFIED = "verified" @dataclass @@ -121,7 +121,7 @@ class AlertRule: rule_type: AlertRuleType severity: AlertSeverity metric: str # 监控指标 - condition: str # 条件: >, <, ==, >=, <=, != + condition: str # 条件: >, <, ==, >= , <= , != threshold: float duration: int # 持续时间(秒) evaluation_interval: int # 评估间隔(秒) @@ -449,24 +449,24 @@ class CostOptimizationSuggestion: class OpsManager: """运维与监控管理主类""" - def __init__(self, db_path: str = DB_PATH): - self.db_path = db_path - self._alert_evaluators: dict[str, Callable] = {} - self._running = False - self._evaluator_thread = None + def __init__(self, db_path: str = DB_PATH) -> None: + self.db_path = db_path + self._alert_evaluators: dict[str, Callable] = {} + self._running = False + self._evaluator_thread = None self._register_default_evaluators() def _get_db(self) -> None: """获取数据库连接""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn def _register_default_evaluators(self) -> None: """注册默认的告警评估器""" - self._alert_evaluators[AlertRuleType.THRESHOLD.value] = self._evaluate_threshold_rule - self._alert_evaluators[AlertRuleType.ANOMALY.value] = self._evaluate_anomaly_rule - self._alert_evaluators[AlertRuleType.PREDICTIVE.value] = self._evaluate_predictive_rule + self._alert_evaluators[AlertRuleType.THRESHOLD.value] = self._evaluate_threshold_rule + self._alert_evaluators[AlertRuleType.ANOMALY.value] = self._evaluate_anomaly_rule + self._alert_evaluators[AlertRuleType.PREDICTIVE.value] = self._evaluate_predictive_rule # ==================== 告警规则管理 ==================== @@ -488,28 +488,28 @@ class OpsManager: created_by: str, ) -> AlertRule: """创建告警规则""" - rule_id = f"ar_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + rule_id = f"ar_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - rule = AlertRule( - id=rule_id, - tenant_id=tenant_id, - name=name, - description=description, - rule_type=rule_type, - severity=severity, - metric=metric, - condition=condition, - threshold=threshold, - duration=duration, - evaluation_interval=evaluation_interval, - channels=channels, - labels=labels or {}, - annotations=annotations or {}, - is_enabled=True, - created_at=now, - updated_at=now, - created_by=created_by, + rule = AlertRule( + id = rule_id, + tenant_id = tenant_id, + name = name, + description = description, + rule_type = rule_type, + severity = severity, + metric = metric, + condition = condition, + threshold = threshold, + duration = duration, + evaluation_interval = evaluation_interval, + channels = channels, + labels = labels or {}, + annotations = annotations or {}, + is_enabled = True, + created_at = now, + updated_at = now, + created_by = created_by, ) with self._get_db() as conn: @@ -549,16 +549,16 @@ class OpsManager: def get_alert_rule(self, rule_id: str) -> AlertRule | None: """获取告警规则""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM alert_rules WHERE id = ?", (rule_id,)).fetchone() + row = conn.execute("SELECT * FROM alert_rules WHERE id = ?", (rule_id, )).fetchone() if row: return self._row_to_alert_rule(row) return None - def list_alert_rules(self, tenant_id: str, is_enabled: bool | None = None) -> list[AlertRule]: + def list_alert_rules(self, tenant_id: str, is_enabled: bool | None = None) -> list[AlertRule]: """列出租户的所有告警规则""" - query = "SELECT * FROM alert_rules WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM alert_rules WHERE tenant_id = ?" + params = [tenant_id] if is_enabled is not None: query += " AND is_enabled = ?" @@ -567,12 +567,12 @@ class OpsManager: query += " ORDER BY created_at DESC" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_alert_rule(row) for row in rows] def update_alert_rule(self, rule_id: str, **kwargs) -> AlertRule | None: """更新告警规则""" - allowed_fields = [ + allowed_fields = [ "name", "description", "severity", @@ -587,26 +587,26 @@ class OpsManager: "is_enabled", ] - updates = {k: v for k, v in kwargs.items() if k in allowed_fields} + updates = {k: v for k, v in kwargs.items() if k in allowed_fields} if not updates: return self.get_alert_rule(rule_id) # 处理列表和字典字段 if "channels" in updates: - updates["channels"] = json.dumps(updates["channels"]) + updates["channels"] = json.dumps(updates["channels"]) if "labels" in updates: - updates["labels"] = json.dumps(updates["labels"]) + updates["labels"] = json.dumps(updates["labels"]) if "annotations" in updates: - updates["annotations"] = json.dumps(updates["annotations"]) + updates["annotations"] = json.dumps(updates["annotations"]) if "severity" in updates and isinstance(updates["severity"], AlertSeverity): - updates["severity"] = updates["severity"].value + updates["severity"] = updates["severity"].value - updates["updated_at"] = datetime.now().isoformat() + updates["updated_at"] = datetime.now().isoformat() with self._get_db() as conn: - set_clause = ", ".join([f"{k} = ?" for k in updates.keys()]) + set_clause = ", ".join([f"{k} = ?" for k in updates.keys()]) conn.execute( - f"UPDATE alert_rules SET {set_clause} WHERE id = ?", + f"UPDATE alert_rules SET {set_clause} WHERE id = ?", list(updates.values()) + [rule_id], ) conn.commit() @@ -616,7 +616,7 @@ class OpsManager: def delete_alert_rule(self, rule_id: str) -> bool: """删除告警规则""" with self._get_db() as conn: - conn.execute("DELETE FROM alert_rules WHERE id = ?", (rule_id,)) + conn.execute("DELETE FROM alert_rules WHERE id = ?", (rule_id, )) conn.commit() return conn.total_changes > 0 @@ -628,25 +628,25 @@ class OpsManager: name: str, channel_type: AlertChannelType, config: dict, - severity_filter: list[str] = None, + severity_filter: list[str] = None, ) -> AlertChannel: """创建告警渠道""" - channel_id = f"ac_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + channel_id = f"ac_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - channel = AlertChannel( - id=channel_id, - tenant_id=tenant_id, - name=name, - channel_type=channel_type, - config=config, - severity_filter=severity_filter or [s.value for s in AlertSeverity], - is_enabled=True, - success_count=0, - fail_count=0, - last_used_at=None, - created_at=now, - updated_at=now, + channel = AlertChannel( + id = channel_id, + tenant_id = tenant_id, + name = name, + channel_type = channel_type, + config = config, + severity_filter = severity_filter or [s.value for s in AlertSeverity], + is_enabled = True, + success_count = 0, + fail_count = 0, + last_used_at = None, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -679,8 +679,8 @@ class OpsManager: def get_alert_channel(self, channel_id: str) -> AlertChannel | None: """获取告警渠道""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM alert_channels WHERE id = ?", (channel_id,) + row = conn.execute( + "SELECT * FROM alert_channels WHERE id = ?", (channel_id, ) ).fetchone() if row: @@ -690,37 +690,37 @@ class OpsManager: def list_alert_channels(self, tenant_id: str) -> list[AlertChannel]: """列出租户的所有告警渠道""" with self._get_db() as conn: - rows = conn.execute( - "SELECT * FROM alert_channels WHERE tenant_id = ? ORDER BY created_at DESC", - (tenant_id,), + rows = conn.execute( + "SELECT * FROM alert_channels WHERE tenant_id = ? ORDER BY created_at DESC", + (tenant_id, ), ).fetchall() return [self._row_to_alert_channel(row) for row in rows] def test_alert_channel(self, channel_id: str) -> bool: """测试告警渠道""" - channel = self.get_alert_channel(channel_id) + channel = self.get_alert_channel(channel_id) if not channel: return False - test_alert = Alert( - id="test", - rule_id="test", - tenant_id=channel.tenant_id, - severity=AlertSeverity.P3, - status=AlertStatus.FIRING, - title="测试告警", - description="这是一条测试告警消息,用于验证告警渠道配置。", - metric="test_metric", - value=0.0, - threshold=0.0, - labels={"test": "true"}, - annotations={}, - started_at=datetime.now().isoformat(), - resolved_at=None, - acknowledged_by=None, - acknowledged_at=None, - notification_sent={}, - suppression_count=0, + test_alert = Alert( + id = "test", + rule_id = "test", + tenant_id = channel.tenant_id, + severity = AlertSeverity.P3, + status = AlertStatus.FIRING, + title = "测试告警", + description = "这是一条测试告警消息,用于验证告警渠道配置。", + metric = "test_metric", + value = 0.0, + threshold = 0.0, + labels = {"test": "true"}, + annotations = {}, + started_at = datetime.now().isoformat(), + resolved_at = None, + acknowledged_by = None, + acknowledged_at = None, + notification_sent = {}, + suppression_count = 0, ) return asyncio.run(self._send_alert_to_channel(test_alert, channel)) @@ -733,14 +733,14 @@ class OpsManager: return False # 获取最近 duration 秒内的指标 - cutoff_time = datetime.now() - timedelta(seconds=rule.duration) - recent_metrics = [m for m in metrics if datetime.fromisoformat(m.timestamp) > cutoff_time] + cutoff_time = datetime.now() - timedelta(seconds = rule.duration) + recent_metrics = [m for m in metrics if datetime.fromisoformat(m.timestamp) > cutoff_time] if not recent_metrics: return False # 计算平均值 - avg_value = statistics.mean([m.metric_value for m in recent_metrics]) + avg_value = statistics.mean([m.metric_value for m in recent_metrics]) # 评估条件 condition_map = { @@ -752,7 +752,7 @@ class OpsManager: "!=": lambda x, y: x != y, } - evaluator = condition_map.get(rule.condition) + evaluator = condition_map.get(rule.condition) if evaluator: return evaluator(avg_value, rule.threshold) @@ -763,16 +763,16 @@ class OpsManager: if len(metrics) < 10: return False - values = [m.metric_value for m in metrics] - mean = statistics.mean(values) - std = statistics.stdev(values) if len(values) > 1 else 0 + values = [m.metric_value for m in metrics] + mean = statistics.mean(values) + std = statistics.stdev(values) if len(values) > 1 else 0 if std == 0: return False # 最近值偏离均值超过3个标准差视为异常 - latest_value = values[-1] - z_score = abs(latest_value - mean) / std + latest_value = values[-1] + z_score = abs(latest_value - mean) / std return z_score > 3.0 @@ -782,15 +782,15 @@ class OpsManager: return False # 简单的线性趋势预测 - values = [m.metric_value for m in metrics[-10:]] # 最近10个点 - n = len(values) + values = [m.metric_value for m in metrics[-10:]] # 最近10个点 + n = len(values) if n < 2: return False - x = list(range(n)) - mean_x = sum(x) / n - mean_y = sum(values) / n + x = list(range(n)) + mean_x = sum(x) / n + mean_y = sum(values) / n # 计算斜率 numerator = sum((x[i] - mean_x) * (values[i] - mean_y) for i in range(n)) @@ -801,37 +801,37 @@ class OpsManager: predicted = values[-1] + slope # 如果预测值超过阈值,触发告警 - condition_map = { + condition_map = { ">": lambda x, y: x > y, "<": lambda x, y: x < y, } - evaluator = condition_map.get(rule.condition) + evaluator = condition_map.get(rule.condition) if evaluator: return evaluator(predicted, rule.threshold) return False - async def evaluate_alert_rules(self, tenant_id: str): + async def evaluate_alert_rules(self, tenant_id: str) -> None: """评估所有告警规则""" - rules = self.list_alert_rules(tenant_id, is_enabled=True) + rules = self.list_alert_rules(tenant_id, is_enabled = True) for rule in rules: # 获取相关指标 - metrics = self.get_recent_metrics( - tenant_id, rule.metric, seconds=rule.duration + rule.evaluation_interval + metrics = self.get_recent_metrics( + tenant_id, rule.metric, seconds = rule.duration + rule.evaluation_interval ) # 评估规则 - evaluator = self._alert_evaluators.get(rule.rule_type.value) + evaluator = self._alert_evaluators.get(rule.rule_type.value) if evaluator and evaluator(rule, metrics): # 触发告警 await self._trigger_alert(rule, metrics[-1] if metrics else None) - async def _trigger_alert(self, rule: AlertRule, metric: ResourceMetric | None): + async def _trigger_alert(self, rule: AlertRule, metric: ResourceMetric | None) -> None: """触发告警""" # 检查是否已有相同告警在触发中 - existing = self.get_active_alert_by_rule(rule.id) + existing = self.get_active_alert_by_rule(rule.id) if existing: # 更新抑制计数 self._increment_suppression_count(existing.id) @@ -841,28 +841,28 @@ class OpsManager: if self._is_alert_suppressed(rule): return - alert_id = f"al_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + alert_id = f"al_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - alert = Alert( - id=alert_id, - rule_id=rule.id, - tenant_id=rule.tenant_id, - severity=rule.severity, - status=AlertStatus.FIRING, - title=rule.annotations.get("summary", f"告警: {rule.name}"), - description=rule.annotations.get("description", rule.description), - metric=rule.metric, - value=metric.metric_value if metric else 0.0, - threshold=rule.threshold, - labels=rule.labels, - annotations=rule.annotations, - started_at=now, - resolved_at=None, - acknowledged_by=None, - acknowledged_at=None, - notification_sent={}, - suppression_count=0, + alert = Alert( + id = alert_id, + rule_id = rule.id, + tenant_id = rule.tenant_id, + severity = rule.severity, + status = AlertStatus.FIRING, + title = rule.annotations.get("summary", f"告警: {rule.name}"), + description = rule.annotations.get("description", rule.description), + metric = rule.metric, + value = metric.metric_value if metric else 0.0, + threshold = rule.threshold, + labels = rule.labels, + annotations = rule.annotations, + started_at = now, + resolved_at = None, + acknowledged_by = None, + acknowledged_at = None, + notification_sent = {}, + suppression_count = 0, ) # 保存告警 @@ -898,11 +898,11 @@ class OpsManager: # 发送告警通知 await self._send_alert_notifications(alert, rule) - async def _send_alert_notifications(self, alert: Alert, rule: AlertRule): + async def _send_alert_notifications(self, alert: Alert, rule: AlertRule) -> None: """发送告警通知到所有配置的渠道""" - channels = [] + channels = [] for channel_id in rule.channels: - channel = self.get_alert_channel(channel_id) + channel = self.get_alert_channel(channel_id) if channel and channel.is_enabled: channels.append(channel) @@ -911,10 +911,10 @@ class OpsManager: if alert.severity.value not in channel.severity_filter: continue - success = await self._send_alert_to_channel(alert, channel) + success = await self._send_alert_to_channel(alert, channel) # 更新发送状态 - alert.notification_sent[channel.id] = success + alert.notification_sent[channel.id] = success self._update_alert_notification_status(alert.id, channel.id, success) async def _send_alert_to_channel(self, alert: Alert, channel: AlertChannel) -> bool: @@ -942,22 +942,22 @@ class OpsManager: async def _send_feishu_alert(self, alert: Alert, channel: AlertChannel) -> bool: """发送飞书告警""" - config = channel.config - webhook_url = config.get("webhook_url") + config = channel.config + webhook_url = config.get("webhook_url") config.get("secret", "") if not webhook_url: return False # 构建飞书消息 - severity_colors = { + severity_colors = { AlertSeverity.P0.value: "red", AlertSeverity.P1.value: "orange", AlertSeverity.P2.value: "yellow", AlertSeverity.P3.value: "blue", } - message = { + message = { "msg_type": "interactive", "card": { "config": {"wide_screen_mode": True}, @@ -990,27 +990,27 @@ class OpsManager: } async with httpx.AsyncClient() as client: - response = await client.post(webhook_url, json=message, timeout=30.0) - success = response.status_code == 200 + response = await client.post(webhook_url, json = message, timeout = 30.0) + success = response.status_code == 200 if success: - self._update_channel_stats(channel.id, success=True) + self._update_channel_stats(channel.id, success = True) else: - self._update_channel_stats(channel.id, success=False) + self._update_channel_stats(channel.id, success = False) return success async def _send_dingtalk_alert(self, alert: Alert, channel: AlertChannel) -> bool: """发送钉钉告警""" - config = channel.config - webhook_url = config.get("webhook_url") + config = channel.config + webhook_url = config.get("webhook_url") config.get("secret", "") if not webhook_url: return False # 构建钉钉消息 - message = { + message = { "msgtype": "markdown", "markdown": { "title": f"[{alert.severity.value.upper()}] {alert.title}", @@ -1024,29 +1024,29 @@ class OpsManager: } async with httpx.AsyncClient() as client: - response = await client.post(webhook_url, json=message, timeout=30.0) - success = response.status_code == 200 + response = await client.post(webhook_url, json = message, timeout = 30.0) + success = response.status_code == 200 self._update_channel_stats(channel.id, success) return success async def _send_slack_alert(self, alert: Alert, channel: AlertChannel) -> bool: """发送 Slack 告警""" - config = channel.config - webhook_url = config.get("webhook_url") + config = channel.config + webhook_url = config.get("webhook_url") if not webhook_url: return False - severity_emojis = { + severity_emojis = { AlertSeverity.P0.value: "🔴", AlertSeverity.P1.value: "🟠", AlertSeverity.P2.value: "🟡", AlertSeverity.P3.value: "🔵", } - emoji = severity_emojis.get(alert.severity.value, "⚪") + emoji = severity_emojis.get(alert.severity.value, "⚪") - message = { + message = { "text": f"{emoji} [{alert.severity.value.upper()}] {alert.title}", "blocks": [ { @@ -1073,20 +1073,20 @@ class OpsManager: } async with httpx.AsyncClient() as client: - response = await client.post(webhook_url, json=message, timeout=30.0) - success = response.status_code == 200 + response = await client.post(webhook_url, json = message, timeout = 30.0) + success = response.status_code == 200 self._update_channel_stats(channel.id, success) return success async def _send_email_alert(self, alert: Alert, channel: AlertChannel) -> bool: """发送邮件告警(模拟实现)""" # 实际实现需要集成邮件服务如 SendGrid、AWS SES 等 - config = channel.config - smtp_host = config.get("smtp_host") + config = channel.config + smtp_host = config.get("smtp_host") config.get("smtp_port", 587) - username = config.get("username") - password = config.get("password") - to_addresses = config.get("to_addresses", []) + username = config.get("username") + password = config.get("password") + to_addresses = config.get("to_addresses", []) if not all([smtp_host, username, password, to_addresses]): return False @@ -1097,20 +1097,20 @@ class OpsManager: async def _send_pagerduty_alert(self, alert: Alert, channel: AlertChannel) -> bool: """发送 PagerDuty 告警""" - config = channel.config - integration_key = config.get("integration_key") + config = channel.config + integration_key = config.get("integration_key") if not integration_key: return False - severity_map = { + severity_map = { AlertSeverity.P0.value: "critical", AlertSeverity.P1.value: "error", AlertSeverity.P2.value: "warning", AlertSeverity.P3.value: "info", } - message = { + message = { "routing_key": integration_key, "event_action": "trigger", "dedup_key": alert.id, @@ -1128,29 +1128,29 @@ class OpsManager: } async with httpx.AsyncClient() as client: - response = await client.post( - "https://events.pagerduty.com/v2/enqueue", json=message, timeout=30.0 + response = await client.post( + "https://events.pagerduty.com/v2/enqueue", json = message, timeout = 30.0 ) - success = response.status_code == 202 + success = response.status_code == 202 self._update_channel_stats(channel.id, success) return success async def _send_opsgenie_alert(self, alert: Alert, channel: AlertChannel) -> bool: """发送 Opsgenie 告警""" - config = channel.config - api_key = config.get("api_key") + config = channel.config + api_key = config.get("api_key") if not api_key: return False - priority_map = { + priority_map = { AlertSeverity.P0.value: "P1", AlertSeverity.P1.value: "P2", AlertSeverity.P2.value: "P3", AlertSeverity.P3.value: "P4", } - message = { + message = { "message": alert.title, "description": alert.description, "priority": priority_map.get(alert.severity.value, "P3"), @@ -1163,26 +1163,26 @@ class OpsManager: } async with httpx.AsyncClient() as client: - response = await client.post( + response = await client.post( "https://api.opsgenie.com/v2/alerts", - json=message, - headers={"Authorization": f"GenieKey {api_key}"}, - timeout=30.0, + json = message, + headers = {"Authorization": f"GenieKey {api_key}"}, + timeout = 30.0, ) - success = response.status_code in [200, 201, 202] + success = response.status_code in [200, 201, 202] self._update_channel_stats(channel.id, success) return success async def _send_webhook_alert(self, alert: Alert, channel: AlertChannel) -> bool: """发送 Webhook 告警""" - config = channel.config - webhook_url = config.get("webhook_url") - headers = config.get("headers", {}) + config = channel.config + webhook_url = config.get("webhook_url") + headers = config.get("headers", {}) if not webhook_url: return False - message = { + message = { "alert_id": alert.id, "severity": alert.severity.value, "status": alert.status.value, @@ -1196,8 +1196,8 @@ class OpsManager: } async with httpx.AsyncClient() as client: - response = await client.post(webhook_url, json=message, headers=headers, timeout=30.0) - success = response.status_code in [200, 201, 202] + response = await client.post(webhook_url, json = message, headers = headers, timeout = 30.0) + success = response.status_code in [200, 201, 202] self._update_channel_stats(channel.id, success) return success @@ -1206,9 +1206,9 @@ class OpsManager: def get_active_alert_by_rule(self, rule_id: str) -> Alert | None: """获取规则对应的活跃告警""" with self._get_db() as conn: - row = conn.execute( + row = conn.execute( """SELECT * FROM alerts - WHERE rule_id = ? AND status = ? + WHERE rule_id = ? AND status = ? ORDER BY started_at DESC LIMIT 1""", (rule_id, AlertStatus.FIRING.value), ).fetchone() @@ -1220,7 +1220,7 @@ class OpsManager: def get_alert(self, alert_id: str) -> Alert | None: """获取告警详情""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM alerts WHERE id = ?", (alert_id,)).fetchone() + row = conn.execute("SELECT * FROM alerts WHERE id = ?", (alert_id, )).fetchone() if row: return self._row_to_alert(row) @@ -1229,38 +1229,38 @@ class OpsManager: def list_alerts( self, tenant_id: str, - status: AlertStatus | None = None, - severity: AlertSeverity | None = None, - limit: int = 100, + status: AlertStatus | None = None, + severity: AlertSeverity | None = None, + limit: int = 100, ) -> list[Alert]: """列出租户的告警""" - query = "SELECT * FROM alerts WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM alerts WHERE tenant_id = ?" + params = [tenant_id] if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status.value) if severity: - query += " AND severity = ?" + query += " AND severity = ?" params.append(severity.value) query += " ORDER BY started_at DESC LIMIT ?" params.append(limit) with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_alert(row) for row in rows] def acknowledge_alert(self, alert_id: str, user_id: str) -> Alert | None: """确认告警""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE alerts - SET status = ?, acknowledged_by = ?, acknowledged_at = ? - WHERE id = ? + SET status = ?, acknowledged_by = ?, acknowledged_at = ? + WHERE id = ? """, (AlertStatus.ACKNOWLEDGED.value, user_id, now, alert_id), ) @@ -1270,14 +1270,14 @@ class OpsManager: def resolve_alert(self, alert_id: str) -> Alert | None: """解决告警""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE alerts - SET status = ?, resolved_at = ? - WHERE id = ? + SET status = ?, resolved_at = ? + WHERE id = ? """, (AlertStatus.RESOLVED.value, now, alert_id), ) @@ -1291,10 +1291,10 @@ class OpsManager: conn.execute( """ UPDATE alerts - SET suppression_count = suppression_count + 1 - WHERE id = ? + SET suppression_count = suppression_count + 1 + WHERE id = ? """, - (alert_id,), + (alert_id, ), ) conn.commit() @@ -1303,31 +1303,31 @@ class OpsManager: ) -> None: """更新告警通知状态""" with self._get_db() as conn: - row = conn.execute( - "SELECT notification_sent FROM alerts WHERE id = ?", (alert_id,) + row = conn.execute( + "SELECT notification_sent FROM alerts WHERE id = ?", (alert_id, ) ).fetchone() if row: - notification_sent = json.loads(row["notification_sent"]) - notification_sent[channel_id] = success + notification_sent = json.loads(row["notification_sent"]) + notification_sent[channel_id] = success conn.execute( - "UPDATE alerts SET notification_sent = ? WHERE id = ?", + "UPDATE alerts SET notification_sent = ? WHERE id = ?", (json.dumps(notification_sent), alert_id), ) conn.commit() def _update_channel_stats(self, channel_id: str, success: bool) -> None: """更新渠道统计""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: if success: conn.execute( """ UPDATE alert_channels - SET success_count = success_count + 1, last_used_at = ? - WHERE id = ? + SET success_count = success_count + 1, last_used_at = ? + WHERE id = ? """, (now, channel_id), ) @@ -1335,8 +1335,8 @@ class OpsManager: conn.execute( """ UPDATE alert_channels - SET fail_count = fail_count + 1, last_used_at = ? - WHERE id = ? + SET fail_count = fail_count + 1, last_used_at = ? + WHERE id = ? """, (now, channel_id), ) @@ -1350,22 +1350,22 @@ class OpsManager: name: str, matchers: dict[str, str], duration: int, - is_regex: bool = False, - expires_at: str | None = None, + is_regex: bool = False, + expires_at: str | None = None, ) -> AlertSuppressionRule: """创建告警抑制规则""" - rule_id = f"sr_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + rule_id = f"sr_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - rule = AlertSuppressionRule( - id=rule_id, - tenant_id=tenant_id, - name=name, - matchers=matchers, - duration=duration, - is_regex=is_regex, - created_at=now, - expires_at=expires_at, + rule = AlertSuppressionRule( + id = rule_id, + tenant_id = tenant_id, + name = name, + matchers = matchers, + duration = duration, + is_regex = is_regex, + created_at = now, + expires_at = expires_at, ) with self._get_db() as conn: @@ -1393,12 +1393,12 @@ class OpsManager: def _is_alert_suppressed(self, rule: AlertRule) -> bool: """检查告警是否被抑制""" with self._get_db() as conn: - rows = conn.execute( - "SELECT * FROM alert_suppression_rules WHERE tenant_id = ?", (rule.tenant_id,) + rows = conn.execute( + "SELECT * FROM alert_suppression_rules WHERE tenant_id = ?", (rule.tenant_id, ) ).fetchall() for row in rows: - suppression_rule = self._row_to_suppression_rule(row) + suppression_rule = self._row_to_suppression_rule(row) # 检查是否过期 if suppression_rule.expires_at: @@ -1406,19 +1406,19 @@ class OpsManager: continue # 检查匹配 - matchers = suppression_rule.matchers - match = True + matchers = suppression_rule.matchers + match = True for key, pattern in matchers.items(): - value = rule.labels.get(key, "") + value = rule.labels.get(key, "") if suppression_rule.is_regex: if not re.match(pattern, value): - match = False + match = False break else: if value != pattern: - match = False + match = False break if match: @@ -1436,22 +1436,22 @@ class OpsManager: metric_name: str, metric_value: float, unit: str, - metadata: dict = None, + metadata: dict = None, ) -> ResourceMetric: """记录资源指标""" - metric_id = f"rm_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + metric_id = f"rm_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - metric = ResourceMetric( - id=metric_id, - tenant_id=tenant_id, - resource_type=resource_type, - resource_id=resource_id, - metric_name=metric_name, - metric_value=metric_value, - unit=unit, - timestamp=now, - metadata=metadata or {}, + metric = ResourceMetric( + id = metric_id, + tenant_id = tenant_id, + resource_type = resource_type, + resource_id = resource_id, + metric_name = metric_name, + metric_value = metric_value, + unit = unit, + timestamp = now, + metadata = metadata or {}, ) with self._get_db() as conn: @@ -1479,15 +1479,15 @@ class OpsManager: return metric def get_recent_metrics( - self, tenant_id: str, metric_name: str, seconds: int = 3600 + self, tenant_id: str, metric_name: str, seconds: int = 3600 ) -> list[ResourceMetric]: """获取最近的指标数据""" - cutoff_time = (datetime.now() - timedelta(seconds=seconds)).isoformat() + cutoff_time = (datetime.now() - timedelta(seconds = seconds)).isoformat() with self._get_db() as conn: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM resource_metrics - WHERE tenant_id = ? AND metric_name = ? AND timestamp > ? + WHERE tenant_id = ? AND metric_name = ? AND timestamp > ? ORDER BY timestamp DESC""", (tenant_id, metric_name, cutoff_time), ).fetchall() @@ -1505,10 +1505,10 @@ class OpsManager: ) -> list[ResourceMetric]: """获取指定资源的指标数据""" with self._get_db() as conn: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM resource_metrics - WHERE tenant_id = ? AND resource_type = ? AND resource_id = ? - AND metric_name = ? AND timestamp BETWEEN ? AND ? + WHERE tenant_id = ? AND resource_type = ? AND resource_id = ? + AND metric_name = ? AND timestamp BETWEEN ? AND ? ORDER BY timestamp ASC""", (tenant_id, resource_type.value, resource_id, metric_name, start_time, end_time), ).fetchall() @@ -1523,51 +1523,51 @@ class OpsManager: resource_type: ResourceType, current_capacity: float, prediction_date: str, - confidence: float = 0.8, + confidence: float = 0.8, ) -> CapacityPlan: """创建容量规划""" - plan_id = f"cp_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + plan_id = f"cp_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() # 基于历史数据预测 - metrics = self.get_recent_metrics( - tenant_id, f"{resource_type.value}_usage", seconds=30 * 24 * 3600 + metrics = self.get_recent_metrics( + tenant_id, f"{resource_type.value}_usage", seconds = 30 * 24 * 3600 ) if metrics: - values = [m.metric_value for m in metrics] - trend = self._calculate_trend(values) + values = [m.metric_value for m in metrics] + trend = self._calculate_trend(values) # 预测未来容量需求 - days_ahead = (datetime.fromisoformat(prediction_date) - datetime.now()).days - predicted_capacity = current_capacity * (1 + trend * days_ahead / 30) + days_ahead = (datetime.fromisoformat(prediction_date) - datetime.now()).days + predicted_capacity = current_capacity * (1 + trend * days_ahead / 30) # 推荐操作 if predicted_capacity > current_capacity * 1.2: - recommended_action = "scale_up" - estimated_cost = (predicted_capacity - current_capacity) * 10 # 简化计算 + recommended_action = "scale_up" + estimated_cost = (predicted_capacity - current_capacity) * 10 # 简化计算 elif predicted_capacity < current_capacity * 0.5: - recommended_action = "scale_down" - estimated_cost = 0 + recommended_action = "scale_down" + estimated_cost = 0 else: - recommended_action = "maintain" - estimated_cost = 0 + recommended_action = "maintain" + estimated_cost = 0 else: - predicted_capacity = current_capacity - recommended_action = "insufficient_data" - estimated_cost = 0 + predicted_capacity = current_capacity + recommended_action = "insufficient_data" + estimated_cost = 0 - plan = CapacityPlan( - id=plan_id, - tenant_id=tenant_id, - resource_type=resource_type, - current_capacity=current_capacity, - predicted_capacity=predicted_capacity, - prediction_date=prediction_date, - confidence=confidence, - recommended_action=recommended_action, - estimated_cost=estimated_cost, - created_at=now, + plan = CapacityPlan( + id = plan_id, + tenant_id = tenant_id, + resource_type = resource_type, + current_capacity = current_capacity, + predicted_capacity = predicted_capacity, + prediction_date = prediction_date, + confidence = confidence, + recommended_action = recommended_action, + estimated_cost = estimated_cost, + created_at = now, ) with self._get_db() as conn: @@ -1601,21 +1601,21 @@ class OpsManager: return 0.0 # 使用最近的数据计算趋势 - recent = values[-10:] if len(values) > 10 else values - n = len(recent) + recent = values[-10:] if len(values) > 10 else values + n = len(recent) if n < 2: return 0.0 # 简单线性回归计算斜率 - x = list(range(n)) - mean_x = sum(x) / n - mean_y = sum(recent) / n + x = list(range(n)) + mean_x = sum(x) / n + mean_y = sum(recent) / n - numerator = sum((x[i] - mean_x) * (recent[i] - mean_y) for i in range(n)) - denominator = sum((x[i] - mean_x) ** 2 for i in range(n)) + numerator = sum((x[i] - mean_x) * (recent[i] - mean_y) for i in range(n)) + denominator = sum((x[i] - mean_x) ** 2 for i in range(n)) - slope = numerator / denominator if denominator != 0 else 0 + slope = numerator / denominator if denominator != 0 else 0 # 归一化为增长率 if mean_y != 0: @@ -1625,9 +1625,9 @@ class OpsManager: def get_capacity_plans(self, tenant_id: str) -> list[CapacityPlan]: """获取容量规划列表""" with self._get_db() as conn: - rows = conn.execute( - "SELECT * FROM capacity_plans WHERE tenant_id = ? ORDER BY created_at DESC", - (tenant_id,), + rows = conn.execute( + "SELECT * FROM capacity_plans WHERE tenant_id = ? ORDER BY created_at DESC", + (tenant_id, ), ).fetchall() return [self._row_to_capacity_plan(row) for row in rows] @@ -1643,30 +1643,30 @@ class OpsManager: target_utilization: float, scale_up_threshold: float, scale_down_threshold: float, - scale_up_step: int = 1, - scale_down_step: int = 1, - cooldown_period: int = 300, + scale_up_step: int = 1, + scale_down_step: int = 1, + cooldown_period: int = 300, ) -> AutoScalingPolicy: """创建自动扩缩容策略""" - policy_id = f"asp_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + policy_id = f"asp_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - policy = AutoScalingPolicy( - id=policy_id, - tenant_id=tenant_id, - name=name, - resource_type=resource_type, - min_instances=min_instances, - max_instances=max_instances, - target_utilization=target_utilization, - scale_up_threshold=scale_up_threshold, - scale_down_threshold=scale_down_threshold, - scale_up_step=scale_up_step, - scale_down_step=scale_down_step, - cooldown_period=cooldown_period, - is_enabled=True, - created_at=now, - updated_at=now, + policy = AutoScalingPolicy( + id = policy_id, + tenant_id = tenant_id, + name = name, + resource_type = resource_type, + min_instances = min_instances, + max_instances = max_instances, + target_utilization = target_utilization, + scale_up_threshold = scale_up_threshold, + scale_down_threshold = scale_down_threshold, + scale_up_step = scale_up_step, + scale_down_step = scale_down_step, + cooldown_period = cooldown_period, + is_enabled = True, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -1703,8 +1703,8 @@ class OpsManager: def get_auto_scaling_policy(self, policy_id: str) -> AutoScalingPolicy | None: """获取自动扩缩容策略""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM auto_scaling_policies WHERE id = ?", (policy_id,) + row = conn.execute( + "SELECT * FROM auto_scaling_policies WHERE id = ?", (policy_id, ) ).fetchone() if row: @@ -1714,9 +1714,9 @@ class OpsManager: def list_auto_scaling_policies(self, tenant_id: str) -> list[AutoScalingPolicy]: """列出租户的自动扩缩容策略""" with self._get_db() as conn: - rows = conn.execute( - "SELECT * FROM auto_scaling_policies WHERE tenant_id = ? ORDER BY created_at DESC", - (tenant_id,), + rows = conn.execute( + "SELECT * FROM auto_scaling_policies WHERE tenant_id = ? ORDER BY created_at DESC", + (tenant_id, ), ).fetchall() return [self._row_to_auto_scaling_policy(row) for row in rows] @@ -1724,36 +1724,36 @@ class OpsManager: self, policy_id: str, current_instances: int, current_utilization: float ) -> ScalingEvent | None: """评估扩缩容策略""" - policy = self.get_auto_scaling_policy(policy_id) + policy = self.get_auto_scaling_policy(policy_id) if not policy or not policy.is_enabled: return None # 检查是否在冷却期 - last_event = self.get_last_scaling_event(policy_id) + last_event = self.get_last_scaling_event(policy_id) if last_event: - last_time = datetime.fromisoformat(last_event.started_at) + last_time = datetime.fromisoformat(last_event.started_at) if (datetime.now() - last_time).total_seconds() < policy.cooldown_period: return None - action = None - reason = "" + action = None + reason = "" if current_utilization > policy.scale_up_threshold: if current_instances < policy.max_instances: - action = ScalingAction.SCALE_UP - reason = ( + action = ScalingAction.SCALE_UP + reason = ( f"利用率 {current_utilization:.1%} 超过扩容阈值 {policy.scale_up_threshold:.1%}" ) elif current_utilization < policy.scale_down_threshold: if current_instances > policy.min_instances: - action = ScalingAction.SCALE_DOWN - reason = f"利用率 {current_utilization:.1%} 低于缩容阈值 {policy.scale_down_threshold:.1%}" + action = ScalingAction.SCALE_DOWN + reason = f"利用率 {current_utilization:.1%} 低于缩容阈值 {policy.scale_down_threshold:.1%}" if action: if action == ScalingAction.SCALE_UP: - new_count = min(current_instances + policy.scale_up_step, policy.max_instances) + new_count = min(current_instances + policy.scale_up_step, policy.max_instances) else: - new_count = max(current_instances - policy.scale_down_step, policy.min_instances) + new_count = max(current_instances - policy.scale_down_step, policy.min_instances) return self._create_scaling_event(policy, action, current_instances, new_count, reason) @@ -1768,22 +1768,22 @@ class OpsManager: reason: str, ) -> ScalingEvent: """创建扩缩容事件""" - event_id = f"se_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + event_id = f"se_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - event = ScalingEvent( - id=event_id, - policy_id=policy.id, - tenant_id=policy.tenant_id, - action=action, - from_count=from_count, - to_count=to_count, - reason=reason, - triggered_by="auto", - status="pending", - started_at=now, - completed_at=None, - error_message=None, + event = ScalingEvent( + id = event_id, + policy_id = policy.id, + tenant_id = policy.tenant_id, + action = action, + from_count = from_count, + to_count = to_count, + reason = reason, + triggered_by = "auto", + status = "pending", + started_at = now, + completed_at = None, + error_message = None, ) with self._get_db() as conn: @@ -1814,11 +1814,11 @@ class OpsManager: def get_last_scaling_event(self, policy_id: str) -> ScalingEvent | None: """获取最近的扩缩容事件""" with self._get_db() as conn: - row = conn.execute( + row = conn.execute( """SELECT * FROM scaling_events - WHERE policy_id = ? + WHERE policy_id = ? ORDER BY started_at DESC LIMIT 1""", - (policy_id,), + (policy_id, ), ).fetchone() if row: @@ -1826,18 +1826,18 @@ class OpsManager: return None def update_scaling_event_status( - self, event_id: str, status: str, error_message: str = None + self, event_id: str, status: str, error_message: str = None ) -> ScalingEvent | None: """更新扩缩容事件状态""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: if status in ["completed", "failed"]: conn.execute( """ UPDATE scaling_events - SET status = ?, completed_at = ?, error_message = ? - WHERE id = ? + SET status = ?, completed_at = ?, error_message = ? + WHERE id = ? """, (status, now, error_message, event_id), ) @@ -1845,8 +1845,8 @@ class OpsManager: conn.execute( """ UPDATE scaling_events - SET status = ?, error_message = ? - WHERE id = ? + SET status = ?, error_message = ? + WHERE id = ? """, (status, error_message, event_id), ) @@ -1857,28 +1857,28 @@ class OpsManager: def get_scaling_event(self, event_id: str) -> ScalingEvent | None: """获取扩缩容事件""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM scaling_events WHERE id = ?", (event_id,)).fetchone() + row = conn.execute("SELECT * FROM scaling_events WHERE id = ?", (event_id, )).fetchone() if row: return self._row_to_scaling_event(row) return None def list_scaling_events( - self, tenant_id: str, policy_id: str = None, limit: int = 100 + self, tenant_id: str, policy_id: str = None, limit: int = 100 ) -> list[ScalingEvent]: """列出租户的扩缩容事件""" - query = "SELECT * FROM scaling_events WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM scaling_events WHERE tenant_id = ?" + params = [tenant_id] if policy_id: - query += " AND policy_id = ?" + query += " AND policy_id = ?" params.append(policy_id) query += " ORDER BY started_at DESC LIMIT ?" params.append(limit) with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_scaling_event(row) for row in rows] # ==================== 健康检查与故障转移 ==================== @@ -1891,30 +1891,30 @@ class OpsManager: target_id: str, check_type: str, check_config: dict, - interval: int = 60, - timeout: int = 10, - retry_count: int = 3, + interval: int = 60, + timeout: int = 10, + retry_count: int = 3, ) -> HealthCheck: """创建健康检查""" - check_id = f"hc_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + check_id = f"hc_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - check = HealthCheck( - id=check_id, - tenant_id=tenant_id, - name=name, - target_type=target_type, - target_id=target_id, - check_type=check_type, - check_config=check_config, - interval=interval, - timeout=timeout, - retry_count=retry_count, - healthy_threshold=2, - unhealthy_threshold=3, - is_enabled=True, - created_at=now, - updated_at=now, + check = HealthCheck( + id = check_id, + tenant_id = tenant_id, + name = name, + target_type = target_type, + target_id = target_id, + check_type = check_type, + check_config = check_config, + interval = interval, + timeout = timeout, + retry_count = retry_count, + healthy_threshold = 2, + unhealthy_threshold = 3, + is_enabled = True, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -1951,7 +1951,7 @@ class OpsManager: def get_health_check(self, check_id: str) -> HealthCheck | None: """获取健康检查配置""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM health_checks WHERE id = ?", (check_id,)).fetchone() + row = conn.execute("SELECT * FROM health_checks WHERE id = ?", (check_id, )).fetchone() if row: return self._row_to_health_check(row) @@ -1960,40 +1960,40 @@ class OpsManager: def list_health_checks(self, tenant_id: str) -> list[HealthCheck]: """列出租户的健康检查""" with self._get_db() as conn: - rows = conn.execute( - "SELECT * FROM health_checks WHERE tenant_id = ? ORDER BY created_at DESC", - (tenant_id,), + rows = conn.execute( + "SELECT * FROM health_checks WHERE tenant_id = ? ORDER BY created_at DESC", + (tenant_id, ), ).fetchall() return [self._row_to_health_check(row) for row in rows] async def execute_health_check(self, check_id: str) -> HealthCheckResult: """执行健康检查""" - check = self.get_health_check(check_id) + check = self.get_health_check(check_id) if not check: raise ValueError(f"Health check {check_id} not found") - result_id = f"hcr_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + result_id = f"hcr_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() # 模拟健康检查(实际实现需要根据 check_type 执行具体检查) if check.check_type == "http": - status, response_time, message = await self._check_http_health(check) + status, response_time, message = await self._check_http_health(check) elif check.check_type == "tcp": - status, response_time, message = await self._check_tcp_health(check) + status, response_time, message = await self._check_tcp_health(check) elif check.check_type == "ping": - status, response_time, message = await self._check_ping_health(check) + status, response_time, message = await self._check_ping_health(check) else: - status, response_time, message = HealthStatus.UNKNOWN, 0, "Unknown check type" + status, response_time, message = HealthStatus.UNKNOWN, 0, "Unknown check type" - result = HealthCheckResult( - id=result_id, - check_id=check_id, - tenant_id=check.tenant_id, - status=status, - response_time=response_time, - message=message, - details={}, - checked_at=now, + result = HealthCheckResult( + id = result_id, + check_id = check_id, + tenant_id = check.tenant_id, + status = status, + response_time = response_time, + message = message, + details = {}, + checked_at = now, ) with self._get_db() as conn: @@ -2020,18 +2020,18 @@ class OpsManager: async def _check_http_health(self, check: HealthCheck) -> tuple[HealthStatus, float, str]: """HTTP 健康检查""" - config = check.check_config - url = config.get("url") - expected_status = config.get("expected_status", 200) + config = check.check_config + url = config.get("url") + expected_status = config.get("expected_status", 200) if not url: return HealthStatus.UNHEALTHY, 0, "URL not configured" - start_time = time.time() + start_time = time.time() try: async with httpx.AsyncClient() as client: - response = await client.get(url, timeout=check.timeout) - response_time = (time.time() - start_time) * 1000 + response = await client.get(url, timeout = check.timeout) + response_time = (time.time() - start_time) * 1000 if response.status_code == expected_status: return HealthStatus.HEALTHY, response_time, "OK" @@ -2046,19 +2046,19 @@ class OpsManager: async def _check_tcp_health(self, check: HealthCheck) -> tuple[HealthStatus, float, str]: """TCP 健康检查""" - config = check.check_config - host = config.get("host") - port = config.get("port") + config = check.check_config + host = config.get("host") + port = config.get("port") if not host or not port: return HealthStatus.UNHEALTHY, 0, "Host or port not configured" - start_time = time.time() + start_time = time.time() try: - reader, writer = await asyncio.wait_for( - asyncio.open_connection(host, port), timeout=check.timeout + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout = check.timeout ) - response_time = (time.time() - start_time) * 1000 + response_time = (time.time() - start_time) * 1000 writer.close() await writer.wait_closed() return HealthStatus.HEALTHY, response_time, "TCP connection successful" @@ -2069,8 +2069,8 @@ class OpsManager: async def _check_ping_health(self, check: HealthCheck) -> tuple[HealthStatus, float, str]: """Ping 健康检查(模拟)""" - config = check.check_config - host = config.get("host") + config = check.check_config + host = config.get("host") if not host: return HealthStatus.UNHEALTHY, 0, "Host not configured" @@ -2079,12 +2079,12 @@ class OpsManager: # 这里模拟成功 return HealthStatus.HEALTHY, 10.0, "Ping successful" - def get_health_check_results(self, check_id: str, limit: int = 100) -> list[HealthCheckResult]: + def get_health_check_results(self, check_id: str, limit: int = 100) -> list[HealthCheckResult]: """获取健康检查历史结果""" with self._get_db() as conn: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM health_check_results - WHERE check_id = ? + WHERE check_id = ? ORDER BY checked_at DESC LIMIT ?""", (check_id, limit), ).fetchall() @@ -2099,27 +2099,27 @@ class OpsManager: primary_region: str, secondary_regions: list[str], failover_trigger: str, - auto_failover: bool = False, - failover_timeout: int = 300, - health_check_id: str = None, + auto_failover: bool = False, + failover_timeout: int = 300, + health_check_id: str = None, ) -> FailoverConfig: """创建故障转移配置""" - config_id = f"fc_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + config_id = f"fc_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - config = FailoverConfig( - id=config_id, - tenant_id=tenant_id, - name=name, - primary_region=primary_region, - secondary_regions=secondary_regions, - failover_trigger=failover_trigger, - auto_failover=auto_failover, - failover_timeout=failover_timeout, - health_check_id=health_check_id, - is_enabled=True, - created_at=now, - updated_at=now, + config = FailoverConfig( + id = config_id, + tenant_id = tenant_id, + name = name, + primary_region = primary_region, + secondary_regions = secondary_regions, + failover_trigger = failover_trigger, + auto_failover = auto_failover, + failover_timeout = failover_timeout, + health_check_id = health_check_id, + is_enabled = True, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -2152,8 +2152,8 @@ class OpsManager: def get_failover_config(self, config_id: str) -> FailoverConfig | None: """获取故障转移配置""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM failover_configs WHERE id = ?", (config_id,) + row = conn.execute( + "SELECT * FROM failover_configs WHERE id = ?", (config_id, ) ).fetchone() if row: @@ -2163,38 +2163,38 @@ class OpsManager: def list_failover_configs(self, tenant_id: str) -> list[FailoverConfig]: """列出租户的故障转移配置""" with self._get_db() as conn: - rows = conn.execute( - "SELECT * FROM failover_configs WHERE tenant_id = ? ORDER BY created_at DESC", - (tenant_id,), + rows = conn.execute( + "SELECT * FROM failover_configs WHERE tenant_id = ? ORDER BY created_at DESC", + (tenant_id, ), ).fetchall() return [self._row_to_failover_config(row) for row in rows] def initiate_failover(self, config_id: str, reason: str) -> FailoverEvent | None: """发起故障转移""" - config = self.get_failover_config(config_id) + config = self.get_failover_config(config_id) if not config or not config.is_enabled: return None - event_id = f"fe_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + event_id = f"fe_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() # 选择备用区域 - to_region = config.secondary_regions[0] if config.secondary_regions else None + to_region = config.secondary_regions[0] if config.secondary_regions else None if not to_region: return None - event = FailoverEvent( - id=event_id, - config_id=config_id, - tenant_id=config.tenant_id, - from_region=config.primary_region, - to_region=to_region, - reason=reason, - status="initiated", - started_at=now, - completed_at=None, - rolled_back_at=None, + event = FailoverEvent( + id = event_id, + config_id = config_id, + tenant_id = config.tenant_id, + from_region = config.primary_region, + to_region = to_region, + reason = reason, + status = "initiated", + started_at = now, + completed_at = None, + rolled_back_at = None, ) with self._get_db() as conn: @@ -2221,15 +2221,15 @@ class OpsManager: def update_failover_status(self, event_id: str, status: str) -> FailoverEvent | None: """更新故障转移状态""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: if status == "completed": conn.execute( """ UPDATE failover_events - SET status = ?, completed_at = ? - WHERE id = ? + SET status = ?, completed_at = ? + WHERE id = ? """, (status, now, event_id), ) @@ -2237,8 +2237,8 @@ class OpsManager: conn.execute( """ UPDATE failover_events - SET status = ?, rolled_back_at = ? - WHERE id = ? + SET status = ?, rolled_back_at = ? + WHERE id = ? """, (status, now, event_id), ) @@ -2246,8 +2246,8 @@ class OpsManager: conn.execute( """ UPDATE failover_events - SET status = ? - WHERE id = ? + SET status = ? + WHERE id = ? """, (status, event_id), ) @@ -2258,18 +2258,18 @@ class OpsManager: def get_failover_event(self, event_id: str) -> FailoverEvent | None: """获取故障转移事件""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM failover_events WHERE id = ?", (event_id,)).fetchone() + row = conn.execute("SELECT * FROM failover_events WHERE id = ?", (event_id, )).fetchone() if row: return self._row_to_failover_event(row) return None - def list_failover_events(self, tenant_id: str, limit: int = 100) -> list[FailoverEvent]: + def list_failover_events(self, tenant_id: str, limit: int = 100) -> list[FailoverEvent]: """列出租户的故障转移事件""" with self._get_db() as conn: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM failover_events - WHERE tenant_id = ? + WHERE tenant_id = ? ORDER BY started_at DESC LIMIT ?""", (tenant_id, limit), ).fetchall() @@ -2285,30 +2285,30 @@ class OpsManager: target_type: str, target_id: str, schedule: str, - retention_days: int = 30, - encryption_enabled: bool = True, - compression_enabled: bool = True, - storage_location: str = None, + retention_days: int = 30, + encryption_enabled: bool = True, + compression_enabled: bool = True, + storage_location: str = None, ) -> BackupJob: """创建备份任务""" - job_id = f"bj_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + job_id = f"bj_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - job = BackupJob( - id=job_id, - tenant_id=tenant_id, - name=name, - backup_type=backup_type, - target_type=target_type, - target_id=target_id, - schedule=schedule, - retention_days=retention_days, - encryption_enabled=encryption_enabled, - compression_enabled=compression_enabled, - storage_location=storage_location or f"backups/{tenant_id}", - is_enabled=True, - created_at=now, - updated_at=now, + job = BackupJob( + id = job_id, + tenant_id = tenant_id, + name = name, + backup_type = backup_type, + target_type = target_type, + target_id = target_id, + schedule = schedule, + retention_days = retention_days, + encryption_enabled = encryption_enabled, + compression_enabled = compression_enabled, + storage_location = storage_location or f"backups/{tenant_id}", + is_enabled = True, + created_at = now, + updated_at = now, ) with self._get_db() as conn: @@ -2344,7 +2344,7 @@ class OpsManager: def get_backup_job(self, job_id: str) -> BackupJob | None: """获取备份任务""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone() + row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id, )).fetchone() if row: return self._row_to_backup_job(row) @@ -2353,33 +2353,33 @@ class OpsManager: def list_backup_jobs(self, tenant_id: str) -> list[BackupJob]: """列出租户的备份任务""" with self._get_db() as conn: - rows = conn.execute( - "SELECT * FROM backup_jobs WHERE tenant_id = ? ORDER BY created_at DESC", - (tenant_id,), + rows = conn.execute( + "SELECT * FROM backup_jobs WHERE tenant_id = ? ORDER BY created_at DESC", + (tenant_id, ), ).fetchall() return [self._row_to_backup_job(row) for row in rows] def execute_backup(self, job_id: str) -> BackupRecord | None: """执行备份""" - job = self.get_backup_job(job_id) + job = self.get_backup_job(job_id) if not job or not job.is_enabled: return None - record_id = f"br_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + record_id = f"br_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - record = BackupRecord( - id=record_id, - job_id=job_id, - tenant_id=job.tenant_id, - status=BackupStatus.IN_PROGRESS, - size_bytes=0, - checksum="", - started_at=now, - completed_at=None, - verified_at=None, - error_message=None, - storage_path=f"{job.storage_location}/{record_id}", + record = BackupRecord( + id = record_id, + job_id = job_id, + tenant_id = job.tenant_id, + status = BackupStatus.IN_PROGRESS, + size_bytes = 0, + checksum = "", + started_at = now, + completed_at = None, + verified_at = None, + error_message = None, + storage_path = f"{job.storage_location}/{record_id}", ) with self._get_db() as conn: @@ -2404,21 +2404,21 @@ class OpsManager: # 异步执行备份(实际实现中应该启动后台任务) # 这里模拟备份完成 - self._complete_backup(record_id, size_bytes=1024 * 1024 * 100) # 模拟100MB + self._complete_backup(record_id, size_bytes = 1024 * 1024 * 100) # 模拟100MB return record - def _complete_backup(self, record_id: str, size_bytes: int, checksum: str = None) -> None: + def _complete_backup(self, record_id: str, size_bytes: int, checksum: str = None) -> None: """完成备份""" - now = datetime.now().isoformat() - checksum = checksum or hashlib.sha256(str(time.time()).encode()).hexdigest()[:16] + now = datetime.now().isoformat() + checksum = checksum or hashlib.sha256(str(time.time()).encode()).hexdigest()[:16] with self._get_db() as conn: conn.execute( """ UPDATE backup_records - SET status = ?, size_bytes = ?, checksum = ?, completed_at = ? - WHERE id = ? + SET status = ?, size_bytes = ?, checksum = ?, completed_at = ? + WHERE id = ? """, (BackupStatus.COMPLETED.value, size_bytes, checksum, now, record_id), ) @@ -2427,33 +2427,33 @@ class OpsManager: def get_backup_record(self, record_id: str) -> BackupRecord | None: """获取备份记录""" with self._get_db() as conn: - row = conn.execute("SELECT * FROM backup_records WHERE id = ?", (record_id,)).fetchone() + row = conn.execute("SELECT * FROM backup_records WHERE id = ?", (record_id, )).fetchone() if row: return self._row_to_backup_record(row) return None def list_backup_records( - self, tenant_id: str, job_id: str = None, limit: int = 100 + self, tenant_id: str, job_id: str = None, limit: int = 100 ) -> list[BackupRecord]: """列出租户的备份记录""" - query = "SELECT * FROM backup_records WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM backup_records WHERE tenant_id = ?" + params = [tenant_id] if job_id: - query += " AND job_id = ?" + query += " AND job_id = ?" params.append(job_id) query += " ORDER BY started_at DESC LIMIT ?" params.append(limit) with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_backup_record(row) for row in rows] def restore_from_backup(self, record_id: str) -> bool: """从备份恢复""" - record = self.get_backup_record(record_id) + record = self.get_backup_record(record_id) if not record or record.status != BackupStatus.COMPLETED: return False @@ -2465,42 +2465,42 @@ class OpsManager: def generate_cost_report(self, tenant_id: str, year: int, month: int) -> CostReport: """生成成本报告""" - report_id = f"cr_{uuid.uuid4().hex[:16]}" - report_period = f"{year:04d}-{month:02d}" - now = datetime.now().isoformat() + report_id = f"cr_{uuid.uuid4().hex[:16]}" + report_period = f"{year:04d}-{month:02d}" + now = datetime.now().isoformat() # 获取资源利用率数据 - utilizations = self.get_resource_utilizations(tenant_id, report_period) + utilizations = self.get_resource_utilizations(tenant_id, report_period) # 计算成本分解 - breakdown = {} - total_cost = 0.0 + breakdown = {} + total_cost = 0.0 for util in utilizations: # 简化计算:假设每单位资源每月成本 - unit_cost = 10.0 - resource_cost = unit_cost * util.utilization_rate - breakdown[util.resource_type.value] = ( + unit_cost = 10.0 + resource_cost = unit_cost * util.utilization_rate + breakdown[util.resource_type.value] = ( breakdown.get(util.resource_type.value, 0) + resource_cost ) total_cost += resource_cost # 检测异常 - anomalies = self._detect_cost_anomalies(utilizations) + anomalies = self._detect_cost_anomalies(utilizations) # 计算趋势 - trends = self._calculate_cost_trends(tenant_id, year, month) + trends = self._calculate_cost_trends(tenant_id, year, month) - report = CostReport( - id=report_id, - tenant_id=tenant_id, - report_period=report_period, - total_cost=total_cost, - currency="CNY", - breakdown=breakdown, - trends=trends, - anomalies=anomalies, - created_at=now, + report = CostReport( + id = report_id, + tenant_id = tenant_id, + report_period = report_period, + total_cost = total_cost, + currency = "CNY", + breakdown = breakdown, + trends = trends, + anomalies = anomalies, + created_at = now, ) with self._get_db() as conn: @@ -2528,7 +2528,7 @@ class OpsManager: def _detect_cost_anomalies(self, utilizations: list[ResourceUtilization]) -> list[dict]: """检测成本异常""" - anomalies = [] + anomalies = [] for util in utilizations: # 检测低利用率 @@ -2576,22 +2576,22 @@ class OpsManager: avg_utilization: float, idle_time_percent: float, report_date: str, - recommendations: list[str] = None, + recommendations: list[str] = None, ) -> ResourceUtilization: """记录资源利用率""" - util_id = f"ru_{uuid.uuid4().hex[:16]}" + util_id = f"ru_{uuid.uuid4().hex[:16]}" - util = ResourceUtilization( - id=util_id, - tenant_id=tenant_id, - resource_type=resource_type, - resource_id=resource_id, - utilization_rate=utilization_rate, - peak_utilization=peak_utilization, - avg_utilization=avg_utilization, - idle_time_percent=idle_time_percent, - report_date=report_date, - recommendations=recommendations or [], + util = ResourceUtilization( + id = util_id, + tenant_id = tenant_id, + resource_type = resource_type, + resource_id = resource_id, + utilization_rate = utilization_rate, + peak_utilization = peak_utilization, + avg_utilization = avg_utilization, + idle_time_percent = idle_time_percent, + report_date = report_date, + recommendations = recommendations or [], ) with self._get_db() as conn: @@ -2624,9 +2624,9 @@ class OpsManager: ) -> list[ResourceUtilization]: """获取资源利用率列表""" with self._get_db() as conn: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM resource_utilizations - WHERE tenant_id = ? AND report_date LIKE ? + WHERE tenant_id = ? AND report_date LIKE ? ORDER BY report_date DESC""", (tenant_id, f"{report_period}%"), ).fetchall() @@ -2634,37 +2634,37 @@ class OpsManager: def detect_idle_resources(self, tenant_id: str) -> list[IdleResource]: """检测闲置资源""" - idle_resources = [] + idle_resources = [] # 获取最近30天的利用率数据 with self._get_db() as conn: - thirty_days_ago = (datetime.now() - timedelta(days=30)).isoformat() - rows = conn.execute( + thirty_days_ago = (datetime.now() - timedelta(days = 30)).isoformat() + rows = conn.execute( """SELECT resource_type, resource_id, AVG(utilization_rate) as avg_utilization, MAX(idle_time_percent) as max_idle_time FROM resource_utilizations - WHERE tenant_id = ? AND report_date > ? + WHERE tenant_id = ? AND report_date > ? GROUP BY resource_type, resource_id HAVING avg_utilization < 0.1 AND max_idle_time > 0.8""", (tenant_id, thirty_days_ago), ).fetchall() for row in rows: - idle_id = f"ir_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + idle_id = f"ir_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - idle_resource = IdleResource( - id=idle_id, - tenant_id=tenant_id, - resource_type=ResourceType(row["resource_type"]), - resource_id=row["resource_id"], - resource_name=f"{row['resource_type']}-{row['resource_id']}", - idle_since=thirty_days_ago, - estimated_monthly_cost=50.0, # 简化计算 - currency="CNY", - reason="Low utilization rate over 30 days", - recommendation="Consider downsizing or terminating this resource", - detected_at=now, + idle_resource = IdleResource( + id = idle_id, + tenant_id = tenant_id, + resource_type = ResourceType(row["resource_type"]), + resource_id = row["resource_id"], + resource_name = f"{row['resource_type']}-{row['resource_id']}", + idle_since = thirty_days_ago, + estimated_monthly_cost = 50.0, # 简化计算 + currency = "CNY", + reason = "Low utilization rate over 30 days", + recommendation = "Consider downsizing or terminating this resource", + detected_at = now, ) conn.execute( @@ -2698,9 +2698,9 @@ class OpsManager: def get_idle_resources(self, tenant_id: str) -> list[IdleResource]: """获取闲置资源列表""" with self._get_db() as conn: - rows = conn.execute( - "SELECT * FROM idle_resources WHERE tenant_id = ? ORDER BY detected_at DESC", - (tenant_id,), + rows = conn.execute( + "SELECT * FROM idle_resources WHERE tenant_id = ? ORDER BY detected_at DESC", + (tenant_id, ), ).fetchall() return [self._row_to_idle_resource(row) for row in rows] @@ -2708,36 +2708,36 @@ class OpsManager: self, tenant_id: str ) -> list[CostOptimizationSuggestion]: """生成成本优化建议""" - suggestions = [] + suggestions = [] # 基于闲置资源生成建议 - idle_resources = self.detect_idle_resources(tenant_id) + idle_resources = self.detect_idle_resources(tenant_id) - total_potential_savings = sum(r.estimated_monthly_cost for r in idle_resources) + total_potential_savings = sum(r.estimated_monthly_cost for r in idle_resources) if total_potential_savings > 0: - suggestion_id = f"cos_{uuid.uuid4().hex[:16]}" - now = datetime.now().isoformat() + suggestion_id = f"cos_{uuid.uuid4().hex[:16]}" + now = datetime.now().isoformat() - suggestion = CostOptimizationSuggestion( - id=suggestion_id, - tenant_id=tenant_id, - category="resource_rightsize", - title="清理闲置资源", - description=f"检测到 {len(idle_resources)} 个闲置资源,建议清理以节省成本。", - potential_savings=total_potential_savings, - currency="CNY", - confidence=0.85, - difficulty="easy", - implementation_steps=[ + suggestion = CostOptimizationSuggestion( + id = suggestion_id, + tenant_id = tenant_id, + category = "resource_rightsize", + title = "清理闲置资源", + description = f"检测到 {len(idle_resources)} 个闲置资源,建议清理以节省成本。", + potential_savings = total_potential_savings, + currency = "CNY", + confidence = 0.85, + difficulty = "easy", + implementation_steps = [ "Review the list of idle resources", "Confirm resources are no longer needed", "Terminate or downsize unused resources", ], - risk_level="low", - is_applied=False, - created_at=now, - applied_at=None, + risk_level = "low", + is_applied = False, + created_at = now, + applied_at = None, ) with self._get_db() as conn: @@ -2773,34 +2773,34 @@ class OpsManager: return suggestions def get_cost_optimization_suggestions( - self, tenant_id: str, is_applied: bool = None + self, tenant_id: str, is_applied: bool = None ) -> list[CostOptimizationSuggestion]: """获取成本优化建议""" - query = "SELECT * FROM cost_optimization_suggestions WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM cost_optimization_suggestions WHERE tenant_id = ?" + params = [tenant_id] if is_applied is not None: - query += " AND is_applied = ?" + query += " AND is_applied = ?" params.append(1 if is_applied else 0) query += " ORDER BY potential_savings DESC" with self._get_db() as conn: - rows = conn.execute(query, params).fetchall() + rows = conn.execute(query, params).fetchall() return [self._row_to_cost_optimization_suggestion(row) for row in rows] def apply_cost_optimization_suggestion( self, suggestion_id: str ) -> CostOptimizationSuggestion | None: """应用成本优化建议""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() with self._get_db() as conn: conn.execute( """ UPDATE cost_optimization_suggestions - SET is_applied = ?, applied_at = ? - WHERE id = ? + SET is_applied = ?, applied_at = ? + WHERE id = ? """, (True, now, suggestion_id), ) @@ -2813,8 +2813,8 @@ class OpsManager: ) -> CostOptimizationSuggestion | None: """获取成本优化建议详情""" with self._get_db() as conn: - row = conn.execute( - "SELECT * FROM cost_optimization_suggestions WHERE id = ?", (suggestion_id,) + row = conn.execute( + "SELECT * FROM cost_optimization_suggestions WHERE id = ?", (suggestion_id, ) ).fetchone() if row: @@ -2825,286 +2825,286 @@ class OpsManager: def _row_to_alert_rule(self, row) -> AlertRule: return AlertRule( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - description=row["description"], - rule_type=AlertRuleType(row["rule_type"]), - severity=AlertSeverity(row["severity"]), - metric=row["metric"], - condition=row["condition"], - threshold=row["threshold"], - duration=row["duration"], - evaluation_interval=row["evaluation_interval"], - channels=json.loads(row["channels"]), - labels=json.loads(row["labels"]), - annotations=json.loads(row["annotations"]), - is_enabled=bool(row["is_enabled"]), - created_at=row["created_at"], - updated_at=row["updated_at"], - created_by=row["created_by"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + description = row["description"], + rule_type = AlertRuleType(row["rule_type"]), + severity = AlertSeverity(row["severity"]), + metric = row["metric"], + condition = row["condition"], + threshold = row["threshold"], + duration = row["duration"], + evaluation_interval = row["evaluation_interval"], + channels = json.loads(row["channels"]), + labels = json.loads(row["labels"]), + annotations = json.loads(row["annotations"]), + is_enabled = bool(row["is_enabled"]), + created_at = row["created_at"], + updated_at = row["updated_at"], + created_by = row["created_by"], ) def _row_to_alert_channel(self, row) -> AlertChannel: return AlertChannel( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - channel_type=AlertChannelType(row["channel_type"]), - config=json.loads(row["config"]), - severity_filter=json.loads(row["severity_filter"]), - is_enabled=bool(row["is_enabled"]), - success_count=row["success_count"], - fail_count=row["fail_count"], - last_used_at=row["last_used_at"], - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + channel_type = AlertChannelType(row["channel_type"]), + config = json.loads(row["config"]), + severity_filter = json.loads(row["severity_filter"]), + is_enabled = bool(row["is_enabled"]), + success_count = row["success_count"], + fail_count = row["fail_count"], + last_used_at = row["last_used_at"], + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_alert(self, row) -> Alert: return Alert( - id=row["id"], - rule_id=row["rule_id"], - tenant_id=row["tenant_id"], - severity=AlertSeverity(row["severity"]), - status=AlertStatus(row["status"]), - title=row["title"], - description=row["description"], - metric=row["metric"], - value=row["value"], - threshold=row["threshold"], - labels=json.loads(row["labels"]), - annotations=json.loads(row["annotations"]), - started_at=row["started_at"], - resolved_at=row["resolved_at"], - acknowledged_by=row["acknowledged_by"], - acknowledged_at=row["acknowledged_at"], - notification_sent=json.loads(row["notification_sent"]), - suppression_count=row["suppression_count"], + id = row["id"], + rule_id = row["rule_id"], + tenant_id = row["tenant_id"], + severity = AlertSeverity(row["severity"]), + status = AlertStatus(row["status"]), + title = row["title"], + description = row["description"], + metric = row["metric"], + value = row["value"], + threshold = row["threshold"], + labels = json.loads(row["labels"]), + annotations = json.loads(row["annotations"]), + started_at = row["started_at"], + resolved_at = row["resolved_at"], + acknowledged_by = row["acknowledged_by"], + acknowledged_at = row["acknowledged_at"], + notification_sent = json.loads(row["notification_sent"]), + suppression_count = row["suppression_count"], ) def _row_to_suppression_rule(self, row) -> AlertSuppressionRule: return AlertSuppressionRule( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - matchers=json.loads(row["matchers"]), - duration=row["duration"], - is_regex=bool(row["is_regex"]), - created_at=row["created_at"], - expires_at=row["expires_at"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + matchers = json.loads(row["matchers"]), + duration = row["duration"], + is_regex = bool(row["is_regex"]), + created_at = row["created_at"], + expires_at = row["expires_at"], ) def _row_to_resource_metric(self, row) -> ResourceMetric: return ResourceMetric( - id=row["id"], - tenant_id=row["tenant_id"], - resource_type=ResourceType(row["resource_type"]), - resource_id=row["resource_id"], - metric_name=row["metric_name"], - metric_value=row["metric_value"], - unit=row["unit"], - timestamp=row["timestamp"], - metadata=json.loads(row["metadata"]), + id = row["id"], + tenant_id = row["tenant_id"], + resource_type = ResourceType(row["resource_type"]), + resource_id = row["resource_id"], + metric_name = row["metric_name"], + metric_value = row["metric_value"], + unit = row["unit"], + timestamp = row["timestamp"], + metadata = json.loads(row["metadata"]), ) def _row_to_capacity_plan(self, row) -> CapacityPlan: return CapacityPlan( - id=row["id"], - tenant_id=row["tenant_id"], - resource_type=ResourceType(row["resource_type"]), - current_capacity=row["current_capacity"], - predicted_capacity=row["predicted_capacity"], - prediction_date=row["prediction_date"], - confidence=row["confidence"], - recommended_action=row["recommended_action"], - estimated_cost=row["estimated_cost"], - created_at=row["created_at"], + id = row["id"], + tenant_id = row["tenant_id"], + resource_type = ResourceType(row["resource_type"]), + current_capacity = row["current_capacity"], + predicted_capacity = row["predicted_capacity"], + prediction_date = row["prediction_date"], + confidence = row["confidence"], + recommended_action = row["recommended_action"], + estimated_cost = row["estimated_cost"], + created_at = row["created_at"], ) def _row_to_auto_scaling_policy(self, row) -> AutoScalingPolicy: return AutoScalingPolicy( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - resource_type=ResourceType(row["resource_type"]), - min_instances=row["min_instances"], - max_instances=row["max_instances"], - target_utilization=row["target_utilization"], - scale_up_threshold=row["scale_up_threshold"], - scale_down_threshold=row["scale_down_threshold"], - scale_up_step=row["scale_up_step"], - scale_down_step=row["scale_down_step"], - cooldown_period=row["cooldown_period"], - is_enabled=bool(row["is_enabled"]), - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + resource_type = ResourceType(row["resource_type"]), + min_instances = row["min_instances"], + max_instances = row["max_instances"], + target_utilization = row["target_utilization"], + scale_up_threshold = row["scale_up_threshold"], + scale_down_threshold = row["scale_down_threshold"], + scale_up_step = row["scale_up_step"], + scale_down_step = row["scale_down_step"], + cooldown_period = row["cooldown_period"], + is_enabled = bool(row["is_enabled"]), + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_scaling_event(self, row) -> ScalingEvent: return ScalingEvent( - id=row["id"], - policy_id=row["policy_id"], - tenant_id=row["tenant_id"], - action=ScalingAction(row["action"]), - from_count=row["from_count"], - to_count=row["to_count"], - reason=row["reason"], - triggered_by=row["triggered_by"], - status=row["status"], - started_at=row["started_at"], - completed_at=row["completed_at"], - error_message=row["error_message"], + id = row["id"], + policy_id = row["policy_id"], + tenant_id = row["tenant_id"], + action = ScalingAction(row["action"]), + from_count = row["from_count"], + to_count = row["to_count"], + reason = row["reason"], + triggered_by = row["triggered_by"], + status = row["status"], + started_at = row["started_at"], + completed_at = row["completed_at"], + error_message = row["error_message"], ) def _row_to_health_check(self, row) -> HealthCheck: return HealthCheck( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - target_type=row["target_type"], - target_id=row["target_id"], - check_type=row["check_type"], - check_config=json.loads(row["check_config"]), - interval=row["interval"], - timeout=row["timeout"], - retry_count=row["retry_count"], - healthy_threshold=row["healthy_threshold"], - unhealthy_threshold=row["unhealthy_threshold"], - is_enabled=bool(row["is_enabled"]), - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + target_type = row["target_type"], + target_id = row["target_id"], + check_type = row["check_type"], + check_config = json.loads(row["check_config"]), + interval = row["interval"], + timeout = row["timeout"], + retry_count = row["retry_count"], + healthy_threshold = row["healthy_threshold"], + unhealthy_threshold = row["unhealthy_threshold"], + is_enabled = bool(row["is_enabled"]), + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_health_check_result(self, row) -> HealthCheckResult: return HealthCheckResult( - id=row["id"], - check_id=row["check_id"], - tenant_id=row["tenant_id"], - status=HealthStatus(row["status"]), - response_time=row["response_time"], - message=row["message"], - details=json.loads(row["details"]), - checked_at=row["checked_at"], + id = row["id"], + check_id = row["check_id"], + tenant_id = row["tenant_id"], + status = HealthStatus(row["status"]), + response_time = row["response_time"], + message = row["message"], + details = json.loads(row["details"]), + checked_at = row["checked_at"], ) def _row_to_failover_config(self, row) -> FailoverConfig: return FailoverConfig( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - primary_region=row["primary_region"], - secondary_regions=json.loads(row["secondary_regions"]), - failover_trigger=row["failover_trigger"], - auto_failover=bool(row["auto_failover"]), - failover_timeout=row["failover_timeout"], - health_check_id=row["health_check_id"], - is_enabled=bool(row["is_enabled"]), - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + primary_region = row["primary_region"], + secondary_regions = json.loads(row["secondary_regions"]), + failover_trigger = row["failover_trigger"], + auto_failover = bool(row["auto_failover"]), + failover_timeout = row["failover_timeout"], + health_check_id = row["health_check_id"], + is_enabled = bool(row["is_enabled"]), + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_failover_event(self, row) -> FailoverEvent: return FailoverEvent( - id=row["id"], - config_id=row["config_id"], - tenant_id=row["tenant_id"], - from_region=row["from_region"], - to_region=row["to_region"], - reason=row["reason"], - status=row["status"], - started_at=row["started_at"], - completed_at=row["completed_at"], - rolled_back_at=row["rolled_back_at"], + id = row["id"], + config_id = row["config_id"], + tenant_id = row["tenant_id"], + from_region = row["from_region"], + to_region = row["to_region"], + reason = row["reason"], + status = row["status"], + started_at = row["started_at"], + completed_at = row["completed_at"], + rolled_back_at = row["rolled_back_at"], ) def _row_to_backup_job(self, row) -> BackupJob: return BackupJob( - id=row["id"], - tenant_id=row["tenant_id"], - name=row["name"], - backup_type=row["backup_type"], - target_type=row["target_type"], - target_id=row["target_id"], - schedule=row["schedule"], - retention_days=row["retention_days"], - encryption_enabled=bool(row["encryption_enabled"]), - compression_enabled=bool(row["compression_enabled"]), - storage_location=row["storage_location"], - is_enabled=bool(row["is_enabled"]), - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + tenant_id = row["tenant_id"], + name = row["name"], + backup_type = row["backup_type"], + target_type = row["target_type"], + target_id = row["target_id"], + schedule = row["schedule"], + retention_days = row["retention_days"], + encryption_enabled = bool(row["encryption_enabled"]), + compression_enabled = bool(row["compression_enabled"]), + storage_location = row["storage_location"], + is_enabled = bool(row["is_enabled"]), + created_at = row["created_at"], + updated_at = row["updated_at"], ) def _row_to_backup_record(self, row) -> BackupRecord: return BackupRecord( - id=row["id"], - job_id=row["job_id"], - tenant_id=row["tenant_id"], - status=BackupStatus(row["status"]), - size_bytes=row["size_bytes"], - checksum=row["checksum"], - started_at=row["started_at"], - completed_at=row["completed_at"], - verified_at=row["verified_at"], - error_message=row["error_message"], - storage_path=row["storage_path"], + id = row["id"], + job_id = row["job_id"], + tenant_id = row["tenant_id"], + status = BackupStatus(row["status"]), + size_bytes = row["size_bytes"], + checksum = row["checksum"], + started_at = row["started_at"], + completed_at = row["completed_at"], + verified_at = row["verified_at"], + error_message = row["error_message"], + storage_path = row["storage_path"], ) def _row_to_resource_utilization(self, row) -> ResourceUtilization: return ResourceUtilization( - id=row["id"], - tenant_id=row["tenant_id"], - resource_type=ResourceType(row["resource_type"]), - resource_id=row["resource_id"], - utilization_rate=row["utilization_rate"], - peak_utilization=row["peak_utilization"], - avg_utilization=row["avg_utilization"], - idle_time_percent=row["idle_time_percent"], - report_date=row["report_date"], - recommendations=json.loads(row["recommendations"]), + id = row["id"], + tenant_id = row["tenant_id"], + resource_type = ResourceType(row["resource_type"]), + resource_id = row["resource_id"], + utilization_rate = row["utilization_rate"], + peak_utilization = row["peak_utilization"], + avg_utilization = row["avg_utilization"], + idle_time_percent = row["idle_time_percent"], + report_date = row["report_date"], + recommendations = json.loads(row["recommendations"]), ) def _row_to_idle_resource(self, row) -> IdleResource: return IdleResource( - id=row["id"], - tenant_id=row["tenant_id"], - resource_type=ResourceType(row["resource_type"]), - resource_id=row["resource_id"], - resource_name=row["resource_name"], - idle_since=row["idle_since"], - estimated_monthly_cost=row["estimated_monthly_cost"], - currency=row["currency"], - reason=row["reason"], - recommendation=row["recommendation"], - detected_at=row["detected_at"], + id = row["id"], + tenant_id = row["tenant_id"], + resource_type = ResourceType(row["resource_type"]), + resource_id = row["resource_id"], + resource_name = row["resource_name"], + idle_since = row["idle_since"], + estimated_monthly_cost = row["estimated_monthly_cost"], + currency = row["currency"], + reason = row["reason"], + recommendation = row["recommendation"], + detected_at = row["detected_at"], ) def _row_to_cost_optimization_suggestion(self, row) -> CostOptimizationSuggestion: return CostOptimizationSuggestion( - id=row["id"], - tenant_id=row["tenant_id"], - category=row["category"], - title=row["title"], - description=row["description"], - potential_savings=row["potential_savings"], - currency=row["currency"], - confidence=row["confidence"], - difficulty=row["difficulty"], - implementation_steps=json.loads(row["implementation_steps"]), - risk_level=row["risk_level"], - is_applied=bool(row["is_applied"]), - created_at=row["created_at"], - applied_at=row["applied_at"], + id = row["id"], + tenant_id = row["tenant_id"], + category = row["category"], + title = row["title"], + description = row["description"], + potential_savings = row["potential_savings"], + currency = row["currency"], + confidence = row["confidence"], + difficulty = row["difficulty"], + implementation_steps = json.loads(row["implementation_steps"]), + risk_level = row["risk_level"], + is_applied = bool(row["is_applied"]), + created_at = row["created_at"], + applied_at = row["applied_at"], ) # Singleton instance -_ops_manager = None +_ops_manager = None def get_ops_manager() -> OpsManager: global _ops_manager if _ops_manager is None: - _ops_manager = OpsManager() + _ops_manager = OpsManager() return _ops_manager diff --git a/backend/oss_uploader.py b/backend/oss_uploader.py index 83de463..edbbf7d 100644 --- a/backend/oss_uploader.py +++ b/backend/oss_uploader.py @@ -11,30 +11,30 @@ import oss2 class OSSUploader: - def __init__(self): - self.access_key = os.getenv("ALI_ACCESS_KEY") - self.secret_key = os.getenv("ALI_SECRET_KEY") - self.bucket_name = os.getenv("OSS_BUCKET", "insightflow-audio") - self.region = os.getenv("OSS_REGION", "oss-cn-hangzhou.aliyuncs.com") - self.endpoint = f"https://{self.region}" + def __init__(self) -> None: + self.access_key = os.getenv("ALI_ACCESS_KEY") + self.secret_key = os.getenv("ALI_SECRET_KEY") + self.bucket_name = os.getenv("OSS_BUCKET", "insightflow-audio") + self.region = os.getenv("OSS_REGION", "oss-cn-hangzhou.aliyuncs.com") + self.endpoint = f"https://{self.region}" if not self.access_key or not self.secret_key: raise ValueError("ALI_ACCESS_KEY and ALI_SECRET_KEY must be set") - self.auth = oss2.Auth(self.access_key, self.secret_key) - self.bucket = oss2.Bucket(self.auth, self.endpoint, self.bucket_name) + self.auth = oss2.Auth(self.access_key, self.secret_key) + self.bucket = oss2.Bucket(self.auth, self.endpoint, self.bucket_name) def upload_audio(self, audio_data: bytes, filename: str) -> tuple: """上传音频到 OSS,返回 (URL, object_name)""" # 生成唯一文件名 - ext = os.path.splitext(filename)[1] or ".wav" - object_name = f"audio/{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}{ext}" + ext = os.path.splitext(filename)[1] or ".wav" + object_name = f"audio/{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}{ext}" # 上传文件 self.bucket.put_object(object_name, audio_data) # 生成临时访问 URL (1小时有效) - url = self.bucket.sign_url("GET", object_name, 3600) + url = self.bucket.sign_url("GET", object_name, 3600) return url, object_name def delete_object(self, object_name: str) -> None: @@ -43,11 +43,11 @@ class OSSUploader: # 单例 -_oss_uploader = None +_oss_uploader = None def get_oss_uploader() -> OSSUploader: global _oss_uploader if _oss_uploader is None: - _oss_uploader = OSSUploader() + _oss_uploader = OSSUploader() return _oss_uploader diff --git a/backend/performance_manager.py b/backend/performance_manager.py index 0b98a8e..88a43d2 100644 --- a/backend/performance_manager.py +++ b/backend/performance_manager.py @@ -27,18 +27,18 @@ from typing import Any try: import redis - REDIS_AVAILABLE = True + REDIS_AVAILABLE = True except ImportError: - REDIS_AVAILABLE = False + REDIS_AVAILABLE = False # 尝试导入 Celery try: from celery import Celery from celery.result import AsyncResult - CELERY_AVAILABLE = True + CELERY_AVAILABLE = True except ImportError: - CELERY_AVAILABLE = False + CELERY_AVAILABLE = False # ==================== 数据模型 ==================== @@ -47,17 +47,17 @@ except ImportError: class CacheStats: """缓存统计数据模型""" - total_requests: int = 0 - hits: int = 0 - misses: int = 0 - evictions: int = 0 - expired: int = 0 - hit_rate: float = 0.0 + total_requests: int = 0 + hits: int = 0 + misses: int = 0 + evictions: int = 0 + expired: int = 0 + hit_rate: float = 0.0 def update_hit_rate(self) -> None: """更新命中率""" if self.total_requests > 0: - self.hit_rate = round(self.hits / self.total_requests, 4) + self.hit_rate = round(self.hits / self.total_requests, 4) @dataclass @@ -68,9 +68,9 @@ class CacheEntry: value: Any created_at: float expires_at: float | None - access_count: int = 0 - last_accessed: float = 0 - size_bytes: int = 0 + access_count: int = 0 + last_accessed: float = 0 + size_bytes: int = 0 @dataclass @@ -82,7 +82,7 @@ class PerformanceMetric: endpoint: str | None duration_ms: float timestamp: str - metadata: dict = field(default_factory=dict) + metadata: dict = field(default_factory = dict) def to_dict(self) -> dict: return { @@ -104,12 +104,12 @@ class TaskInfo: status: str # pending, running, success, failed, retrying payload: dict created_at: str - started_at: str | None = None - completed_at: str | None = None - result: Any | None = None - error_message: str | None = None - retry_count: int = 0 - max_retries: int = 3 + started_at: str | None = None + completed_at: str | None = None + result: Any | None = None + error_message: str | None = None + retry_count: int = 0 + max_retries: int = 3 def to_dict(self) -> dict: return { @@ -134,10 +134,10 @@ class ShardInfo: shard_id: str shard_key_range: tuple[str, str] # (start, end) db_path: str - entity_count: int = 0 - is_active: bool = True - created_at: str = "" - last_accessed: str = "" + entity_count: int = 0 + is_active: bool = True + created_at: str = "" + last_accessed: str = "" # ==================== Redis 缓存层 ==================== @@ -160,42 +160,42 @@ class CacheManager: def __init__( self, - redis_url: str | None = None, - max_memory_size: int = 100 * 1024 * 1024, # 100MB - default_ttl: int = 3600, # 1小时 - db_path: str = "insightflow.db", - ): - self.db_path = db_path - self.default_ttl = default_ttl - self.max_memory_size = max_memory_size - self.current_memory_size = 0 + redis_url: str | None = None, + max_memory_size: int = 100 * 1024 * 1024, # 100MB + default_ttl: int = 3600, # 1小时 + db_path: str = "insightflow.db", + ) -> None: + self.db_path = db_path + self.default_ttl = default_ttl + self.max_memory_size = max_memory_size + self.current_memory_size = 0 # Redis 客户端 - self.redis_client = None - self.use_redis = False + self.redis_client = None + self.use_redis = False if REDIS_AVAILABLE and redis_url: try: - self.redis_client = redis.from_url(redis_url, decode_responses=True) + self.redis_client = redis.from_url(redis_url, decode_responses = True) self.redis_client.ping() - self.use_redis = True + self.use_redis = True print(f"Redis 缓存已连接: {redis_url}") except Exception as e: print(f"Redis 连接失败,使用内存缓存: {e}") # 内存缓存(LRU) - self.memory_cache: OrderedDict[str, CacheEntry] = OrderedDict() - self.cache_lock = threading.RLock() + self.memory_cache: OrderedDict[str, CacheEntry] = OrderedDict() + self.cache_lock = threading.RLock() # 统计 - self.stats = CacheStats() + self.stats = CacheStats() # 初始化缓存统计表 self._init_cache_tables() def _init_cache_tables(self) -> None: """初始化缓存统计表""" - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS cache_stats ( @@ -233,11 +233,11 @@ class CacheManager: def _get_entry_size(self, value: Any) -> int: """估算缓存条目大小""" try: - return len(json.dumps(value, ensure_ascii=False).encode("utf-8")) + return len(json.dumps(value, ensure_ascii = False).encode("utf-8")) except (TypeError, ValueError): return 1024 # 默认估算 - def _evict_lru(self, required_space: int = 0) -> None: + def _evict_lru(self, required_space: int = 0) -> None: """LRU 淘汰策略""" with self.cache_lock: while ( @@ -245,7 +245,7 @@ class CacheManager: and self.memory_cache ): # 移除最久未访问的 - oldest_key, oldest_entry = self.memory_cache.popitem(last=False) + oldest_key, oldest_entry = self.memory_cache.popitem(last = False) self.current_memory_size -= oldest_entry.size_bytes self.stats.evictions += 1 @@ -263,7 +263,7 @@ class CacheManager: if self.use_redis: try: - value = self.redis_client.get(key) + value = self.redis_client.get(key) if value: self.stats.hits += 1 return json.loads(value) @@ -276,7 +276,7 @@ class CacheManager: else: # 内存缓存 with self.cache_lock: - entry = self.memory_cache.get(key) + entry = self.memory_cache.get(key) if entry: # 检查是否过期 @@ -289,7 +289,7 @@ class CacheManager: # 更新访问信息 entry.access_count += 1 - entry.last_accessed = time.time() + entry.last_accessed = time.time() self.memory_cache.move_to_end(key) self.stats.hits += 1 @@ -298,7 +298,7 @@ class CacheManager: self.stats.misses += 1 return None - def set(self, key: str, value: Any, ttl: int | None = None) -> bool: + def set(self, key: str, value: Any, ttl: int | None = None) -> bool: """ 设置缓存值 @@ -310,11 +310,11 @@ class CacheManager: Returns: bool: 是否成功 """ - ttl = ttl or self.default_ttl + ttl = ttl or self.default_ttl if self.use_redis: try: - serialized = json.dumps(value, ensure_ascii=False) + serialized = json.dumps(value, ensure_ascii = False) self.redis_client.setex(key, ttl, serialized) return True except Exception as e: @@ -323,27 +323,27 @@ class CacheManager: else: # 内存缓存 with self.cache_lock: - size = self._get_entry_size(value) + size = self._get_entry_size(value) # 检查是否需要淘汰 if self.current_memory_size + size > self.max_memory_size: self._evict_lru(size) - now = time.time() - entry = CacheEntry( - key=key, - value=value, - created_at=now, - expires_at=now + ttl if ttl > 0 else None, - size_bytes=size, - last_accessed=now, + now = time.time() + entry = CacheEntry( + key = key, + value = value, + created_at = now, + expires_at = now + ttl if ttl > 0 else None, + size_bytes = size, + last_accessed = now, ) # 如果已存在,更新大小 if key in self.memory_cache: self.current_memory_size -= self.memory_cache[key].size_bytes - self.memory_cache[key] = entry + self.memory_cache[key] = entry self.memory_cache.move_to_end(key) self.current_memory_size += size @@ -360,7 +360,7 @@ class CacheManager: else: with self.cache_lock: if key in self.memory_cache: - entry = self.memory_cache.pop(key) + entry = self.memory_cache.pop(key) self.current_memory_size -= entry.size_bytes return True return False @@ -377,19 +377,19 @@ class CacheManager: else: with self.cache_lock: self.memory_cache.clear() - self.current_memory_size = 0 + self.current_memory_size = 0 return True def get_many(self, keys: list[str]) -> dict[str, Any]: """批量获取缓存""" - results = {} + results = {} if self.use_redis: try: - values = self.redis_client.mget(keys) + values = self.redis_client.mget(keys) for key, value in zip(keys, values): if value: - results[key] = json.loads(value) + results[key] = json.loads(value) self.stats.hits += 1 else: self.stats.misses += 1 @@ -398,21 +398,21 @@ class CacheManager: print(f"Redis mget 失败: {e}") else: for key in keys: - value = self.get(key) + value = self.get(key) if value is not None: - results[key] = value + results[key] = value return results - def set_many(self, mapping: dict[str, Any], ttl: int | None = None) -> bool: + def set_many(self, mapping: dict[str, Any], ttl: int | None = None) -> bool: """批量设置缓存""" - ttl = ttl or self.default_ttl + ttl = ttl or self.default_ttl if self.use_redis: try: - pipe = self.redis_client.pipeline() + pipe = self.redis_client.pipeline() for key, value in mapping.items(): - serialized = json.dumps(value, ensure_ascii=False) + serialized = json.dumps(value, ensure_ascii = False) pipe.setex(key, ttl, serialized) pipe.execute() return True @@ -428,7 +428,7 @@ class CacheManager: """获取缓存统计""" self.stats.update_hit_rate() - stats = { + stats = { "total_requests": self.stats.total_requests, "hits": self.stats.hits, "misses": self.stats.misses, @@ -454,7 +454,7 @@ class CacheManager: def save_stats(self) -> None: """保存缓存统计到数据库""" - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path) self.stats.update_hit_rate() @@ -487,81 +487,81 @@ class CacheManager: Returns: Dict: 预热统计 """ - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row - stats = {"entities": 0, "relations": 0, "transcripts": 0} + stats = {"entities": 0, "relations": 0, "transcripts": 0} # 预热实体数据 - entities = conn.execute( + entities = conn.execute( """SELECT e.*, - (SELECT COUNT(*) FROM entity_mentions m WHERE m.entity_id = e.id) as mention_count + (SELECT COUNT(*) FROM entity_mentions m WHERE m.entity_id = e.id) as mention_count FROM entities e - WHERE e.project_id = ? + WHERE e.project_id = ? ORDER BY mention_count DESC LIMIT 100""", - (project_id,), + (project_id, ), ).fetchall() for entity in entities: - key = f"entity:{entity['id']}" - self.set(key, dict(entity), ttl=7200) # 2小时 + key = f"entity:{entity['id']}" + self.set(key, dict(entity), ttl = 7200) # 2小时 stats["entities"] += 1 # 预热关系数据 - relations = conn.execute( + relations = conn.execute( """SELECT r.*, e1.name as source_name, e2.name as target_name FROM entity_relations r - JOIN entities e1 ON r.source_entity_id = e1.id - JOIN entities e2 ON r.target_entity_id = e2.id - WHERE r.project_id = ? + JOIN entities e1 ON r.source_entity_id = e1.id + JOIN entities e2 ON r.target_entity_id = e2.id + WHERE r.project_id = ? LIMIT 200""", - (project_id,), + (project_id, ), ).fetchall() for relation in relations: - key = f"relation:{relation['id']}" - self.set(key, dict(relation), ttl=3600) + key = f"relation:{relation['id']}" + self.set(key, dict(relation), ttl = 3600) stats["relations"] += 1 # 预热最近的转录 - transcripts = conn.execute( + transcripts = conn.execute( """SELECT * FROM transcripts - WHERE project_id = ? + WHERE project_id = ? ORDER BY created_at DESC LIMIT 10""", - (project_id,), + (project_id, ), ).fetchall() for transcript in transcripts: - key = f"transcript:{transcript['id']}" + key = f"transcript:{transcript['id']}" # 只缓存元数据,不缓存完整文本 - meta = { + meta = { "id": transcript["id"], "filename": transcript["filename"], "type": transcript.get("type", "audio"), "created_at": transcript["created_at"], } - self.set(key, meta, ttl=1800) # 30分钟 + self.set(key, meta, ttl = 1800) # 30分钟 stats["transcripts"] += 1 # 预热项目知识库摘要 - entity_count = conn.execute( - "SELECT COUNT(*) FROM entities WHERE project_id = ?", (project_id,) + entity_count = conn.execute( + "SELECT COUNT(*) FROM entities WHERE project_id = ?", (project_id, ) ).fetchone()[0] - relation_count = conn.execute( - "SELECT COUNT(*) FROM entity_relations WHERE project_id = ?", (project_id,) + relation_count = conn.execute( + "SELECT COUNT(*) FROM entity_relations WHERE project_id = ?", (project_id, ) ).fetchone()[0] - summary = { + summary = { "project_id": project_id, "entity_count": entity_count, "relation_count": relation_count, "cached_at": datetime.now().isoformat(), } - self.set(f"project_summary:{project_id}", summary, ttl=3600) + self.set(f"project_summary:{project_id}", summary, ttl = 3600) conn.close() @@ -577,13 +577,13 @@ class CacheManager: Returns: int: 清除的缓存数量 """ - count = 0 + count = 0 if self.use_redis: try: # 使用 Redis 的 scan 查找相关 key - pattern = f"*:{project_id}:*" - for key in self.redis_client.scan_iter(match=pattern): + pattern = f"*:{project_id}:*" + for key in self.redis_client.scan_iter(match = pattern): self.redis_client.delete(key) count += 1 except Exception as e: @@ -591,9 +591,9 @@ class CacheManager: else: # 内存缓存 - 查找并删除相关 key with self.cache_lock: - keys_to_delete = [key for key in self.memory_cache.keys() if project_id in key] + keys_to_delete = [key for key in self.memory_cache.keys() if project_id in key] for key in keys_to_delete: - entry = self.memory_cache.pop(key) + entry = self.memory_cache.pop(key) self.current_memory_size -= entry.size_bytes count += 1 @@ -616,19 +616,19 @@ class DatabaseSharding: def __init__( self, - base_db_path: str = "insightflow.db", - shard_db_dir: str = "./shards", - shards_count: int = 4, - ): - self.base_db_path = base_db_path - self.shard_db_dir = shard_db_dir - self.shards_count = shards_count + base_db_path: str = "insightflow.db", + shard_db_dir: str = "./shards", + shards_count: int = 4, + ) -> None: + self.base_db_path = base_db_path + self.shard_db_dir = shard_db_dir + self.shards_count = shards_count # 确保分片目录存在 - os.makedirs(shard_db_dir, exist_ok=True) + os.makedirs(shard_db_dir, exist_ok = True) # 分片映射 - self.shard_map: dict[str, ShardInfo] = {} + self.shard_map: dict[str, ShardInfo] = {} # 初始化分片 self._init_shards() @@ -636,24 +636,24 @@ class DatabaseSharding: def _init_shards(self) -> None: """初始化分片""" # 计算每个分片的 key 范围 - chars = "0123456789abcdef" - chars_per_shard = len(chars) // self.shards_count + chars = "0123456789abcdef" + chars_per_shard = len(chars) // self.shards_count for i in range(self.shards_count): - start_idx = i * chars_per_shard - end_idx = start_idx + chars_per_shard if i < self.shards_count - 1 else len(chars) + start_idx = i * chars_per_shard + end_idx = start_idx + chars_per_shard if i < self.shards_count - 1 else len(chars) - start_char = chars[start_idx] - end_char = chars[end_idx - 1] + start_char = chars[start_idx] + end_char = chars[end_idx - 1] - shard_id = f"shard_{i}" - db_path = os.path.join(self.shard_db_dir, f"{shard_id}.db") + shard_id = f"shard_{i}" + db_path = os.path.join(self.shard_db_dir, f"{shard_id}.db") - self.shard_map[shard_id] = ShardInfo( - shard_id=shard_id, - shard_key_range=(start_char, end_char), - db_path=db_path, - created_at=datetime.now().isoformat(), + self.shard_map[shard_id] = ShardInfo( + shard_id = shard_id, + shard_key_range = (start_char, end_char), + db_path = db_path, + created_at = datetime.now().isoformat(), ) # 确保分片数据库存在 @@ -662,7 +662,7 @@ class DatabaseSharding: def _create_shard_db(self, db_path: str) -> None: """创建分片数据库""" - conn = sqlite3.connect(db_path) + conn = sqlite3.connect(db_path) # 创建与主库相同的表结构(简化版) conn.executescript(""" @@ -702,10 +702,10 @@ class DatabaseSharding: if not project_id: return "shard_0" - first_char = project_id[0].lower() + first_char = project_id[0].lower() for shard_id, shard_info in self.shard_map.items(): - start, end = shard_info.shard_key_range + start, end = shard_info.shard_key_range if start <= first_char <= end: return shard_id @@ -713,14 +713,14 @@ class DatabaseSharding: def get_shard_connection(self, project_id: str) -> sqlite3.Connection: """获取项目对应的分片连接""" - shard_id = self._get_shard_id(project_id) - shard_info = self.shard_map[shard_id] + shard_id = self._get_shard_id(project_id) + shard_info = self.shard_map[shard_id] - conn = sqlite3.connect(shard_info.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(shard_info.db_path) + conn.row_factory = sqlite3.Row # 更新访问时间 - shard_info.last_accessed = datetime.now().isoformat() + shard_info.last_accessed = datetime.now().isoformat() return conn @@ -740,34 +740,34 @@ class DatabaseSharding: bool: 是否成功 """ # 获取源分片 - source_shard_id = self._get_shard_id(project_id) + source_shard_id = self._get_shard_id(project_id) if source_shard_id == target_shard_id: return True # 已经在目标分片 - source_info = self.shard_map.get(source_shard_id) - target_info = self.shard_map.get(target_shard_id) + source_info = self.shard_map.get(source_shard_id) + target_info = self.shard_map.get(target_shard_id) if not source_info or not target_info: return False try: # 从源分片读取数据 - source_conn = sqlite3.connect(source_info.db_path) - source_conn.row_factory = sqlite3.Row + source_conn = sqlite3.connect(source_info.db_path) + source_conn.row_factory = sqlite3.Row - entities = source_conn.execute( - "SELECT * FROM entities WHERE project_id = ?", (project_id,) + entities = source_conn.execute( + "SELECT * FROM entities WHERE project_id = ?", (project_id, ) ).fetchall() - relations = source_conn.execute( - "SELECT * FROM entity_relations WHERE project_id = ?", (project_id,) + relations = source_conn.execute( + "SELECT * FROM entity_relations WHERE project_id = ?", (project_id, ) ).fetchall() source_conn.close() # 写入目标分片 - target_conn = sqlite3.connect(target_info.db_path) + target_conn = sqlite3.connect(target_info.db_path) for entity in entities: target_conn.execute( @@ -793,9 +793,9 @@ class DatabaseSharding: target_conn.close() # 从源分片删除数据 - source_conn = sqlite3.connect(source_info.db_path) - source_conn.execute("DELETE FROM entities WHERE project_id = ?", (project_id,)) - source_conn.execute("DELETE FROM entity_relations WHERE project_id = ?", (project_id,)) + source_conn = sqlite3.connect(source_info.db_path) + source_conn.execute("DELETE FROM entities WHERE project_id = ?", (project_id, )) + source_conn.execute("DELETE FROM entity_relations WHERE project_id = ?", (project_id, )) source_conn.commit() source_conn.close() @@ -811,15 +811,15 @@ class DatabaseSharding: def _update_shard_stats(self, shard_id: str) -> None: """更新分片统计""" - shard_info = self.shard_map.get(shard_id) + shard_info = self.shard_map.get(shard_id) if not shard_info: return - conn = sqlite3.connect(shard_info.db_path) + conn = sqlite3.connect(shard_info.db_path) - count = conn.execute("SELECT COUNT(DISTINCT project_id) FROM entities").fetchone()[0] + count = conn.execute("SELECT COUNT(DISTINCT project_id) FROM entities").fetchone()[0] - shard_info.entity_count = count + shard_info.entity_count = count conn.close() @@ -833,14 +833,14 @@ class DatabaseSharding: Returns: List[Dict]: 合并的查询结果 """ - results = [] + results = [] for shard_info in self.shard_map.values(): - conn = sqlite3.connect(shard_info.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(shard_info.db_path) + conn.row_factory = sqlite3.Row try: - shard_results = query_func(conn) + shard_results = query_func(conn) results.extend(shard_results) except Exception as e: print(f"分片 {shard_info.shard_id} 查询失败: {e}") @@ -851,7 +851,7 @@ class DatabaseSharding: def get_shard_stats(self) -> list[dict]: """获取所有分片的统计信息""" - stats = [] + stats = [] for shard_info in self.shard_map.values(): self._update_shard_stats(shard_info.shard_id) @@ -880,17 +880,17 @@ class DatabaseSharding: Dict: 重新平衡统计 """ # 获取各分片的负载 - stats = self.get_shard_stats() + stats = self.get_shard_stats() if not stats: return {"message": "No shards to rebalance"} # 计算平均负载 - avg_load = sum(s["entity_count"] for s in stats) / len(stats) + avg_load = sum(s["entity_count"] for s in stats) / len(stats) # 找出过载和欠载的分片 - overloaded = [s for s in stats if s["entity_count"] > avg_load * 1.5] - underloaded = [s for s in stats if s["entity_count"] < avg_load * 0.5] + overloaded = [s for s in stats if s["entity_count"] > avg_load * 1.5] + underloaded = [s for s in stats if s["entity_count"] < avg_load * 0.5] # 简化的重新平衡逻辑 # 实际生产环境需要更复杂的算法 @@ -917,16 +917,16 @@ class TaskQueue: - 任务状态追踪和重试机制 """ - def __init__(self, redis_url: str | None = None, db_path: str = "insightflow.db"): - self.db_path = db_path - self.redis_url = redis_url - self.celery_app = None - self.use_celery = False + def __init__(self, redis_url: str | None = None, db_path: str = "insightflow.db") -> None: + self.db_path = db_path + self.redis_url = redis_url + self.celery_app = None + self.use_celery = False # 内存任务存储(非 Celery 模式) - self.tasks: dict[str, TaskInfo] = {} - self.task_handlers: dict[str, Callable] = {} - self.task_lock = threading.RLock() + self.tasks: dict[str, TaskInfo] = {} + self.task_handlers: dict[str, Callable] = {} + self.task_lock = threading.RLock() # 初始化任务队列表 self._init_task_tables() @@ -934,15 +934,15 @@ class TaskQueue: # 初始化 Celery if CELERY_AVAILABLE and redis_url: try: - self.celery_app = Celery("insightflow", broker=redis_url, backend=redis_url) - self.use_celery = True + self.celery_app = Celery("insightflow", broker = redis_url, backend = redis_url) + self.use_celery = True print("Celery 任务队列已初始化") except Exception as e: print(f"Celery 初始化失败,使用内存任务队列: {e}") def _init_task_tables(self) -> None: """初始化任务队列表""" - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS task_queue ( @@ -972,9 +972,9 @@ class TaskQueue: def register_handler(self, task_type: str, handler: Callable) -> None: """注册任务处理器""" - self.task_handlers[task_type] = handler + self.task_handlers[task_type] = handler - def submit(self, task_type: str, payload: dict, max_retries: int = 3) -> str: + def submit(self, task_type: str, payload: dict, max_retries: int = 3) -> str: """ 提交任务 @@ -986,45 +986,45 @@ class TaskQueue: Returns: str: 任务ID """ - task_id = str(uuid.uuid4())[:16] + task_id = str(uuid.uuid4())[:16] - task = TaskInfo( - id=task_id, - task_type=task_type, - status="pending", - payload=payload, - created_at=datetime.now().isoformat(), - max_retries=max_retries, + task = TaskInfo( + id = task_id, + task_type = task_type, + status = "pending", + payload = payload, + created_at = datetime.now().isoformat(), + max_retries = max_retries, ) if self.use_celery: # 使用 Celery try: # 这里简化处理,实际应该定义具体的 Celery 任务 - result = self.celery_app.send_task( + result = self.celery_app.send_task( f"insightflow.tasks.{task_type}", - args=[payload], - task_id=task_id, - retry=True, - retry_policy={ + args = [payload], + task_id = task_id, + retry = True, + retry_policy = { "max_retries": max_retries, "interval_start": 10, "interval_step": 10, "interval_max": 60, }, ) - task.id = result.id + task.id = result.id except Exception as e: print(f"Celery 任务提交失败: {e}") # 回退到内存模式 - self.use_celery = False + self.use_celery = False if not self.use_celery: # 内存模式 with self.task_lock: - self.tasks[task_id] = task + self.tasks[task_id] = task # 异步执行 - threading.Thread(target=self._execute_task, args=(task_id,), daemon=True).start() + threading.Thread(target = self._execute_task, args = (task_id, ), daemon = True).start() # 保存到数据库 self._save_task(task) @@ -1034,49 +1034,49 @@ class TaskQueue: def _execute_task(self, task_id: str) -> None: """执行任务(内存模式)""" with self.task_lock: - task = self.tasks.get(task_id) + task = self.tasks.get(task_id) if not task: return - task.status = "running" - task.started_at = datetime.now().isoformat() + task.status = "running" + task.started_at = datetime.now().isoformat() self._update_task_status(task) # 获取处理器 - handler = self.task_handlers.get(task.task_type) + handler = self.task_handlers.get(task.task_type) if not handler: - task.status = "failed" - task.error_message = f"No handler for task type: {task.task_type}" + task.status = "failed" + task.error_message = f"No handler for task type: {task.task_type}" else: try: - result = handler(task.payload) - task.status = "success" - task.result = result + result = handler(task.payload) + task.status = "success" + task.result = result except Exception as e: task.retry_count += 1 if task.retry_count <= task.max_retries: - task.status = "retrying" + task.status = "retrying" # 延迟重试 threading.Timer( - 10 * task.retry_count, self._execute_task, args=(task_id,) + 10 * task.retry_count, self._execute_task, args = (task_id, ) ).start() else: - task.status = "failed" - task.error_message = str(e) + task.status = "failed" + task.error_message = str(e) - task.completed_at = datetime.now().isoformat() + task.completed_at = datetime.now().isoformat() with self.task_lock: - self.tasks[task_id] = task + self.tasks[task_id] = task self._update_task_status(task) def _save_task(self, task: TaskInfo) -> None: """保存任务到数据库""" - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path) conn.execute( """ @@ -1089,8 +1089,8 @@ class TaskQueue: task.id, task.task_type, task.status, - json.dumps(task.payload, ensure_ascii=False), - json.dumps(task.result, ensure_ascii=False) if task.result else None, + json.dumps(task.payload, ensure_ascii = False), + json.dumps(task.result, ensure_ascii = False) if task.result else None, task.error_message, task.retry_count, task.max_retries, @@ -1105,22 +1105,22 @@ class TaskQueue: def _update_task_status(self, task: TaskInfo) -> None: """更新任务状态""" - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path) conn.execute( """ UPDATE task_queue SET - status = ?, - result = ?, - error_message = ?, - retry_count = ?, - started_at = ?, - completed_at = ? - WHERE id = ? + status = ?, + result = ?, + error_message = ?, + retry_count = ?, + started_at = ?, + completed_at = ? + WHERE id = ? """, ( task.status, - json.dumps(task.result, ensure_ascii=False) if task.result else None, + json.dumps(task.result, ensure_ascii = False) if task.result else None, task.error_message, task.retry_count, task.started_at, @@ -1136,9 +1136,9 @@ class TaskQueue: """获取任务状态""" if self.use_celery: try: - result = AsyncResult(task_id, app=self.celery_app) + result = AsyncResult(task_id, app = self.celery_app) - status_map = { + status_map = { "PENDING": "pending", "STARTED": "running", "SUCCESS": "success", @@ -1147,13 +1147,13 @@ class TaskQueue: } return TaskInfo( - id=task_id, - task_type="celery_task", - status=status_map.get(result.status, "unknown"), - payload={}, - created_at="", - result=result.result if result.successful() else None, - error_message=str(result.result) if result.failed() else None, + id = task_id, + task_type = "celery_task", + status = status_map.get(result.status, "unknown"), + payload = {}, + created_at = "", + result = result.result if result.successful() else None, + error_message = str(result.result) if result.failed() else None, ) except Exception as e: print(f"获取 Celery 任务状态失败: {e}") @@ -1163,26 +1163,26 @@ class TaskQueue: return self.tasks.get(task_id) def list_tasks( - self, status: str | None = None, task_type: str | None = None, limit: int = 100 + self, status: str | None = None, task_type: str | None = None, limit: int = 100 ) -> list[TaskInfo]: """列出任务""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row - where_clauses = [] - params = [] + where_clauses = [] + params = [] if status: - where_clauses.append("status = ?") + where_clauses.append("status = ?") params.append(status) if task_type: - where_clauses.append("task_type = ?") + where_clauses.append("task_type = ?") params.append(task_type) - where_str = " AND ".join(where_clauses) if where_clauses else "1=1" + where_str = " AND ".join(where_clauses) if where_clauses else "1 = 1" - rows = conn.execute( + rows = conn.execute( f""" SELECT * FROM task_queue WHERE {where_str} @@ -1194,21 +1194,21 @@ class TaskQueue: conn.close() - tasks = [] + tasks = [] for row in rows: tasks.append( TaskInfo( - id=row["id"], - task_type=row["task_type"], - status=row["status"], - payload=json.loads(row["payload"]) if row["payload"] else {}, - created_at=row["created_at"], - started_at=row["started_at"], - completed_at=row["completed_at"], - result=json.loads(row["result"]) if row["result"] else None, - error_message=row["error_message"], - retry_count=row["retry_count"], - max_retries=row["max_retries"], + id = row["id"], + task_type = row["task_type"], + status = row["status"], + payload = json.loads(row["payload"]) if row["payload"] else {}, + created_at = row["created_at"], + started_at = row["started_at"], + completed_at = row["completed_at"], + result = json.loads(row["result"]) if row["result"] else None, + error_message = row["error_message"], + retry_count = row["retry_count"], + max_retries = row["max_retries"], ) ) @@ -1218,16 +1218,16 @@ class TaskQueue: """取消任务""" if self.use_celery: try: - self.celery_app.control.revoke(task_id, terminate=True) + self.celery_app.control.revoke(task_id, terminate = True) return True except Exception as e: print(f"取消 Celery 任务失败: {e}") with self.task_lock: - task = self.tasks.get(task_id) + task = self.tasks.get(task_id) if task and task.status in ["pending", "running"]: - task.status = "cancelled" - task.completed_at = datetime.now().isoformat() + task.status = "cancelled" + task.completed_at = datetime.now().isoformat() self._update_task_status(task) return True @@ -1235,44 +1235,44 @@ class TaskQueue: def retry(self, task_id: str) -> bool: """重试失败的任务""" - task = self.get_status(task_id) + task = self.get_status(task_id) if not task or task.status != "failed": return False - task.status = "pending" - task.retry_count = 0 - task.error_message = None - task.completed_at = None + task.status = "pending" + task.retry_count = 0 + task.error_message = None + task.completed_at = None if not self.use_celery: with self.task_lock: - self.tasks[task_id] = task - threading.Thread(target=self._execute_task, args=(task_id,), daemon=True).start() + self.tasks[task_id] = task + threading.Thread(target = self._execute_task, args = (task_id, ), daemon = True).start() self._update_task_status(task) return True def get_stats(self) -> dict: """获取任务队列统计""" - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path) # 各状态任务数量 - status_counts = conn.execute(""" + status_counts = conn.execute(""" SELECT status, COUNT(*) as count FROM task_queue GROUP BY status """).fetchall() # 各类型任务数量 - type_counts = conn.execute(""" + type_counts = conn.execute(""" SELECT task_type, COUNT(*) as count FROM task_queue GROUP BY task_type """).fetchall() # 最近24小时任务数 - recent_count = conn.execute(""" + recent_count = conn.execute(""" SELECT COUNT(*) as count FROM task_queue WHERE created_at > datetime('now', '-1 day') @@ -1304,29 +1304,29 @@ class PerformanceMonitor: def __init__( self, - db_path: str = "insightflow.db", - slow_query_threshold: int = 1000, - alert_threshold: int = 5000, # 毫秒 - ): # 毫秒 - self.db_path = db_path - self.slow_query_threshold = slow_query_threshold - self.alert_threshold = alert_threshold + db_path: str = "insightflow.db", + slow_query_threshold: int = 1000, + alert_threshold: int = 5000, # 毫秒 + ) -> None: # 毫秒 + self.db_path = db_path + self.slow_query_threshold = slow_query_threshold + self.alert_threshold = alert_threshold # 内存中的指标缓存 - self.metrics_buffer: list[PerformanceMetric] = [] - self.buffer_lock = threading.RLock() - self.buffer_size = 100 + self.metrics_buffer: list[PerformanceMetric] = [] + self.buffer_lock = threading.RLock() + self.buffer_size = 100 # 告警回调 - self.alert_handlers: list[Callable] = [] + self.alert_handlers: list[Callable] = [] def record_metric( self, metric_type: str, duration_ms: float, - endpoint: str | None = None, - metadata: dict | None = None, - ): + endpoint: str | None = None, + metadata: dict | None = None, + ) -> None: """ 记录性能指标 @@ -1336,13 +1336,13 @@ class PerformanceMonitor: endpoint: 端点/查询标识 metadata: 额外元数据 """ - metric = PerformanceMetric( - id=str(uuid.uuid4())[:16], - metric_type=metric_type, - endpoint=endpoint, - duration_ms=duration_ms, - timestamp=datetime.now().isoformat(), - metadata=metadata or {}, + metric = PerformanceMetric( + id = str(uuid.uuid4())[:16], + metric_type = metric_type, + endpoint = endpoint, + duration_ms = duration_ms, + timestamp = datetime.now().isoformat(), + metadata = metadata or {}, ) # 添加到缓冲区 @@ -1364,7 +1364,7 @@ class PerformanceMonitor: if not self.metrics_buffer: return - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path) for metric in self.metrics_buffer: conn.execute( @@ -1379,14 +1379,14 @@ class PerformanceMonitor: metric.endpoint, metric.duration_ms, metric.timestamp, - json.dumps(metric.metadata, ensure_ascii=False), + json.dumps(metric.metadata, ensure_ascii = False), ), ) conn.commit() conn.close() - self.metrics_buffer = [] + self.metrics_buffer = [] def _record_slow_query(self, metric: PerformanceMetric) -> None: """记录慢查询""" @@ -1395,7 +1395,7 @@ class PerformanceMonitor: def _trigger_alert(self, metric: PerformanceMetric) -> None: """触发告警""" - alert_data = { + alert_data = { "type": "performance_alert", "metric": metric.to_dict(), "threshold": self.alert_threshold, @@ -1412,7 +1412,7 @@ class PerformanceMonitor: """注册告警处理器""" self.alert_handlers.append(handler) - def get_stats(self, hours: int = 24) -> dict: + def get_stats(self, hours: int = 24) -> dict: """ 获取性能统计 @@ -1425,11 +1425,11 @@ class PerformanceMonitor: # 先刷新缓冲区 self._flush_metrics() - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row # 总体统计 - overall = conn.execute( + overall = conn.execute( """ SELECT COUNT(*) as total, @@ -1439,11 +1439,11 @@ class PerformanceMonitor: FROM performance_metrics WHERE timestamp > datetime('now', ?) """, - (f"-{hours} hours",), + (f"-{hours} hours", ), ).fetchone() # 按类型统计 - by_type = conn.execute( + by_type = conn.execute( """ SELECT metric_type, @@ -1454,11 +1454,11 @@ class PerformanceMonitor: WHERE timestamp > datetime('now', ?) GROUP BY metric_type """, - (f"-{hours} hours",), + (f"-{hours} hours", ), ).fetchall() # 按端点统计(API) - by_endpoint = conn.execute( + by_endpoint = conn.execute( """ SELECT endpoint, @@ -1467,16 +1467,16 @@ class PerformanceMonitor: MAX(duration_ms) as max_duration FROM performance_metrics WHERE timestamp > datetime('now', ?) - AND metric_type = 'api_response' + AND metric_type = 'api_response' GROUP BY endpoint ORDER BY avg_duration DESC LIMIT 20 """, - (f"-{hours} hours",), + (f"-{hours} hours", ), ).fetchall() # 慢查询统计 - slow_queries = conn.execute( + slow_queries = conn.execute( """ SELECT metric_type, @@ -1531,22 +1531,22 @@ class PerformanceMonitor: ], } - def get_api_performance(self, endpoint: str | None = None, hours: int = 24) -> dict: + def get_api_performance(self, endpoint: str | None = None, hours: int = 24) -> dict: """获取 API 性能详情""" self._flush_metrics() - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row - where_clause = "metric_type = 'api_response'" - params = [f"-{hours} hours"] + where_clause = "metric_type = 'api_response'" + params = [f"-{hours} hours"] if endpoint: - where_clause += " AND endpoint = ?" + where_clause += " AND endpoint = ?" params.append(endpoint) # 百分位数统计 - percentiles = conn.execute( + percentiles = conn.execute( f""" SELECT endpoint, @@ -1580,7 +1580,7 @@ class PerformanceMonitor: ], } - def cleanup_old_metrics(self, days: int = 30) -> int: + def cleanup_old_metrics(self, days: int = 30) -> int: """ 清理旧的性能指标数据 @@ -1590,17 +1590,17 @@ class PerformanceMonitor: Returns: int: 删除的记录数 """ - conn = sqlite3.connect(self.db_path) + conn = sqlite3.connect(self.db_path) - cursor = conn.execute( + cursor = conn.execute( """ DELETE FROM performance_metrics WHERE timestamp < datetime('now', ?) """, - (f"-{days} days",), + (f"-{days} days", ), ) - deleted = cursor.rowcount + deleted = cursor.rowcount conn.commit() conn.close() @@ -1613,9 +1613,9 @@ class PerformanceMonitor: def cached( cache_manager: CacheManager, - key_prefix: str = "", - ttl: int = 3600, - key_func: Callable | None = None, + key_prefix: str = "", + ttl: int = 3600, + key_func: Callable | None = None, ) -> None: """ 缓存装饰器 @@ -1632,19 +1632,19 @@ def cached( def wrapper(*args, **kwargs) -> None: # 生成缓存键 if key_func: - cache_key = key_func(*args, **kwargs) + cache_key = key_func(*args, **kwargs) else: # 默认使用函数名和参数哈希 - key_data = f"{func.__name__}:{str(args)}:{str(kwargs)}" - cache_key = f"{key_prefix}:{hashlib.md5(key_data.encode()).hexdigest()[:16]}" + key_data = f"{func.__name__}:{str(args)}:{str(kwargs)}" + cache_key = f"{key_prefix}:{hashlib.md5(key_data.encode()).hexdigest()[:16]}" # 尝试从缓存获取 - cached_value = cache_manager.get(cache_key) + cached_value = cache_manager.get(cache_key) if cached_value is not None: return cached_value # 执行函数 - result = func(*args, **kwargs) + result = func(*args, **kwargs) # 写入缓存 cache_manager.set(cache_key, result, ttl) @@ -1656,7 +1656,7 @@ def cached( return decorator -def monitored(monitor: PerformanceMonitor, metric_type: str, endpoint: str | None = None) -> None: +def monitored(monitor: PerformanceMonitor, metric_type: str, endpoint: str | None = None) -> None: """ 性能监控装饰器 @@ -1668,15 +1668,15 @@ def monitored(monitor: PerformanceMonitor, metric_type: str, endpoint: str | Non def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper(*args, **kwargs): - start_time = time.time() + def wrapper(*args, **kwargs) -> None: + start_time = time.time() try: - result = func(*args, **kwargs) + result = func(*args, **kwargs) return result finally: - duration_ms = (time.time() - start_time) * 1000 - ep = endpoint or func.__name__ + duration_ms = (time.time() - start_time) * 1000 + ep = endpoint or func.__name__ monitor.record_metric(metric_type, duration_ms, ep) return wrapper @@ -1696,20 +1696,20 @@ class PerformanceManager: def __init__( self, - db_path: str = "insightflow.db", - redis_url: str | None = None, - enable_sharding: bool = False, - ): - self.db_path = db_path + db_path: str = "insightflow.db", + redis_url: str | None = None, + enable_sharding: bool = False, + ) -> None: + self.db_path = db_path # 初始化各模块 - self.cache = CacheManager(redis_url=redis_url, db_path=db_path) + self.cache = CacheManager(redis_url = redis_url, db_path = db_path) - self.sharding = DatabaseSharding(base_db_path=db_path) if enable_sharding else None + self.sharding = DatabaseSharding(base_db_path = db_path) if enable_sharding else None - self.task_queue = TaskQueue(redis_url=redis_url, db_path=db_path) + self.task_queue = TaskQueue(redis_url = redis_url, db_path = db_path) - self.monitor = PerformanceMonitor(db_path=db_path) + self.monitor = PerformanceMonitor(db_path = db_path) def get_health_status(self) -> dict: """获取系统健康状态""" @@ -1737,29 +1737,29 @@ class PerformanceManager: def get_full_stats(self) -> dict: """获取完整统计信息""" - stats = { + stats = { "cache": self.cache.get_stats(), "task_queue": self.task_queue.get_stats(), "performance": self.monitor.get_stats(), } if self.sharding: - stats["sharding"] = self.sharding.get_shard_stats() + stats["sharding"] = self.sharding.get_shard_stats() return stats # 单例模式 -_performance_manager = None +_performance_manager = None def get_performance_manager( - db_path: str = "insightflow.db", redis_url: str | None = None, enable_sharding: bool = False + db_path: str = "insightflow.db", redis_url: str | None = None, enable_sharding: bool = False ) -> PerformanceManager: """获取性能管理器单例""" global _performance_manager if _performance_manager is None: - _performance_manager = PerformanceManager( - db_path=db_path, redis_url=redis_url, enable_sharding=enable_sharding + _performance_manager = PerformanceManager( + db_path = db_path, redis_url = redis_url, enable_sharding = enable_sharding ) return _performance_manager diff --git a/backend/plugin_manager.py b/backend/plugin_manager.py index 2f70244..64e8375 100644 --- a/backend/plugin_manager.py +++ b/backend/plugin_manager.py @@ -22,36 +22,36 @@ from plugin_manager import PluginManager import urllib.parse # Constants -UUID_LENGTH = 8 # UUID 截断长度 +UUID_LENGTH = 8 # UUID 截断长度 # WebDAV 支持 try: import webdav4.client as webdav_client - WEBDAV_AVAILABLE = True + WEBDAV_AVAILABLE = True except ImportError: - WEBDAV_AVAILABLE = False + WEBDAV_AVAILABLE = False class PluginType(Enum): """插件类型""" - CHROME_EXTENSION = "chrome_extension" - FEISHU_BOT = "feishu_bot" - DINGTALK_BOT = "dingtalk_bot" - ZAPIER = "zapier" - MAKE = "make" - WEBDAV = "webdav" - CUSTOM = "custom" + CHROME_EXTENSION = "chrome_extension" + FEISHU_BOT = "feishu_bot" + DINGTALK_BOT = "dingtalk_bot" + ZAPIER = "zapier" + MAKE = "make" + WEBDAV = "webdav" + CUSTOM = "custom" class PluginStatus(Enum): """插件状态""" - ACTIVE = "active" - INACTIVE = "inactive" - ERROR = "error" - PENDING = "pending" + ACTIVE = "active" + INACTIVE = "inactive" + ERROR = "error" + PENDING = "pending" @dataclass @@ -62,12 +62,12 @@ class Plugin: name: str plugin_type: str project_id: str - status: str = "active" - config: dict = field(default_factory=dict) - created_at: str = "" - updated_at: str = "" - last_used_at: str | None = None - use_count: int = 0 + status: str = "active" + config: dict = field(default_factory = dict) + created_at: str = "" + updated_at: str = "" + last_used_at: str | None = None + use_count: int = 0 @dataclass @@ -78,9 +78,9 @@ class PluginConfig: plugin_id: str config_key: str config_value: str - is_encrypted: bool = False - created_at: str = "" - updated_at: str = "" + is_encrypted: bool = False + created_at: str = "" + updated_at: str = "" @dataclass @@ -91,14 +91,14 @@ class BotSession: bot_type: str # feishu, dingtalk session_id: str # 群ID或会话ID session_name: str - project_id: str | None = None - webhook_url: str = "" - secret: str = "" - is_active: bool = True - created_at: str = "" - updated_at: str = "" - last_message_at: str | None = None - message_count: int = 0 + project_id: str | None = None + webhook_url: str = "" + secret: str = "" + is_active: bool = True + created_at: str = "" + updated_at: str = "" + last_message_at: str | None = None + message_count: int = 0 @dataclass @@ -109,15 +109,15 @@ class WebhookEndpoint: name: str endpoint_type: str # zapier, make, custom endpoint_url: str - project_id: str | None = None - auth_type: str = "none" # none, api_key, oauth, custom - auth_config: dict = field(default_factory=dict) - trigger_events: list[str] = field(default_factory=list) - is_active: bool = True - created_at: str = "" - updated_at: str = "" - last_triggered_at: str | None = None - trigger_count: int = 0 + project_id: str | None = None + auth_type: str = "none" # none, api_key, oauth, custom + auth_config: dict = field(default_factory = dict) + trigger_events: list[str] = field(default_factory = list) + is_active: bool = True + created_at: str = "" + updated_at: str = "" + last_triggered_at: str | None = None + trigger_count: int = 0 @dataclass @@ -129,17 +129,17 @@ class WebDAVSync: project_id: str server_url: str username: str - password: str = "" # 加密存储 - remote_path: str = "/insightflow" - sync_mode: str = "bidirectional" # bidirectional, upload_only, download_only - sync_interval: int = 3600 # 秒 - last_sync_at: str | None = None - last_sync_status: str = "pending" # pending, success, failed - last_sync_error: str = "" - is_active: bool = True - created_at: str = "" - updated_at: str = "" - sync_count: int = 0 + password: str = "" # 加密存储 + remote_path: str = "/insightflow" + sync_mode: str = "bidirectional" # bidirectional, upload_only, download_only + sync_interval: int = 3600 # 秒 + last_sync_at: str | None = None + last_sync_status: str = "pending" # pending, success, failed + last_sync_error: str = "" + is_active: bool = True + created_at: str = "" + updated_at: str = "" + sync_count: int = 0 @dataclass @@ -148,33 +148,33 @@ class ChromeExtensionToken: id: str token: str - user_id: str | None = None - project_id: str | None = None - name: str = "" - permissions: list[str] = field(default_factory=lambda: ["read", "write"]) - expires_at: str | None = None - created_at: str = "" - last_used_at: str | None = None - use_count: int = 0 - is_revoked: bool = False + user_id: str | None = None + project_id: str | None = None + name: str = "" + permissions: list[str] = field(default_factory = lambda: ["read", "write"]) + expires_at: str | None = None + created_at: str = "" + last_used_at: str | None = None + use_count: int = 0 + is_revoked: bool = False class PluginManager: """插件管理主类""" - def __init__(self, db_manager=None): - self.db = db_manager - self._handlers = {} + def __init__(self, db_manager = None) -> None: + self.db = db_manager + self._handlers = {} self._register_default_handlers() def _register_default_handlers(self) -> None: """注册默认处理器""" - self._handlers[PluginType.CHROME_EXTENSION] = ChromeExtensionHandler(self) - self._handlers[PluginType.FEISHU_BOT] = BotHandler(self, "feishu") - self._handlers[PluginType.DINGTALK_BOT] = BotHandler(self, "dingtalk") - self._handlers[PluginType.ZAPIER] = WebhookIntegration(self, "zapier") - self._handlers[PluginType.MAKE] = WebhookIntegration(self, "make") - self._handlers[PluginType.WEBDAV] = WebDAVSyncManager(self) + self._handlers[PluginType.CHROME_EXTENSION] = ChromeExtensionHandler(self) + self._handlers[PluginType.FEISHU_BOT] = BotHandler(self, "feishu") + self._handlers[PluginType.DINGTALK_BOT] = BotHandler(self, "dingtalk") + self._handlers[PluginType.ZAPIER] = WebhookIntegration(self, "zapier") + self._handlers[PluginType.MAKE] = WebhookIntegration(self, "make") + self._handlers[PluginType.WEBDAV] = WebDAVSyncManager(self) def get_handler(self, plugin_type: PluginType) -> Any | None: """获取插件处理器""" @@ -184,8 +184,8 @@ class PluginManager: def create_plugin(self, plugin: Plugin) -> Plugin: """创建插件""" - conn = self.db.get_conn() - now = datetime.now().isoformat() + conn = self.db.get_conn() + now = datetime.now().isoformat() conn.execute( """INSERT INTO plugins @@ -206,14 +206,14 @@ class PluginManager: conn.commit() conn.close() - plugin.created_at = now - plugin.updated_at = now + plugin.created_at = now + plugin.updated_at = now return plugin def get_plugin(self, plugin_id: str) -> Plugin | None: """获取插件""" - conn = self.db.get_conn() - row = conn.execute("SELECT * FROM plugins WHERE id = ?", (plugin_id,)).fetchone() + conn = self.db.get_conn() + row = conn.execute("SELECT * FROM plugins WHERE id = ?", (plugin_id, )).fetchone() conn.close() if row: @@ -221,27 +221,27 @@ class PluginManager: return None def list_plugins( - self, project_id: str = None, plugin_type: str = None, status: str = None + self, project_id: str = None, plugin_type: str = None, status: str = None ) -> list[Plugin]: """列出插件""" - conn = self.db.get_conn() + conn = self.db.get_conn() - conditions = [] - params = [] + conditions = [] + params = [] if project_id: - conditions.append("project_id = ?") + conditions.append("project_id = ?") params.append(project_id) if plugin_type: - conditions.append("plugin_type = ?") + conditions.append("plugin_type = ?") params.append(plugin_type) if status: - conditions.append("status = ?") + conditions.append("status = ?") params.append(status) - where_clause = " AND ".join(conditions) if conditions else "1=1" + where_clause = " AND ".join(conditions) if conditions else "1 = 1" - rows = conn.execute( + rows = conn.execute( f"SELECT * FROM plugins WHERE {where_clause} ORDER BY created_at DESC", params ).fetchall() conn.close() @@ -250,15 +250,15 @@ class PluginManager: def update_plugin(self, plugin_id: str, **kwargs) -> Plugin | None: """更新插件""" - conn = self.db.get_conn() + conn = self.db.get_conn() - allowed_fields = ["name", "status", "config"] - updates = [] - values = [] + allowed_fields = ["name", "status", "config"] + updates = [] + values = [] for f in allowed_fields: if f in kwargs: - updates.append(f"{f} = ?") + updates.append(f"{f} = ?") if f == "config": values.append(json.dumps(kwargs[f])) else: @@ -268,11 +268,11 @@ class PluginManager: conn.close() return self.get_plugin(plugin_id) - updates.append("updated_at = ?") + updates.append("updated_at = ?") values.append(datetime.now().isoformat()) values.append(plugin_id) - query = f"UPDATE plugins SET {', '.join(updates)} WHERE id = ?" + query = f"UPDATE plugins SET {', '.join(updates)} WHERE id = ?" conn.execute(query, values) conn.commit() conn.close() @@ -281,13 +281,13 @@ class PluginManager: def delete_plugin(self, plugin_id: str) -> bool: """删除插件""" - conn = self.db.get_conn() + conn = self.db.get_conn() # 删除关联的配置 - conn.execute("DELETE FROM plugin_configs WHERE plugin_id = ?", (plugin_id,)) + conn.execute("DELETE FROM plugin_configs WHERE plugin_id = ?", (plugin_id, )) # 删除插件 - cursor = conn.execute("DELETE FROM plugins WHERE id = ?", (plugin_id,)) + cursor = conn.execute("DELETE FROM plugins WHERE id = ?", (plugin_id, )) conn.commit() conn.close() @@ -296,42 +296,42 @@ class PluginManager: def _row_to_plugin(self, row: sqlite3.Row) -> Plugin: """将数据库行转换为 Plugin 对象""" return Plugin( - id=row["id"], - name=row["name"], - plugin_type=row["plugin_type"], - project_id=row["project_id"], - status=row["status"], - config=json.loads(row["config"]) if row["config"] else {}, - created_at=row["created_at"], - updated_at=row["updated_at"], - last_used_at=row["last_used_at"], - use_count=row["use_count"], + id = row["id"], + name = row["name"], + plugin_type = row["plugin_type"], + project_id = row["project_id"], + status = row["status"], + config = json.loads(row["config"]) if row["config"] else {}, + created_at = row["created_at"], + updated_at = row["updated_at"], + last_used_at = row["last_used_at"], + use_count = row["use_count"], ) # ==================== Plugin Config ==================== def set_plugin_config( - self, plugin_id: str, key: str, value: str, is_encrypted: bool = False + self, plugin_id: str, key: str, value: str, is_encrypted: bool = False ) -> PluginConfig: """设置插件配置""" - conn = self.db.get_conn() - now = datetime.now().isoformat() + conn = self.db.get_conn() + now = datetime.now().isoformat() # 检查是否已存在 - existing = conn.execute( - "SELECT id FROM plugin_configs WHERE plugin_id = ? AND config_key = ?", (plugin_id, key) + existing = conn.execute( + "SELECT id FROM plugin_configs WHERE plugin_id = ? AND config_key = ?", (plugin_id, key) ).fetchone() if existing: conn.execute( """UPDATE plugin_configs - SET config_value = ?, is_encrypted = ?, updated_at = ? - WHERE id = ?""", + SET config_value = ?, is_encrypted = ?, updated_at = ? + WHERE id = ?""", (value, is_encrypted, now, existing["id"]), ) - config_id = existing["id"] + config_id = existing["id"] else: - config_id = str(uuid.uuid4())[:UUID_LENGTH] + config_id = str(uuid.uuid4())[:UUID_LENGTH] conn.execute( """INSERT INTO plugin_configs (id, plugin_id, config_key, config_value, is_encrypted, created_at, updated_at) @@ -343,20 +343,20 @@ class PluginManager: conn.close() return PluginConfig( - id=config_id, - plugin_id=plugin_id, - config_key=key, - config_value=value, - is_encrypted=is_encrypted, - created_at=now, - updated_at=now, + id = config_id, + plugin_id = plugin_id, + config_key = key, + config_value = value, + is_encrypted = is_encrypted, + created_at = now, + updated_at = now, ) def get_plugin_config(self, plugin_id: str, key: str) -> str | None: """获取插件配置""" - conn = self.db.get_conn() - row = conn.execute( - "SELECT config_value FROM plugin_configs WHERE plugin_id = ? AND config_key = ?", + conn = self.db.get_conn() + row = conn.execute( + "SELECT config_value FROM plugin_configs WHERE plugin_id = ? AND config_key = ?", (plugin_id, key), ).fetchone() conn.close() @@ -365,9 +365,9 @@ class PluginManager: def get_all_plugin_configs(self, plugin_id: str) -> dict[str, str]: """获取插件所有配置""" - conn = self.db.get_conn() - rows = conn.execute( - "SELECT config_key, config_value FROM plugin_configs WHERE plugin_id = ?", (plugin_id,) + conn = self.db.get_conn() + rows = conn.execute( + "SELECT config_key, config_value FROM plugin_configs WHERE plugin_id = ?", (plugin_id, ) ).fetchall() conn.close() @@ -375,9 +375,9 @@ class PluginManager: def delete_plugin_config(self, plugin_id: str, key: str) -> bool: """删除插件配置""" - conn = self.db.get_conn() - cursor = conn.execute( - "DELETE FROM plugin_configs WHERE plugin_id = ? AND config_key = ?", (plugin_id, key) + conn = self.db.get_conn() + cursor = conn.execute( + "DELETE FROM plugin_configs WHERE plugin_id = ? AND config_key = ?", (plugin_id, key) ) conn.commit() conn.close() @@ -386,13 +386,13 @@ class PluginManager: def record_plugin_usage(self, plugin_id: str) -> None: """记录插件使用""" - conn = self.db.get_conn() - now = datetime.now().isoformat() + conn = self.db.get_conn() + now = datetime.now().isoformat() conn.execute( """UPDATE plugins - SET use_count = use_count + 1, last_used_at = ? - WHERE id = ?""", + SET use_count = use_count + 1, last_used_at = ? + WHERE id = ?""", (now, plugin_id), ) conn.commit() @@ -402,34 +402,34 @@ class PluginManager: class ChromeExtensionHandler: """Chrome 扩展处理器""" - def __init__(self, plugin_manager: PluginManager): - self.pm = plugin_manager + def __init__(self, plugin_manager: PluginManager) -> None: + self.pm = plugin_manager def create_token( self, name: str, - user_id: str = None, - project_id: str = None, - permissions: list[str] = None, - expires_days: int = None, + user_id: str = None, + project_id: str = None, + permissions: list[str] = None, + expires_days: int = None, ) -> ChromeExtensionToken: """创建 Chrome 扩展令牌""" - token_id = str(uuid.uuid4())[:UUID_LENGTH] + token_id = str(uuid.uuid4())[:UUID_LENGTH] # 生成随机令牌 - raw_token = f"if_ext_{base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip('=')}" + raw_token = f"if_ext_{base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip(' = ')}" # 哈希存储 - token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() - now = datetime.now().isoformat() - expires_at = None + now = datetime.now().isoformat() + expires_at = None if expires_days: from datetime import timedelta - expires_at = (datetime.now() + timedelta(days=expires_days)).isoformat() + expires_at = (datetime.now() + timedelta(days = expires_days)).isoformat() - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() conn.execute( """INSERT INTO chrome_extension_tokens (id, token_hash, user_id, project_id, name, permissions, expires_at, @@ -452,25 +452,25 @@ class ChromeExtensionHandler: conn.close() return ChromeExtensionToken( - id=token_id, - token=raw_token, # 仅返回一次 - user_id=user_id, - project_id=project_id, - name=name, - permissions=permissions or ["read"], - expires_at=expires_at, - created_at=now, + id = token_id, + token = raw_token, # 仅返回一次 + user_id = user_id, + project_id = project_id, + name = name, + permissions = permissions or ["read"], + expires_at = expires_at, + created_at = now, ) def validate_token(self, token: str) -> ChromeExtensionToken | None: """验证 Chrome 扩展令牌""" - token_hash = hashlib.sha256(token.encode()).hexdigest() + token_hash = hashlib.sha256(token.encode()).hexdigest() - conn = self.pm.db.get_conn() - row = conn.execute( + conn = self.pm.db.get_conn() + row = conn.execute( """SELECT * FROM chrome_extension_tokens - WHERE token_hash = ? AND is_revoked = 0""", - (token_hash,), + WHERE token_hash = ? AND is_revoked = 0""", + (token_hash, ), ).fetchone() conn.close() @@ -482,35 +482,35 @@ class ChromeExtensionHandler: return None # 更新使用记录 - now = datetime.now().isoformat() - conn = self.pm.db.get_conn() + now = datetime.now().isoformat() + conn = self.pm.db.get_conn() conn.execute( """UPDATE chrome_extension_tokens - SET use_count = use_count + 1, last_used_at = ? - WHERE id = ?""", + SET use_count = use_count + 1, last_used_at = ? + WHERE id = ?""", (now, row["id"]), ) conn.commit() conn.close() return ChromeExtensionToken( - id=row["id"], - token="", # 不返回实际令牌 - user_id=row["user_id"], - project_id=row["project_id"], - name=row["name"], - permissions=json.loads(row["permissions"]), - expires_at=row["expires_at"], - created_at=row["created_at"], - last_used_at=now, - use_count=row["use_count"] + 1, + id = row["id"], + token = "", # 不返回实际令牌 + user_id = row["user_id"], + project_id = row["project_id"], + name = row["name"], + permissions = json.loads(row["permissions"]), + expires_at = row["expires_at"], + created_at = row["created_at"], + last_used_at = now, + use_count = row["use_count"] + 1, ) def revoke_token(self, token_id: str) -> bool: """撤销令牌""" - conn = self.pm.db.get_conn() - cursor = conn.execute( - "UPDATE chrome_extension_tokens SET is_revoked = 1 WHERE id = ?", (token_id,) + conn = self.pm.db.get_conn() + cursor = conn.execute( + "UPDATE chrome_extension_tokens SET is_revoked = 1 WHERE id = ?", (token_id, ) ) conn.commit() conn.close() @@ -518,44 +518,44 @@ class ChromeExtensionHandler: return cursor.rowcount > 0 def list_tokens( - self, user_id: str = None, project_id: str = None + self, user_id: str = None, project_id: str = None ) -> list[ChromeExtensionToken]: """列出令牌""" - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() - conditions = ["is_revoked = 0"] - params = [] + conditions = ["is_revoked = 0"] + params = [] if user_id: - conditions.append("user_id = ?") + conditions.append("user_id = ?") params.append(user_id) if project_id: - conditions.append("project_id = ?") + conditions.append("project_id = ?") params.append(project_id) - where_clause = " AND ".join(conditions) + where_clause = " AND ".join(conditions) - rows = conn.execute( + rows = conn.execute( f"SELECT * FROM chrome_extension_tokens WHERE {where_clause} ORDER BY created_at DESC", params, ).fetchall() conn.close() - tokens = [] + tokens = [] for row in rows: tokens.append( ChromeExtensionToken( - id=row["id"], - token="", # 不返回实际令牌 - user_id=row["user_id"], - project_id=row["project_id"], - name=row["name"], - permissions=json.loads(row["permissions"]), - expires_at=row["expires_at"], - created_at=row["created_at"], - last_used_at=row["last_used_at"], - use_count=row["use_count"], - is_revoked=bool(row["is_revoked"]), + id = row["id"], + token = "", # 不返回实际令牌 + user_id = row["user_id"], + project_id = row["project_id"], + name = row["name"], + permissions = json.loads(row["permissions"]), + expires_at = row["expires_at"], + created_at = row["created_at"], + last_used_at = row["last_used_at"], + use_count = row["use_count"], + is_revoked = bool(row["is_revoked"]), ) ) @@ -567,7 +567,7 @@ class ChromeExtensionHandler: url: str, title: str, content: str, - html_content: str = None, + html_content: str = None, ) -> dict: """导入网页内容""" if not token.project_id: @@ -577,13 +577,13 @@ class ChromeExtensionHandler: return {"success": False, "error": "Insufficient permissions"} # 创建转录记录(将网页作为文档处理) - transcript_id = str(uuid.uuid4())[:UUID_LENGTH] - now = datetime.now().isoformat() + transcript_id = str(uuid.uuid4())[:UUID_LENGTH] + now = datetime.now().isoformat() # 构建完整文本 - full_text = f"# {title}\n\nURL: {url}\n\n{content}" + full_text = f"# {title}\n\nURL: {url}\n\n{content}" - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() conn.execute( """INSERT INTO transcripts (id, project_id, filename, full_text, type, created_at) @@ -606,23 +606,23 @@ class ChromeExtensionHandler: class BotHandler: """飞书/钉钉机器人处理器""" - def __init__(self, plugin_manager: PluginManager, bot_type: str): - self.pm = plugin_manager - self.bot_type = bot_type + def __init__(self, plugin_manager: PluginManager, bot_type: str) -> None: + self.pm = plugin_manager + self.bot_type = bot_type def create_session( self, session_id: str, session_name: str, - project_id: str = None, - webhook_url: str = "", - secret: str = "", + project_id: str = None, + webhook_url: str = "", + secret: str = "", ) -> BotSession: """创建机器人会话""" - bot_id = str(uuid.uuid4())[:UUID_LENGTH] - now = datetime.now().isoformat() + bot_id = str(uuid.uuid4())[:UUID_LENGTH] + now = datetime.now().isoformat() - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() conn.execute( """INSERT INTO bot_sessions (id, bot_type, session_id, session_name, project_id, webhook_url, secret, @@ -646,24 +646,24 @@ class BotHandler: conn.close() return BotSession( - id=bot_id, - bot_type=self.bot_type, - session_id=session_id, - session_name=session_name, - project_id=project_id, - webhook_url=webhook_url, - secret=secret, - is_active=True, - created_at=now, - updated_at=now, + id = bot_id, + bot_type = self.bot_type, + session_id = session_id, + session_name = session_name, + project_id = project_id, + webhook_url = webhook_url, + secret = secret, + is_active = True, + created_at = now, + updated_at = now, ) def get_session(self, session_id: str) -> BotSession | None: """获取会话""" - conn = self.pm.db.get_conn() - row = conn.execute( + conn = self.pm.db.get_conn() + row = conn.execute( """SELECT * FROM bot_sessions - WHERE session_id = ? AND bot_type = ?""", + WHERE session_id = ? AND bot_type = ?""", (session_id, self.bot_type), ).fetchone() conn.close() @@ -672,21 +672,21 @@ class BotHandler: return self._row_to_session(row) return None - def list_sessions(self, project_id: str = None) -> list[BotSession]: + def list_sessions(self, project_id: str = None) -> list[BotSession]: """列出会话""" - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() if project_id: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM bot_sessions - WHERE bot_type = ? AND project_id = ? ORDER BY created_at DESC""", + WHERE bot_type = ? AND project_id = ? ORDER BY created_at DESC""", (self.bot_type, project_id), ).fetchall() else: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM bot_sessions - WHERE bot_type = ? ORDER BY created_at DESC""", - (self.bot_type,), + WHERE bot_type = ? ORDER BY created_at DESC""", + (self.bot_type, ), ).fetchall() conn.close() @@ -695,28 +695,28 @@ class BotHandler: def update_session(self, session_id: str, **kwargs) -> BotSession | None: """更新会话""" - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() - allowed_fields = ["session_name", "project_id", "webhook_url", "secret", "is_active"] - updates = [] - values = [] + allowed_fields = ["session_name", "project_id", "webhook_url", "secret", "is_active"] + updates = [] + values = [] for f in allowed_fields: if f in kwargs: - updates.append(f"{f} = ?") + updates.append(f"{f} = ?") values.append(kwargs[f]) if not updates: conn.close() return self.get_session(session_id) - updates.append("updated_at = ?") + updates.append("updated_at = ?") values.append(datetime.now().isoformat()) values.append(session_id) values.append(self.bot_type) - query = ( - f"UPDATE bot_sessions SET {', '.join(updates)} WHERE session_id = ? AND bot_type = ?" + query = ( + f"UPDATE bot_sessions SET {', '.join(updates)} WHERE session_id = ? AND bot_type = ?" ) conn.execute(query, values) conn.commit() @@ -726,9 +726,9 @@ class BotHandler: def delete_session(self, session_id: str) -> bool: """删除会话""" - conn = self.pm.db.get_conn() - cursor = conn.execute( - "DELETE FROM bot_sessions WHERE session_id = ? AND bot_type = ?", + conn = self.pm.db.get_conn() + cursor = conn.execute( + "DELETE FROM bot_sessions WHERE session_id = ? AND bot_type = ?", (session_id, self.bot_type), ) conn.commit() @@ -739,41 +739,41 @@ class BotHandler: def _row_to_session(self, row: sqlite3.Row) -> BotSession: """将数据库行转换为 BotSession 对象""" return BotSession( - id=row["id"], - bot_type=row["bot_type"], - session_id=row["session_id"], - session_name=row["session_name"], - project_id=row["project_id"], - webhook_url=row["webhook_url"], - secret=row["secret"], - is_active=bool(row["is_active"]), - created_at=row["created_at"], - updated_at=row["updated_at"], - last_message_at=row["last_message_at"], - message_count=row["message_count"], + id = row["id"], + bot_type = row["bot_type"], + session_id = row["session_id"], + session_name = row["session_name"], + project_id = row["project_id"], + webhook_url = row["webhook_url"], + secret = row["secret"], + is_active = bool(row["is_active"]), + created_at = row["created_at"], + updated_at = row["updated_at"], + last_message_at = row["last_message_at"], + message_count = row["message_count"], ) async def handle_message(self, session: BotSession, message: dict) -> dict: """处理收到的消息""" - now = datetime.now().isoformat() + now = datetime.now().isoformat() # 更新消息统计 - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() conn.execute( """UPDATE bot_sessions - SET message_count = message_count + 1, last_message_at = ? - WHERE id = ?""", + SET message_count = message_count + 1, last_message_at = ? + WHERE id = ?""", (now, session.id), ) conn.commit() conn.close() # 处理消息 - msg_type = message.get("msg_type", "text") - content = message.get("content", {}) + msg_type = message.get("msg_type", "text") + content = message.get("content", {}) if msg_type == "text": - text = content.get("text", "") + text = content.get("text", "") return await self._handle_text_message(session, text, message) elif msg_type == "audio": # 处理音频消息 @@ -802,8 +802,8 @@ class BotHandler: return {"success": True, "response": "⚠️ 当前会话未绑定项目"} # 获取项目状态 - summary = self.pm.db.get_project_summary(session.project_id) - stats = summary.get("statistics", {}) + summary = self.pm.db.get_project_summary(session.project_id) + stats = summary.get("statistics", {}) return { "success": True, @@ -825,17 +825,17 @@ class BotHandler: return {"success": False, "error": "Session not bound to any project"} # 下载音频文件 - audio_url = message.get("content", {}).get("download_url") + audio_url = message.get("content", {}).get("download_url") if not audio_url: return {"success": False, "error": "No audio URL provided"} try: async with httpx.AsyncClient() as client: - response = await client.get(audio_url) - audio_data = response.content + response = await client.get(audio_url) + audio_data = response.content # 保存音频文件 - filename = f"bot_audio_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp3" + filename = f"bot_audio_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp3" # 这里应该调用 ASR 服务进行转录 # 简化处理,返回提示 @@ -853,7 +853,7 @@ class BotHandler: """处理文件消息""" return {"success": True, "response": "📎 收到文件,正在处理中..."} - async def send_message(self, session: BotSession, message: str, msg_type: str = "text") -> bool: + async def send_message(self, session: BotSession, message: str, msg_type: str = "text") -> bool: """发送消息到群聊""" if not session.webhook_url: return False @@ -872,21 +872,21 @@ class BotHandler: async def _send_feishu_message(self, session: BotSession, message: str, msg_type: str) -> bool: """发送飞书消息""" - timestamp = str(int(time.time())) + timestamp = str(int(time.time())) # 生成签名 if session.secret: - string_to_sign = f"{timestamp}\n{session.secret}" - hmac_code = hmac.new( + string_to_sign = f"{timestamp}\n{session.secret}" + hmac_code = hmac.new( session.secret.encode("utf-8"), string_to_sign.encode("utf-8"), - digestmod=hashlib.sha256, + digestmod = hashlib.sha256, ).digest() - sign = base64.b64encode(hmac_code).decode("utf-8") + sign = base64.b64encode(hmac_code).decode("utf-8") else: - sign = "" + sign = "" - payload = { + payload = { "timestamp": timestamp, "sign": sign, "msg_type": "text", @@ -894,8 +894,8 @@ class BotHandler: } async with httpx.AsyncClient() as client: - response = await client.post( - session.webhook_url, json=payload, headers={"Content-Type": "application/json"} + response = await client.post( + session.webhook_url, json = payload, headers = {"Content-Type": "application/json"} ) return response.status_code == 200 @@ -903,30 +903,30 @@ class BotHandler: self, session: BotSession, message: str, msg_type: str ) -> bool: """发送钉钉消息""" - timestamp = str(round(time.time() * 1000)) + timestamp = str(round(time.time() * 1000)) # 生成签名 if session.secret: - string_to_sign = f"{timestamp}\n{session.secret}" - hmac_code = hmac.new( + string_to_sign = f"{timestamp}\n{session.secret}" + hmac_code = hmac.new( session.secret.encode("utf-8"), string_to_sign.encode("utf-8"), - digestmod=hashlib.sha256, + digestmod = hashlib.sha256, ).digest() - sign = base64.b64encode(hmac_code).decode("utf-8") - sign = urllib.parse.quote(sign) + sign = base64.b64encode(hmac_code).decode("utf-8") + sign = urllib.parse.quote(sign) else: - sign = "" + sign = "" - payload = {"msgtype": "text", "text": {"content": message}} + payload = {"msgtype": "text", "text": {"content": message}} - url = session.webhook_url + url = session.webhook_url if sign: - url = f"{url}×tamp={timestamp}&sign={sign}" + url = f"{url}×tamp = {timestamp}&sign = {sign}" async with httpx.AsyncClient() as client: - response = await client.post( - url, json=payload, headers={"Content-Type": "application/json"} + response = await client.post( + url, json = payload, headers = {"Content-Type": "application/json"} ) return response.status_code == 200 @@ -934,24 +934,24 @@ class BotHandler: class WebhookIntegration: """Zapier/Make Webhook 集成""" - def __init__(self, plugin_manager: PluginManager, endpoint_type: str): - self.pm = plugin_manager - self.endpoint_type = endpoint_type + def __init__(self, plugin_manager: PluginManager, endpoint_type: str) -> None: + self.pm = plugin_manager + self.endpoint_type = endpoint_type def create_endpoint( self, name: str, endpoint_url: str, - project_id: str = None, - auth_type: str = "none", - auth_config: dict = None, - trigger_events: list[str] = None, + project_id: str = None, + auth_type: str = "none", + auth_config: dict = None, + trigger_events: list[str] = None, ) -> WebhookEndpoint: """创建 Webhook 端点""" - endpoint_id = str(uuid.uuid4())[:UUID_LENGTH] - now = datetime.now().isoformat() + endpoint_id = str(uuid.uuid4())[:UUID_LENGTH] + now = datetime.now().isoformat() - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() conn.execute( """INSERT INTO webhook_endpoints (id, name, endpoint_type, endpoint_url, project_id, auth_type, auth_config, @@ -976,24 +976,24 @@ class WebhookIntegration: conn.close() return WebhookEndpoint( - id=endpoint_id, - name=name, - endpoint_type=self.endpoint_type, - endpoint_url=endpoint_url, - project_id=project_id, - auth_type=auth_type, - auth_config=auth_config or {}, - trigger_events=trigger_events or [], - is_active=True, - created_at=now, - updated_at=now, + id = endpoint_id, + name = name, + endpoint_type = self.endpoint_type, + endpoint_url = endpoint_url, + project_id = project_id, + auth_type = auth_type, + auth_config = auth_config or {}, + trigger_events = trigger_events or [], + is_active = True, + created_at = now, + updated_at = now, ) def get_endpoint(self, endpoint_id: str) -> WebhookEndpoint | None: """获取端点""" - conn = self.pm.db.get_conn() - row = conn.execute( - "SELECT * FROM webhook_endpoints WHERE id = ? AND endpoint_type = ?", + conn = self.pm.db.get_conn() + row = conn.execute( + "SELECT * FROM webhook_endpoints WHERE id = ? AND endpoint_type = ?", (endpoint_id, self.endpoint_type), ).fetchone() conn.close() @@ -1002,21 +1002,21 @@ class WebhookIntegration: return self._row_to_endpoint(row) return None - def list_endpoints(self, project_id: str = None) -> list[WebhookEndpoint]: + def list_endpoints(self, project_id: str = None) -> list[WebhookEndpoint]: """列出端点""" - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() if project_id: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM webhook_endpoints - WHERE endpoint_type = ? AND project_id = ? ORDER BY created_at DESC""", + WHERE endpoint_type = ? AND project_id = ? ORDER BY created_at DESC""", (self.endpoint_type, project_id), ).fetchall() else: - rows = conn.execute( + rows = conn.execute( """SELECT * FROM webhook_endpoints - WHERE endpoint_type = ? ORDER BY created_at DESC""", - (self.endpoint_type,), + WHERE endpoint_type = ? ORDER BY created_at DESC""", + (self.endpoint_type, ), ).fetchall() conn.close() @@ -1025,9 +1025,9 @@ class WebhookIntegration: def update_endpoint(self, endpoint_id: str, **kwargs) -> WebhookEndpoint | None: """更新端点""" - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() - allowed_fields = [ + allowed_fields = [ "name", "endpoint_url", "project_id", @@ -1036,12 +1036,12 @@ class WebhookIntegration: "trigger_events", "is_active", ] - updates = [] - values = [] + updates = [] + values = [] for f in allowed_fields: if f in kwargs: - updates.append(f"{f} = ?") + updates.append(f"{f} = ?") if f in ["auth_config", "trigger_events"]: values.append(json.dumps(kwargs[f])) else: @@ -1051,11 +1051,11 @@ class WebhookIntegration: conn.close() return self.get_endpoint(endpoint_id) - updates.append("updated_at = ?") + updates.append("updated_at = ?") values.append(datetime.now().isoformat()) values.append(endpoint_id) - query = f"UPDATE webhook_endpoints SET {', '.join(updates)} WHERE id = ?" + query = f"UPDATE webhook_endpoints SET {', '.join(updates)} WHERE id = ?" conn.execute(query, values) conn.commit() conn.close() @@ -1064,8 +1064,8 @@ class WebhookIntegration: def delete_endpoint(self, endpoint_id: str) -> bool: """删除端点""" - conn = self.pm.db.get_conn() - cursor = conn.execute("DELETE FROM webhook_endpoints WHERE id = ?", (endpoint_id,)) + conn = self.pm.db.get_conn() + cursor = conn.execute("DELETE FROM webhook_endpoints WHERE id = ?", (endpoint_id, )) conn.commit() conn.close() @@ -1074,19 +1074,19 @@ class WebhookIntegration: def _row_to_endpoint(self, row: sqlite3.Row) -> WebhookEndpoint: """将数据库行转换为 WebhookEndpoint 对象""" return WebhookEndpoint( - id=row["id"], - name=row["name"], - endpoint_type=row["endpoint_type"], - endpoint_url=row["endpoint_url"], - project_id=row["project_id"], - auth_type=row["auth_type"], - auth_config=json.loads(row["auth_config"]) if row["auth_config"] else {}, - trigger_events=json.loads(row["trigger_events"]) if row["trigger_events"] else [], - is_active=bool(row["is_active"]), - created_at=row["created_at"], - updated_at=row["updated_at"], - last_triggered_at=row["last_triggered_at"], - trigger_count=row["trigger_count"], + id = row["id"], + name = row["name"], + endpoint_type = row["endpoint_type"], + endpoint_url = row["endpoint_url"], + project_id = row["project_id"], + auth_type = row["auth_type"], + auth_config = json.loads(row["auth_config"]) if row["auth_config"] else {}, + trigger_events = json.loads(row["trigger_events"]) if row["trigger_events"] else [], + is_active = bool(row["is_active"]), + created_at = row["created_at"], + updated_at = row["updated_at"], + last_triggered_at = row["last_triggered_at"], + trigger_count = row["trigger_count"], ) async def trigger(self, endpoint: WebhookEndpoint, event_type: str, data: dict) -> bool: @@ -1098,33 +1098,33 @@ class WebhookIntegration: return False try: - headers = {"Content-Type": "application/json"} + headers = {"Content-Type": "application/json"} # 添加认证头 if endpoint.auth_type == "api_key": - api_key = endpoint.auth_config.get("api_key", "") - header_name = endpoint.auth_config.get("header_name", "X-API-Key") - headers[header_name] = api_key + api_key = endpoint.auth_config.get("api_key", "") + header_name = endpoint.auth_config.get("header_name", "X-API-Key") + headers[header_name] = api_key elif endpoint.auth_type == "bearer": - token = endpoint.auth_config.get("token", "") - headers["Authorization"] = f"Bearer {token}" + token = endpoint.auth_config.get("token", "") + headers["Authorization"] = f"Bearer {token}" - payload = {"event": event_type, "timestamp": datetime.now().isoformat(), "data": data} + payload = {"event": event_type, "timestamp": datetime.now().isoformat(), "data": data} async with httpx.AsyncClient() as client: - response = await client.post( - endpoint.endpoint_url, json=payload, headers=headers, timeout=30.0 + response = await client.post( + endpoint.endpoint_url, json = payload, headers = headers, timeout = 30.0 ) - success = response.status_code in [200, 201, 202] + success = response.status_code in [200, 201, 202] # 更新触发统计 - now = datetime.now().isoformat() - conn = self.pm.db.get_conn() + now = datetime.now().isoformat() + conn = self.pm.db.get_conn() conn.execute( """UPDATE webhook_endpoints - SET trigger_count = trigger_count + 1, last_triggered_at = ? - WHERE id = ?""", + SET trigger_count = trigger_count + 1, last_triggered_at = ? + WHERE id = ?""", (now, endpoint.id), ) conn.commit() @@ -1138,13 +1138,13 @@ class WebhookIntegration: async def test_endpoint(self, endpoint: WebhookEndpoint) -> dict: """测试端点""" - test_data = { + test_data = { "message": "This is a test event from InsightFlow", "test": True, "timestamp": datetime.now().isoformat(), } - success = await self.trigger(endpoint, "test", test_data) + success = await self.trigger(endpoint, "test", test_data) return { "success": success, @@ -1157,8 +1157,8 @@ class WebhookIntegration: class WebDAVSyncManager: """WebDAV 同步管理""" - def __init__(self, plugin_manager: PluginManager): - self.pm = plugin_manager + def __init__(self, plugin_manager: PluginManager) -> None: + self.pm = plugin_manager def create_sync( self, @@ -1167,15 +1167,15 @@ class WebDAVSyncManager: server_url: str, username: str, password: str, - remote_path: str = "/insightflow", - sync_mode: str = "bidirectional", - sync_interval: int = 3600, + remote_path: str = "/insightflow", + sync_mode: str = "bidirectional", + sync_interval: int = 3600, ) -> WebDAVSync: """创建 WebDAV 同步配置""" - sync_id = str(uuid.uuid4())[:UUID_LENGTH] - now = datetime.now().isoformat() + sync_id = str(uuid.uuid4())[:UUID_LENGTH] + now = datetime.now().isoformat() - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() conn.execute( """INSERT INTO webdav_syncs (id, name, project_id, server_url, username, password, remote_path, @@ -1202,42 +1202,42 @@ class WebDAVSyncManager: conn.close() return WebDAVSync( - id=sync_id, - name=name, - project_id=project_id, - server_url=server_url, - username=username, - password=password, - remote_path=remote_path, - sync_mode=sync_mode, - sync_interval=sync_interval, - last_sync_status="pending", - is_active=True, - created_at=now, - updated_at=now, + id = sync_id, + name = name, + project_id = project_id, + server_url = server_url, + username = username, + password = password, + remote_path = remote_path, + sync_mode = sync_mode, + sync_interval = sync_interval, + last_sync_status = "pending", + is_active = True, + created_at = now, + updated_at = now, ) def get_sync(self, sync_id: str) -> WebDAVSync | None: """获取同步配置""" - conn = self.pm.db.get_conn() - row = conn.execute("SELECT * FROM webdav_syncs WHERE id = ?", (sync_id,)).fetchone() + conn = self.pm.db.get_conn() + row = conn.execute("SELECT * FROM webdav_syncs WHERE id = ?", (sync_id, )).fetchone() conn.close() if row: return self._row_to_sync(row) return None - def list_syncs(self, project_id: str = None) -> list[WebDAVSync]: + def list_syncs(self, project_id: str = None) -> list[WebDAVSync]: """列出同步配置""" - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() if project_id: - rows = conn.execute( - "SELECT * FROM webdav_syncs WHERE project_id = ? ORDER BY created_at DESC", - (project_id,), + rows = conn.execute( + "SELECT * FROM webdav_syncs WHERE project_id = ? ORDER BY created_at DESC", + (project_id, ), ).fetchall() else: - rows = conn.execute("SELECT * FROM webdav_syncs ORDER BY created_at DESC").fetchall() + rows = conn.execute("SELECT * FROM webdav_syncs ORDER BY created_at DESC").fetchall() conn.close() @@ -1245,9 +1245,9 @@ class WebDAVSyncManager: def update_sync(self, sync_id: str, **kwargs) -> WebDAVSync | None: """更新同步配置""" - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() - allowed_fields = [ + allowed_fields = [ "name", "server_url", "username", @@ -1257,23 +1257,23 @@ class WebDAVSyncManager: "sync_interval", "is_active", ] - updates = [] - values = [] + updates = [] + values = [] for f in allowed_fields: if f in kwargs: - updates.append(f"{f} = ?") + updates.append(f"{f} = ?") values.append(kwargs[f]) if not updates: conn.close() return self.get_sync(sync_id) - updates.append("updated_at = ?") + updates.append("updated_at = ?") values.append(datetime.now().isoformat()) values.append(sync_id) - query = f"UPDATE webdav_syncs SET {', '.join(updates)} WHERE id = ?" + query = f"UPDATE webdav_syncs SET {', '.join(updates)} WHERE id = ?" conn.execute(query, values) conn.commit() conn.close() @@ -1282,8 +1282,8 @@ class WebDAVSyncManager: def delete_sync(self, sync_id: str) -> bool: """删除同步配置""" - conn = self.pm.db.get_conn() - cursor = conn.execute("DELETE FROM webdav_syncs WHERE id = ?", (sync_id,)) + conn = self.pm.db.get_conn() + cursor = conn.execute("DELETE FROM webdav_syncs WHERE id = ?", (sync_id, )) conn.commit() conn.close() @@ -1292,22 +1292,22 @@ class WebDAVSyncManager: def _row_to_sync(self, row: sqlite3.Row) -> WebDAVSync: """将数据库行转换为 WebDAVSync 对象""" return WebDAVSync( - id=row["id"], - name=row["name"], - project_id=row["project_id"], - server_url=row["server_url"], - username=row["username"], - password=row["password"], - remote_path=row["remote_path"], - sync_mode=row["sync_mode"], - sync_interval=row["sync_interval"], - last_sync_at=row["last_sync_at"], - last_sync_status=row["last_sync_status"], - last_sync_error=row["last_sync_error"] or "", - is_active=bool(row["is_active"]), - created_at=row["created_at"], - updated_at=row["updated_at"], - sync_count=row["sync_count"], + id = row["id"], + name = row["name"], + project_id = row["project_id"], + server_url = row["server_url"], + username = row["username"], + password = row["password"], + remote_path = row["remote_path"], + sync_mode = row["sync_mode"], + sync_interval = row["sync_interval"], + last_sync_at = row["last_sync_at"], + last_sync_status = row["last_sync_status"], + last_sync_error = row["last_sync_error"] or "", + is_active = bool(row["is_active"]), + created_at = row["created_at"], + updated_at = row["updated_at"], + sync_count = row["sync_count"], ) async def test_connection(self, sync: WebDAVSync) -> dict: @@ -1316,7 +1316,7 @@ class WebDAVSyncManager: return {"success": False, "error": "WebDAV library not available"} try: - client = webdav_client.Client(sync.server_url, auth=(sync.username, sync.password)) + client = webdav_client.Client(sync.server_url, auth = (sync.username, sync.password)) # 尝试列出根目录 client.list("/") @@ -1335,26 +1335,26 @@ class WebDAVSyncManager: return {"success": False, "error": "Sync is not active"} try: - client = webdav_client.Client(sync.server_url, auth=(sync.username, sync.password)) + client = webdav_client.Client(sync.server_url, auth = (sync.username, sync.password)) # 确保远程目录存在 - remote_project_path = f"{sync.remote_path}/{sync.project_id}" + remote_project_path = f"{sync.remote_path}/{sync.project_id}" try: client.mkdir(remote_project_path) except (OSError, IOError): pass # 目录可能已存在 # 获取项目数据 - project = self.pm.db.get_project(sync.project_id) + project = self.pm.db.get_project(sync.project_id) if not project: return {"success": False, "error": "Project not found"} # 导出项目数据为 JSON - entities = self.pm.db.list_project_entities(sync.project_id) - relations = self.pm.db.list_project_relations(sync.project_id) - transcripts = self.pm.db.list_project_transcripts(sync.project_id) + entities = self.pm.db.list_project_entities(sync.project_id) + relations = self.pm.db.list_project_relations(sync.project_id) + transcripts = self.pm.db.list_project_transcripts(sync.project_id) - export_data = { + export_data = { "project": { "id": project.id, "name": project.name, @@ -1367,26 +1367,26 @@ class WebDAVSyncManager: } # 上传 JSON 文件 - json_content = json.dumps(export_data, ensure_ascii=False, indent=2) - json_path = f"{remote_project_path}/project_export.json" + json_content = json.dumps(export_data, ensure_ascii = False, indent = 2) + json_path = f"{remote_project_path}/project_export.json" # 使用临时文件上传 import tempfile - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + with tempfile.NamedTemporaryFile(mode = "w", suffix = ".json", delete = False) as f: f.write(json_content) - temp_path = f.name + temp_path = f.name client.upload_file(temp_path, json_path) os.unlink(temp_path) # 更新同步状态 - now = datetime.now().isoformat() - conn = self.pm.db.get_conn() + now = datetime.now().isoformat() + conn = self.pm.db.get_conn() conn.execute( """UPDATE webdav_syncs - SET last_sync_at = ?, last_sync_status = ?, sync_count = sync_count + 1 - WHERE id = ?""", + SET last_sync_at = ?, last_sync_status = ?, sync_count = sync_count + 1 + WHERE id = ?""", (now, "success", sync.id), ) conn.commit() @@ -1402,11 +1402,11 @@ class WebDAVSyncManager: except Exception as e: # 更新失败状态 - conn = self.pm.db.get_conn() + conn = self.pm.db.get_conn() conn.execute( """UPDATE webdav_syncs - SET last_sync_status = ?, last_sync_error = ? - WHERE id = ?""", + SET last_sync_status = ?, last_sync_error = ? + WHERE id = ?""", ("failed", str(e), sync.id), ) conn.commit() @@ -1416,12 +1416,12 @@ class WebDAVSyncManager: # Singleton instance -_plugin_manager = None +_plugin_manager = None -def get_plugin_manager(db_manager=None) -> None: +def get_plugin_manager(db_manager = None) -> None: """获取 PluginManager 单例""" global _plugin_manager if _plugin_manager is None: - _plugin_manager = PluginManager(db_manager) + _plugin_manager = PluginManager(db_manager) return _plugin_manager diff --git a/backend/rate_limiter.py b/backend/rate_limiter.py index 58ce2ec..ea1ea8e 100644 --- a/backend/rate_limiter.py +++ b/backend/rate_limiter.py @@ -17,9 +17,9 @@ from functools import wraps class RateLimitConfig: """限流配置""" - requests_per_minute: int = 60 - burst_size: int = 10 # 突发请求数 - window_size: int = 60 # 窗口大小(秒) + requests_per_minute: int = 60 + burst_size: int = 10 # 突发请求数 + window_size: int = 60 # 窗口大小(秒) @dataclass @@ -35,16 +35,16 @@ class RateLimitInfo: class SlidingWindowCounter: """滑动窗口计数器""" - def __init__(self, window_size: int = 60): - self.window_size = window_size - self.requests: dict[int, int] = defaultdict(int) # 秒级计数 - self._lock = asyncio.Lock() - self._cleanup_lock = asyncio.Lock() + def __init__(self, window_size: int = 60) -> None: + self.window_size = window_size + self.requests: dict[int, int] = defaultdict(int) # 秒级计数 + self._lock = asyncio.Lock() + self._cleanup_lock = asyncio.Lock() async def add_request(self) -> int: """添加请求,返回当前窗口内的请求数""" async with self._lock: - now = int(time.time()) + now = int(time.time()) self.requests[now] += 1 self._cleanup_old(now) return sum(self.requests.values()) @@ -52,14 +52,14 @@ class SlidingWindowCounter: async def get_count(self) -> int: """获取当前窗口内的请求数""" async with self._lock: - now = int(time.time()) + now = int(time.time()) self._cleanup_old(now) return sum(self.requests.values()) def _cleanup_old(self, now: int) -> None: """清理过期的请求记录 - 使用独立锁避免竞态条件""" - cutoff = now - self.window_size - old_keys = [k for k in list(self.requests.keys()) if k < cutoff] + cutoff = now - self.window_size + old_keys = [k for k in list(self.requests.keys()) if k < cutoff] for k in old_keys: self.requests.pop(k, None) @@ -69,13 +69,13 @@ class RateLimiter: def __init__(self) -> None: # key -> SlidingWindowCounter - self.counters: dict[str, SlidingWindowCounter] = {} + self.counters: dict[str, SlidingWindowCounter] = {} # key -> RateLimitConfig - self.configs: dict[str, RateLimitConfig] = {} - self._lock = asyncio.Lock() - self._cleanup_lock = asyncio.Lock() + self.configs: dict[str, RateLimitConfig] = {} + self._lock = asyncio.Lock() + self._cleanup_lock = asyncio.Lock() - async def is_allowed(self, key: str, config: RateLimitConfig | None = None) -> RateLimitInfo: + async def is_allowed(self, key: str, config: RateLimitConfig | None = None) -> RateLimitInfo: """ 检查是否允许请求 @@ -87,70 +87,70 @@ class RateLimiter: RateLimitInfo """ if config is None: - config = RateLimitConfig() + config = RateLimitConfig() async with self._lock: if key not in self.counters: - self.counters[key] = SlidingWindowCounter(config.window_size) - self.configs[key] = config + self.counters[key] = SlidingWindowCounter(config.window_size) + self.configs[key] = config - counter = self.counters[key] - stored_config = self.configs.get(key, config) + counter = self.counters[key] + stored_config = self.configs.get(key, config) # 获取当前计数 - current_count = await counter.get_count() + current_count = await counter.get_count() # 计算剩余配额 - remaining = max(0, stored_config.requests_per_minute - current_count) + remaining = max(0, stored_config.requests_per_minute - current_count) # 计算重置时间 - now = int(time.time()) - reset_time = now + stored_config.window_size + now = int(time.time()) + reset_time = now + stored_config.window_size # 检查是否超过限制 if current_count >= stored_config.requests_per_minute: return RateLimitInfo( - allowed=False, - remaining=0, - reset_time=reset_time, - retry_after=stored_config.window_size, + allowed = False, + remaining = 0, + reset_time = reset_time, + retry_after = stored_config.window_size, ) # 允许请求,增加计数 await counter.add_request() return RateLimitInfo( - allowed=True, remaining=remaining - 1, reset_time=reset_time, retry_after=0 + allowed = True, remaining = remaining - 1, reset_time = reset_time, retry_after = 0 ) async def get_limit_info(self, key: str) -> RateLimitInfo: """获取限流信息(不增加计数)""" if key not in self.counters: - config = RateLimitConfig() + config = RateLimitConfig() return RateLimitInfo( - allowed=True, - remaining=config.requests_per_minute, - reset_time=int(time.time()) + config.window_size, - retry_after=0, + allowed = True, + remaining = config.requests_per_minute, + reset_time = int(time.time()) + config.window_size, + retry_after = 0, ) - counter = self.counters[key] - config = self.configs.get(key, RateLimitConfig()) + counter = self.counters[key] + config = self.configs.get(key, RateLimitConfig()) - current_count = await counter.get_count() - remaining = max(0, config.requests_per_minute - current_count) - reset_time = int(time.time()) + config.window_size + current_count = await counter.get_count() + remaining = max(0, config.requests_per_minute - current_count) + reset_time = int(time.time()) + config.window_size return RateLimitInfo( - allowed=current_count < config.requests_per_minute, - remaining=remaining, - reset_time=reset_time, - retry_after=max(0, config.window_size) + allowed = current_count < config.requests_per_minute, + remaining = remaining, + reset_time = reset_time, + retry_after = max(0, config.window_size) if current_count >= config.requests_per_minute else 0, ) - def reset(self, key: str | None = None) -> None: + def reset(self, key: str | None = None) -> None: """重置限流计数器""" if key: self.counters.pop(key, None) @@ -161,21 +161,21 @@ class RateLimiter: # 全局限流器实例 -_rate_limiter: RateLimiter | None = None +_rate_limiter: RateLimiter | None = None def get_rate_limiter() -> RateLimiter: """获取限流器实例""" global _rate_limiter if _rate_limiter is None: - _rate_limiter = RateLimiter() + _rate_limiter = RateLimiter() return _rate_limiter # 限流装饰器(用于函数级别限流) -def rate_limit(requests_per_minute: int = 60, key_func: Callable | None = None) -> None: +def rate_limit(requests_per_minute: int = 60, key_func: Callable | None = None) -> None: """ 限流装饰器 @@ -184,14 +184,14 @@ def rate_limit(requests_per_minute: int = 60, key_func: Callable | None = None) key_func: 生成限流键的函数,默认为 None(使用函数名) """ - def decorator(func): - limiter = get_rate_limiter() - config = RateLimitConfig(requests_per_minute=requests_per_minute) + def decorator(func) -> None: + limiter = get_rate_limiter() + config = RateLimitConfig(requests_per_minute = requests_per_minute) @wraps(func) - async def async_wrapper(*args, **kwargs): - key = key_func(*args, **kwargs) if key_func else func.__name__ - info = await limiter.is_allowed(key, config) + async def async_wrapper(*args, **kwargs) -> None: + key = key_func(*args, **kwargs) if key_func else func.__name__ + info = await limiter.is_allowed(key, config) if not info.allowed: raise RateLimitExceeded( @@ -201,10 +201,10 @@ def rate_limit(requests_per_minute: int = 60, key_func: Callable | None = None) return await func(*args, **kwargs) @wraps(func) - def sync_wrapper(*args, **kwargs): - key = key_func(*args, **kwargs) if key_func else func.__name__ + def sync_wrapper(*args, **kwargs) -> None: + key = key_func(*args, **kwargs) if key_func else func.__name__ # 同步版本使用 asyncio.run - info = asyncio.run(limiter.is_allowed(key, config)) + info = asyncio.run(limiter.is_allowed(key, config)) if not info.allowed: raise RateLimitExceeded( diff --git a/backend/search_manager.py b/backend/search_manager.py index 45e2343..641bb6c 100644 --- a/backend/search_manager.py +++ b/backend/search_manager.py @@ -23,9 +23,9 @@ from enum import Enum class SearchOperator(Enum): """搜索操作符""" - AND = "AND" - OR = "OR" - NOT = "NOT" + AND = "AND" + OR = "OR" + NOT = "NOT" # 尝试导入 sentence-transformers 用于语义搜索 @@ -33,9 +33,9 @@ try: from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity - SENTENCE_TRANSFORMERS_AVAILABLE = True + SENTENCE_TRANSFORMERS_AVAILABLE = True except ImportError: - SENTENCE_TRANSFORMERS_AVAILABLE = False + SENTENCE_TRANSFORMERS_AVAILABLE = False # ==================== 数据模型 ==================== @@ -49,8 +49,8 @@ class SearchResult: content_type: str # transcript, entity, relation project_id: str score: float - highlights: list[tuple[int, int]] = field(default_factory=list) # 高亮位置 - metadata: dict = field(default_factory=dict) + highlights: list[tuple[int, int]] = field(default_factory = list) # 高亮位置 + metadata: dict = field(default_factory = dict) def to_dict(self) -> dict: return { @@ -73,11 +73,11 @@ class SemanticSearchResult: content_type: str project_id: str similarity: float - embedding: list[float] | None = None - metadata: dict = field(default_factory=dict) + embedding: list[float] | None = None + metadata: dict = field(default_factory = dict) def to_dict(self) -> dict: - result = { + result = { "id": self.id, "content": self.content[:500] + "..." if len(self.content) > 500 else self.content, "content_type": self.content_type, @@ -86,7 +86,7 @@ class SemanticSearchResult: "metadata": self.metadata, } if self.embedding: - result["embedding_dim"] = len(self.embedding) + result["embedding_dim"] = len(self.embedding) return result @@ -132,7 +132,7 @@ class KnowledgeGap: severity: str # high, medium, low suggestions: list[str] related_entities: list[str] - metadata: dict = field(default_factory=dict) + metadata: dict = field(default_factory = dict) def to_dict(self) -> dict: return { @@ -189,19 +189,19 @@ class FullTextSearch: - 支持布尔搜索(AND/OR/NOT) """ - def __init__(self, db_path: str = "insightflow.db"): - self.db_path = db_path + def __init__(self, db_path: str = "insightflow.db") -> None: + self.db_path = db_path self._init_search_tables() def _get_conn(self) -> sqlite3.Connection: """获取数据库连接""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn def _init_search_tables(self) -> None: """初始化搜索相关表""" - conn = self._get_conn() + conn = self._get_conn() # 搜索索引表 conn.execute(""" @@ -251,25 +251,25 @@ class FullTextSearch: 实际生产环境可以使用 jieba 等分词工具 """ # 清理文本 - text = text.lower() + text = text.lower() # 提取中文字符、英文单词和数字 - tokens = re.findall(r"[\u4e00-\u9fa5]+|[a-z]+|\d+", text) + tokens = re.findall(r"[\u4e00-\u9fa5]+|[a-z]+|\d+", text) return tokens def _extract_positions(self, text: str, tokens: list[str]) -> dict[str, list[int]]: """提取每个词在文本中的位置""" - positions = defaultdict(list) - text_lower = text.lower() + positions = defaultdict(list) + text_lower = text.lower() for token in tokens: # 查找所有出现位置 - start = 0 + start = 0 while True: - pos = text_lower.find(token, start) + pos = text_lower.find(token, start) if pos == -1: break positions[token].append(pos) - start = pos + 1 + start = pos + 1 return dict(positions) @@ -287,24 +287,24 @@ class FullTextSearch: bool: 是否成功 """ try: - conn = self._get_conn() + conn = self._get_conn() # 分词 - tokens = self._tokenize(text) + tokens = self._tokenize(text) if not tokens: conn.close() return False # 提取位置信息 - token_positions = self._extract_positions(text, tokens) + token_positions = self._extract_positions(text, tokens) # 计算词频 - token_freq = defaultdict(int) + token_freq = defaultdict(int) for token in tokens: token_freq[token] += 1 - index_id = hashlib.md5(f"{content_id}:{content_type}".encode()).hexdigest()[:16] - now = datetime.now().isoformat() + index_id = hashlib.md5(f"{content_id}:{content_type}".encode()).hexdigest()[:16] + now = datetime.now().isoformat() # 保存索引 conn.execute( @@ -318,8 +318,8 @@ class FullTextSearch: content_id, content_type, project_id, - json.dumps(tokens, ensure_ascii=False), - json.dumps(token_positions, ensure_ascii=False), + json.dumps(tokens, ensure_ascii = False), + json.dumps(token_positions, ensure_ascii = False), now, now, ), @@ -327,7 +327,7 @@ class FullTextSearch: # 保存词频统计 for token, freq in token_freq.items(): - positions = token_positions.get(token, []) + positions = token_positions.get(token, []) conn.execute( """ INSERT OR REPLACE INTO search_term_freq @@ -340,7 +340,7 @@ class FullTextSearch: content_type, project_id, freq, - json.dumps(positions, ensure_ascii=False), + json.dumps(positions, ensure_ascii = False), ), ) @@ -355,10 +355,10 @@ class FullTextSearch: def search( self, query: str, - project_id: str | None = None, - content_types: list[str] | None = None, - limit: int = 20, - offset: int = 0, + project_id: str | None = None, + content_types: list[str] | None = None, + limit: int = 20, + offset: int = 0, ) -> list[SearchResult]: """ 全文搜索 @@ -374,16 +374,16 @@ class FullTextSearch: List[SearchResult]: 搜索结果列表 """ # 解析布尔查询 - parsed_query = self._parse_boolean_query(query) + parsed_query = self._parse_boolean_query(query) # 执行搜索 - results = self._execute_boolean_search(parsed_query, project_id, content_types) + results = self._execute_boolean_search(parsed_query, project_id, content_types) # 计算相关性分数 - scored_results = self._score_results(results, parsed_query) + scored_results = self._score_results(results, parsed_query) # 排序和分页 - scored_results.sort(key=lambda x: x.score, reverse=True) + scored_results.sort(key = lambda x: x.score, reverse = True) return scored_results[offset : offset + limit] @@ -397,138 +397,138 @@ class FullTextSearch: - NOT: NOT 词1 或 词1 -词2 - 短语: "精确短语" """ - query = query.strip() + query = query.strip() # 提取短语(引号内的内容) - phrases = re.findall(r'"([^"]+)"', query) - query_without_phrases = re.sub(r'"[^"]+"', "", query) + phrases = re.findall(r'"([^"]+)"', query) + query_without_phrases = re.sub(r'"[^"]+"', "", query) # 解析布尔操作 - and_terms = [] - or_terms = [] - not_terms = [] + and_terms = [] + or_terms = [] + not_terms = [] # 处理 NOT - not_pattern = r"(?:NOT\s+|\-)(\w+)" - not_matches = re.findall(not_pattern, query_without_phrases, re.IGNORECASE) + not_pattern = r"(?:NOT\s+|\-)(\w+)" + not_matches = re.findall(not_pattern, query_without_phrases, re.IGNORECASE) not_terms.extend(not_matches) - query_without_phrases = re.sub(not_pattern, "", query_without_phrases, flags=re.IGNORECASE) + query_without_phrases = re.sub(not_pattern, "", query_without_phrases, flags = re.IGNORECASE) # 处理 OR - or_parts = re.split(r"\s+OR\s+", query_without_phrases, flags=re.IGNORECASE) + or_parts = re.split(r"\s+OR\s+", query_without_phrases, flags = re.IGNORECASE) if len(or_parts) > 1: - or_terms = [p.strip() for p in or_parts[1:] if p.strip()] - query_without_phrases = or_parts[0] + or_terms = [p.strip() for p in or_parts[1:] if p.strip()] + query_without_phrases = or_parts[0] # 剩余的作为 AND 条件 - and_terms = [t.strip() for t in query_without_phrases.split() if t.strip()] + and_terms = [t.strip() for t in query_without_phrases.split() if t.strip()] return {"and": and_terms + phrases, "or": or_terms, "not": not_terms, "phrases": phrases} def _execute_boolean_search( self, parsed_query: dict, - project_id: str | None = None, - content_types: list[str] | None = None, + project_id: str | None = None, + content_types: list[str] | None = None, ) -> list[dict]: """执行布尔搜索""" - conn = self._get_conn() + conn = self._get_conn() # 构建基础查询 - base_where = [] - params = [] + base_where = [] + params = [] if project_id: - base_where.append("project_id = ?") + base_where.append("project_id = ?") params.append(project_id) if content_types: - placeholders = ",".join(["?" for _ in content_types]) + placeholders = ", ".join(["?" for _ in content_types]) base_where.append(f"content_type IN ({placeholders})") params.extend(content_types) - base_where_str = " AND ".join(base_where) if base_where else "1=1" + base_where_str = " AND ".join(base_where) if base_where else "1 = 1" # 获取候选结果 - candidates = set() + candidates = set() # 处理 AND 条件 if parsed_query["and"]: for term in parsed_query["and"]: - term_results = conn.execute( + term_results = conn.execute( f""" SELECT content_id, content_type, project_id, frequency, positions FROM search_term_freq - WHERE term = ? AND {base_where_str} + WHERE term = ? AND {base_where_str} """, [term] + params, ).fetchall() - term_contents = {(r["content_id"], r["content_type"]) for r in term_results} + term_contents = {(r["content_id"], r["content_type"]) for r in term_results} if not candidates: - candidates = term_contents + candidates = term_contents else: candidates &= term_contents # 交集 # 处理 OR 条件 if parsed_query["or"]: for term in parsed_query["or"]: - term_results = conn.execute( + term_results = conn.execute( f""" SELECT content_id, content_type, project_id, frequency, positions FROM search_term_freq - WHERE term = ? AND {base_where_str} + WHERE term = ? AND {base_where_str} """, [term] + params, ).fetchall() - term_contents = {(r["content_id"], r["content_type"]) for r in term_results} + term_contents = {(r["content_id"], r["content_type"]) for r in term_results} candidates |= term_contents # 并集 # 如果没有 AND 和 OR,但有 phrases,使用 phrases if not candidates and parsed_query["phrases"]: for phrase in parsed_query["phrases"]: - phrase_tokens = self._tokenize(phrase) + phrase_tokens = self._tokenize(phrase) if phrase_tokens: # 查找包含所有短语的文档 for token in phrase_tokens: - term_results = conn.execute( + term_results = conn.execute( f""" SELECT content_id, content_type, project_id, frequency, positions FROM search_term_freq - WHERE term = ? AND {base_where_str} + WHERE term = ? AND {base_where_str} """, [token] + params, ).fetchall() - term_contents = {(r["content_id"], r["content_type"]) for r in term_results} + term_contents = {(r["content_id"], r["content_type"]) for r in term_results} if not candidates: - candidates = term_contents + candidates = term_contents else: candidates &= term_contents # 处理 NOT 条件(排除) if parsed_query["not"]: for term in parsed_query["not"]: - term_results = conn.execute( + term_results = conn.execute( f""" SELECT content_id, content_type FROM search_term_freq - WHERE term = ? AND {base_where_str} + WHERE term = ? AND {base_where_str} """, [term] + params, ).fetchall() - term_contents = {(r["content_id"], r["content_type"]) for r in term_results} + term_contents = {(r["content_id"], r["content_type"]) for r in term_results} candidates -= term_contents # 差集 # 获取完整内容 - results = [] + results = [] for content_id, content_type in candidates: # 获取原始内容 - content = self._get_content_by_id(conn, content_id, content_type) + content = self._get_content_by_id(conn, content_id, content_type) if content: results.append( { @@ -550,28 +550,28 @@ class FullTextSearch: """根据ID获取内容""" try: if content_type == "transcript": - row = conn.execute( - "SELECT full_text FROM transcripts WHERE id = ?", (content_id,) + row = conn.execute( + "SELECT full_text FROM transcripts WHERE id = ?", (content_id, ) ).fetchone() return row["full_text"] if row else None elif content_type == "entity": - row = conn.execute( - "SELECT name, definition FROM entities WHERE id = ?", (content_id,) + row = conn.execute( + "SELECT name, definition FROM entities WHERE id = ?", (content_id, ) ).fetchone() if row: return f"{row['name']} {row['definition'] or ''}" return None elif content_type == "relation": - row = conn.execute( + row = conn.execute( """SELECT r.relation_type, r.evidence, e1.name as source_name, e2.name as target_name FROM entity_relations r - JOIN entities e1 ON r.source_entity_id = e1.id - JOIN entities e2 ON r.target_entity_id = e2.id - WHERE r.id = ?""", - (content_id,), + JOIN entities e1 ON r.source_entity_id = e1.id + JOIN entities e2 ON r.target_entity_id = e2.id + WHERE r.id = ?""", + (content_id, ), ).fetchone() if row: return f"{row['source_name']} {row['relation_type']} {row['target_name']} {row['evidence'] or ''}" @@ -588,16 +588,16 @@ class FullTextSearch: """获取内容所属的项目ID""" try: if content_type == "transcript": - row = conn.execute( - "SELECT project_id FROM transcripts WHERE id = ?", (content_id,) + row = conn.execute( + "SELECT project_id FROM transcripts WHERE id = ?", (content_id, ) ).fetchone() elif content_type == "entity": - row = conn.execute( - "SELECT project_id FROM entities WHERE id = ?", (content_id,) + row = conn.execute( + "SELECT project_id FROM entities WHERE id = ?", (content_id, ) ).fetchone() elif content_type == "relation": - row = conn.execute( - "SELECT project_id FROM entity_relations WHERE id = ?", (content_id,) + row = conn.execute( + "SELECT project_id FROM entity_relations WHERE id = ?", (content_id, ) ).fetchone() else: return None @@ -608,39 +608,39 @@ class FullTextSearch: def _score_results(self, results: list[dict], parsed_query: dict) -> list[SearchResult]: """计算搜索结果的相关性分数""" - scored = [] - all_terms = parsed_query["and"] + parsed_query["or"] + parsed_query["phrases"] + scored = [] + all_terms = parsed_query["and"] + parsed_query["or"] + parsed_query["phrases"] for result in results: - content = result["content"].lower() + content = result["content"].lower() # 基础分数 - score = 0.0 - highlights = [] + score = 0.0 + highlights = [] # 计算每个词的匹配分数 for term in all_terms: - term_lower = term.lower() - count = content.count(term_lower) + term_lower = term.lower() + count = content.count(term_lower) if count > 0: # TF 分数(词频) - tf_score = math.log(1 + count) + tf_score = math.log(1 + count) # 位置加分(标题/开头匹配分数更高) - position_bonus = 0 - first_pos = content.find(term_lower) + position_bonus = 0 + first_pos = content.find(term_lower) if first_pos != -1: if first_pos < 50: # 开头50个字符 - position_bonus = 2.0 + position_bonus = 2.0 elif first_pos < 200: # 开头200个字符 - position_bonus = 1.0 + position_bonus = 1.0 # 记录高亮位置 - start = first_pos + start = first_pos while start != -1: highlights.append((start, start + len(term))) - start = content.find(term_lower, start + 1) + start = content.find(term_lower, start + 1) score += tf_score + position_bonus @@ -650,23 +650,23 @@ class FullTextSearch: score *= 1.5 # 短语匹配加权 # 归一化分数 - score = min(score / max(len(all_terms), 1), 10.0) + score = min(score / max(len(all_terms), 1), 10.0) scored.append( SearchResult( - id=result["id"], - content=result["content"], - content_type=result["content_type"], - project_id=result["project_id"], - score=round(score, 4), - highlights=highlights[:10], # 限制高亮数量 - metadata={}, + id = result["id"], + content = result["content"], + content_type = result["content_type"], + project_id = result["project_id"], + score = round(score, 4), + highlights = highlights[:10], # 限制高亮数量 + metadata = {}, ) ) return scored - def highlight_text(self, text: str, query: str, max_length: int = 300) -> str: + def highlight_text(self, text: str, query: str, max_length: int = 300) -> str: """ 高亮文本中的关键词 @@ -678,47 +678,47 @@ class FullTextSearch: Returns: str: 带高亮标记的文本 """ - parsed = self._parse_boolean_query(query) - all_terms = parsed["and"] + parsed["or"] + parsed["phrases"] + parsed = self._parse_boolean_query(query) + all_terms = parsed["and"] + parsed["or"] + parsed["phrases"] # 找到第一个匹配位置 - first_match = len(text) + first_match = len(text) for term in all_terms: - pos = text.lower().find(term.lower()) + pos = text.lower().find(term.lower()) if pos != -1 and pos < first_match: - first_match = pos + first_match = pos # 截取上下文 - start = max(0, first_match - 100) - end = min(len(text), start + max_length) - snippet = text[start:end] + start = max(0, first_match - 100) + end = min(len(text), start + max_length) + snippet = text[start:end] if start > 0: - snippet = "..." + snippet + snippet = "..." + snippet if end < len(text): - snippet = snippet + "..." + snippet = snippet + "..." # 添加高亮标记 - for term in sorted(all_terms, key=len, reverse=True): # 长的先替换 - pattern = re.compile(re.escape(term), re.IGNORECASE) - snippet = pattern.sub(f"**{term}**", snippet) + for term in sorted(all_terms, key = len, reverse = True): # 长的先替换 + pattern = re.compile(re.escape(term), re.IGNORECASE) + snippet = pattern.sub(f"**{term}**", snippet) return snippet def delete_index(self, content_id: str, content_type: str) -> bool: """删除内容的搜索索引""" try: - conn = self._get_conn() + conn = self._get_conn() # 删除索引 conn.execute( - "DELETE FROM search_indexes WHERE content_id = ? AND content_type = ?", + "DELETE FROM search_indexes WHERE content_id = ? AND content_type = ?", (content_id, content_type), ) # 删除词频统计 conn.execute( - "DELETE FROM search_term_freq WHERE content_id = ? AND content_type = ?", + "DELETE FROM search_term_freq WHERE content_id = ? AND content_type = ?", (content_id, content_type), ) @@ -731,14 +731,14 @@ class FullTextSearch: def reindex_project(self, project_id: str) -> dict: """重新索引整个项目""" - conn = self._get_conn() - stats = {"transcripts": 0, "entities": 0, "relations": 0, "errors": 0} + conn = self._get_conn() + stats = {"transcripts": 0, "entities": 0, "relations": 0, "errors": 0} try: # 索引转录文本 - transcripts = conn.execute( - "SELECT id, project_id, full_text FROM transcripts WHERE project_id = ?", - (project_id,), + transcripts = conn.execute( + "SELECT id, project_id, full_text FROM transcripts WHERE project_id = ?", + (project_id, ), ).fetchall() for t in transcripts: @@ -749,31 +749,31 @@ class FullTextSearch: stats["errors"] += 1 # 索引实体 - entities = conn.execute( - "SELECT id, project_id, name, definition FROM entities WHERE project_id = ?", - (project_id,), + entities = conn.execute( + "SELECT id, project_id, name, definition FROM entities WHERE project_id = ?", + (project_id, ), ).fetchall() for e in entities: - text = f"{e['name']} {e['definition'] or ''}" + text = f"{e['name']} {e['definition'] or ''}" if self.index_content(e["id"], "entity", e["project_id"], text): stats["entities"] += 1 else: stats["errors"] += 1 # 索引关系 - relations = conn.execute( + relations = conn.execute( """SELECT r.id, r.project_id, r.relation_type, r.evidence, e1.name as source_name, e2.name as target_name FROM entity_relations r - JOIN entities e1 ON r.source_entity_id = e1.id - JOIN entities e2 ON r.target_entity_id = e2.id - WHERE r.project_id = ?""", - (project_id,), + JOIN entities e1 ON r.source_entity_id = e1.id + JOIN entities e2 ON r.target_entity_id = e2.id + WHERE r.project_id = ?""", + (project_id, ), ).fetchall() for r in relations: - text = f"{r['source_name']} {r['relation_type']} {r['target_name']} {r['evidence'] or ''}" + text = f"{r['source_name']} {r['relation_type']} {r['target_name']} {r['evidence'] or ''}" if self.index_content(r["id"], "relation", r["project_id"], text): stats["relations"] += 1 else: @@ -803,31 +803,31 @@ class SemanticSearch: def __init__( self, - db_path: str = "insightflow.db", - model_name: str = "paraphrase-multilingual-MiniLM-L12-v2", - ): - self.db_path = db_path - self.model_name = model_name - self.model = None + db_path: str = "insightflow.db", + model_name: str = "paraphrase-multilingual-MiniLM-L12-v2", + ) -> None: + self.db_path = db_path + self.model_name = model_name + self.model = None self._init_embedding_tables() # 延迟加载模型 if SENTENCE_TRANSFORMERS_AVAILABLE: try: - self.model = SentenceTransformer(model_name) + self.model = SentenceTransformer(model_name) print(f"语义搜索模型加载成功: {model_name}") except Exception as e: print(f"模型加载失败: {e}") def _get_conn(self) -> sqlite3.Connection: """获取数据库连接""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn def _init_embedding_tables(self) -> None: """初始化 embedding 相关表""" - conn = self._get_conn() + conn = self._get_conn() conn.execute(""" CREATE TABLE IF NOT EXISTS embeddings ( @@ -869,11 +869,11 @@ class SemanticSearch: try: # 截断长文本 - max_chars = 5000 + max_chars = 5000 if len(text) > max_chars: - text = text[:max_chars] + text = text[:max_chars] - embedding = self.model.encode(text, convert_to_list=True) + embedding = self.model.encode(text, convert_to_list = True) return embedding except Exception as e: print(f"生成 embedding 失败: {e}") @@ -898,13 +898,13 @@ class SemanticSearch: return False try: - embedding = self.generate_embedding(text) + embedding = self.generate_embedding(text) if not embedding: return False - conn = self._get_conn() + conn = self._get_conn() - embedding_id = hashlib.md5(f"{content_id}:{content_type}".encode()).hexdigest()[:16] + embedding_id = hashlib.md5(f"{content_id}:{content_type}".encode()).hexdigest()[:16] conn.execute( """ @@ -934,10 +934,10 @@ class SemanticSearch: def search( self, query: str, - project_id: str | None = None, - content_types: list[str] | None = None, - top_k: int = 10, - threshold: float = 0.5, + project_id: str | None = None, + content_types: list[str] | None = None, + top_k: int = 10, + threshold: float = 0.5, ) -> list[SemanticSearchResult]: """ 语义搜索 @@ -956,28 +956,28 @@ class SemanticSearch: return [] # 生成查询的 embedding - query_embedding = self.generate_embedding(query) + query_embedding = self.generate_embedding(query) if not query_embedding: return [] # 获取候选 embedding - conn = self._get_conn() + conn = self._get_conn() - where_clauses = [] - params = [] + where_clauses = [] + params = [] if project_id: - where_clauses.append("project_id = ?") + where_clauses.append("project_id = ?") params.append(project_id) if content_types: - placeholders = ",".join(["?" for _ in content_types]) + placeholders = ", ".join(["?" for _ in content_types]) where_clauses.append(f"content_type IN ({placeholders})") params.extend(content_types) - where_str = " AND ".join(where_clauses) if where_clauses else "1=1" + where_str = " AND ".join(where_clauses) if where_clauses else "1 = 1" - rows = conn.execute( + rows = conn.execute( f""" SELECT content_id, content_type, project_id, embedding FROM embeddings @@ -989,29 +989,29 @@ class SemanticSearch: conn.close() # 计算相似度 - results = [] - query_vec = [query_embedding] + results = [] + query_vec = [query_embedding] for row in rows: try: - content_embedding = json.loads(row["embedding"]) + content_embedding = json.loads(row["embedding"]) # 计算余弦相似度 - similarity = cosine_similarity(query_vec, [content_embedding])[0][0] + similarity = cosine_similarity(query_vec, [content_embedding])[0][0] if similarity >= threshold: # 获取原始内容 - content = self._get_content_text(row["content_id"], row["content_type"]) + content = self._get_content_text(row["content_id"], row["content_type"]) results.append( SemanticSearchResult( - id=row["content_id"], - content=content or "", - content_type=row["content_type"], - project_id=row["project_id"], - similarity=float(similarity), - embedding=None, # 不返回 embedding 以节省带宽 - metadata={}, + id = row["content_id"], + content = content or "", + content_type = row["content_type"], + project_id = row["project_id"], + similarity = float(similarity), + embedding = None, # 不返回 embedding 以节省带宽 + metadata = {}, ) ) except Exception as e: @@ -1019,44 +1019,44 @@ class SemanticSearch: continue # 排序并返回 top_k - results.sort(key=lambda x: x.similarity, reverse=True) + results.sort(key = lambda x: x.similarity, reverse = True) return results[:top_k] def _get_content_text(self, content_id: str, content_type: str) -> str | None: """获取内容文本""" - conn = self._get_conn() + conn = self._get_conn() try: if content_type == "transcript": - row = conn.execute( - "SELECT full_text FROM transcripts WHERE id = ?", (content_id,) + row = conn.execute( + "SELECT full_text FROM transcripts WHERE id = ?", (content_id, ) ).fetchone() - result = row["full_text"] if row else None + result = row["full_text"] if row else None elif content_type == "entity": - row = conn.execute( - "SELECT name, definition FROM entities WHERE id = ?", (content_id,) + row = conn.execute( + "SELECT name, definition FROM entities WHERE id = ?", (content_id, ) ).fetchone() - result = f"{row['name']}: {row['definition']}" if row else None + result = f"{row['name']}: {row['definition']}" if row else None elif content_type == "relation": - row = conn.execute( + row = conn.execute( """SELECT r.relation_type, r.evidence, e1.name as source_name, e2.name as target_name FROM entity_relations r - JOIN entities e1 ON r.source_entity_id = e1.id - JOIN entities e2 ON r.target_entity_id = e2.id - WHERE r.id = ?""", - (content_id,), + JOIN entities e1 ON r.source_entity_id = e1.id + JOIN entities e2 ON r.target_entity_id = e2.id + WHERE r.id = ?""", + (content_id, ), ).fetchone() - result = ( + result = ( f"{row['source_name']} {row['relation_type']} {row['target_name']}" if row else None ) else: - result = None + result = None conn.close() return result @@ -1067,7 +1067,7 @@ class SemanticSearch: return None def find_similar_content( - self, content_id: str, content_type: str, top_k: int = 5 + self, content_id: str, content_type: str, top_k: int = 5 ) -> list[SemanticSearchResult]: """ 查找与指定内容相似的内容 @@ -1084,10 +1084,10 @@ class SemanticSearch: return [] # 获取源内容的 embedding - conn = self._get_conn() + conn = self._get_conn() - row = conn.execute( - "SELECT embedding, project_id FROM embeddings WHERE content_id = ? AND content_type = ?", + row = conn.execute( + "SELECT embedding, project_id FROM embeddings WHERE content_id = ? AND content_type = ?", (content_id, content_type), ).fetchone() @@ -1095,52 +1095,52 @@ class SemanticSearch: conn.close() return [] - source_embedding = json.loads(row["embedding"]) - project_id = row["project_id"] + source_embedding = json.loads(row["embedding"]) + project_id = row["project_id"] # 获取其他内容的 embedding - rows = conn.execute( + rows = conn.execute( """SELECT content_id, content_type, project_id, embedding FROM embeddings - WHERE project_id = ? AND (content_id != ? OR content_type != ?)""", + WHERE project_id = ? AND (content_id != ? OR content_type != ?)""", (project_id, content_id, content_type), ).fetchall() conn.close() # 计算相似度 - results = [] - source_vec = [source_embedding] + results = [] + source_vec = [source_embedding] for row in rows: try: - content_embedding = json.loads(row["embedding"]) - similarity = cosine_similarity(source_vec, [content_embedding])[0][0] + content_embedding = json.loads(row["embedding"]) + similarity = cosine_similarity(source_vec, [content_embedding])[0][0] - content = self._get_content_text(row["content_id"], row["content_type"]) + content = self._get_content_text(row["content_id"], row["content_type"]) results.append( SemanticSearchResult( - id=row["content_id"], - content=content or "", - content_type=row["content_type"], - project_id=row["project_id"], - similarity=float(similarity), - metadata={}, + id = row["content_id"], + content = content or "", + content_type = row["content_type"], + project_id = row["project_id"], + similarity = float(similarity), + metadata = {}, ) ) except (KeyError, ValueError): continue - results.sort(key=lambda x: x.similarity, reverse=True) + results.sort(key = lambda x: x.similarity, reverse = True) return results[:top_k] def delete_embedding(self, content_id: str, content_type: str) -> bool: """删除内容的 embedding""" try: - conn = self._get_conn() + conn = self._get_conn() conn.execute( - "DELETE FROM embeddings WHERE content_id = ? AND content_type = ?", + "DELETE FROM embeddings WHERE content_id = ? AND content_type = ?", (content_id, content_type), ) conn.commit() @@ -1165,17 +1165,17 @@ class EntityPathDiscovery: - 路径可视化数据生成 """ - def __init__(self, db_path: str = "insightflow.db"): - self.db_path = db_path + def __init__(self, db_path: str = "insightflow.db") -> None: + self.db_path = db_path def _get_conn(self) -> sqlite3.Connection: """获取数据库连接""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn def find_shortest_path( - self, source_entity_id: str, target_entity_id: str, max_depth: int = 5 + self, source_entity_id: str, target_entity_id: str, max_depth: int = 5 ) -> EntityPath | None: """ 查找两个实体之间的最短路径(BFS算法) @@ -1188,22 +1188,22 @@ class EntityPathDiscovery: Returns: Optional[EntityPath]: 最短路径 """ - conn = self._get_conn() + conn = self._get_conn() # 获取项目ID - row = conn.execute( - "SELECT project_id FROM entities WHERE id = ?", (source_entity_id,) + row = conn.execute( + "SELECT project_id FROM entities WHERE id = ?", (source_entity_id, ) ).fetchone() if not row: conn.close() return None - project_id = row["project_id"] + project_id = row["project_id"] # 验证目标实体也在同一项目 - row = conn.execute( - "SELECT 1 FROM entities WHERE id = ? AND project_id = ?", (target_entity_id, project_id) + row = conn.execute( + "SELECT 1 FROM entities WHERE id = ? AND project_id = ?", (target_entity_id, project_id) ).fetchone() if not row: @@ -1211,11 +1211,11 @@ class EntityPathDiscovery: return None # BFS - visited = {source_entity_id} - queue = [(source_entity_id, [source_entity_id])] + visited = {source_entity_id} + queue = [(source_entity_id, [source_entity_id])] while queue: - current_id, path = queue.pop(0) + current_id, path = queue.pop(0) if len(path) > max_depth + 1: continue @@ -1226,21 +1226,21 @@ class EntityPathDiscovery: return self._build_path_object(path, project_id) # 获取邻居 - neighbors = conn.execute( + neighbors = conn.execute( """ SELECT target_entity_id as neighbor_id, relation_type, evidence FROM entity_relations - WHERE source_entity_id = ? AND project_id = ? + WHERE source_entity_id = ? AND project_id = ? UNION SELECT source_entity_id as neighbor_id, relation_type, evidence FROM entity_relations - WHERE target_entity_id = ? AND project_id = ? + WHERE target_entity_id = ? AND project_id = ? """, (current_id, project_id, current_id, project_id), ).fetchall() for neighbor in neighbors: - neighbor_id = neighbor["neighbor_id"] + neighbor_id = neighbor["neighbor_id"] if neighbor_id not in visited: visited.add(neighbor_id) queue.append((neighbor_id, path + [neighbor_id])) @@ -1249,7 +1249,7 @@ class EntityPathDiscovery: return None def find_all_paths( - self, source_entity_id: str, target_entity_id: str, max_depth: int = 4, max_paths: int = 10 + self, source_entity_id: str, target_entity_id: str, max_depth: int = 4, max_paths: int = 10 ) -> list[EntityPath]: """ 查找两个实体之间的所有路径(限制数量和深度) @@ -1263,22 +1263,22 @@ class EntityPathDiscovery: Returns: List[EntityPath]: 路径列表 """ - conn = self._get_conn() + conn = self._get_conn() # 获取项目ID - row = conn.execute( - "SELECT project_id FROM entities WHERE id = ?", (source_entity_id,) + row = conn.execute( + "SELECT project_id FROM entities WHERE id = ?", (source_entity_id, ) ).fetchone() if not row: conn.close() return [] - project_id = row["project_id"] + project_id = row["project_id"] - paths = [] + paths = [] - def dfs(current_id: str, target_id: str, path: list[str], visited: set[str], depth: int): + def dfs(current_id: str, target_id: str, path: list[str], visited: set[str], depth: int) -> None: if depth > max_depth: return @@ -1287,21 +1287,21 @@ class EntityPathDiscovery: return # 获取邻居 - neighbors = conn.execute( + neighbors = conn.execute( """ SELECT target_entity_id as neighbor_id FROM entity_relations - WHERE source_entity_id = ? AND project_id = ? + WHERE source_entity_id = ? AND project_id = ? UNION SELECT source_entity_id as neighbor_id FROM entity_relations - WHERE target_entity_id = ? AND project_id = ? + WHERE target_entity_id = ? AND project_id = ? """, (current_id, project_id, current_id, project_id), ).fetchall() for neighbor in neighbors: - neighbor_id = neighbor["neighbor_id"] + neighbor_id = neighbor["neighbor_id"] if neighbor_id not in visited and len(paths) < max_paths: visited.add(neighbor_id) path.append(neighbor_id) @@ -1309,7 +1309,7 @@ class EntityPathDiscovery: path.pop() visited.remove(neighbor_id) - visited = {source_entity_id} + visited = {source_entity_id} dfs(source_entity_id, target_entity_id, [source_entity_id], visited, 0) conn.close() @@ -1319,30 +1319,30 @@ class EntityPathDiscovery: def _build_path_object(self, entity_ids: list[str], project_id: str) -> EntityPath: """构建路径对象""" - conn = self._get_conn() + conn = self._get_conn() # 获取实体信息 - nodes = [] + nodes = [] for entity_id in entity_ids: - row = conn.execute( - "SELECT id, name, type FROM entities WHERE id = ?", (entity_id,) + row = conn.execute( + "SELECT id, name, type FROM entities WHERE id = ?", (entity_id, ) ).fetchone() if row: nodes.append({"id": row["id"], "name": row["name"], "type": row["type"]}) # 获取边信息 - edges = [] + edges = [] for i in range(len(entity_ids) - 1): - source_id = entity_ids[i] - target_id = entity_ids[i + 1] + source_id = entity_ids[i] + target_id = entity_ids[i + 1] - row = conn.execute( + row = conn.execute( """ SELECT id, relation_type, evidence FROM entity_relations - WHERE ((source_entity_id = ? AND target_entity_id = ?) - OR (source_entity_id = ? AND target_entity_id = ?)) - AND project_id = ? + WHERE ((source_entity_id = ? AND target_entity_id = ?) + OR (source_entity_id = ? AND target_entity_id = ?)) + AND project_id = ? """, (source_id, target_id, target_id, source_id, project_id), ).fetchone() @@ -1361,26 +1361,26 @@ class EntityPathDiscovery: conn.close() # 生成路径描述 - node_names = [n["name"] for n in nodes] - path_desc = " → ".join(node_names) + node_names = [n["name"] for n in nodes] + path_desc = " → ".join(node_names) # 计算置信度(基于路径长度和关系数量) - confidence = 1.0 / (len(entity_ids) - 1) if len(entity_ids) > 1 else 1.0 + confidence = 1.0 / (len(entity_ids) - 1) if len(entity_ids) > 1 else 1.0 return EntityPath( - path_id=f"path_{entity_ids[0]}_{entity_ids[-1]}_{hash(tuple(entity_ids))}", - source_entity_id=entity_ids[0], - source_entity_name=nodes[0]["name"] if nodes else "", - target_entity_id=entity_ids[-1], - target_entity_name=nodes[-1]["name"] if nodes else "", - path_length=len(entity_ids) - 1, - nodes=nodes, - edges=edges, - confidence=round(confidence, 4), - path_description=path_desc, + path_id = f"path_{entity_ids[0]}_{entity_ids[-1]}_{hash(tuple(entity_ids))}", + source_entity_id = entity_ids[0], + source_entity_name = nodes[0]["name"] if nodes else "", + target_entity_id = entity_ids[-1], + target_entity_name = nodes[-1]["name"] if nodes else "", + path_length = len(entity_ids) - 1, + nodes = nodes, + edges = edges, + confidence = round(confidence, 4), + path_description = path_desc, ) - def find_multi_hop_relations(self, entity_id: str, max_hops: int = 3) -> list[dict]: + def find_multi_hop_relations(self, entity_id: str, max_hops: int = 3) -> list[dict]: """ 查找实体的多跳关系 @@ -1391,58 +1391,58 @@ class EntityPathDiscovery: Returns: List[Dict]: 多跳关系列表 """ - conn = self._get_conn() + conn = self._get_conn() # 获取项目ID - row = conn.execute( - "SELECT project_id, name FROM entities WHERE id = ?", (entity_id,) + row = conn.execute( + "SELECT project_id, name FROM entities WHERE id = ?", (entity_id, ) ).fetchone() if not row: conn.close() return [] - project_id = row["project_id"] + project_id = row["project_id"] row["name"] # BFS 收集多跳关系 - visited = {entity_id: 0} - queue = [(entity_id, 0)] - relations = [] + visited = {entity_id: 0} + queue = [(entity_id, 0)] + relations = [] while queue: - current_id, depth = queue.pop(0) + current_id, depth = queue.pop(0) if depth >= max_hops: continue # 获取邻居 - neighbors = conn.execute( + neighbors = conn.execute( """ SELECT CASE - WHEN source_entity_id = ? THEN target_entity_id + WHEN source_entity_id = ? THEN target_entity_id ELSE source_entity_id END as neighbor_id, relation_type, evidence FROM entity_relations - WHERE (source_entity_id = ? OR target_entity_id = ?) - AND project_id = ? + WHERE (source_entity_id = ? OR target_entity_id = ?) + AND project_id = ? """, (current_id, current_id, current_id, project_id), ).fetchall() for neighbor in neighbors: - neighbor_id = neighbor["neighbor_id"] + neighbor_id = neighbor["neighbor_id"] if neighbor_id not in visited: - visited[neighbor_id] = depth + 1 + visited[neighbor_id] = depth + 1 queue.append((neighbor_id, depth + 1)) # 获取邻居信息 - neighbor_info = conn.execute( - "SELECT name, type FROM entities WHERE id = ?", (neighbor_id,) + neighbor_info = conn.execute( + "SELECT name, type FROM entities WHERE id = ?", (neighbor_id, ) ).fetchone() if neighbor_info: @@ -1463,7 +1463,7 @@ class EntityPathDiscovery: conn.close() # 按跳数排序 - relations.sort(key=lambda x: x["hops"]) + relations.sort(key = lambda x: x["hops"]) return relations def _get_path_to_entity( @@ -1471,11 +1471,11 @@ class EntityPathDiscovery: ) -> list[str]: """获取从源实体到目标实体的路径(简化版)""" # BFS 找路径 - visited = {source_id} - queue = [(source_id, [source_id])] + visited = {source_id} + queue = [(source_id, [source_id])] while queue: - current, path = queue.pop(0) + current, path = queue.pop(0) if current == target_id: return path @@ -1483,22 +1483,22 @@ class EntityPathDiscovery: if len(path) > 5: # 限制路径长度 continue - neighbors = conn.execute( + neighbors = conn.execute( """ SELECT CASE - WHEN source_entity_id = ? THEN target_entity_id + WHEN source_entity_id = ? THEN target_entity_id ELSE source_entity_id END as neighbor_id FROM entity_relations - WHERE (source_entity_id = ? OR target_entity_id = ?) - AND project_id = ? + WHERE (source_entity_id = ? OR target_entity_id = ?) + AND project_id = ? """, (current, current, current, project_id), ).fetchall() for neighbor in neighbors: - neighbor_id = neighbor["neighbor_id"] + neighbor_id = neighbor["neighbor_id"] if neighbor_id not in visited: visited.add(neighbor_id) queue.append((neighbor_id, path + [neighbor_id])) @@ -1516,7 +1516,7 @@ class EntityPathDiscovery: Dict: D3.js 可视化数据格式 """ # 节点数据 - nodes = [] + nodes = [] for node in path.nodes: nodes.append( { @@ -1529,7 +1529,7 @@ class EntityPathDiscovery: ) # 边数据 - links = [] + links = [] for edge in path.edges: links.append( { @@ -1558,55 +1558,55 @@ class EntityPathDiscovery: Returns: List[Dict]: 中心性分析结果 """ - conn = self._get_conn() + conn = self._get_conn() # 获取所有实体 - entities = conn.execute( - "SELECT id, name FROM entities WHERE project_id = ?", (project_id,) + entities = conn.execute( + "SELECT id, name FROM entities WHERE project_id = ?", (project_id, ) ).fetchall() # 计算每个实体作为桥梁的次数 - bridge_scores = [] + bridge_scores = [] for entity in entities: - entity_id = entity["id"] + entity_id = entity["id"] # 计算该实体连接的不同群组数量 - neighbors = conn.execute( + neighbors = conn.execute( """ SELECT CASE - WHEN source_entity_id = ? THEN target_entity_id + WHEN source_entity_id = ? THEN target_entity_id ELSE source_entity_id END as neighbor_id FROM entity_relations - WHERE (source_entity_id = ? OR target_entity_id = ?) - AND project_id = ? + WHERE (source_entity_id = ? OR target_entity_id = ?) + AND project_id = ? """, (entity_id, entity_id, entity_id, project_id), ).fetchall() - neighbor_ids = {n["neighbor_id"] for n in neighbors} + neighbor_ids = {n["neighbor_id"] for n in neighbors} # 计算邻居之间的连接数(用于评估桥接程度) if len(neighbor_ids) > 1: - connections = conn.execute( + connections = conn.execute( f""" SELECT COUNT(*) as count FROM entity_relations - WHERE ((source_entity_id IN ({",".join(["?" for _ in neighbor_ids])}) - AND target_entity_id IN ({",".join(["?" for _ in neighbor_ids])})) - OR (target_entity_id IN ({",".join(["?" for _ in neighbor_ids])}) - AND source_entity_id IN ({",".join(["?" for _ in neighbor_ids])}))) - AND project_id = ? + WHERE ((source_entity_id IN ({", ".join(["?" for _ in neighbor_ids])}) + AND target_entity_id IN ({", ".join(["?" for _ in neighbor_ids])})) + OR (target_entity_id IN ({", ".join(["?" for _ in neighbor_ids])}) + AND source_entity_id IN ({", ".join(["?" for _ in neighbor_ids])}))) + AND project_id = ? """, list(neighbor_ids) * 4 + [project_id], ).fetchone() - # 桥接分数 = 邻居数量 / (邻居间连接数 + 1) - bridge_score = len(neighbor_ids) / (connections["count"] + 1) + # 桥接分数 = 邻居数量 / (邻居间连接数 + 1) + bridge_score = len(neighbor_ids) / (connections["count"] + 1) else: - bridge_score = 0 + bridge_score = 0 bridge_scores.append( { @@ -1620,7 +1620,7 @@ class EntityPathDiscovery: conn.close() # 按桥接分数排序 - bridge_scores.sort(key=lambda x: x["bridge_score"], reverse=True) + bridge_scores.sort(key = lambda x: x["bridge_score"], reverse = True) return bridge_scores[:20] # 返回前20 @@ -1638,13 +1638,13 @@ class KnowledgeGapDetection: - 生成知识补全建议 """ - def __init__(self, db_path: str = "insightflow.db"): - self.db_path = db_path + def __init__(self, db_path: str = "insightflow.db") -> None: + self.db_path = db_path def _get_conn(self) -> sqlite3.Connection: """获取数据库连接""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn def analyze_project(self, project_id: str) -> list[KnowledgeGap]: @@ -1657,7 +1657,7 @@ class KnowledgeGapDetection: Returns: List[KnowledgeGap]: 知识缺口列表 """ - gaps = [] + gaps = [] # 1. 检查实体属性完整性 gaps.extend(self._check_entity_attribute_completeness(project_id)) @@ -1675,55 +1675,55 @@ class KnowledgeGapDetection: gaps.extend(self._check_missing_key_entities(project_id)) # 按严重程度排序 - severity_order = {"high": 0, "medium": 1, "low": 2} - gaps.sort(key=lambda x: severity_order.get(x.severity, 3)) + severity_order = {"high": 0, "medium": 1, "low": 2} + gaps.sort(key = lambda x: severity_order.get(x.severity, 3)) return gaps def _check_entity_attribute_completeness(self, project_id: str) -> list[KnowledgeGap]: """检查实体属性完整性""" - conn = self._get_conn() - gaps = [] + conn = self._get_conn() + gaps = [] # 获取项目的属性模板 - templates = conn.execute( - "SELECT id, name, type, is_required FROM attribute_templates WHERE project_id = ?", - (project_id,), + templates = conn.execute( + "SELECT id, name, type, is_required FROM attribute_templates WHERE project_id = ?", + (project_id, ), ).fetchall() if not templates: conn.close() return [] - required_template_ids = {t["id"] for t in templates if t["is_required"]} + required_template_ids = {t["id"] for t in templates if t["is_required"]} if not required_template_ids: conn.close() return [] # 检查每个实体的属性完整性 - entities = conn.execute( - "SELECT id, name FROM entities WHERE project_id = ?", (project_id,) + entities = conn.execute( + "SELECT id, name FROM entities WHERE project_id = ?", (project_id, ) ).fetchall() for entity in entities: - entity_id = entity["id"] + entity_id = entity["id"] # 获取实体已有的属性 - existing_attrs = conn.execute( - "SELECT template_id FROM entity_attributes WHERE entity_id = ?", (entity_id,) + existing_attrs = conn.execute( + "SELECT template_id FROM entity_attributes WHERE entity_id = ?", (entity_id, ) ).fetchall() - existing_template_ids = {a["template_id"] for a in existing_attrs} + existing_template_ids = {a["template_id"] for a in existing_attrs} # 找出缺失的必需属性 - missing_templates = required_template_ids - existing_template_ids + missing_templates = required_template_ids - existing_template_ids if missing_templates: - missing_names = [] + missing_names = [] for template_id in missing_templates: - template = conn.execute( - "SELECT name FROM attribute_templates WHERE id = ?", (template_id,) + template = conn.execute( + "SELECT name FROM attribute_templates WHERE id = ?", (template_id, ) ).fetchone() if template: missing_names.append(template["name"]) @@ -1731,18 +1731,18 @@ class KnowledgeGapDetection: if missing_names: gaps.append( KnowledgeGap( - gap_id=f"gap_attr_{entity_id}", - gap_type="missing_attribute", - entity_id=entity_id, - entity_name=entity["name"], - description=f"实体 '{entity['name']}' 缺少必需属性: {', '.join(missing_names)}", - severity="medium", - suggestions=[ + gap_id = f"gap_attr_{entity_id}", + gap_type = "missing_attribute", + entity_id = entity_id, + entity_name = entity["name"], + description = f"实体 '{entity['name']}' 缺少必需属性: {', '.join(missing_names)}", + severity = "medium", + suggestions = [ f"为实体 '{entity['name']}' 补充以下属性: {', '.join(missing_names)}", "检查属性模板定义是否合理", ], - related_entities=[], - metadata={"missing_attributes": missing_names}, + related_entities = [], + metadata = {"missing_attributes": missing_names}, ) ) @@ -1751,39 +1751,39 @@ class KnowledgeGapDetection: def _check_relation_sparsity(self, project_id: str) -> list[KnowledgeGap]: """检查关系稀疏度""" - conn = self._get_conn() - gaps = [] + conn = self._get_conn() + gaps = [] # 获取所有实体及其关系数量 - entities = conn.execute( - "SELECT id, name, type FROM entities WHERE project_id = ?", (project_id,) + entities = conn.execute( + "SELECT id, name, type FROM entities WHERE project_id = ?", (project_id, ) ).fetchall() for entity in entities: - entity_id = entity["id"] + entity_id = entity["id"] # 计算关系数量 - relation_count = conn.execute( + relation_count = conn.execute( """ SELECT COUNT(*) as count FROM entity_relations - WHERE (source_entity_id = ? OR target_entity_id = ?) - AND project_id = ? + WHERE (source_entity_id = ? OR target_entity_id = ?) + AND project_id = ? """, (entity_id, entity_id, project_id), ).fetchone()["count"] # 根据实体类型判断阈值 - threshold = 1 if entity["type"] in ["PERSON", "ORG"] else 0 + threshold = 1 if entity["type"] in ["PERSON", "ORG"] else 0 if relation_count <= threshold: # 查找潜在的相关实体 - potential_related = conn.execute( + potential_related = conn.execute( """ SELECT e.id, e.name FROM entities e - JOIN transcripts t ON t.project_id = e.project_id - WHERE e.project_id = ? + JOIN transcripts t ON t.project_id = e.project_id + WHERE e.project_id = ? AND e.id != ? AND t.full_text LIKE ? LIMIT 5 @@ -1793,19 +1793,19 @@ class KnowledgeGapDetection: gaps.append( KnowledgeGap( - gap_id=f"gap_sparse_{entity_id}", - gap_type="sparse_relation", - entity_id=entity_id, - entity_name=entity["name"], - description=f"实体 '{entity['name']}' 关系稀疏(仅有 {relation_count} 个关系)", - severity="medium" if relation_count == 0 else "low", - suggestions=[ + gap_id = f"gap_sparse_{entity_id}", + gap_type = "sparse_relation", + entity_id = entity_id, + entity_name = entity["name"], + description = f"实体 '{entity['name']}' 关系稀疏(仅有 {relation_count} 个关系)", + severity = "medium" if relation_count == 0 else "low", + suggestions = [ f"检查转录文本中提及 '{entity['name']}' 的其他实体", f"手动添加 '{entity['name']}' 与其他实体的关系", "使用实体对齐功能合并相似实体", ], - related_entities=[r["id"] for r in potential_related], - metadata={ + related_entities = [r["id"] for r in potential_related], + metadata = { "relation_count": relation_count, "potential_related": [r["name"] for r in potential_related], }, @@ -1817,39 +1817,39 @@ class KnowledgeGapDetection: def _check_isolated_entities(self, project_id: str) -> list[KnowledgeGap]: """检查孤立实体(没有任何关系)""" - conn = self._get_conn() - gaps = [] + conn = self._get_conn() + gaps = [] # 查找没有关系的实体 - isolated = conn.execute( + isolated = conn.execute( """ SELECT e.id, e.name, e.type FROM entities e - LEFT JOIN entity_relations r1 ON e.id = r1.source_entity_id - LEFT JOIN entity_relations r2 ON e.id = r2.target_entity_id - WHERE e.project_id = ? + LEFT JOIN entity_relations r1 ON e.id = r1.source_entity_id + LEFT JOIN entity_relations r2 ON e.id = r2.target_entity_id + WHERE e.project_id = ? AND r1.id IS NULL AND r2.id IS NULL """, - (project_id,), + (project_id, ), ).fetchall() for entity in isolated: gaps.append( KnowledgeGap( - gap_id=f"gap_iso_{entity['id']}", - gap_type="isolated_entity", - entity_id=entity["id"], - entity_name=entity["name"], - description=f"实体 '{entity['name']}' 是孤立实体(没有任何关系)", - severity="high", - suggestions=[ + gap_id = f"gap_iso_{entity['id']}", + gap_type = "isolated_entity", + entity_id = entity["id"], + entity_name = entity["name"], + description = f"实体 '{entity['name']}' 是孤立实体(没有任何关系)", + severity = "high", + suggestions = [ f"检查 '{entity['name']}' 是否应该与其他实体建立关系", f"考虑删除不相关的实体 '{entity['name']}'", "运行关系发现算法自动识别潜在关系", ], - related_entities=[], - metadata={"entity_type": entity["type"]}, + related_entities = [], + metadata = {"entity_type": entity["type"]}, ) ) @@ -1858,32 +1858,32 @@ class KnowledgeGapDetection: def _check_incomplete_entities(self, project_id: str) -> list[KnowledgeGap]: """检查不完整实体(缺少名称、类型或定义)""" - conn = self._get_conn() - gaps = [] + conn = self._get_conn() + gaps = [] # 查找缺少定义的实体 - incomplete = conn.execute( + incomplete = conn.execute( """ SELECT id, name, type, definition FROM entities - WHERE project_id = ? - AND (definition IS NULL OR definition = '') + WHERE project_id = ? + AND (definition IS NULL OR definition = '') """, - (project_id,), + (project_id, ), ).fetchall() for entity in incomplete: gaps.append( KnowledgeGap( - gap_id=f"gap_inc_{entity['id']}", - gap_type="incomplete_entity", - entity_id=entity["id"], - entity_name=entity["name"], - description=f"实体 '{entity['name']}' 缺少定义", - severity="low", - suggestions=[f"为 '{entity['name']}' 添加定义", "从转录文本中提取定义信息"], - related_entities=[], - metadata={"entity_type": entity["type"]}, + gap_id = f"gap_inc_{entity['id']}", + gap_type = "incomplete_entity", + entity_id = entity["id"], + entity_name = entity["name"], + description = f"实体 '{entity['name']}' 缺少定义", + severity = "low", + suggestions = [f"为 '{entity['name']}' 添加定义", "从转录文本中提取定义信息"], + related_entities = [], + metadata = {"entity_type": entity["type"]}, ) ) @@ -1892,30 +1892,30 @@ class KnowledgeGapDetection: def _check_missing_key_entities(self, project_id: str) -> list[KnowledgeGap]: """检查可能缺失的关键实体""" - conn = self._get_conn() - gaps = [] + conn = self._get_conn() + gaps = [] # 分析转录文本中频繁提及但未提取为实体的词 - transcripts = conn.execute( - "SELECT full_text FROM transcripts WHERE project_id = ?", (project_id,) + transcripts = conn.execute( + "SELECT full_text FROM transcripts WHERE project_id = ?", (project_id, ) ).fetchall() # 合并所有文本 - all_text = " ".join([t["full_text"] or "" for t in transcripts]) + all_text = " ".join([t["full_text"] or "" for t in transcripts]) # 获取现有实体名称 - existing_entities = conn.execute( - "SELECT name FROM entities WHERE project_id = ?", (project_id,) + existing_entities = conn.execute( + "SELECT name FROM entities WHERE project_id = ?", (project_id, ) ).fetchall() - existing_names = {e["name"].lower() for e in existing_entities} + existing_names = {e["name"].lower() for e in existing_entities} # 简单的关键词提取(实际可以使用更复杂的 NLP 方法) # 查找大写的词组(可能是专有名词) - potential_entities = re.findall(r"[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*", all_text) + potential_entities = re.findall(r"[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*", all_text) # 统计频率 - freq = defaultdict(int) + freq = defaultdict(int) for entity in potential_entities: if len(entity) > 3 and entity.lower() not in existing_names: freq[entity] += 1 @@ -1925,18 +1925,18 @@ class KnowledgeGapDetection: if count >= 3: # 出现3次以上 gaps.append( KnowledgeGap( - gap_id=f"gap_missing_{hash(entity) % 10000}", - gap_type="missing_key_entity", - entity_id=None, - entity_name=None, - description=f"文本中频繁提及 '{entity}' 但未提取为实体(出现 {count} 次)", - severity="low", - suggestions=[ + gap_id = f"gap_missing_{hash(entity) % 10000}", + gap_type = "missing_key_entity", + entity_id = None, + entity_name = None, + description = f"文本中频繁提及 '{entity}' 但未提取为实体(出现 {count} 次)", + severity = "low", + suggestions = [ f"考虑将 '{entity}' 添加为实体", "检查实体提取算法是否需要优化", ], - related_entities=[], - metadata={"mention_count": count}, + related_entities = [], + metadata = {"mention_count": count}, ) ) @@ -1953,36 +1953,36 @@ class KnowledgeGapDetection: Returns: Dict: 完整性报告 """ - conn = self._get_conn() + conn = self._get_conn() # 基础统计 - stats = conn.execute( + stats = conn.execute( """ SELECT - (SELECT COUNT(*) FROM entities WHERE project_id = ?) as entity_count, - (SELECT COUNT(*) FROM entity_relations WHERE project_id = ?) as relation_count, - (SELECT COUNT(*) FROM transcripts WHERE project_id = ?) as transcript_count + (SELECT COUNT(*) FROM entities WHERE project_id = ?) as entity_count, + (SELECT COUNT(*) FROM entity_relations WHERE project_id = ?) as relation_count, + (SELECT COUNT(*) FROM transcripts WHERE project_id = ?) as transcript_count """, (project_id, project_id, project_id), ).fetchone() # 计算完整性分数 - gaps = self.analyze_project(project_id) + gaps = self.analyze_project(project_id) # 按类型统计 - gap_by_type = defaultdict(int) - severity_count = {"high": 0, "medium": 0, "low": 0} + gap_by_type = defaultdict(int) + severity_count = {"high": 0, "medium": 0, "low": 0} for gap in gaps: gap_by_type[gap.gap_type] += 1 severity_count[gap.severity] += 1 # 计算完整性分数(100 - 扣分) - score = 100 + score = 100 score -= severity_count["high"] * 10 score -= severity_count["medium"] * 5 score -= severity_count["low"] * 2 - score = max(0, score) + score = max(0, score) conn.close() @@ -2005,9 +2005,9 @@ class KnowledgeGapDetection: def _generate_recommendations(self, gaps: list[KnowledgeGap]) -> list[str]: """生成改进建议""" - recommendations = [] + recommendations = [] - gap_types = {g.gap_type for g in gaps} + gap_types = {g.gap_type for g in gaps} if "isolated_entity" in gap_types: recommendations.append("优先处理孤立实体,建立实体间的关系连接") @@ -2040,14 +2040,14 @@ class SearchManager: 整合全文搜索、语义搜索、实体路径发现和知识缺口识别功能 """ - def __init__(self, db_path: str = "insightflow.db"): - self.db_path = db_path - self.fulltext_search = FullTextSearch(db_path) - self.semantic_search = SemanticSearch(db_path) - self.path_discovery = EntityPathDiscovery(db_path) - self.gap_detection = KnowledgeGapDetection(db_path) + def __init__(self, db_path: str = "insightflow.db") -> None: + self.db_path = db_path + self.fulltext_search = FullTextSearch(db_path) + self.semantic_search = SemanticSearch(db_path) + self.path_discovery = EntityPathDiscovery(db_path) + self.gap_detection = KnowledgeGapDetection(db_path) - def hybrid_search(self, query: str, project_id: str | None = None, limit: int = 20) -> dict: + def hybrid_search(self, query: str, project_id: str | None = None, limit: int = 20) -> dict: """ 混合搜索(全文 + 语义) @@ -2060,20 +2060,20 @@ class SearchManager: Dict: 混合搜索结果 """ # 全文搜索 - fulltext_results = self.fulltext_search.search(query, project_id, limit=limit) + fulltext_results = self.fulltext_search.search(query, project_id, limit = limit) # 语义搜索 - semantic_results = [] + semantic_results = [] if self.semantic_search.is_available(): - semantic_results = self.semantic_search.search(query, project_id, top_k=limit) + semantic_results = self.semantic_search.search(query, project_id, top_k = limit) # 合并结果(去重并加权) - combined = {} + combined = {} # 添加全文搜索结果 for r in fulltext_results: - key = (r.id, r.content_type) - combined[key] = { + key = (r.id, r.content_type) + combined[key] = { "id": r.id, "content": r.content, "content_type": r.content_type, @@ -2086,12 +2086,12 @@ class SearchManager: # 添加语义搜索结果 for r in semantic_results: - key = (r.id, r.content_type) + key = (r.id, r.content_type) if key in combined: - combined[key]["semantic_score"] = r.similarity + combined[key]["semantic_score"] = r.similarity combined[key]["combined_score"] += r.similarity * 0.4 # 语义权重 40% else: - combined[key] = { + combined[key] = { "id": r.id, "content": r.content, "content_type": r.content_type, @@ -2103,8 +2103,8 @@ class SearchManager: } # 排序 - results = list(combined.values()) - results.sort(key=lambda x: x["combined_score"], reverse=True) + results = list(combined.values()) + results.sort(key = lambda x: x["combined_score"], reverse = True) return { "query": query, @@ -2126,19 +2126,19 @@ class SearchManager: Dict: 索引统计 """ # 全文索引 - fulltext_stats = self.fulltext_search.reindex_project(project_id) + fulltext_stats = self.fulltext_search.reindex_project(project_id) # 语义索引 - semantic_stats = {"indexed": 0, "errors": 0} + semantic_stats = {"indexed": 0, "errors": 0} if self.semantic_search.is_available(): - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row # 索引转录文本 - transcripts = conn.execute( - "SELECT id, project_id, full_text FROM transcripts WHERE project_id = ?", - (project_id,), + transcripts = conn.execute( + "SELECT id, project_id, full_text FROM transcripts WHERE project_id = ?", + (project_id, ), ).fetchall() for t in transcripts: @@ -2150,13 +2150,13 @@ class SearchManager: semantic_stats["errors"] += 1 # 索引实体 - entities = conn.execute( - "SELECT id, project_id, name, definition FROM entities WHERE project_id = ?", - (project_id,), + entities = conn.execute( + "SELECT id, project_id, name, definition FROM entities WHERE project_id = ?", + (project_id, ), ).fetchall() for e in entities: - text = f"{e['name']} {e['definition'] or ''}" + text = f"{e['name']} {e['definition'] or ''}" if self.semantic_search.index_embedding(e["id"], "entity", e["project_id"], text): semantic_stats["indexed"] += 1 else: @@ -2166,34 +2166,34 @@ class SearchManager: return {"project_id": project_id, "fulltext": fulltext_stats, "semantic": semantic_stats} - def get_search_stats(self, project_id: str | None = None) -> dict: + def get_search_stats(self, project_id: str | None = None) -> dict: """获取搜索统计信息""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row - where_clause = "WHERE project_id = ?" if project_id else "" - params = [project_id] if project_id else [] + where_clause = "WHERE project_id = ?" if project_id else "" + params = [project_id] if project_id else [] # 全文索引统计 - fulltext_count = conn.execute( + fulltext_count = conn.execute( f"SELECT COUNT(*) as count FROM search_indexes {where_clause}", params ).fetchone()["count"] # 语义索引统计 - semantic_count = conn.execute( + semantic_count = conn.execute( f"SELECT COUNT(*) as count FROM embeddings {where_clause}", params ).fetchone()["count"] # 按类型统计 - type_stats = {} + type_stats = {} if project_id: - rows = conn.execute( + rows = conn.execute( """SELECT content_type, COUNT(*) as count - FROM search_indexes WHERE project_id = ? + FROM search_indexes WHERE project_id = ? GROUP BY content_type""", - (project_id,), + (project_id, ), ).fetchall() - type_stats = {r["content_type"]: r["count"] for r in rows} + type_stats = {r["content_type"]: r["count"] for r in rows} conn.close() @@ -2207,14 +2207,14 @@ class SearchManager: # 单例模式 -_search_manager = None +_search_manager = None -def get_search_manager(db_path: str = "insightflow.db") -> SearchManager: +def get_search_manager(db_path: str = "insightflow.db") -> SearchManager: """获取搜索管理器单例""" global _search_manager if _search_manager is None: - _search_manager = SearchManager(db_path) + _search_manager = SearchManager(db_path) return _search_manager @@ -2222,28 +2222,28 @@ def get_search_manager(db_path: str = "insightflow.db") -> SearchManager: def fulltext_search( - query: str, project_id: str | None = None, limit: int = 20 + query: str, project_id: str | None = None, limit: int = 20 ) -> list[SearchResult]: """全文搜索便捷函数""" - manager = get_search_manager() - return manager.fulltext_search.search(query, project_id, limit=limit) + manager = get_search_manager() + return manager.fulltext_search.search(query, project_id, limit = limit) def semantic_search( - query: str, project_id: str | None = None, top_k: int = 10 + query: str, project_id: str | None = None, top_k: int = 10 ) -> list[SemanticSearchResult]: """语义搜索便捷函数""" - manager = get_search_manager() - return manager.semantic_search.search(query, project_id, top_k=top_k) + manager = get_search_manager() + return manager.semantic_search.search(query, project_id, top_k = top_k) -def find_entity_path(source_id: str, target_id: str, max_depth: int = 5) -> EntityPath | None: +def find_entity_path(source_id: str, target_id: str, max_depth: int = 5) -> EntityPath | None: """查找实体路径便捷函数""" - manager = get_search_manager() + manager = get_search_manager() return manager.path_discovery.find_shortest_path(source_id, target_id, max_depth) def detect_knowledge_gaps(project_id: str) -> list[KnowledgeGap]: """知识缺口检测便捷函数""" - manager = get_search_manager() + manager = get_search_manager() return manager.gap_detection.analyze_project(project_id) diff --git a/backend/security_manager.py b/backend/security_manager.py index aac14d5..3f7161b 100644 --- a/backend/security_manager.py +++ b/backend/security_manager.py @@ -20,54 +20,54 @@ try: from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC - CRYPTO_AVAILABLE = True + CRYPTO_AVAILABLE = True except ImportError: - CRYPTO_AVAILABLE = False + CRYPTO_AVAILABLE = False print("Warning: cryptography not available, encryption features disabled") class AuditActionType(Enum): """审计动作类型""" - CREATE = "create" - READ = "read" - UPDATE = "update" - DELETE = "delete" - LOGIN = "login" - LOGOUT = "logout" - EXPORT = "export" - IMPORT = "import" - SHARE = "share" - PERMISSION_CHANGE = "permission_change" - ENCRYPTION_ENABLE = "encryption_enable" - ENCRYPTION_DISABLE = "encryption_disable" - DATA_MASKING = "data_masking" - API_KEY_CREATE = "api_key_create" - API_KEY_REVOKE = "api_key_revoke" - WORKFLOW_TRIGGER = "workflow_trigger" - WEBHOOK_SEND = "webhook_send" - BOT_MESSAGE = "bot_message" + CREATE = "create" + READ = "read" + UPDATE = "update" + DELETE = "delete" + LOGIN = "login" + LOGOUT = "logout" + EXPORT = "export" + IMPORT = "import" + SHARE = "share" + PERMISSION_CHANGE = "permission_change" + ENCRYPTION_ENABLE = "encryption_enable" + ENCRYPTION_DISABLE = "encryption_disable" + DATA_MASKING = "data_masking" + API_KEY_CREATE = "api_key_create" + API_KEY_REVOKE = "api_key_revoke" + WORKFLOW_TRIGGER = "workflow_trigger" + WEBHOOK_SEND = "webhook_send" + BOT_MESSAGE = "bot_message" class DataSensitivityLevel(Enum): """数据敏感度级别""" - PUBLIC = "public" # 公开 - INTERNAL = "internal" # 内部 - CONFIDENTIAL = "confidential" # 机密 - SECRET = "secret" # 绝密 + PUBLIC = "public" # 公开 + INTERNAL = "internal" # 内部 + CONFIDENTIAL = "confidential" # 机密 + SECRET = "secret" # 绝密 class MaskingRuleType(Enum): """脱敏规则类型""" - PHONE = "phone" # 手机号 - EMAIL = "email" # 邮箱 - ID_CARD = "id_card" # 身份证号 - BANK_CARD = "bank_card" # 银行卡号 - NAME = "name" # 姓名 - ADDRESS = "address" # 地址 - CUSTOM = "custom" # 自定义 + PHONE = "phone" # 手机号 + EMAIL = "email" # 邮箱 + ID_CARD = "id_card" # 身份证号 + BANK_CARD = "bank_card" # 银行卡号 + NAME = "name" # 姓名 + ADDRESS = "address" # 地址 + CUSTOM = "custom" # 自定义 @dataclass @@ -76,17 +76,17 @@ class AuditLog: id: str action_type: str - user_id: str | None = None - user_ip: str | None = None - user_agent: str | None = None - resource_type: str | None = None # project, entity, transcript, etc. - resource_id: str | None = None - action_details: str | None = None # JSON string - before_value: str | None = None - after_value: str | None = None - success: bool = True - error_message: str | None = None - created_at: str = field(default_factory=lambda: datetime.now().isoformat()) + user_id: str | None = None + user_ip: str | None = None + user_agent: str | None = None + resource_type: str | None = None # project, entity, transcript, etc. + resource_id: str | None = None + action_details: str | None = None # JSON string + before_value: str | None = None + after_value: str | None = None + success: bool = True + error_message: str | None = None + created_at: str = field(default_factory = lambda: datetime.now().isoformat()) def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -98,13 +98,13 @@ class EncryptionConfig: id: str project_id: str - is_enabled: bool = False - encryption_type: str = "aes-256-gcm" # aes-256-gcm, chacha20-poly1305 - key_derivation: str = "pbkdf2" # pbkdf2, argon2 - master_key_hash: str | None = None # 主密钥哈希(用于验证) - salt: str | None = None - created_at: str = field(default_factory=lambda: datetime.now().isoformat()) - updated_at: str = field(default_factory=lambda: datetime.now().isoformat()) + is_enabled: bool = False + encryption_type: str = "aes-256-gcm" # aes-256-gcm, chacha20-poly1305 + key_derivation: str = "pbkdf2" # pbkdf2, argon2 + master_key_hash: str | None = None # 主密钥哈希(用于验证) + salt: str | None = None + created_at: str = field(default_factory = lambda: datetime.now().isoformat()) + updated_at: str = field(default_factory = lambda: datetime.now().isoformat()) def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -120,11 +120,11 @@ class MaskingRule: rule_type: str # phone, email, id_card, bank_card, name, address, custom pattern: str # 正则表达式 replacement: str # 替换模板,如 "****" - is_active: bool = True - priority: int = 0 - description: str | None = None - created_at: str = field(default_factory=lambda: datetime.now().isoformat()) - updated_at: str = field(default_factory=lambda: datetime.now().isoformat()) + is_active: bool = True + priority: int = 0 + description: str | None = None + created_at: str = field(default_factory = lambda: datetime.now().isoformat()) + updated_at: str = field(default_factory = lambda: datetime.now().isoformat()) def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -137,16 +137,16 @@ class DataAccessPolicy: id: str project_id: str name: str - description: str | None = None - allowed_users: str | None = None # JSON array of user IDs - allowed_roles: str | None = None # JSON array of roles - allowed_ips: str | None = None # JSON array of IP patterns - time_restrictions: str | None = None # JSON: {"start_time": "09:00", "end_time": "18:00"} - max_access_count: int | None = None # 最大访问次数 - require_approval: bool = False - is_active: bool = True - created_at: str = field(default_factory=lambda: datetime.now().isoformat()) - updated_at: str = field(default_factory=lambda: datetime.now().isoformat()) + description: str | None = None + allowed_users: str | None = None # JSON array of user IDs + allowed_roles: str | None = None # JSON array of roles + allowed_ips: str | None = None # JSON array of IP patterns + time_restrictions: str | None = None # JSON: {"start_time": "09:00", "end_time": "18:00"} + max_access_count: int | None = None # 最大访问次数 + require_approval: bool = False + is_active: bool = True + created_at: str = field(default_factory = lambda: datetime.now().isoformat()) + updated_at: str = field(default_factory = lambda: datetime.now().isoformat()) def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -159,12 +159,12 @@ class AccessRequest: id: str policy_id: str user_id: str - request_reason: str | None = None - status: str = "pending" # pending, approved, rejected, expired - approved_by: str | None = None - approved_at: str | None = None - expires_at: str | None = None - created_at: str = field(default_factory=lambda: datetime.now().isoformat()) + request_reason: str | None = None + status: str = "pending" # pending, approved, rejected, expired + approved_by: str | None = None + approved_at: str | None = None + expires_at: str | None = None + created_at: str = field(default_factory = lambda: datetime.now().isoformat()) def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -174,9 +174,9 @@ class SecurityManager: """安全管理器""" # 预定义脱敏规则 - DEFAULT_MASKING_RULES = { + DEFAULT_MASKING_RULES = { MaskingRuleType.PHONE: {"pattern": r"(\d{3})\d{4}(\d{4})", "replacement": r"\1****\2"}, - MaskingRuleType.EMAIL: {"pattern": r"(\w{1,3})\w+(@\w+\.\w+)", "replacement": r"\1***\2"}, + MaskingRuleType.EMAIL: {"pattern": r"(\w{1, 3})\w+(@\w+\.\w+)", "replacement": r"\1***\2"}, MaskingRuleType.ID_CARD: { "pattern": r"(\d{6})\d{8}(\d{4})", "replacement": r"\1********\2", @@ -190,22 +190,22 @@ class SecurityManager: "replacement": r"\1**", }, MaskingRuleType.ADDRESS: { - "pattern": r"([\u4e00-\u9fa5]{2,})([\u4e00-\u9fa5]+路|街|巷|号)(.+)", + "pattern": r"([\u4e00-\u9fa5]{2, })([\u4e00-\u9fa5]+路|街|巷|号)(.+)", "replacement": r"\1\2***", }, } - def __init__(self, db_path: str = "insightflow.db"): - self.db_path = db_path + def __init__(self, db_path: str = "insightflow.db") -> None: + self.db_path = db_path # 预编译正则缓存 - self._compiled_patterns: dict[str, re.Pattern] = {} - self._local = {} + self._compiled_patterns: dict[str, re.Pattern] = {} + self._local = {} self._init_db() def _init_db(self) -> None: """初始化数据库表""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() # 审计日志表 cursor.execute(""" @@ -332,35 +332,35 @@ class SecurityManager: def log_audit( self, action_type: AuditActionType, - user_id: str | None = None, - user_ip: str | None = None, - user_agent: str | None = None, - resource_type: str | None = None, - resource_id: str | None = None, - action_details: dict | None = None, - before_value: str | None = None, - after_value: str | None = None, - success: bool = True, - error_message: str | None = None, + user_id: str | None = None, + user_ip: str | None = None, + user_agent: str | None = None, + resource_type: str | None = None, + resource_id: str | None = None, + action_details: dict | None = None, + before_value: str | None = None, + after_value: str | None = None, + success: bool = True, + error_message: str | None = None, ) -> AuditLog: """记录审计日志""" - log = AuditLog( - id=self._generate_id(), - action_type=action_type.value, - user_id=user_id, - user_ip=user_ip, - user_agent=user_agent, - resource_type=resource_type, - resource_id=resource_id, - action_details=json.dumps(action_details) if action_details else None, - before_value=before_value, - after_value=after_value, - success=success, - error_message=error_message, + log = AuditLog( + id = self._generate_id(), + action_type = action_type.value, + user_id = user_id, + user_ip = user_ip, + user_agent = user_agent, + resource_type = resource_type, + resource_id = resource_id, + action_details = json.dumps(action_details) if action_details else None, + before_value = before_value, + after_value = after_value, + success = success, + error_message = error_message, ) - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() cursor.execute( """ INSERT INTO audit_logs @@ -391,34 +391,34 @@ class SecurityManager: def get_audit_logs( self, - user_id: str | None = None, - resource_type: str | None = None, - resource_id: str | None = None, - action_type: str | None = None, - start_time: str | None = None, - end_time: str | None = None, - success: bool | None = None, - limit: int = 100, - offset: int = 0, + user_id: str | None = None, + resource_type: str | None = None, + resource_id: str | None = None, + action_type: str | None = None, + start_time: str | None = None, + end_time: str | None = None, + success: bool | None = None, + limit: int = 100, + offset: int = 0, ) -> list[AuditLog]: """查询审计日志""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() - query = "SELECT * FROM audit_logs WHERE 1=1" - params = [] + query = "SELECT * FROM audit_logs WHERE 1 = 1" + params = [] if user_id: - query += " AND user_id = ?" + query += " AND user_id = ?" params.append(user_id) if resource_type: - query += " AND resource_type = ?" + query += " AND resource_type = ?" params.append(resource_type) if resource_id: - query += " AND resource_id = ?" + query += " AND resource_id = ?" params.append(resource_id) if action_type: - query += " AND action_type = ?" + query += " AND action_type = ?" params.append(action_type) if start_time: query += " AND created_at >= ?" @@ -427,36 +427,36 @@ class SecurityManager: query += " AND created_at <= ?" params.append(end_time) if success is not None: - query += " AND success = ?" + query += " AND success = ?" params.append(int(success)) query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() conn.close() - logs = [] - col_names = [desc[0] for desc in cursor.description] if cursor.description else [] + logs = [] + col_names = [desc[0] for desc in cursor.description] if cursor.description else [] if not col_names: return logs for row in rows: - log = AuditLog( - id=row[0], - action_type=row[1], - user_id=row[2], - user_ip=row[3], - user_agent=row[4], - resource_type=row[5], - resource_id=row[6], - action_details=row[7], - before_value=row[8], - after_value=row[9], - success=bool(row[10]), - error_message=row[11], - created_at=row[12], + log = AuditLog( + id = row[0], + action_type = row[1], + user_id = row[2], + user_ip = row[3], + user_agent = row[4], + resource_type = row[5], + resource_id = row[6], + action_details = row[7], + before_value = row[8], + after_value = row[9], + success = bool(row[10]), + error_message = row[11], + created_at = row[12], ) logs.append(log) @@ -464,14 +464,14 @@ class SecurityManager: return logs def get_audit_stats( - self, start_time: str | None = None, end_time: str | None = None + self, start_time: str | None = None, end_time: str | None = None ) -> dict[str, Any]: """获取审计统计""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() - query = "SELECT action_type, success, COUNT(*) FROM audit_logs WHERE 1=1" - params = [] + query = "SELECT action_type, success, COUNT(*) FROM audit_logs WHERE 1 = 1" + params = [] if start_time: query += " AND created_at >= ?" @@ -483,9 +483,9 @@ class SecurityManager: query += " GROUP BY action_type, success" cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() - stats = {"total_actions": 0, "success_count": 0, "failure_count": 0, "action_breakdown": {}} + stats = {"total_actions": 0, "success_count": 0, "failure_count": 0, "action_breakdown": {}} for action_type, success, count in rows: stats["total_actions"] += count @@ -495,7 +495,7 @@ class SecurityManager: stats["failure_count"] += count if action_type not in stats["action_breakdown"]: - stats["action_breakdown"][action_type] = {"success": 0, "failure": 0} + stats["action_breakdown"][action_type] = {"success": 0, "failure": 0} if success: stats["action_breakdown"][action_type]["success"] += count @@ -512,11 +512,11 @@ class SecurityManager: if not CRYPTO_AVAILABLE: raise RuntimeError("cryptography library not available") - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=salt, - iterations=100000, + kdf = PBKDF2HMAC( + algorithm = hashes.SHA256(), + length = 32, + salt = salt, + iterations = 100000, ) return base64.urlsafe_b64encode(kdf.derive(password.encode())) @@ -526,36 +526,36 @@ class SecurityManager: raise RuntimeError("cryptography library not available") # 生成盐值 - salt = secrets.token_hex(16) + salt = secrets.token_hex(16) # 派生密钥并哈希(用于验证) - key = self._derive_key(master_password, salt.encode()) - key_hash = hashlib.sha256(key).hexdigest() + key = self._derive_key(master_password, salt.encode()) + key_hash = hashlib.sha256(key).hexdigest() - config = EncryptionConfig( - id=self._generate_id(), - project_id=project_id, - is_enabled=True, - encryption_type="aes-256-gcm", - key_derivation="pbkdf2", - master_key_hash=key_hash, - salt=salt, + config = EncryptionConfig( + id = self._generate_id(), + project_id = project_id, + is_enabled = True, + encryption_type = "aes-256-gcm", + key_derivation = "pbkdf2", + master_key_hash = key_hash, + salt = salt, ) - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() # 检查是否已存在配置 - cursor.execute("SELECT id FROM encryption_configs WHERE project_id = ?", (project_id,)) - existing = cursor.fetchone() + cursor.execute("SELECT id FROM encryption_configs WHERE project_id = ?", (project_id, )) + existing = cursor.fetchone() if existing: cursor.execute( """ UPDATE encryption_configs - SET is_enabled = 1, encryption_type = ?, key_derivation = ?, - master_key_hash = ?, salt = ?, updated_at = ? - WHERE project_id = ? + SET is_enabled = 1, encryption_type = ?, key_derivation = ?, + master_key_hash = ?, salt = ?, updated_at = ? + WHERE project_id = ? """, ( config.encryption_type, @@ -566,7 +566,7 @@ class SecurityManager: project_id, ), ) - config.id = existing[0] + config.id = existing[0] else: cursor.execute( """ @@ -593,10 +593,10 @@ class SecurityManager: # 记录审计日志 self.log_audit( - action_type=AuditActionType.ENCRYPTION_ENABLE, - resource_type="project", - resource_id=project_id, - action_details={"encryption_type": config.encryption_type}, + action_type = AuditActionType.ENCRYPTION_ENABLE, + resource_type = "project", + resource_id = project_id, + action_details = {"encryption_type": config.encryption_type}, ) return config @@ -607,14 +607,14 @@ class SecurityManager: if not self.verify_encryption_password(project_id, master_password): return False - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() cursor.execute( """ UPDATE encryption_configs - SET is_enabled = 0, updated_at = ? - WHERE project_id = ? + SET is_enabled = 0, updated_at = ? + WHERE project_id = ? """, (datetime.now().isoformat(), project_id), ) @@ -624,9 +624,9 @@ class SecurityManager: # 记录审计日志 self.log_audit( - action_type=AuditActionType.ENCRYPTION_DISABLE, - resource_type="project", - resource_id=project_id, + action_type = AuditActionType.ENCRYPTION_DISABLE, + resource_type = "project", + resource_id = project_id, ) return True @@ -636,60 +636,60 @@ class SecurityManager: if not CRYPTO_AVAILABLE: return False - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() cursor.execute( - "SELECT master_key_hash, salt FROM encryption_configs WHERE project_id = ?", - (project_id,), + "SELECT master_key_hash, salt FROM encryption_configs WHERE project_id = ?", + (project_id, ), ) - row = cursor.fetchone() + row = cursor.fetchone() conn.close() if not row: return False - stored_hash, salt = row - key = self._derive_key(password, salt.encode()) - key_hash = hashlib.sha256(key).hexdigest() + stored_hash, salt = row + key = self._derive_key(password, salt.encode()) + key_hash = hashlib.sha256(key).hexdigest() return key_hash == stored_hash def get_encryption_config(self, project_id: str) -> EncryptionConfig | None: """获取加密配置""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() - cursor.execute("SELECT * FROM encryption_configs WHERE project_id = ?", (project_id,)) - row = cursor.fetchone() + cursor.execute("SELECT * FROM encryption_configs WHERE project_id = ?", (project_id, )) + row = cursor.fetchone() conn.close() if not row: return None return EncryptionConfig( - id=row[0], - project_id=row[1], - is_enabled=bool(row[2]), - encryption_type=row[3], - key_derivation=row[4], - master_key_hash=row[5], - salt=row[6], - created_at=row[7], - updated_at=row[8], + id = row[0], + project_id = row[1], + is_enabled = bool(row[2]), + encryption_type = row[3], + key_derivation = row[4], + master_key_hash = row[5], + salt = row[6], + created_at = row[7], + updated_at = row[8], ) - def encrypt_data(self, data: str, password: str, salt: str | None = None) -> tuple[str, str]: + def encrypt_data(self, data: str, password: str, salt: str | None = None) -> tuple[str, str]: """加密数据""" if not CRYPTO_AVAILABLE: raise RuntimeError("cryptography library not available") if salt is None: - salt = secrets.token_hex(16) + salt = secrets.token_hex(16) - key = self._derive_key(password, salt.encode()) - f = Fernet(key) - encrypted = f.encrypt(data.encode()) + key = self._derive_key(password, salt.encode()) + f = Fernet(key) + encrypted = f.encrypt(data.encode()) return base64.b64encode(encrypted).decode(), salt @@ -698,9 +698,9 @@ class SecurityManager: if not CRYPTO_AVAILABLE: raise RuntimeError("cryptography library not available") - key = self._derive_key(password, salt.encode()) - f = Fernet(key) - decrypted = f.decrypt(base64.b64decode(encrypted_data)) + key = self._derive_key(password, salt.encode()) + f = Fernet(key) + decrypted = f.decrypt(base64.b64decode(encrypted_data)) return decrypted.decode() @@ -711,31 +711,31 @@ class SecurityManager: project_id: str, name: str, rule_type: MaskingRuleType, - pattern: str | None = None, - replacement: str | None = None, - description: str | None = None, - priority: int = 0, + pattern: str | None = None, + replacement: str | None = None, + description: str | None = None, + priority: int = 0, ) -> MaskingRule: """创建脱敏规则""" # 使用预定义规则或自定义规则 if rule_type in self.DEFAULT_MASKING_RULES and not pattern: - default = self.DEFAULT_MASKING_RULES[rule_type] - pattern = default["pattern"] - replacement = replacement or default["replacement"] + default = self.DEFAULT_MASKING_RULES[rule_type] + pattern = default["pattern"] + replacement = replacement or default["replacement"] - rule = MaskingRule( - id=self._generate_id(), - project_id=project_id, - name=name, - rule_type=rule_type.value, - pattern=pattern or "", - replacement=replacement or "****", - description=description, - priority=priority, + rule = MaskingRule( + id = self._generate_id(), + project_id = project_id, + name = name, + rule_type = rule_type.value, + pattern = pattern or "", + replacement = replacement or "****", + description = description, + priority = priority, ) - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() cursor.execute( """ @@ -764,46 +764,46 @@ class SecurityManager: # 记录审计日志 self.log_audit( - action_type=AuditActionType.DATA_MASKING, - resource_type="project", - resource_id=project_id, - action_details={"action": "create_rule", "rule_name": name}, + action_type = AuditActionType.DATA_MASKING, + resource_type = "project", + resource_id = project_id, + action_details = {"action": "create_rule", "rule_name": name}, ) return rule - def get_masking_rules(self, project_id: str, active_only: bool = True) -> list[MaskingRule]: + def get_masking_rules(self, project_id: str, active_only: bool = True) -> list[MaskingRule]: """获取脱敏规则""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() - query = "SELECT * FROM masking_rules WHERE project_id = ?" - params = [project_id] + query = "SELECT * FROM masking_rules WHERE project_id = ?" + params = [project_id] if active_only: - query += " AND is_active = 1" + query += " AND is_active = 1" query += " ORDER BY priority DESC" cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() conn.close() - rules = [] + rules = [] for row in rows: rules.append( MaskingRule( - id=row[0], - project_id=row[1], - name=row[2], - rule_type=row[3], - pattern=row[4], - replacement=row[5], - is_active=bool(row[6]), - priority=row[7], - description=row[8], - created_at=row[9], - updated_at=row[10], + id = row[0], + project_id = row[1], + name = row[2], + rule_type = row[3], + pattern = row[4], + replacement = row[5], + is_active = bool(row[6]), + priority = row[7], + description = row[8], + created_at = row[9], + updated_at = row[10], ) ) @@ -811,24 +811,24 @@ class SecurityManager: def update_masking_rule(self, rule_id: str, **kwargs) -> MaskingRule | None: """更新脱敏规则""" - allowed_fields = ["name", "pattern", "replacement", "is_active", "priority", "description"] + allowed_fields = ["name", "pattern", "replacement", "is_active", "priority", "description"] - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() - set_clauses = [] - params = [] + set_clauses = [] + params = [] for key, value in kwargs.items(): if key in allowed_fields: - set_clauses.append(f"{key} = ?") + set_clauses.append(f"{key} = ?") params.append(int(value) if key == "is_active" else value) if not set_clauses: conn.close() return None - set_clauses.append("updated_at = ?") + set_clauses.append("updated_at = ?") params.append(datetime.now().isoformat()) params.append(rule_id) @@ -836,7 +836,7 @@ class SecurityManager: f""" UPDATE masking_rules SET {", ".join(set_clauses)} - WHERE id = ? + WHERE id = ? """, params, ) @@ -845,52 +845,52 @@ class SecurityManager: conn.close() # 获取更新后的规则 - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute("SELECT * FROM masking_rules WHERE id = ?", (rule_id,)) - row = cursor.fetchone() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute("SELECT * FROM masking_rules WHERE id = ?", (rule_id, )) + row = cursor.fetchone() conn.close() if not row: return None return MaskingRule( - id=row[0], - project_id=row[1], - name=row[2], - rule_type=row[3], - pattern=row[4], - replacement=row[5], - is_active=bool(row[6]), - priority=row[7], - description=row[8], - created_at=row[9], - updated_at=row[10], + id = row[0], + project_id = row[1], + name = row[2], + rule_type = row[3], + pattern = row[4], + replacement = row[5], + is_active = bool(row[6]), + priority = row[7], + description = row[8], + created_at = row[9], + updated_at = row[10], ) def delete_masking_rule(self, rule_id: str) -> bool: """删除脱敏规则""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() - cursor.execute("DELETE FROM masking_rules WHERE id = ?", (rule_id,)) + cursor.execute("DELETE FROM masking_rules WHERE id = ?", (rule_id, )) - success = cursor.rowcount > 0 + success = cursor.rowcount > 0 conn.commit() conn.close() return success def apply_masking( - self, text: str, project_id: str, rule_types: list[MaskingRuleType] | None = None + self, text: str, project_id: str, rule_types: list[MaskingRuleType] | None = None ) -> str: """应用脱敏规则到文本""" - rules = self.get_masking_rules(project_id) + rules = self.get_masking_rules(project_id) if not rules: return text - masked_text = text + masked_text = text for rule in rules: # 如果指定了规则类型,只应用指定类型的规则 @@ -898,7 +898,7 @@ class SecurityManager: continue try: - masked_text = re.sub(rule.pattern, rule.replacement, masked_text) + masked_text = re.sub(rule.pattern, rule.replacement, masked_text) except re.error: # 忽略无效的正则表达式 continue @@ -909,14 +909,14 @@ class SecurityManager: self, entity_data: dict[str, Any], project_id: str ) -> dict[str, Any]: """对实体数据应用脱敏""" - masked_data = entity_data.copy() + masked_data = entity_data.copy() # 对可能包含敏感信息的字段进行脱敏 - sensitive_fields = ["name", "definition", "description", "value"] + sensitive_fields = ["name", "definition", "description", "value"] for f in sensitive_fields: if f in masked_data and isinstance(masked_data[f], str): - masked_data[f] = self.apply_masking(masked_data[f], project_id) + masked_data[f] = self.apply_masking(masked_data[f], project_id) return masked_data @@ -926,30 +926,30 @@ class SecurityManager: self, project_id: str, name: str, - description: str | None = None, - allowed_users: list[str] | None = None, - allowed_roles: list[str] | None = None, - allowed_ips: list[str] | None = None, - time_restrictions: dict | None = None, - max_access_count: int | None = None, - require_approval: bool = False, + description: str | None = None, + allowed_users: list[str] | None = None, + allowed_roles: list[str] | None = None, + allowed_ips: list[str] | None = None, + time_restrictions: dict | None = None, + max_access_count: int | None = None, + require_approval: bool = False, ) -> DataAccessPolicy: """创建数据访问策略""" - policy = DataAccessPolicy( - id=self._generate_id(), - project_id=project_id, - name=name, - description=description, - allowed_users=json.dumps(allowed_users) if allowed_users else None, - allowed_roles=json.dumps(allowed_roles) if allowed_roles else None, - allowed_ips=json.dumps(allowed_ips) if allowed_ips else None, - time_restrictions=json.dumps(time_restrictions) if time_restrictions else None, - max_access_count=max_access_count, - require_approval=require_approval, + policy = DataAccessPolicy( + id = self._generate_id(), + project_id = project_id, + name = name, + description = description, + allowed_users = json.dumps(allowed_users) if allowed_users else None, + allowed_roles = json.dumps(allowed_roles) if allowed_roles else None, + allowed_ips = json.dumps(allowed_ips) if allowed_ips else None, + time_restrictions = json.dumps(time_restrictions) if time_restrictions else None, + max_access_count = max_access_count, + require_approval = require_approval, ) - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() cursor.execute( """ @@ -982,100 +982,100 @@ class SecurityManager: return policy def get_access_policies( - self, project_id: str, active_only: bool = True + self, project_id: str, active_only: bool = True ) -> list[DataAccessPolicy]: """获取数据访问策略""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() - query = "SELECT * FROM data_access_policies WHERE project_id = ?" - params = [project_id] + query = "SELECT * FROM data_access_policies WHERE project_id = ?" + params = [project_id] if active_only: - query += " AND is_active = 1" + query += " AND is_active = 1" cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() conn.close() - policies = [] + policies = [] for row in rows: policies.append( DataAccessPolicy( - id=row[0], - project_id=row[1], - name=row[2], - description=row[3], - allowed_users=row[4], - allowed_roles=row[5], - allowed_ips=row[6], - time_restrictions=row[7], - max_access_count=row[8], - require_approval=bool(row[9]), - is_active=bool(row[10]), - created_at=row[11], - updated_at=row[12], + id = row[0], + project_id = row[1], + name = row[2], + description = row[3], + allowed_users = row[4], + allowed_roles = row[5], + allowed_ips = row[6], + time_restrictions = row[7], + max_access_count = row[8], + require_approval = bool(row[9]), + is_active = bool(row[10]), + created_at = row[11], + updated_at = row[12], ) ) return policies def check_access_permission( - self, policy_id: str, user_id: str, user_ip: str | None = None + self, policy_id: str, user_id: str, user_ip: str | None = None ) -> tuple[bool, str | None]: """检查访问权限""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() cursor.execute( - "SELECT * FROM data_access_policies WHERE id = ? AND is_active = 1", (policy_id,) + "SELECT * FROM data_access_policies WHERE id = ? AND is_active = 1", (policy_id, ) ) - row = cursor.fetchone() + row = cursor.fetchone() conn.close() if not row: return False, "Policy not found or inactive" - policy = DataAccessPolicy( - id=row[0], - project_id=row[1], - name=row[2], - description=row[3], - allowed_users=row[4], - allowed_roles=row[5], - allowed_ips=row[6], - time_restrictions=row[7], - max_access_count=row[8], - require_approval=bool(row[9]), - is_active=bool(row[10]), - created_at=row[11], - updated_at=row[12], + policy = DataAccessPolicy( + id = row[0], + project_id = row[1], + name = row[2], + description = row[3], + allowed_users = row[4], + allowed_roles = row[5], + allowed_ips = row[6], + time_restrictions = row[7], + max_access_count = row[8], + require_approval = bool(row[9]), + is_active = bool(row[10]), + created_at = row[11], + updated_at = row[12], ) # 检查用户白名单 if policy.allowed_users: - allowed = json.loads(policy.allowed_users) + allowed = json.loads(policy.allowed_users) if user_id not in allowed: return False, "User not in allowed list" # 检查IP白名单 if policy.allowed_ips and user_ip: - allowed_ips = json.loads(policy.allowed_ips) - ip_allowed = False + allowed_ips = json.loads(policy.allowed_ips) + ip_allowed = False for ip_pattern in allowed_ips: if self._match_ip_pattern(user_ip, ip_pattern): - ip_allowed = True + ip_allowed = True break if not ip_allowed: return False, "IP not in allowed list" # 检查时间限制 if policy.time_restrictions: - restrictions = json.loads(policy.time_restrictions) - now = datetime.now() + restrictions = json.loads(policy.time_restrictions) + now = datetime.now() if "start_time" in restrictions and "end_time" in restrictions: - current_time = now.strftime("%H:%M") + current_time = now.strftime("%H:%M") if not (restrictions["start_time"] <= current_time <= restrictions["end_time"]): return False, "Access not allowed at this time" @@ -1086,19 +1086,19 @@ class SecurityManager: # 检查是否需要审批 if policy.require_approval: # 检查是否有有效的访问请求 - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() cursor.execute( """ SELECT * FROM access_requests - WHERE policy_id = ? AND user_id = ? AND status = 'approved' + WHERE policy_id = ? AND user_id = ? AND status = 'approved' AND (expires_at IS NULL OR expires_at > ?) """, (policy_id, user_id, datetime.now().isoformat()), ) - request = cursor.fetchone() + request = cursor.fetchone() conn.close() if not request: @@ -1113,7 +1113,7 @@ class SecurityManager: try: if "/" in pattern: # CIDR 表示法 - network = ipaddress.ip_network(pattern, strict=False) + network = ipaddress.ip_network(pattern, strict = False) return ipaddress.ip_address(ip) in network else: # 精确匹配 @@ -1125,20 +1125,20 @@ class SecurityManager: self, policy_id: str, user_id: str, - request_reason: str | None = None, - expires_hours: int = 24, + request_reason: str | None = None, + expires_hours: int = 24, ) -> AccessRequest: """创建访问请求""" - request = AccessRequest( - id=self._generate_id(), - policy_id=policy_id, - user_id=user_id, - request_reason=request_reason, - expires_at=(datetime.now() + timedelta(hours=expires_hours)).isoformat(), + request = AccessRequest( + id = self._generate_id(), + policy_id = policy_id, + user_id = user_id, + request_reason = request_reason, + expires_at = (datetime.now() + timedelta(hours = expires_hours)).isoformat(), ) - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() cursor.execute( """ @@ -1163,20 +1163,20 @@ class SecurityManager: return request def approve_access_request( - self, request_id: str, approved_by: str, expires_hours: int = 24 + self, request_id: str, approved_by: str, expires_hours: int = 24 ) -> AccessRequest | None: """批准访问请求""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() - expires_at = (datetime.now() + timedelta(hours=expires_hours)).isoformat() - approved_at = datetime.now().isoformat() + expires_at = (datetime.now() + timedelta(hours = expires_hours)).isoformat() + approved_at = datetime.now().isoformat() cursor.execute( """ UPDATE access_requests - SET status = 'approved', approved_by = ?, approved_at = ?, expires_at = ? - WHERE id = ? + SET status = 'approved', approved_by = ?, approved_at = ?, expires_at = ? + WHERE id = ? """, (approved_by, approved_at, expires_at, request_id), ) @@ -1184,68 +1184,68 @@ class SecurityManager: conn.commit() # 获取更新后的请求 - cursor.execute("SELECT * FROM access_requests WHERE id = ?", (request_id,)) - row = cursor.fetchone() + cursor.execute("SELECT * FROM access_requests WHERE id = ?", (request_id, )) + row = cursor.fetchone() conn.close() if not row: return None return AccessRequest( - id=row[0], - policy_id=row[1], - user_id=row[2], - request_reason=row[3], - status=row[4], - approved_by=row[5], - approved_at=row[6], - expires_at=row[7], - created_at=row[8], + id = row[0], + policy_id = row[1], + user_id = row[2], + request_reason = row[3], + status = row[4], + approved_by = row[5], + approved_at = row[6], + expires_at = row[7], + created_at = row[8], ) def reject_access_request(self, request_id: str, rejected_by: str) -> AccessRequest | None: """拒绝访问请求""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() cursor.execute( """ UPDATE access_requests - SET status = 'rejected', approved_by = ? - WHERE id = ? + SET status = 'rejected', approved_by = ? + WHERE id = ? """, (rejected_by, request_id), ) conn.commit() - cursor.execute("SELECT * FROM access_requests WHERE id = ?", (request_id,)) - row = cursor.fetchone() + cursor.execute("SELECT * FROM access_requests WHERE id = ?", (request_id, )) + row = cursor.fetchone() conn.close() if not row: return None return AccessRequest( - id=row[0], - policy_id=row[1], - user_id=row[2], - request_reason=row[3], - status=row[4], - approved_by=row[5], - approved_at=row[6], - expires_at=row[7], - created_at=row[8], + id = row[0], + policy_id = row[1], + user_id = row[2], + request_reason = row[3], + status = row[4], + approved_by = row[5], + approved_at = row[6], + expires_at = row[7], + created_at = row[8], ) # 全局安全管理器实例 -_security_manager = None +_security_manager = None -def get_security_manager(db_path: str = "insightflow.db") -> SecurityManager: +def get_security_manager(db_path: str = "insightflow.db") -> SecurityManager: """获取安全管理器实例""" global _security_manager if _security_manager is None: - _security_manager = SecurityManager(db_path) + _security_manager = SecurityManager(db_path) return _security_manager diff --git a/backend/subscription_manager.py b/backend/subscription_manager.py index 166febf..87f0f89 100644 --- a/backend/subscription_manager.py +++ b/backend/subscription_manager.py @@ -19,59 +19,59 @@ from datetime import datetime, timedelta from enum import StrEnum from typing import Any -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) class SubscriptionStatus(StrEnum): """订阅状态""" - ACTIVE = "active" # 活跃 - CANCELLED = "cancelled" # 已取消 - EXPIRED = "expired" # 已过期 - PAST_DUE = "past_due" # 逾期 - TRIAL = "trial" # 试用中 - PENDING = "pending" # 待支付 + ACTIVE = "active" # 活跃 + CANCELLED = "cancelled" # 已取消 + EXPIRED = "expired" # 已过期 + PAST_DUE = "past_due" # 逾期 + TRIAL = "trial" # 试用中 + PENDING = "pending" # 待支付 class PaymentProvider(StrEnum): """支付提供商""" - STRIPE = "stripe" # Stripe - ALIPAY = "alipay" # 支付宝 - WECHAT = "wechat" # 微信支付 - BANK_TRANSFER = "bank_transfer" # 银行转账 + STRIPE = "stripe" # Stripe + ALIPAY = "alipay" # 支付宝 + WECHAT = "wechat" # 微信支付 + BANK_TRANSFER = "bank_transfer" # 银行转账 class PaymentStatus(StrEnum): """支付状态""" - PENDING = "pending" # 待支付 - PROCESSING = "processing" # 处理中 - COMPLETED = "completed" # 已完成 - FAILED = "failed" # 失败 - REFUNDED = "refunded" # 已退款 - PARTIAL_REFUNDED = "partial_refunded" # 部分退款 + PENDING = "pending" # 待支付 + PROCESSING = "processing" # 处理中 + COMPLETED = "completed" # 已完成 + FAILED = "failed" # 失败 + REFUNDED = "refunded" # 已退款 + PARTIAL_REFUNDED = "partial_refunded" # 部分退款 class InvoiceStatus(StrEnum): """发票状态""" - DRAFT = "draft" # 草稿 - ISSUED = "issued" # 已开具 - PAID = "paid" # 已支付 - OVERDUE = "overdue" # 逾期 - VOID = "void" # 作废 - CREDIT_NOTE = "credit_note" # 贷项通知单 + DRAFT = "draft" # 草稿 + ISSUED = "issued" # 已开具 + PAID = "paid" # 已支付 + OVERDUE = "overdue" # 逾期 + VOID = "void" # 作废 + CREDIT_NOTE = "credit_note" # 贷项通知单 class RefundStatus(StrEnum): """退款状态""" - PENDING = "pending" # 待处理 - APPROVED = "approved" # 已批准 - REJECTED = "rejected" # 已拒绝 - COMPLETED = "completed" # 已完成 - FAILED = "failed" # 失败 + PENDING = "pending" # 待处理 + APPROVED = "approved" # 已批准 + REJECTED = "rejected" # 已拒绝 + COMPLETED = "completed" # 已完成 + FAILED = "failed" # 失败 @dataclass @@ -218,7 +218,7 @@ class SubscriptionManager: """订阅与计费管理器""" # 默认订阅计划配置 - DEFAULT_PLANS = { + DEFAULT_PLANS = { "free": { "name": "Free", "tier": "free", @@ -298,7 +298,7 @@ class SubscriptionManager: } # 按量计费单价(CNY) - USAGE_PRICING = { + USAGE_PRICING = { "transcription": { "unit": "minute", "price": 0.5, @@ -313,22 +313,22 @@ class SubscriptionManager: "export": {"unit": "page", "price": 0.1, "free_quota": 100}, # 0.1元/页(PDF导出) } - def __init__(self, db_path: str = "insightflow.db"): - self.db_path = db_path + def __init__(self, db_path: str = "insightflow.db") -> None: + self.db_path = db_path self._init_db() self._init_default_plans() def _get_connection(self) -> sqlite3.Connection: """获取数据库连接""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn def _init_db(self) -> None: """初始化数据库表""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() # 订阅计划表 cursor.execute(""" @@ -528,9 +528,9 @@ class SubscriptionManager: def _init_default_plans(self) -> None: """初始化默认订阅计划""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() for tier, plan_data in self.DEFAULT_PLANS.items(): cursor.execute( @@ -569,11 +569,11 @@ class SubscriptionManager: def get_plan(self, plan_id: str) -> SubscriptionPlan | None: """获取订阅计划""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM subscription_plans WHERE id = ?", (plan_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM subscription_plans WHERE id = ?", (plan_id, )) + row = cursor.fetchone() if row: return self._row_to_plan(row) @@ -584,13 +584,13 @@ class SubscriptionManager: def get_plan_by_tier(self, tier: str) -> SubscriptionPlan | None: """通过层级获取订阅计划""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( - "SELECT * FROM subscription_plans WHERE tier = ? AND is_active = 1", (tier,) + "SELECT * FROM subscription_plans WHERE tier = ? AND is_active = 1", (tier, ) ) - row = cursor.fetchone() + row = cursor.fetchone() if row: return self._row_to_plan(row) @@ -599,20 +599,20 @@ class SubscriptionManager: finally: conn.close() - def list_plans(self, include_inactive: bool = False) -> list[SubscriptionPlan]: + def list_plans(self, include_inactive: bool = False) -> list[SubscriptionPlan]: """列出所有订阅计划""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() if include_inactive: cursor.execute("SELECT * FROM subscription_plans ORDER BY price_monthly") else: cursor.execute( - "SELECT * FROM subscription_plans WHERE is_active = 1 ORDER BY price_monthly" + "SELECT * FROM subscription_plans WHERE is_active = 1 ORDER BY price_monthly" ) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_plan(row) for row in rows] finally: @@ -625,32 +625,32 @@ class SubscriptionManager: description: str, price_monthly: float, price_yearly: float, - currency: str = "CNY", - features: list[str] = None, - limits: dict[str, Any] = None, + currency: str = "CNY", + features: list[str] = None, + limits: dict[str, Any] = None, ) -> SubscriptionPlan: """创建新订阅计划""" - conn = self._get_connection() + conn = self._get_connection() try: - plan_id = str(uuid.uuid4()) + plan_id = str(uuid.uuid4()) - plan = SubscriptionPlan( - id=plan_id, - name=name, - tier=tier, - description=description, - price_monthly=price_monthly, - price_yearly=price_yearly, - currency=currency, - features=features or [], - limits=limits or {}, - is_active=True, - created_at=datetime.now(), - updated_at=datetime.now(), - metadata={}, + plan = SubscriptionPlan( + id = plan_id, + name = name, + tier = tier, + description = description, + price_monthly = price_monthly, + price_yearly = price_yearly, + currency = currency, + features = features or [], + limits = limits or {}, + is_active = True, + created_at = datetime.now(), + updated_at = datetime.now(), + metadata = {}, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO subscription_plans @@ -688,16 +688,16 @@ class SubscriptionManager: def update_plan(self, plan_id: str, **kwargs) -> SubscriptionPlan | None: """更新订阅计划""" - conn = self._get_connection() + conn = self._get_connection() try: - plan = self.get_plan(plan_id) + plan = self.get_plan(plan_id) if not plan: return None - updates = [] - params = [] + updates = [] + params = [] - allowed_fields = [ + allowed_fields = [ "name", "description", "price_monthly", @@ -710,7 +710,7 @@ class SubscriptionManager: for key, value in kwargs.items(): if key in allowed_fields: - updates.append(f"{key} = ?") + updates.append(f"{key} = ?") if key in ["features", "limits"]: params.append(json.dumps(value) if value else "{}") elif key == "is_active": @@ -721,15 +721,15 @@ class SubscriptionManager: if not updates: return plan - updates.append("updated_at = ?") + updates.append("updated_at = ?") params.append(datetime.now()) params.append(plan_id) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( f""" UPDATE subscription_plans SET {", ".join(updates)} - WHERE id = ? + WHERE id = ? """, params, ) @@ -746,67 +746,67 @@ class SubscriptionManager: self, tenant_id: str, plan_id: str, - payment_provider: str | None = None, - trial_days: int = 0, - billing_cycle: str = "monthly", + payment_provider: str | None = None, + trial_days: int = 0, + billing_cycle: str = "monthly", ) -> Subscription: """创建新订阅""" - conn = self._get_connection() + conn = self._get_connection() try: # 检查是否已有活跃订阅 - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ SELECT * FROM subscriptions - WHERE tenant_id = ? AND status IN ('active', 'trial', 'pending') + WHERE tenant_id = ? AND status IN ('active', 'trial', 'pending') """, - (tenant_id,), + (tenant_id, ), ) - existing = cursor.fetchone() + existing = cursor.fetchone() if existing: raise ValueError(f"Tenant {tenant_id} already has an active subscription") # 获取计划信息 - plan = self.get_plan(plan_id) + plan = self.get_plan(plan_id) if not plan: raise ValueError(f"Plan {plan_id} not found") - subscription_id = str(uuid.uuid4()) - now = datetime.now() + subscription_id = str(uuid.uuid4()) + now = datetime.now() # 计算周期 if billing_cycle == "yearly": - period_end = now + timedelta(days=365) + period_end = now + timedelta(days = 365) else: - period_end = now + timedelta(days=30) + period_end = now + timedelta(days = 30) # 试用处理 - trial_start = None - trial_end = None + trial_start = None + trial_end = None if trial_days > 0: - trial_start = now - trial_end = now + timedelta(days=trial_days) - status = SubscriptionStatus.TRIAL.value + trial_start = now + trial_end = now + timedelta(days = trial_days) + status = SubscriptionStatus.TRIAL.value else: - status = SubscriptionStatus.PENDING.value + status = SubscriptionStatus.PENDING.value - subscription = Subscription( - id=subscription_id, - tenant_id=tenant_id, - plan_id=plan_id, - status=status, - current_period_start=now, - current_period_end=period_end, - cancel_at_period_end=False, - canceled_at=None, - trial_start=trial_start, - trial_end=trial_end, - payment_provider=payment_provider, - provider_subscription_id=None, - created_at=now, - updated_at=now, - metadata={"billing_cycle": billing_cycle}, + subscription = Subscription( + id = subscription_id, + tenant_id = tenant_id, + plan_id = plan_id, + status = status, + current_period_start = now, + current_period_end = period_end, + cancel_at_period_end = False, + canceled_at = None, + trial_start = trial_start, + trial_end = trial_end, + payment_provider = payment_provider, + provider_subscription_id = None, + created_at = now, + updated_at = now, + metadata = {"billing_cycle": billing_cycle}, ) cursor.execute( @@ -837,7 +837,7 @@ class SubscriptionManager: ) # 创建发票 - amount = plan.price_yearly if billing_cycle == "yearly" else plan.price_monthly + amount = plan.price_yearly if billing_cycle == "yearly" else plan.price_monthly if amount > 0 and trial_days == 0: self._create_invoice_internal( conn, @@ -875,11 +875,11 @@ class SubscriptionManager: def get_subscription(self, subscription_id: str) -> Subscription | None: """获取订阅信息""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM subscriptions WHERE id = ?", (subscription_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM subscriptions WHERE id = ?", (subscription_id, )) + row = cursor.fetchone() if row: return self._row_to_subscription(row) @@ -890,18 +890,18 @@ class SubscriptionManager: def get_tenant_subscription(self, tenant_id: str) -> Subscription | None: """获取租户的当前订阅""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ SELECT * FROM subscriptions - WHERE tenant_id = ? AND status IN ('active', 'trial', 'past_due', 'pending') + WHERE tenant_id = ? AND status IN ('active', 'trial', 'past_due', 'pending') ORDER BY created_at DESC LIMIT 1 """, - (tenant_id,), + (tenant_id, ), ) - row = cursor.fetchone() + row = cursor.fetchone() if row: return self._row_to_subscription(row) @@ -912,16 +912,16 @@ class SubscriptionManager: def update_subscription(self, subscription_id: str, **kwargs) -> Subscription | None: """更新订阅""" - conn = self._get_connection() + conn = self._get_connection() try: - subscription = self.get_subscription(subscription_id) + subscription = self.get_subscription(subscription_id) if not subscription: return None - updates = [] - params = [] + updates = [] + params = [] - allowed_fields = [ + allowed_fields = [ "status", "current_period_start", "current_period_end", @@ -934,7 +934,7 @@ class SubscriptionManager: for key, value in kwargs.items(): if key in allowed_fields: - updates.append(f"{key} = ?") + updates.append(f"{key} = ?") if key == "cancel_at_period_end": params.append(int(value)) else: @@ -943,15 +943,15 @@ class SubscriptionManager: if not updates: return subscription - updates.append("updated_at = ?") + updates.append("updated_at = ?") params.append(datetime.now()) params.append(subscription_id) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( f""" UPDATE subscriptions SET {", ".join(updates)} - WHERE id = ? + WHERE id = ? """, params, ) @@ -963,36 +963,36 @@ class SubscriptionManager: conn.close() def cancel_subscription( - self, subscription_id: str, at_period_end: bool = True + self, subscription_id: str, at_period_end: bool = True ) -> Subscription | None: """取消订阅""" - conn = self._get_connection() + conn = self._get_connection() try: - subscription = self.get_subscription(subscription_id) + subscription = self.get_subscription(subscription_id) if not subscription: return None - now = datetime.now() + now = datetime.now() if at_period_end: # 在周期结束时取消 - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE subscriptions - SET cancel_at_period_end = 1, canceled_at = ?, updated_at = ? - WHERE id = ? + SET cancel_at_period_end = 1, canceled_at = ?, updated_at = ? + WHERE id = ? """, (now, now, subscription_id), ) else: # 立即取消 - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE subscriptions - SET status = 'cancelled', canceled_at = ?, updated_at = ? - WHERE id = ? + SET status = 'cancelled', canceled_at = ?, updated_at = ? + WHERE id = ? """, (now, now, subscription_id), ) @@ -1017,34 +1017,34 @@ class SubscriptionManager: conn.close() def change_plan( - self, subscription_id: str, new_plan_id: str, prorate: bool = True + self, subscription_id: str, new_plan_id: str, prorate: bool = True ) -> Subscription | None: """更改订阅计划""" - conn = self._get_connection() + conn = self._get_connection() try: - subscription = self.get_subscription(subscription_id) + subscription = self.get_subscription(subscription_id) if not subscription: return None - old_plan = self.get_plan(subscription.plan_id) - new_plan = self.get_plan(new_plan_id) + old_plan = self.get_plan(subscription.plan_id) + new_plan = self.get_plan(new_plan_id) if not new_plan: raise ValueError(f"Plan {new_plan_id} not found") - now = datetime.now() + now = datetime.now() # 按比例计算差价(简化实现) if prorate and old_plan: # 这里应该实现实际的按比例计算逻辑 pass - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE subscriptions - SET plan_id = ?, updated_at = ? - WHERE id = ? + SET plan_id = ?, updated_at = ? + WHERE id = ? """, (new_plan_id, now, subscription_id), ) @@ -1076,29 +1076,29 @@ class SubscriptionManager: resource_type: str, quantity: float, unit: str, - description: str | None = None, - metadata: dict | None = None, + description: str | None = None, + metadata: dict | None = None, ) -> UsageRecord: """记录用量""" - conn = self._get_connection() + conn = self._get_connection() try: # 计算费用 - cost = self._calculate_usage_cost(resource_type, quantity) + cost = self._calculate_usage_cost(resource_type, quantity) - record_id = str(uuid.uuid4()) - record = UsageRecord( - id=record_id, - tenant_id=tenant_id, - resource_type=resource_type, - quantity=quantity, - unit=unit, - recorded_at=datetime.now(), - cost=cost, - description=description, - metadata=metadata or {}, + record_id = str(uuid.uuid4()) + record = UsageRecord( + id = record_id, + tenant_id = tenant_id, + resource_type = resource_type, + quantity = quantity, + unit = unit, + recorded_at = datetime.now(), + cost = cost, + description = description, + metadata = metadata or {}, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO usage_records @@ -1125,23 +1125,23 @@ class SubscriptionManager: conn.close() def get_usage_summary( - self, tenant_id: str, start_date: datetime | None = None, end_date: datetime | None = None + self, tenant_id: str, start_date: datetime | None = None, end_date: datetime | None = None ) -> dict[str, Any]: """获取用量汇总""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() - query = """ + query = """ SELECT resource_type, SUM(quantity) as total_quantity, SUM(cost) as total_cost, COUNT(*) as record_count FROM usage_records - WHERE tenant_id = ? + WHERE tenant_id = ? """ - params = [tenant_id] + params = [tenant_id] if start_date: query += " AND recorded_at >= ?" @@ -1153,13 +1153,13 @@ class SubscriptionManager: query += " GROUP BY resource_type" cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() - summary = {} - total_cost = 0 + summary = {} + total_cost = 0 for row in rows: - summary[row["resource_type"]] = { + summary[row["resource_type"]] = { "quantity": row["total_quantity"], "cost": row["total_cost"], "records": row["record_count"], @@ -1181,12 +1181,12 @@ class SubscriptionManager: def _calculate_usage_cost(self, resource_type: str, quantity: float) -> float: """计算用量费用""" - pricing = self.USAGE_PRICING.get(resource_type) + pricing = self.USAGE_PRICING.get(resource_type) if not pricing: return 0.0 # 扣除免费额度 - chargeable = max(0, quantity - pricing.get("free_quota", 0)) + chargeable = max(0, quantity - pricing.get("free_quota", 0)) # 计算费用 if pricing["unit"] == "1000_calls": @@ -1202,37 +1202,37 @@ class SubscriptionManager: amount: float, currency: str, provider: str, - subscription_id: str | None = None, - invoice_id: str | None = None, - payment_method: str | None = None, - payment_details: dict | None = None, + subscription_id: str | None = None, + invoice_id: str | None = None, + payment_method: str | None = None, + payment_details: dict | None = None, ) -> Payment: """创建支付记录""" - conn = self._get_connection() + conn = self._get_connection() try: - payment_id = str(uuid.uuid4()) - now = datetime.now() + payment_id = str(uuid.uuid4()) + now = datetime.now() - payment = Payment( - id=payment_id, - tenant_id=tenant_id, - subscription_id=subscription_id, - invoice_id=invoice_id, - amount=amount, - currency=currency, - provider=provider, - provider_payment_id=None, - status=PaymentStatus.PENDING.value, - payment_method=payment_method, - payment_details=payment_details or {}, - paid_at=None, - failed_at=None, - failure_reason=None, - created_at=now, - updated_at=now, + payment = Payment( + id = payment_id, + tenant_id = tenant_id, + subscription_id = subscription_id, + invoice_id = invoice_id, + amount = amount, + currency = currency, + provider = provider, + provider_payment_id = None, + status = PaymentStatus.PENDING.value, + payment_method = payment_method, + payment_details = payment_details or {}, + paid_at = None, + failed_at = None, + failure_reason = None, + created_at = now, + updated_at = now, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO payments @@ -1268,23 +1268,23 @@ class SubscriptionManager: conn.close() def confirm_payment( - self, payment_id: str, provider_payment_id: str | None = None + self, payment_id: str, provider_payment_id: str | None = None ) -> Payment | None: """确认支付完成""" - conn = self._get_connection() + conn = self._get_connection() try: - payment = self._get_payment_internal(conn, payment_id) + payment = self._get_payment_internal(conn, payment_id) if not payment: return None - now = datetime.now() + now = datetime.now() - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE payments - SET status = 'completed', provider_payment_id = ?, paid_at = ?, updated_at = ? - WHERE id = ? + SET status = 'completed', provider_payment_id = ?, paid_at = ?, updated_at = ? + WHERE id = ? """, (provider_payment_id, now, now, payment_id), ) @@ -1294,8 +1294,8 @@ class SubscriptionManager: cursor.execute( """ UPDATE invoices - SET status = 'paid', amount_paid = amount_due, paid_at = ? - WHERE id = ? + SET status = 'paid', amount_paid = amount_due, paid_at = ? + WHERE id = ? """, (now, payment.invoice_id), ) @@ -1305,8 +1305,8 @@ class SubscriptionManager: cursor.execute( """ UPDATE subscriptions - SET status = 'active', updated_at = ? - WHERE id = ? AND status = 'pending' + SET status = 'active', updated_at = ? + WHERE id = ? AND status = 'pending' """, (now, payment.subscription_id), ) @@ -1332,16 +1332,16 @@ class SubscriptionManager: def fail_payment(self, payment_id: str, reason: str) -> Payment | None: """标记支付失败""" - conn = self._get_connection() + conn = self._get_connection() try: - now = datetime.now() + now = datetime.now() - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE payments - SET status = 'failed', failure_reason = ?, failed_at = ?, updated_at = ? - WHERE id = ? + SET status = 'failed', failure_reason = ?, failed_at = ?, updated_at = ? + WHERE id = ? """, (reason, now, now, payment_id), ) @@ -1354,32 +1354,32 @@ class SubscriptionManager: def get_payment(self, payment_id: str) -> Payment | None: """获取支付记录""" - conn = self._get_connection() + conn = self._get_connection() try: return self._get_payment_internal(conn, payment_id) finally: conn.close() def list_payments( - self, tenant_id: str, status: str | None = None, limit: int = 100, offset: int = 0 + self, tenant_id: str, status: str | None = None, limit: int = 100, offset: int = 0 ) -> list[Payment]: """列出支付记录""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() - query = "SELECT * FROM payments WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM payments WHERE tenant_id = ?" + params = [tenant_id] if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status) query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_payment(row) for row in rows] @@ -1388,9 +1388,9 @@ class SubscriptionManager: def _get_payment_internal(self, conn: sqlite3.Connection, payment_id: str) -> Payment | None: """内部方法:获取支付记录""" - cursor = conn.cursor() - cursor.execute("SELECT * FROM payments WHERE id = ?", (payment_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM payments WHERE id = ?", (payment_id, )) + row = cursor.fetchone() if row: return self._row_to_payment(row) @@ -1408,36 +1408,36 @@ class SubscriptionManager: period_start: datetime, period_end: datetime, description: str, - line_items: list[dict] | None = None, + line_items: list[dict] | None = None, ) -> Invoice: """内部方法:创建发票""" - invoice_id = str(uuid.uuid4()) - invoice_number = self._generate_invoice_number() - now = datetime.now() - due_date = now + timedelta(days=7) # 7天付款期限 + invoice_id = str(uuid.uuid4()) + invoice_number = self._generate_invoice_number() + now = datetime.now() + due_date = now + timedelta(days = 7) # 7天付款期限 - invoice = Invoice( - id=invoice_id, - tenant_id=tenant_id, - subscription_id=subscription_id, - invoice_number=invoice_number, - status=InvoiceStatus.DRAFT.value, - amount_due=amount, - amount_paid=0, - currency=currency, - period_start=period_start, - period_end=period_end, - description=description, - line_items=line_items or [{"description": description, "amount": amount}], - due_date=due_date, - paid_at=None, - voided_at=None, - void_reason=None, - created_at=now, - updated_at=now, + invoice = Invoice( + id = invoice_id, + tenant_id = tenant_id, + subscription_id = subscription_id, + invoice_number = invoice_number, + status = InvoiceStatus.DRAFT.value, + amount_due = amount, + amount_paid = 0, + currency = currency, + period_start = period_start, + period_end = period_end, + description = description, + line_items = line_items or [{"description": description, "amount": amount}], + due_date = due_date, + paid_at = None, + voided_at = None, + void_reason = None, + created_at = now, + updated_at = now, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO invoices @@ -1472,11 +1472,11 @@ class SubscriptionManager: def get_invoice(self, invoice_id: str) -> Invoice | None: """获取发票""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM invoices WHERE id = ?", (invoice_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM invoices WHERE id = ?", (invoice_id, )) + row = cursor.fetchone() if row: return self._row_to_invoice(row) @@ -1487,11 +1487,11 @@ class SubscriptionManager: def get_invoice_by_number(self, invoice_number: str) -> Invoice | None: """通过发票号获取发票""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM invoices WHERE invoice_number = ?", (invoice_number,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM invoices WHERE invoice_number = ?", (invoice_number, )) + row = cursor.fetchone() if row: return self._row_to_invoice(row) @@ -1501,25 +1501,25 @@ class SubscriptionManager: conn.close() def list_invoices( - self, tenant_id: str, status: str | None = None, limit: int = 100, offset: int = 0 + self, tenant_id: str, status: str | None = None, limit: int = 100, offset: int = 0 ) -> list[Invoice]: """列出发票""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() - query = "SELECT * FROM invoices WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM invoices WHERE tenant_id = ?" + params = [tenant_id] if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status) query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_invoice(row) for row in rows] @@ -1528,23 +1528,23 @@ class SubscriptionManager: def void_invoice(self, invoice_id: str, reason: str) -> Invoice | None: """作废发票""" - conn = self._get_connection() + conn = self._get_connection() try: - invoice = self.get_invoice(invoice_id) + invoice = self.get_invoice(invoice_id) if not invoice: return None if invoice.status == InvoiceStatus.PAID.value: raise ValueError("Cannot void a paid invoice") - now = datetime.now() + now = datetime.now() - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE invoices - SET status = 'void', voided_at = ?, void_reason = ?, updated_at = ? - WHERE id = ? + SET status = 'void', voided_at = ?, void_reason = ?, updated_at = ? + WHERE id = ? """, (now, reason, now, invoice_id), ) @@ -1557,21 +1557,21 @@ class SubscriptionManager: def _generate_invoice_number(self) -> str: """生成发票号""" - now = datetime.now() - prefix = f"INV-{now.strftime('%Y%m')}" + now = datetime.now() + prefix = f"INV-{now.strftime('%Y%m')}" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ SELECT COUNT(*) as count FROM invoices WHERE invoice_number LIKE ? """, - (f"{prefix}%",), + (f"{prefix}%", ), ) - row = cursor.fetchone() - count = row["count"] + 1 + row = cursor.fetchone() + count = row["count"] + 1 return f"{prefix}-{count:06d}" @@ -1584,10 +1584,10 @@ class SubscriptionManager: self, tenant_id: str, payment_id: str, amount: float, reason: str, requested_by: str ) -> Refund: """申请退款""" - conn = self._get_connection() + conn = self._get_connection() try: # 验证支付记录 - payment = self._get_payment_internal(conn, payment_id) + payment = self._get_payment_internal(conn, payment_id) if not payment: raise ValueError(f"Payment {payment_id} not found") @@ -1600,30 +1600,30 @@ class SubscriptionManager: if amount > payment.amount: raise ValueError("Refund amount cannot exceed payment amount") - refund_id = str(uuid.uuid4()) - now = datetime.now() + refund_id = str(uuid.uuid4()) + now = datetime.now() - refund = Refund( - id=refund_id, - tenant_id=tenant_id, - payment_id=payment_id, - invoice_id=payment.invoice_id, - amount=amount, - currency=payment.currency, - reason=reason, - status=RefundStatus.PENDING.value, - requested_by=requested_by, - requested_at=now, - approved_by=None, - approved_at=None, - completed_at=None, - provider_refund_id=None, - metadata={}, - created_at=now, - updated_at=now, + refund = Refund( + id = refund_id, + tenant_id = tenant_id, + payment_id = payment_id, + invoice_id = payment.invoice_id, + amount = amount, + currency = payment.currency, + reason = reason, + status = RefundStatus.PENDING.value, + requested_by = requested_by, + requested_at = now, + approved_by = None, + approved_at = None, + completed_at = None, + provider_refund_id = None, + metadata = {}, + created_at = now, + updated_at = now, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO refunds @@ -1662,23 +1662,23 @@ class SubscriptionManager: def approve_refund(self, refund_id: str, approved_by: str) -> Refund | None: """批准退款""" - conn = self._get_connection() + conn = self._get_connection() try: - refund = self._get_refund_internal(conn, refund_id) + refund = self._get_refund_internal(conn, refund_id) if not refund: return None if refund.status != RefundStatus.PENDING.value: raise ValueError("Can only approve pending refunds") - now = datetime.now() + now = datetime.now() - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE refunds - SET status = 'approved', approved_by = ?, approved_at = ?, updated_at = ? - WHERE id = ? + SET status = 'approved', approved_by = ?, approved_at = ?, updated_at = ? + WHERE id = ? """, (approved_by, now, now, refund_id), ) @@ -1690,23 +1690,23 @@ class SubscriptionManager: conn.close() def complete_refund( - self, refund_id: str, provider_refund_id: str | None = None + self, refund_id: str, provider_refund_id: str | None = None ) -> Refund | None: """完成退款""" - conn = self._get_connection() + conn = self._get_connection() try: - refund = self._get_refund_internal(conn, refund_id) + refund = self._get_refund_internal(conn, refund_id) if not refund: return None - now = datetime.now() + now = datetime.now() - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE refunds - SET status = 'completed', provider_refund_id = ?, completed_at = ?, updated_at = ? - WHERE id = ? + SET status = 'completed', provider_refund_id = ?, completed_at = ?, updated_at = ? + WHERE id = ? """, (provider_refund_id, now, now, refund_id), ) @@ -1715,8 +1715,8 @@ class SubscriptionManager: cursor.execute( """ UPDATE payments - SET status = 'refunded', updated_at = ? - WHERE id = ? + SET status = 'refunded', updated_at = ? + WHERE id = ? """, (now, refund.payment_id), ) @@ -1742,20 +1742,20 @@ class SubscriptionManager: def reject_refund(self, refund_id: str, reason: str) -> Refund | None: """拒绝退款""" - conn = self._get_connection() + conn = self._get_connection() try: - refund = self._get_refund_internal(conn, refund_id) + refund = self._get_refund_internal(conn, refund_id) if not refund: return None - now = datetime.now() + now = datetime.now() - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE refunds - SET status = 'rejected', metadata = json_set(metadata, '$.rejection_reason', ?), updated_at = ? - WHERE id = ? + SET status = 'rejected', metadata = json_set(metadata, '$.rejection_reason', ?), updated_at = ? + WHERE id = ? """, (reason, now, refund_id), ) @@ -1768,32 +1768,32 @@ class SubscriptionManager: def get_refund(self, refund_id: str) -> Refund | None: """获取退款记录""" - conn = self._get_connection() + conn = self._get_connection() try: return self._get_refund_internal(conn, refund_id) finally: conn.close() def list_refunds( - self, tenant_id: str, status: str | None = None, limit: int = 100, offset: int = 0 + self, tenant_id: str, status: str | None = None, limit: int = 100, offset: int = 0 ) -> list[Refund]: """列出退款记录""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() - query = "SELECT * FROM refunds WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM refunds WHERE tenant_id = ?" + params = [tenant_id] if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status) query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_refund(row) for row in rows] @@ -1802,9 +1802,9 @@ class SubscriptionManager: def _get_refund_internal(self, conn: sqlite3.Connection, refund_id: str) -> Refund | None: """内部方法:获取退款记录""" - cursor = conn.cursor() - cursor.execute("SELECT * FROM refunds WHERE id = ?", (refund_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM refunds WHERE id = ?", (refund_id, )) + row = cursor.fetchone() if row: return self._row_to_refund(row) @@ -1822,11 +1822,11 @@ class SubscriptionManager: description: str, reference_id: str, balance_after: float, - ): + ) -> None: """内部方法:添加账单历史""" - history_id = str(uuid.uuid4()) + history_id = str(uuid.uuid4()) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO billing_history @@ -1850,18 +1850,18 @@ class SubscriptionManager: def get_billing_history( self, tenant_id: str, - start_date: datetime | None = None, - end_date: datetime | None = None, - limit: int = 100, - offset: int = 0, + start_date: datetime | None = None, + end_date: datetime | None = None, + limit: int = 100, + offset: int = 0, ) -> list[BillingHistory]: """获取账单历史""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() - query = "SELECT * FROM billing_history WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM billing_history WHERE tenant_id = ?" + params = [tenant_id] if start_date: query += " AND created_at >= ?" @@ -1874,7 +1874,7 @@ class SubscriptionManager: params.extend([limit, offset]) cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_billing_history(row) for row in rows] @@ -1889,7 +1889,7 @@ class SubscriptionManager: plan_id: str, success_url: str, cancel_url: str, - billing_cycle: str = "monthly", + billing_cycle: str = "monthly", ) -> dict[str, Any]: """创建 Stripe Checkout 会话(占位实现)""" # 这里应该集成 Stripe SDK @@ -1902,12 +1902,12 @@ class SubscriptionManager: } def create_alipay_order( - self, tenant_id: str, plan_id: str, billing_cycle: str = "monthly" + self, tenant_id: str, plan_id: str, billing_cycle: str = "monthly" ) -> dict[str, Any]: """创建支付宝订单(占位实现)""" # 这里应该集成支付宝 SDK - plan = self.get_plan(plan_id) - amount = plan.price_yearly if billing_cycle == "yearly" else plan.price_monthly + plan = self.get_plan(plan_id) + amount = plan.price_yearly if billing_cycle == "yearly" else plan.price_monthly return { "order_id": f"ALI{datetime.now().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:8].upper()}", @@ -1919,12 +1919,12 @@ class SubscriptionManager: } def create_wechat_order( - self, tenant_id: str, plan_id: str, billing_cycle: str = "monthly" + self, tenant_id: str, plan_id: str, billing_cycle: str = "monthly" ) -> dict[str, Any]: """创建微信支付订单(占位实现)""" # 这里应该集成微信支付 SDK - plan = self.get_plan(plan_id) - amount = plan.price_yearly if billing_cycle == "yearly" else plan.price_monthly + plan = self.get_plan(plan_id) + amount = plan.price_yearly if billing_cycle == "yearly" else plan.price_monthly return { "order_id": f"WX{datetime.now().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:8].upper()}", @@ -1940,7 +1940,7 @@ class SubscriptionManager: # 这里应该实现实际的 Webhook 处理逻辑 logger.info(f"Received webhook from {provider}: {payload.get('event_type', 'unknown')}") - event_type = payload.get("event_type", "") + event_type = payload.get("event_type", "") if provider == "stripe": if event_type == "checkout.session.completed": @@ -1962,126 +1962,126 @@ class SubscriptionManager: def _row_to_plan(self, row: sqlite3.Row) -> SubscriptionPlan: """数据库行转换为 SubscriptionPlan 对象""" return SubscriptionPlan( - id=row["id"], - name=row["name"], - tier=row["tier"], - description=row["description"] or "", - price_monthly=row["price_monthly"], - price_yearly=row["price_yearly"], - currency=row["currency"], - features=json.loads(row["features"] or "[]"), - limits=json.loads(row["limits"] or "{}"), - is_active=bool(row["is_active"]), - created_at=( + id = row["id"], + name = row["name"], + tier = row["tier"], + description = row["description"] or "", + price_monthly = row["price_monthly"], + price_yearly = row["price_yearly"], + currency = row["currency"], + features = json.loads(row["features"] or "[]"), + limits = json.loads(row["limits"] or "{}"), + is_active = bool(row["is_active"]), + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] ), - metadata=json.loads(row["metadata"] or "{}"), + metadata = json.loads(row["metadata"] or "{}"), ) def _row_to_subscription(self, row: sqlite3.Row) -> Subscription: """数据库行转换为 Subscription 对象""" return Subscription( - id=row["id"], - tenant_id=row["tenant_id"], - plan_id=row["plan_id"], - status=row["status"], - current_period_start=( + id = row["id"], + tenant_id = row["tenant_id"], + plan_id = row["plan_id"], + status = row["status"], + current_period_start = ( datetime.fromisoformat(row["current_period_start"]) if row["current_period_start"] and isinstance(row["current_period_start"], str) else row["current_period_start"] ), - current_period_end=( + current_period_end = ( datetime.fromisoformat(row["current_period_end"]) if row["current_period_end"] and isinstance(row["current_period_end"], str) else row["current_period_end"] ), - cancel_at_period_end=bool(row["cancel_at_period_end"]), - canceled_at=( + cancel_at_period_end = bool(row["cancel_at_period_end"]), + canceled_at = ( datetime.fromisoformat(row["canceled_at"]) if row["canceled_at"] and isinstance(row["canceled_at"], str) else row["canceled_at"] ), - trial_start=( + trial_start = ( datetime.fromisoformat(row["trial_start"]) if row["trial_start"] and isinstance(row["trial_start"], str) else row["trial_start"] ), - trial_end=( + trial_end = ( datetime.fromisoformat(row["trial_end"]) if row["trial_end"] and isinstance(row["trial_end"], str) else row["trial_end"] ), - payment_provider=row["payment_provider"], - provider_subscription_id=row["provider_subscription_id"], - created_at=( + payment_provider = row["payment_provider"], + provider_subscription_id = row["provider_subscription_id"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] ), - metadata=json.loads(row["metadata"] or "{}"), + metadata = json.loads(row["metadata"] or "{}"), ) def _row_to_usage(self, row: sqlite3.Row) -> UsageRecord: """数据库行转换为 UsageRecord 对象""" return UsageRecord( - id=row["id"], - tenant_id=row["tenant_id"], - resource_type=row["resource_type"], - quantity=row["quantity"], - unit=row["unit"], - recorded_at=( + id = row["id"], + tenant_id = row["tenant_id"], + resource_type = row["resource_type"], + quantity = row["quantity"], + unit = row["unit"], + recorded_at = ( datetime.fromisoformat(row["recorded_at"]) if isinstance(row["recorded_at"], str) else row["recorded_at"] ), - cost=row["cost"], - description=row["description"], - metadata=json.loads(row["metadata"] or "{}"), + cost = row["cost"], + description = row["description"], + metadata = json.loads(row["metadata"] or "{}"), ) def _row_to_payment(self, row: sqlite3.Row) -> Payment: """数据库行转换为 Payment 对象""" return Payment( - id=row["id"], - tenant_id=row["tenant_id"], - subscription_id=row["subscription_id"], - invoice_id=row["invoice_id"], - amount=row["amount"], - currency=row["currency"], - provider=row["provider"], - provider_payment_id=row["provider_payment_id"], - status=row["status"], - payment_method=row["payment_method"], - payment_details=json.loads(row["payment_details"] or "{}"), - paid_at=( + id = row["id"], + tenant_id = row["tenant_id"], + subscription_id = row["subscription_id"], + invoice_id = row["invoice_id"], + amount = row["amount"], + currency = row["currency"], + provider = row["provider"], + provider_payment_id = row["provider_payment_id"], + status = row["status"], + payment_method = row["payment_method"], + payment_details = json.loads(row["payment_details"] or "{}"), + paid_at = ( datetime.fromisoformat(row["paid_at"]) if row["paid_at"] and isinstance(row["paid_at"], str) else row["paid_at"] ), - failed_at=( + failed_at = ( datetime.fromisoformat(row["failed_at"]) if row["failed_at"] and isinstance(row["failed_at"], str) else row["failed_at"] ), - failure_reason=row["failure_reason"], - created_at=( + failure_reason = row["failure_reason"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] @@ -2091,48 +2091,48 @@ class SubscriptionManager: def _row_to_invoice(self, row: sqlite3.Row) -> Invoice: """数据库行转换为 Invoice 对象""" return Invoice( - id=row["id"], - tenant_id=row["tenant_id"], - subscription_id=row["subscription_id"], - invoice_number=row["invoice_number"], - status=row["status"], - amount_due=row["amount_due"], - amount_paid=row["amount_paid"], - currency=row["currency"], - period_start=( + id = row["id"], + tenant_id = row["tenant_id"], + subscription_id = row["subscription_id"], + invoice_number = row["invoice_number"], + status = row["status"], + amount_due = row["amount_due"], + amount_paid = row["amount_paid"], + currency = row["currency"], + period_start = ( datetime.fromisoformat(row["period_start"]) if row["period_start"] and isinstance(row["period_start"], str) else row["period_start"] ), - period_end=( + period_end = ( datetime.fromisoformat(row["period_end"]) if row["period_end"] and isinstance(row["period_end"], str) else row["period_end"] ), - description=row["description"], - line_items=json.loads(row["line_items"] or "[]"), - due_date=( + description = row["description"], + line_items = json.loads(row["line_items"] or "[]"), + due_date = ( datetime.fromisoformat(row["due_date"]) if row["due_date"] and isinstance(row["due_date"], str) else row["due_date"] ), - paid_at=( + paid_at = ( datetime.fromisoformat(row["paid_at"]) if row["paid_at"] and isinstance(row["paid_at"], str) else row["paid_at"] ), - voided_at=( + voided_at = ( datetime.fromisoformat(row["voided_at"]) if row["voided_at"] and isinstance(row["voided_at"], str) else row["voided_at"] ), - void_reason=row["void_reason"], - created_at=( + void_reason = row["void_reason"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] @@ -2142,39 +2142,39 @@ class SubscriptionManager: def _row_to_refund(self, row: sqlite3.Row) -> Refund: """数据库行转换为 Refund 对象""" return Refund( - id=row["id"], - tenant_id=row["tenant_id"], - payment_id=row["payment_id"], - invoice_id=row["invoice_id"], - amount=row["amount"], - currency=row["currency"], - reason=row["reason"], - status=row["status"], - requested_by=row["requested_by"], - requested_at=( + id = row["id"], + tenant_id = row["tenant_id"], + payment_id = row["payment_id"], + invoice_id = row["invoice_id"], + amount = row["amount"], + currency = row["currency"], + reason = row["reason"], + status = row["status"], + requested_by = row["requested_by"], + requested_at = ( datetime.fromisoformat(row["requested_at"]) if isinstance(row["requested_at"], str) else row["requested_at"] ), - approved_by=row["approved_by"], - approved_at=( + approved_by = row["approved_by"], + approved_at = ( datetime.fromisoformat(row["approved_at"]) if row["approved_at"] and isinstance(row["approved_at"], str) else row["approved_at"] ), - completed_at=( + completed_at = ( datetime.fromisoformat(row["completed_at"]) if row["completed_at"] and isinstance(row["completed_at"], str) else row["completed_at"] ), - provider_refund_id=row["provider_refund_id"], - metadata=json.loads(row["metadata"] or "{}"), - created_at=( + provider_refund_id = row["provider_refund_id"], + metadata = json.loads(row["metadata"] or "{}"), + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] @@ -2184,30 +2184,30 @@ class SubscriptionManager: def _row_to_billing_history(self, row: sqlite3.Row) -> BillingHistory: """数据库行转换为 BillingHistory 对象""" return BillingHistory( - id=row["id"], - tenant_id=row["tenant_id"], - type=row["type"], - amount=row["amount"], - currency=row["currency"], - description=row["description"], - reference_id=row["reference_id"], - balance_after=row["balance_after"], - created_at=( + id = row["id"], + tenant_id = row["tenant_id"], + type = row["type"], + amount = row["amount"], + currency = row["currency"], + description = row["description"], + reference_id = row["reference_id"], + balance_after = row["balance_after"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - metadata=json.loads(row["metadata"] or "{}"), + metadata = json.loads(row["metadata"] or "{}"), ) # 全局订阅管理器实例 -subscription_manager = None +subscription_manager = None -def get_subscription_manager(db_path: str = "insightflow.db") -> SubscriptionManager: +def get_subscription_manager(db_path: str = "insightflow.db") -> SubscriptionManager: """获取订阅管理器实例(单例模式)""" global subscription_manager if subscription_manager is None: - subscription_manager = SubscriptionManager(db_path) + subscription_manager = SubscriptionManager(db_path) return subscription_manager diff --git a/backend/tenant_manager.py b/backend/tenant_manager.py index 4611248..26c2034 100644 --- a/backend/tenant_manager.py +++ b/backend/tenant_manager.py @@ -21,63 +21,63 @@ from datetime import datetime from enum import StrEnum from typing import Any -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) class TenantLimits: """租户资源限制常量""" - FREE_MAX_PROJECTS = 3 - FREE_MAX_STORAGE_MB = 100 - FREE_MAX_TRANSCRIPTION_MINUTES = 60 - FREE_MAX_API_CALLS_PER_DAY = 100 - FREE_MAX_TEAM_MEMBERS = 2 - FREE_MAX_ENTITIES = 100 + FREE_MAX_PROJECTS = 3 + FREE_MAX_STORAGE_MB = 100 + FREE_MAX_TRANSCRIPTION_MINUTES = 60 + FREE_MAX_API_CALLS_PER_DAY = 100 + FREE_MAX_TEAM_MEMBERS = 2 + FREE_MAX_ENTITIES = 100 - PRO_MAX_PROJECTS = 20 - PRO_MAX_STORAGE_MB = 1000 - PRO_MAX_TRANSCRIPTION_MINUTES = 600 - PRO_MAX_API_CALLS_PER_DAY = 10000 - PRO_MAX_TEAM_MEMBERS = 10 - PRO_MAX_ENTITIES = 1000 + PRO_MAX_PROJECTS = 20 + PRO_MAX_STORAGE_MB = 1000 + PRO_MAX_TRANSCRIPTION_MINUTES = 600 + PRO_MAX_API_CALLS_PER_DAY = 10000 + PRO_MAX_TEAM_MEMBERS = 10 + PRO_MAX_ENTITIES = 1000 - UNLIMITED = -1 + UNLIMITED = -1 class TenantStatus(StrEnum): """租户状态""" - ACTIVE = "active" # 活跃 - SUSPENDED = "suspended" # 暂停 - TRIAL = "trial" # 试用 - EXPIRED = "expired" # 过期 - PENDING = "pending" # 待激活 + ACTIVE = "active" # 活跃 + SUSPENDED = "suspended" # 暂停 + TRIAL = "trial" # 试用 + EXPIRED = "expired" # 过期 + PENDING = "pending" # 待激活 class TenantTier(StrEnum): """租户订阅层级""" - FREE = "free" # 免费版 - PRO = "pro" # 专业版 - ENTERPRISE = "enterprise" # 企业版 + FREE = "free" # 免费版 + PRO = "pro" # 专业版 + ENTERPRISE = "enterprise" # 企业版 class TenantRole(StrEnum): """租户角色""" - OWNER = "owner" # 所有者 - ADMIN = "admin" # 管理员 - MEMBER = "member" # 成员 - VIEWER = "viewer" # 查看者 + OWNER = "owner" # 所有者 + ADMIN = "admin" # 管理员 + MEMBER = "member" # 成员 + VIEWER = "viewer" # 查看者 class DomainStatus(StrEnum): """域名状态""" - PENDING = "pending" # 待验证 - VERIFIED = "verified" # 已验证 - FAILED = "failed" # 验证失败 - EXPIRED = "expired" # 已过期 + PENDING = "pending" # 待验证 + VERIFIED = "verified" # 已验证 + FAILED = "failed" # 验证失败 + EXPIRED = "expired" # 已过期 @dataclass @@ -171,7 +171,7 @@ class TenantManager: """租户管理器 - 多租户 SaaS 架构核心""" # 默认资源限制配置 - 使用常量 - DEFAULT_LIMITS = { + DEFAULT_LIMITS = { TenantTier.FREE: { "max_projects": TenantLimits.FREE_MAX_PROJECTS, "max_storage_mb": TenantLimits.FREE_MAX_STORAGE_MB, @@ -209,7 +209,7 @@ class TenantManager: } # 角色权限映射 - ROLE_PERMISSIONS = { + ROLE_PERMISSIONS = { TenantRole.OWNER: [ "tenant:*", "project:*", @@ -240,7 +240,7 @@ class TenantManager: } # 权限名称映射 - PERMISSION_NAMES = { + PERMISSION_NAMES = { "tenant:*": "租户完全控制", "tenant:read": "查看租户信息", "project:*": "项目完全控制", @@ -257,21 +257,21 @@ class TenantManager: "export:basic": "基础导出", } - def __init__(self, db_path: str = "insightflow.db"): - self.db_path = db_path + def __init__(self, db_path: str = "insightflow.db") -> None: + self.db_path = db_path self._init_db() def _get_connection(self) -> sqlite3.Connection: """获取数据库连接""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row return conn def _init_db(self) -> None: """初始化数据库表""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() # 租户主表 cursor.execute(""" @@ -418,41 +418,41 @@ class TenantManager: self, name: str, owner_id: str, - tier: str = "free", - description: str | None = None, - settings: dict | None = None, + tier: str = "free", + description: str | None = None, + settings: dict | None = None, ) -> Tenant: """创建新租户""" - conn = self._get_connection() + conn = self._get_connection() try: - tenant_id = str(uuid.uuid4()) - slug = self._generate_slug(name) + tenant_id = str(uuid.uuid4()) + slug = self._generate_slug(name) # 获取对应层级的资源限制 - tier_enum = ( + tier_enum = ( TenantTier(tier) if tier in [t.value for t in TenantTier] else TenantTier.FREE ) - resource_limits = self.DEFAULT_LIMITS.get( + resource_limits = self.DEFAULT_LIMITS.get( tier_enum, self.DEFAULT_LIMITS[TenantTier.FREE] ) - tenant = Tenant( - id=tenant_id, - name=name, - slug=slug, - description=description, - tier=tier, - status=TenantStatus.PENDING.value, - owner_id=owner_id, - created_at=datetime.now(), - updated_at=datetime.now(), - expires_at=None, - settings=settings or {}, - resource_limits=resource_limits, - metadata={}, + tenant = Tenant( + id = tenant_id, + name = name, + slug = slug, + description = description, + tier = tier, + status = TenantStatus.PENDING.value, + owner_id = owner_id, + created_at = datetime.now(), + updated_at = datetime.now(), + expires_at = None, + settings = settings or {}, + resource_limits = resource_limits, + metadata = {}, ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO tenants (id, name, slug, description, tier, status, owner_id, @@ -492,11 +492,11 @@ class TenantManager: def get_tenant(self, tenant_id: str) -> Tenant | None: """获取租户信息""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM tenants WHERE id = ?", (tenant_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM tenants WHERE id = ?", (tenant_id, )) + row = cursor.fetchone() if row: return self._row_to_tenant(row) @@ -507,11 +507,11 @@ class TenantManager: def get_tenant_by_slug(self, slug: str) -> Tenant | None: """通过 slug 获取租户""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM tenants WHERE slug = ?", (slug,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM tenants WHERE slug = ?", (slug, )) + row = cursor.fetchone() if row: return self._row_to_tenant(row) @@ -522,18 +522,18 @@ class TenantManager: def get_tenant_by_domain(self, domain: str) -> Tenant | None: """通过自定义域名获取租户""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ SELECT t.* FROM tenants t - JOIN tenant_domains d ON t.id = d.tenant_id - WHERE d.domain = ? AND d.status = 'verified' + JOIN tenant_domains d ON t.id = d.tenant_id + WHERE d.domain = ? AND d.status = 'verified' """, - (domain,), + (domain, ), ) - row = cursor.fetchone() + row = cursor.fetchone() if row: return self._row_to_tenant(row) @@ -545,51 +545,51 @@ class TenantManager: def update_tenant( self, tenant_id: str, - name: str | None = None, - description: str | None = None, - tier: str | None = None, - status: str | None = None, - settings: dict | None = None, + name: str | None = None, + description: str | None = None, + tier: str | None = None, + status: str | None = None, + settings: dict | None = None, ) -> Tenant | None: """更新租户信息""" - conn = self._get_connection() + conn = self._get_connection() try: - tenant = self.get_tenant(tenant_id) + tenant = self.get_tenant(tenant_id) if not tenant: return None - updates = [] - params = [] + updates = [] + params = [] if name is not None: - updates.append("name = ?") + updates.append("name = ?") params.append(name) if description is not None: - updates.append("description = ?") + updates.append("description = ?") params.append(description) if tier is not None: - updates.append("tier = ?") + updates.append("tier = ?") params.append(tier) # 更新资源限制 - tier_enum = TenantTier(tier) - updates.append("resource_limits = ?") + tier_enum = TenantTier(tier) + updates.append("resource_limits = ?") params.append(json.dumps(self.DEFAULT_LIMITS.get(tier_enum, {}))) if status is not None: - updates.append("status = ?") + updates.append("status = ?") params.append(status) if settings is not None: - updates.append("settings = ?") + updates.append("settings = ?") params.append(json.dumps(settings)) - updates.append("updated_at = ?") + updates.append("updated_at = ?") params.append(datetime.now()) params.append(tenant_id) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( f""" UPDATE tenants SET {", ".join(updates)} - WHERE id = ? + WHERE id = ? """, params, ) @@ -602,38 +602,38 @@ class TenantManager: def delete_tenant(self, tenant_id: str) -> bool: """删除租户(软删除或硬删除)""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("DELETE FROM tenants WHERE id = ?", (tenant_id,)) + cursor = conn.cursor() + cursor.execute("DELETE FROM tenants WHERE id = ?", (tenant_id, )) conn.commit() return cursor.rowcount > 0 finally: conn.close() def list_tenants( - self, status: str | None = None, tier: str | None = None, limit: int = 100, offset: int = 0 + self, status: str | None = None, tier: str | None = None, limit: int = 100, offset: int = 0 ) -> list[Tenant]: """列出租户""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() - query = "SELECT * FROM tenants WHERE 1=1" - params = [] + query = "SELECT * FROM tenants WHERE 1 = 1" + params = [] if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status) if tier: - query += " AND tier = ?" + query += " AND tier = ?" params.append(tier) query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_tenant(row) for row in rows] @@ -646,45 +646,45 @@ class TenantManager: self, tenant_id: str, domain: str, - is_primary: bool = False, - verification_method: str = "dns", + is_primary: bool = False, + verification_method: str = "dns", ) -> TenantDomain: """为租户添加自定义域名""" - conn = self._get_connection() + conn = self._get_connection() try: # 验证域名格式 if not self._validate_domain(domain): raise ValueError(f"Invalid domain format: {domain}") # 生成验证令牌 - verification_token = self._generate_verification_token(tenant_id, domain) + verification_token = self._generate_verification_token(tenant_id, domain) - domain_id = str(uuid.uuid4()) - tenant_domain = TenantDomain( - id=domain_id, - tenant_id=tenant_id, - domain=domain.lower(), - status=DomainStatus.PENDING.value, - verification_token=verification_token, - verification_method=verification_method, - verified_at=None, - created_at=datetime.now(), - updated_at=datetime.now(), - is_primary=is_primary, - ssl_enabled=False, - ssl_expires_at=None, + domain_id = str(uuid.uuid4()) + tenant_domain = TenantDomain( + id = domain_id, + tenant_id = tenant_id, + domain = domain.lower(), + status = DomainStatus.PENDING.value, + verification_token = verification_token, + verification_method = verification_method, + verified_at = None, + created_at = datetime.now(), + updated_at = datetime.now(), + is_primary = is_primary, + ssl_enabled = False, + ssl_expires_at = None, ) - cursor = conn.cursor() + cursor = conn.cursor() # 如果设为主域名,取消其他主域名 if is_primary: cursor.execute( """ - UPDATE tenant_domains SET is_primary = 0 - WHERE tenant_id = ? + UPDATE tenant_domains SET is_primary = 0 + WHERE tenant_id = ? """, - (tenant_id,), + (tenant_id, ), ) cursor.execute( @@ -723,36 +723,36 @@ class TenantManager: def verify_domain(self, tenant_id: str, domain_id: str) -> bool: """验证域名所有权""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() # 获取域名信息 cursor.execute( """ SELECT * FROM tenant_domains - WHERE id = ? AND tenant_id = ? + WHERE id = ? AND tenant_id = ? """, (domain_id, tenant_id), ) - row = cursor.fetchone() + row = cursor.fetchone() if not row: return False - domain = row["domain"] - token = row["verification_token"] - method = row["verification_method"] + domain = row["domain"] + token = row["verification_token"] + method = row["verification_method"] # 执行验证 - is_verified = self._check_domain_verification(domain, token, method) + is_verified = self._check_domain_verification(domain, token, method) if is_verified: cursor.execute( """ UPDATE tenant_domains - SET status = 'verified', verified_at = ?, updated_at = ? - WHERE id = ? + SET status = 'verified', verified_at = ?, updated_at = ? + WHERE id = ? """, (datetime.now(), datetime.now(), domain_id), ) @@ -762,8 +762,8 @@ class TenantManager: cursor.execute( """ UPDATE tenant_domains - SET status = 'failed', updated_at = ? - WHERE id = ? + SET status = 'failed', updated_at = ? + WHERE id = ? """, (datetime.now(), domain_id), ) @@ -779,17 +779,17 @@ class TenantManager: def get_domain_verification_instructions(self, domain_id: str) -> dict[str, Any]: """获取域名验证指导""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM tenant_domains WHERE id = ?", (domain_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM tenant_domains WHERE id = ?", (domain_id, )) + row = cursor.fetchone() if not row: return None - domain = row["domain"] - token = row["verification_token"] + domain = row["domain"] + token = row["verification_token"] return { "domain": domain, @@ -797,7 +797,7 @@ class TenantManager: "dns_record": { "type": "TXT", "name": "_insightflow", - "value": f"insightflow-verify={token}", + "value": f"insightflow-verify = {token}", "ttl": 3600, }, "file_verification": { @@ -805,7 +805,7 @@ class TenantManager: "content": token, }, "instructions": [ - f"DNS 验证: 添加 TXT 记录 _insightflow.{domain},值为 insightflow-verify={token}", + f"DNS 验证: 添加 TXT 记录 _insightflow.{domain},值为 insightflow-verify = {token}", f"文件验证: 在网站根目录创建 .well-known/insightflow-verify.txt,内容为 {token}", ], } @@ -815,13 +815,13 @@ class TenantManager: def remove_domain(self, tenant_id: str, domain_id: str) -> bool: """移除域名绑定""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ DELETE FROM tenant_domains - WHERE id = ? AND tenant_id = ? + WHERE id = ? AND tenant_id = ? """, (domain_id, tenant_id), ) @@ -832,18 +832,18 @@ class TenantManager: def list_domains(self, tenant_id: str) -> list[TenantDomain]: """列出租户的所有域名""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ SELECT * FROM tenant_domains - WHERE tenant_id = ? + WHERE tenant_id = ? ORDER BY is_primary DESC, created_at DESC """, - (tenant_id,), + (tenant_id, ), ) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_domain(row) for row in rows] @@ -854,11 +854,11 @@ class TenantManager: def get_branding(self, tenant_id: str) -> TenantBranding | None: """获取租户品牌配置""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - cursor.execute("SELECT * FROM tenant_branding WHERE tenant_id = ?", (tenant_id,)) - row = cursor.fetchone() + cursor = conn.cursor() + cursor.execute("SELECT * FROM tenant_branding WHERE tenant_id = ?", (tenant_id, )) + row = cursor.fetchone() if row: return self._row_to_branding(row) @@ -870,68 +870,68 @@ class TenantManager: def update_branding( self, tenant_id: str, - logo_url: str | None = None, - favicon_url: str | None = None, - primary_color: str | None = None, - secondary_color: str | None = None, - custom_css: str | None = None, - custom_js: str | None = None, - login_page_bg: str | None = None, - email_template: str | None = None, + logo_url: str | None = None, + favicon_url: str | None = None, + primary_color: str | None = None, + secondary_color: str | None = None, + custom_css: str | None = None, + custom_js: str | None = None, + login_page_bg: str | None = None, + email_template: str | None = None, ) -> TenantBranding: """更新租户品牌配置""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() # 检查是否已存在 - cursor.execute("SELECT id FROM tenant_branding WHERE tenant_id = ?", (tenant_id,)) - existing = cursor.fetchone() + cursor.execute("SELECT id FROM tenant_branding WHERE tenant_id = ?", (tenant_id, )) + existing = cursor.fetchone() if existing: # 更新 - updates = [] - params = [] + updates = [] + params = [] if logo_url is not None: - updates.append("logo_url = ?") + updates.append("logo_url = ?") params.append(logo_url) if favicon_url is not None: - updates.append("favicon_url = ?") + updates.append("favicon_url = ?") params.append(favicon_url) if primary_color is not None: - updates.append("primary_color = ?") + updates.append("primary_color = ?") params.append(primary_color) if secondary_color is not None: - updates.append("secondary_color = ?") + updates.append("secondary_color = ?") params.append(secondary_color) if custom_css is not None: - updates.append("custom_css = ?") + updates.append("custom_css = ?") params.append(custom_css) if custom_js is not None: - updates.append("custom_js = ?") + updates.append("custom_js = ?") params.append(custom_js) if login_page_bg is not None: - updates.append("login_page_bg = ?") + updates.append("login_page_bg = ?") params.append(login_page_bg) if email_template is not None: - updates.append("email_template = ?") + updates.append("email_template = ?") params.append(email_template) - updates.append("updated_at = ?") + updates.append("updated_at = ?") params.append(datetime.now()) params.append(tenant_id) cursor.execute( f""" UPDATE tenant_branding SET {", ".join(updates)} - WHERE tenant_id = ? + WHERE tenant_id = ? """, params, ) else: # 创建 - branding_id = str(uuid.uuid4()) + branding_id = str(uuid.uuid4()) cursor.execute( """ INSERT INTO tenant_branding @@ -963,11 +963,11 @@ class TenantManager: def get_branding_css(self, tenant_id: str) -> str: """生成品牌 CSS""" - branding = self.get_branding(tenant_id) + branding = self.get_branding(tenant_id) if not branding: return "" - css = [] + css = [] if branding.primary_color: css.append(f""" @@ -1007,35 +1007,35 @@ class TenantManager: email: str, role: str, invited_by: str, - permissions: list[str] | None = None, + permissions: list[str] | None = None, ) -> TenantMember: """邀请成员加入租户""" - conn = self._get_connection() + conn = self._get_connection() try: - member_id = str(uuid.uuid4()) + member_id = str(uuid.uuid4()) # 使用角色默认权限 - role_enum = ( + role_enum = ( TenantRole(role) if role in [r.value for r in TenantRole] else TenantRole.MEMBER ) - default_permissions = self.ROLE_PERMISSIONS.get(role_enum, []) - final_permissions = permissions or default_permissions + default_permissions = self.ROLE_PERMISSIONS.get(role_enum, []) + final_permissions = permissions or default_permissions - member = TenantMember( - id=member_id, - tenant_id=tenant_id, - user_id="pending", # 临时值,待用户接受邀请后更新 - email=email, - role=role, - permissions=final_permissions, - invited_by=invited_by, - invited_at=datetime.now(), - joined_at=None, - last_active_at=None, - status="pending", + member = TenantMember( + id = member_id, + tenant_id = tenant_id, + user_id = "pending", # 临时值,待用户接受邀请后更新 + email = email, + role = role, + permissions = final_permissions, + invited_by = invited_by, + invited_at = datetime.now(), + joined_at = None, + last_active_at = None, + status = "pending", ) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO tenant_members @@ -1067,14 +1067,14 @@ class TenantManager: def accept_invitation(self, invitation_id: str, user_id: str) -> bool: """接受邀请""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE tenant_members - SET user_id = ?, status = 'active', joined_at = ? - WHERE id = ? AND status = 'pending' + SET user_id = ?, status = 'active', joined_at = ? + WHERE id = ? AND status = 'pending' """, (user_id, datetime.now(), invitation_id), ) @@ -1087,13 +1087,13 @@ class TenantManager: def remove_member(self, tenant_id: str, member_id: str) -> bool: """移除成员""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ DELETE FROM tenant_members - WHERE id = ? AND tenant_id = ? + WHERE id = ? AND tenant_id = ? """, (member_id, tenant_id), ) @@ -1103,21 +1103,21 @@ class TenantManager: conn.close() def update_member_role( - self, tenant_id: str, member_id: str, role: str, permissions: list[str] | None = None + self, tenant_id: str, member_id: str, role: str, permissions: list[str] | None = None ) -> bool: """更新成员角色""" - conn = self._get_connection() + conn = self._get_connection() try: - role_enum = TenantRole(role) - default_permissions = self.ROLE_PERMISSIONS.get(role_enum, []) - final_permissions = permissions or default_permissions + role_enum = TenantRole(role) + default_permissions = self.ROLE_PERMISSIONS.get(role_enum, []) + final_permissions = permissions or default_permissions - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ UPDATE tenant_members - SET role = ?, permissions = ?, updated_at = ? - WHERE id = ? AND tenant_id = ? + SET role = ?, permissions = ?, updated_at = ? + WHERE id = ? AND tenant_id = ? """, (role, json.dumps(final_permissions), datetime.now(), member_id, tenant_id), ) @@ -1128,23 +1128,23 @@ class TenantManager: finally: conn.close() - def list_members(self, tenant_id: str, status: str | None = None) -> list[TenantMember]: + def list_members(self, tenant_id: str, status: str | None = None) -> list[TenantMember]: """列出租户成员""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() - query = "SELECT * FROM tenant_members WHERE tenant_id = ?" - params = [tenant_id] + query = "SELECT * FROM tenant_members WHERE tenant_id = ?" + params = [tenant_id] if status: - query += " AND status = ?" + query += " AND status = ?" params.append(status) query += " ORDER BY invited_at DESC" cursor.execute(query, params) - rows = cursor.fetchall() + rows = cursor.fetchall() return [self._row_to_member(row) for row in rows] @@ -1153,31 +1153,31 @@ class TenantManager: def check_permission(self, tenant_id: str, user_id: str, resource: str, action: str) -> bool: """检查用户是否有特定权限""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ SELECT role, permissions FROM tenant_members - WHERE tenant_id = ? AND user_id = ? AND status = 'active' + WHERE tenant_id = ? AND user_id = ? AND status = 'active' """, (tenant_id, user_id), ) - row = cursor.fetchone() + row = cursor.fetchone() if not row: return False - role = row["role"] - permissions = json.loads(row["permissions"] or "[]") + role = row["role"] + permissions = json.loads(row["permissions"] or "[]") # 所有者拥有所有权限 if role == TenantRole.OWNER.value: return True # 检查具体权限 - required = f"{resource}:{action}" - wildcard = f"{resource}:*" + required = f"{resource}:{action}" + wildcard = f"{resource}:*" return required in permissions or wildcard in permissions or "*" in permissions @@ -1186,24 +1186,24 @@ class TenantManager: def get_user_tenants(self, user_id: str) -> list[dict[str, Any]]: """获取用户所属的所有租户""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ SELECT t.*, m.role, m.status as member_status FROM tenants t - JOIN tenant_members m ON t.id = m.tenant_id - WHERE m.user_id = ? AND m.status = 'active' + JOIN tenant_members m ON t.id = m.tenant_id + WHERE m.user_id = ? AND m.status = 'active' ORDER BY t.created_at DESC """, - (user_id,), + (user_id, ), ) - rows = cursor.fetchall() + rows = cursor.fetchall() - result = [] + result = [] for row in rows: - tenant = self._row_to_tenant(row) + tenant = self._row_to_tenant(row) result.append( { **asdict(tenant), @@ -1221,20 +1221,20 @@ class TenantManager: def record_usage( self, tenant_id: str, - storage_bytes: int = 0, - transcription_seconds: int = 0, - api_calls: int = 0, - projects_count: int = 0, - entities_count: int = 0, - members_count: int = 0, - ): + storage_bytes: int = 0, + transcription_seconds: int = 0, + api_calls: int = 0, + projects_count: int = 0, + entities_count: int = 0, + members_count: int = 0, + ) -> None: """记录资源使用""" - conn = self._get_connection() + conn = self._get_connection() try: - today = datetime.now().date() - usage_id = str(uuid.uuid4()) + today = datetime.now().date() + usage_id = str(uuid.uuid4()) - cursor = conn.cursor() + cursor = conn.cursor() cursor.execute( """ INSERT INTO tenant_usage @@ -1242,12 +1242,12 @@ class TenantManager: projects_count, entities_count, members_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(tenant_id, date) DO UPDATE SET - storage_bytes = storage_bytes + excluded.storage_bytes, - transcription_seconds = transcription_seconds + excluded.transcription_seconds, - api_calls = api_calls + excluded.api_calls, - projects_count = MAX(projects_count, excluded.projects_count), - entities_count = MAX(entities_count, excluded.entities_count), - members_count = MAX(members_count, excluded.members_count) + storage_bytes = storage_bytes + excluded.storage_bytes, + transcription_seconds = transcription_seconds + excluded.transcription_seconds, + api_calls = api_calls + excluded.api_calls, + projects_count = MAX(projects_count, excluded.projects_count), + entities_count = MAX(entities_count, excluded.entities_count), + members_count = MAX(members_count, excluded.members_count) """, ( usage_id, @@ -1268,14 +1268,14 @@ class TenantManager: conn.close() def get_usage_stats( - self, tenant_id: str, start_date: datetime | None = None, end_date: datetime | None = None + self, tenant_id: str, start_date: datetime | None = None, end_date: datetime | None = None ) -> dict[str, Any]: """获取使用统计""" - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() + cursor = conn.cursor() - query = """ + query = """ SELECT SUM(storage_bytes) as total_storage, SUM(transcription_seconds) as total_transcription, @@ -1284,9 +1284,9 @@ class TenantManager: MAX(entities_count) as max_entities, MAX(members_count) as max_members FROM tenant_usage - WHERE tenant_id = ? + WHERE tenant_id = ? """ - params = [tenant_id] + params = [tenant_id] if start_date: query += " AND date >= ?" @@ -1296,11 +1296,11 @@ class TenantManager: params.append(end_date.date()) cursor.execute(query, params) - row = cursor.fetchone() + row = cursor.fetchone() # 获取租户限制 - tenant = self.get_tenant(tenant_id) - limits = tenant.resource_limits if tenant else {} + tenant = self.get_tenant(tenant_id) + limits = tenant.resource_limits if tenant else {} return { "storage_bytes": row["total_storage"] or 0, @@ -1344,14 +1344,14 @@ class TenantManager: Returns: (是否允许, 当前使用量, 限制值) """ - tenant = self.get_tenant(tenant_id) + tenant = self.get_tenant(tenant_id) if not tenant: return False, 0, 0 - limits = tenant.resource_limits - stats = self.get_usage_stats(tenant_id) + limits = tenant.resource_limits + stats = self.get_usage_stats(tenant_id) - resource_map = { + resource_map = { "storage": ("storage_mb", stats["storage_mb"]), "transcription": ("max_transcription_minutes", stats["transcription_minutes"]), "api_calls": ("max_api_calls_per_day", stats["api_calls"]), @@ -1363,8 +1363,8 @@ class TenantManager: if resource_type not in resource_map: return True, 0, -1 - limit_key, current = resource_map[resource_type] - limit = limits.get(limit_key, 0) + limit_key, current = resource_map[resource_type] + limit = limits.get(limit_key, 0) # -1 表示无限制 if limit == -1: @@ -1377,21 +1377,21 @@ class TenantManager: def _generate_slug(self, name: str) -> str: """生成 URL 友好的 slug""" # 转换为小写,替换空格为连字符 - slug = re.sub(r"[^\w\s-]", "", name.lower()) - slug = re.sub(r"[-\s]+", "-", slug) + slug = re.sub(r"[^\w\s-]", "", name.lower()) + slug = re.sub(r"[-\s]+", "-", slug) # 检查是否已存在 - conn = self._get_connection() + conn = self._get_connection() try: - cursor = conn.cursor() - base_slug = slug - counter = 1 + cursor = conn.cursor() + base_slug = slug + counter = 1 while True: - cursor.execute("SELECT id FROM tenants WHERE slug = ?", (slug,)) + cursor.execute("SELECT id FROM tenants WHERE slug = ?", (slug, )) if not cursor.fetchone(): break - slug = f"{base_slug}-{counter}" + slug = f"{base_slug}-{counter}" counter += 1 return slug @@ -1401,12 +1401,12 @@ class TenantManager: def _generate_verification_token(self, tenant_id: str, domain: str) -> str: """生成域名验证令牌""" - data = f"{tenant_id}:{domain}:{datetime.now().isoformat()}" + data = f"{tenant_id}:{domain}:{datetime.now().isoformat()}" return hashlib.sha256(data.encode()).hexdigest()[:32] def _validate_domain(self, domain: str) -> bool: """验证域名格式""" - pattern = r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])$" + pattern = r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0, 61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0, 61}[a-zA-Z0-9])$" return bool(re.match(pattern, domain)) def _check_domain_verification(self, domain: str, token: str, method: str) -> bool: @@ -1419,7 +1419,7 @@ class TenantManager: # TODO: 实现 DNS TXT 记录查询 # import dns.resolver # try: - # answers = dns.resolver.resolve(f"_insightflow.{domain}", 'TXT') + # answers = dns.resolver.resolve(f"_insightflow.{domain}", 'TXT') # for rdata in answers: # if token in str(rdata): # return True @@ -1431,7 +1431,7 @@ class TenantManager: # TODO: 实现 HTTP 文件验证 # import requests # try: - # response = requests.get(f"http://{domain}/.well-known/insightflow-verify.txt", timeout=10) + # response = requests.get(f"http://{domain}/.well-known/insightflow-verify.txt", timeout = 10) # if response.status_code == 200 and token in response.text: # return True # except (ImportError, Exception): @@ -1442,14 +1442,14 @@ class TenantManager: def _darken_color(self, hex_color: str, percent: int) -> str: """加深颜色""" - hex_color = hex_color.lstrip("#") - r = int(hex_color[0:2], 16) - g = int(hex_color[2:4], 16) - b = int(hex_color[4:6], 16) + hex_color = hex_color.lstrip("#") + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) - r = int(r * (100 - percent) / 100) - g = int(g * (100 - percent) / 100) - b = int(b * (100 - percent) / 100) + r = int(r * (100 - percent) / 100) + g = int(g * (100 - percent) / 100) + b = int(b * (100 - percent) / 100) return f"#{r:02x}{g:02x}{b:02x}" @@ -1467,10 +1467,10 @@ class TenantManager: email: str, role: TenantRole, invited_by: str | None, - ): + ) -> None: """内部方法:添加成员""" - cursor = conn.cursor() - member_id = str(uuid.uuid4()) + cursor = conn.cursor() + member_id = str(uuid.uuid4()) cursor.execute( """ @@ -1497,60 +1497,60 @@ class TenantManager: def _row_to_tenant(self, row: sqlite3.Row) -> Tenant: """数据库行转换为 Tenant 对象""" return Tenant( - id=row["id"], - name=row["name"], - slug=row["slug"], - description=row["description"], - tier=row["tier"], - status=row["status"], - owner_id=row["owner_id"], - created_at=( + id = row["id"], + name = row["name"], + slug = row["slug"], + description = row["description"], + tier = row["tier"], + status = row["status"], + owner_id = row["owner_id"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] ), - expires_at=( + expires_at = ( datetime.fromisoformat(row["expires_at"]) if row["expires_at"] and isinstance(row["expires_at"], str) else row["expires_at"] ), - settings=json.loads(row["settings"] or "{}"), - resource_limits=json.loads(row["resource_limits"] or "{}"), - metadata=json.loads(row["metadata"] or "{}"), + settings = json.loads(row["settings"] or "{}"), + resource_limits = json.loads(row["resource_limits"] or "{}"), + metadata = json.loads(row["metadata"] or "{}"), ) def _row_to_domain(self, row: sqlite3.Row) -> TenantDomain: """数据库行转换为 TenantDomain 对象""" return TenantDomain( - id=row["id"], - tenant_id=row["tenant_id"], - domain=row["domain"], - status=row["status"], - verification_token=row["verification_token"], - verification_method=row["verification_method"], - verified_at=( + id = row["id"], + tenant_id = row["tenant_id"], + domain = row["domain"], + status = row["status"], + verification_token = row["verification_token"], + verification_method = row["verification_method"], + verified_at = ( datetime.fromisoformat(row["verified_at"]) if row["verified_at"] and isinstance(row["verified_at"], str) else row["verified_at"] ), - created_at=( + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] ), - is_primary=bool(row["is_primary"]), - ssl_enabled=bool(row["ssl_enabled"]), - ssl_expires_at=( + is_primary = bool(row["is_primary"]), + ssl_enabled = bool(row["ssl_enabled"]), + ssl_expires_at = ( datetime.fromisoformat(row["ssl_expires_at"]) if row["ssl_expires_at"] and isinstance(row["ssl_expires_at"], str) else row["ssl_expires_at"] @@ -1560,22 +1560,22 @@ class TenantManager: def _row_to_branding(self, row: sqlite3.Row) -> TenantBranding: """数据库行转换为 TenantBranding 对象""" return TenantBranding( - id=row["id"], - tenant_id=row["tenant_id"], - logo_url=row["logo_url"], - favicon_url=row["favicon_url"], - primary_color=row["primary_color"], - secondary_color=row["secondary_color"], - custom_css=row["custom_css"], - custom_js=row["custom_js"], - login_page_bg=row["login_page_bg"], - email_template=row["email_template"], - created_at=( + id = row["id"], + tenant_id = row["tenant_id"], + logo_url = row["logo_url"], + favicon_url = row["favicon_url"], + primary_color = row["primary_color"], + secondary_color = row["secondary_color"], + custom_css = row["custom_css"], + custom_js = row["custom_js"], + login_page_bg = row["login_page_bg"], + email_template = row["email_template"], + created_at = ( datetime.fromisoformat(row["created_at"]) if isinstance(row["created_at"], str) else row["created_at"] ), - updated_at=( + updated_at = ( datetime.fromisoformat(row["updated_at"]) if isinstance(row["updated_at"], str) else row["updated_at"] @@ -1585,29 +1585,29 @@ class TenantManager: def _row_to_member(self, row: sqlite3.Row) -> TenantMember: """数据库行转换为 TenantMember 对象""" return TenantMember( - id=row["id"], - tenant_id=row["tenant_id"], - user_id=row["user_id"], - email=row["email"], - role=row["role"], - permissions=json.loads(row["permissions"] or "[]"), - invited_by=row["invited_by"], - invited_at=( + id = row["id"], + tenant_id = row["tenant_id"], + user_id = row["user_id"], + email = row["email"], + role = row["role"], + permissions = json.loads(row["permissions"] or "[]"), + invited_by = row["invited_by"], + invited_at = ( datetime.fromisoformat(row["invited_at"]) if isinstance(row["invited_at"], str) else row["invited_at"] ), - joined_at=( + joined_at = ( datetime.fromisoformat(row["joined_at"]) if row["joined_at"] and isinstance(row["joined_at"], str) else row["joined_at"] ), - last_active_at=( + last_active_at = ( datetime.fromisoformat(row["last_active_at"]) if row["last_active_at"] and isinstance(row["last_active_at"], str) else row["last_active_at"] ), - status=row["status"], + status = row["status"], ) @@ -1617,13 +1617,13 @@ class TenantManager: class TenantContext: """租户上下文管理器 - 用于请求级别的租户隔离""" - _current_tenant_id: str | None = None - _current_user_id: str | None = None + _current_tenant_id: str | None = None + _current_user_id: str | None = None @classmethod def set_current_tenant(cls, tenant_id: str) -> None: """设置当前租户上下文""" - cls._current_tenant_id = tenant_id + cls._current_tenant_id = tenant_id @classmethod def get_current_tenant(cls) -> str | None: @@ -1633,7 +1633,7 @@ class TenantContext: @classmethod def set_current_user(cls, user_id: str) -> None: """设置当前用户""" - cls._current_user_id = user_id + cls._current_user_id = user_id @classmethod def get_current_user(cls) -> str | None: @@ -1643,17 +1643,17 @@ class TenantContext: @classmethod def clear(cls) -> None: """清除上下文""" - cls._current_tenant_id = None - cls._current_user_id = None + cls._current_tenant_id = None + cls._current_user_id = None # 全局租户管理器实例 -tenant_manager = None +tenant_manager = None -def get_tenant_manager(db_path: str = "insightflow.db") -> TenantManager: +def get_tenant_manager(db_path: str = "insightflow.db") -> TenantManager: """获取租户管理器实例(单例模式)""" global tenant_manager if tenant_manager is None: - tenant_manager = TenantManager(db_path) + tenant_manager = TenantManager(db_path) return tenant_manager diff --git a/backend/test_multimodal.py b/backend/test_multimodal.py index eeb7e8f..b7c26da 100644 --- a/backend/test_multimodal.py +++ b/backend/test_multimodal.py @@ -10,9 +10,9 @@ import sys # 添加 backend 目录到路径 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -print("=" * 60) +print(" = " * 60) print("InsightFlow 多模态模块测试") -print("=" * 60) +print(" = " * 60) # 测试导入 print("\n1. 测试模块导入...") @@ -42,7 +42,7 @@ except ImportError as e: print("\n2. 测试模块初始化...") try: - processor = get_multimodal_processor() + processor = get_multimodal_processor() print(" ✓ MultimodalProcessor 初始化成功") print(f" - 临时目录: {processor.temp_dir}") print(f" - 帧提取间隔: {processor.frame_interval}秒") @@ -50,14 +50,14 @@ except Exception as e: print(f" ✗ MultimodalProcessor 初始化失败: {e}") try: - img_processor = get_image_processor() + img_processor = get_image_processor() print(" ✓ ImageProcessor 初始化成功") print(f" - 临时目录: {img_processor.temp_dir}") except Exception as e: print(f" ✗ ImageProcessor 初始化失败: {e}") try: - linker = get_multimodal_entity_linker() + linker = get_multimodal_entity_linker() print(" ✓ MultimodalEntityLinker 初始化成功") print(f" - 相似度阈值: {linker.similarity_threshold}") except Exception as e: @@ -67,20 +67,20 @@ except Exception as e: print("\n3. 测试实体关联功能...") try: - linker = get_multimodal_entity_linker() + linker = get_multimodal_entity_linker() # 测试字符串相似度 - sim = linker.calculate_string_similarity("Project Alpha", "Project Alpha") + sim = linker.calculate_string_similarity("Project Alpha", "Project Alpha") assert sim == 1.0, "完全匹配应该返回1.0" print(f" ✓ 字符串相似度计算正常 (完全匹配: {sim})") - sim = linker.calculate_string_similarity("K8s", "Kubernetes") + sim = linker.calculate_string_similarity("K8s", "Kubernetes") print(f" ✓ 字符串相似度计算正常 (不同字符串: {sim:.2f})") # 测试实体相似度 - entity1 = {"name": "Project Alpha", "type": "PROJECT", "definition": "核心项目"} - entity2 = {"name": "Project Alpha", "type": "PROJECT", "definition": "主要项目"} - sim, match_type = linker.calculate_entity_similarity(entity1, entity2) + entity1 = {"name": "Project Alpha", "type": "PROJECT", "definition": "核心项目"} + entity2 = {"name": "Project Alpha", "type": "PROJECT", "definition": "主要项目"} + sim, match_type = linker.calculate_entity_similarity(entity1, entity2) print(f" ✓ 实体相似度计算正常 (相似度: {sim:.2f}, 类型: {match_type})") except Exception as e: @@ -90,7 +90,7 @@ except Exception as e: print("\n4. 测试图片处理器功能...") try: - processor = get_image_processor() + processor = get_image_processor() # 测试图片类型检测(使用模拟数据) print(f" ✓ 支持的图片类型: {list(processor.IMAGE_TYPES.keys())}") @@ -103,7 +103,7 @@ except Exception as e: print("\n5. 测试视频处理器配置...") try: - processor = get_multimodal_processor() + processor = get_multimodal_processor() print(f" ✓ 视频目录: {processor.video_dir}") print(f" ✓ 帧目录: {processor.frames_dir}") @@ -129,11 +129,11 @@ print("\n6. 测试数据库多模态方法...") try: from db_manager import get_db_manager - db = get_db_manager() + db = get_db_manager() # 检查多模态表是否存在 - conn = db.get_conn() - tables = ["videos", "video_frames", "images", "multimodal_mentions", "multimodal_entity_links"] + conn = db.get_conn() + tables = ["videos", "video_frames", "images", "multimodal_mentions", "multimodal_entity_links"] for table in tables: try: @@ -147,6 +147,6 @@ try: except Exception as e: print(f" ✗ 数据库多模态方法测试失败: {e}") -print("\n" + "=" * 60) +print("\n" + " = " * 60) print("测试完成") -print("=" * 60) +print(" = " * 60) diff --git a/backend/test_phase7_task6_8.py b/backend/test_phase7_task6_8.py index 6cd872f..40beaae 100644 --- a/backend/test_phase7_task6_8.py +++ b/backend/test_phase7_task6_8.py @@ -21,27 +21,27 @@ from search_manager import ( sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -def test_fulltext_search(): +def test_fulltext_search() -> None: """测试全文搜索""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试全文搜索 (FullTextSearch)") - print("=" * 60) + print(" = " * 60) - search = FullTextSearch() + search = FullTextSearch() # 测试索引创建 print("\n1. 测试索引创建...") - success = search.index_content( - content_id="test_entity_1", - content_type="entity", - project_id="test_project", - text="这是一个测试实体,用于验证全文搜索功能。支持关键词高亮显示。", + success = search.index_content( + content_id = "test_entity_1", + content_type = "entity", + project_id = "test_project", + text = "这是一个测试实体,用于验证全文搜索功能。支持关键词高亮显示。", ) print(f" 索引创建: {'✓ 成功' if success else '✗ 失败'}") # 测试搜索 print("\n2. 测试关键词搜索...") - results = search.search("测试", project_id="test_project") + results = search.search("测试", project_id = "test_project") print(f" 搜索结果数量: {len(results)}") if results: print(f" 第一个结果: {results[0].content[:50]}...") @@ -49,28 +49,28 @@ def test_fulltext_search(): # 测试布尔搜索 print("\n3. 测试布尔搜索...") - results = search.search("测试 AND 全文", project_id="test_project") + results = search.search("测试 AND 全文", project_id = "test_project") print(f" AND 搜索结果: {len(results)}") - results = search.search("测试 OR 关键词", project_id="test_project") + results = search.search("测试 OR 关键词", project_id = "test_project") print(f" OR 搜索结果: {len(results)}") # 测试高亮 print("\n4. 测试文本高亮...") - highlighted = search.highlight_text("这是一个测试实体,用于验证全文搜索功能。", "测试 全文") + highlighted = search.highlight_text("这是一个测试实体,用于验证全文搜索功能。", "测试 全文") print(f" 高亮结果: {highlighted}") print("\n✓ 全文搜索测试完成") return True -def test_semantic_search(): +def test_semantic_search() -> None: """测试语义搜索""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试语义搜索 (SemanticSearch)") - print("=" * 60) + print(" = " * 60) - semantic = SemanticSearch() + semantic = SemanticSearch() # 检查可用性 print(f"\n1. 语义搜索可用性: {'✓ 可用' if semantic.is_available() else '✗ 不可用'}") @@ -81,18 +81,18 @@ def test_semantic_search(): # 测试 embedding 生成 print("\n2. 测试 embedding 生成...") - embedding = semantic.generate_embedding("这是一个测试句子") + embedding = semantic.generate_embedding("这是一个测试句子") if embedding: print(f" Embedding 维度: {len(embedding)}") print(f" 前5个值: {embedding[:5]}") # 测试索引 print("\n3. 测试语义索引...") - success = semantic.index_embedding( - content_id="test_content_1", - content_type="transcript", - project_id="test_project", - text="这是用于语义搜索测试的文本内容。", + success = semantic.index_embedding( + content_id = "test_content_1", + content_type = "transcript", + project_id = "test_project", + text = "这是用于语义搜索测试的文本内容。", ) print(f" 索引创建: {'✓ 成功' if success else '✗ 失败'}") @@ -100,13 +100,13 @@ def test_semantic_search(): return True -def test_entity_path_discovery(): +def test_entity_path_discovery() -> None: """测试实体路径发现""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试实体路径发现 (EntityPathDiscovery)") - print("=" * 60) + print(" = " * 60) - discovery = EntityPathDiscovery() + discovery = EntityPathDiscovery() print("\n1. 测试路径发现初始化...") print(f" 数据库路径: {discovery.db_path}") @@ -119,13 +119,13 @@ def test_entity_path_discovery(): return True -def test_knowledge_gap_detection(): +def test_knowledge_gap_detection() -> None: """测试知识缺口识别""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试知识缺口识别 (KnowledgeGapDetection)") - print("=" * 60) + print(" = " * 60) - detection = KnowledgeGapDetection() + detection = KnowledgeGapDetection() print("\n1. 测试缺口检测初始化...") print(f" 数据库路径: {detection.db_path}") @@ -138,32 +138,32 @@ def test_knowledge_gap_detection(): return True -def test_cache_manager(): +def test_cache_manager() -> None: """测试缓存管理器""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试缓存管理器 (CacheManager)") - print("=" * 60) + print(" = " * 60) - cache = CacheManager() + cache = CacheManager() print(f"\n1. 缓存后端: {'Redis' if cache.use_redis else '内存 LRU'}") print("\n2. 测试缓存操作...") # 设置缓存 - cache.set("test_key_1", {"name": "测试数据", "value": 123}, ttl=60) + cache.set("test_key_1", {"name": "测试数据", "value": 123}, ttl = 60) print(" ✓ 设置缓存 test_key_1") # 获取缓存 - _ = cache.get("test_key_1") + _ = cache.get("test_key_1") print(" ✓ 获取缓存: {value}") # 批量操作 cache.set_many( - {"batch_key_1": "value1", "batch_key_2": "value2", "batch_key_3": "value3"}, ttl=60 + {"batch_key_1": "value1", "batch_key_2": "value2", "batch_key_3": "value3"}, ttl = 60 ) print(" ✓ 批量设置缓存") - _ = cache.get_many(["batch_key_1", "batch_key_2", "batch_key_3"]) + _ = cache.get_many(["batch_key_1", "batch_key_2", "batch_key_3"]) print(" ✓ 批量获取缓存: {len(values)} 个") # 删除缓存 @@ -171,7 +171,7 @@ def test_cache_manager(): print(" ✓ 删除缓存 test_key_1") # 获取统计 - stats = cache.get_stats() + stats = cache.get_stats() print("\n3. 缓存统计:") print(f" 总请求数: {stats['total_requests']}") print(f" 命中数: {stats['hits']}") @@ -186,13 +186,13 @@ def test_cache_manager(): return True -def test_task_queue(): +def test_task_queue() -> None: """测试任务队列""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试任务队列 (TaskQueue)") - print("=" * 60) + print(" = " * 60) - queue = TaskQueue() + queue = TaskQueue() print(f"\n1. 任务队列可用性: {'✓ 可用' if queue.is_available() else '✗ 不可用'}") print(f" 后端: {'Celery' if queue.use_celery else '内存'}") @@ -200,25 +200,25 @@ def test_task_queue(): print("\n2. 测试任务提交...") # 定义测试任务处理器 - def test_task_handler(payload): + def test_task_handler(payload) -> None: print(f" 执行任务: {payload}") return {"status": "success", "processed": True} queue.register_handler("test_task", test_task_handler) # 提交任务 - task_id = queue.submit( - task_type="test_task", payload={"test": "data", "timestamp": time.time()} + task_id = queue.submit( + task_type = "test_task", payload = {"test": "data", "timestamp": time.time()} ) print(" ✓ 提交任务: {task_id}") # 获取任务状态 - task_info = queue.get_status(task_id) + task_info = queue.get_status(task_id) if task_info: print(" ✓ 任务状态: {task_info.status}") # 获取统计 - stats = queue.get_stats() + stats = queue.get_stats() print("\n3. 任务队列统计:") print(f" 后端: {stats['backend']}") print(f" 按状态统计: {stats.get('by_status', {})}") @@ -227,38 +227,38 @@ def test_task_queue(): return True -def test_performance_monitor(): +def test_performance_monitor() -> None: """测试性能监控""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试性能监控 (PerformanceMonitor)") - print("=" * 60) + print(" = " * 60) - monitor = PerformanceMonitor() + monitor = PerformanceMonitor() print("\n1. 测试指标记录...") # 记录一些测试指标 for i in range(5): monitor.record_metric( - metric_type="api_response", - duration_ms=50 + i * 10, - endpoint="/api/v1/test", - metadata={"test": True}, + metric_type = "api_response", + duration_ms = 50 + i * 10, + endpoint = "/api/v1/test", + metadata = {"test": True}, ) for i in range(3): monitor.record_metric( - metric_type="db_query", - duration_ms=20 + i * 5, - endpoint="SELECT test", - metadata={"test": True}, + metric_type = "db_query", + duration_ms = 20 + i * 5, + endpoint = "SELECT test", + metadata = {"test": True}, ) print(" ✓ 记录了 8 个测试指标") # 获取统计 print("\n2. 获取性能统计...") - stats = monitor.get_stats(hours=1) + stats = monitor.get_stats(hours = 1) print(f" 总请求数: {stats['overall']['total_requests']}") print(f" 平均响应时间: {stats['overall']['avg_duration_ms']} ms") print(f" 最大响应时间: {stats['overall']['max_duration_ms']} ms") @@ -274,19 +274,19 @@ def test_performance_monitor(): return True -def test_search_manager(): +def test_search_manager() -> None: """测试搜索管理器""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试搜索管理器 (SearchManager)") - print("=" * 60) + print(" = " * 60) - manager = get_search_manager() + manager = get_search_manager() print("\n1. 搜索管理器初始化...") print(" ✓ 搜索管理器已初始化") print("\n2. 获取搜索统计...") - stats = manager.get_search_stats() + stats = manager.get_search_stats() print(f" 全文索引数: {stats['fulltext_indexed']}") print(f" 语义索引数: {stats['semantic_indexed']}") print(f" 语义搜索可用: {stats['semantic_search_available']}") @@ -295,24 +295,24 @@ def test_search_manager(): return True -def test_performance_manager(): +def test_performance_manager() -> None: """测试性能管理器""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试性能管理器 (PerformanceManager)") - print("=" * 60) + print(" = " * 60) - manager = get_performance_manager() + manager = get_performance_manager() print("\n1. 性能管理器初始化...") print(" ✓ 性能管理器已初始化") print("\n2. 获取系统健康状态...") - health = manager.get_health_status() + health = manager.get_health_status() print(f" 缓存后端: {health['cache']['backend']}") print(f" 任务队列后端: {health['task_queue']['backend']}") print("\n3. 获取完整统计...") - stats = manager.get_full_stats() + stats = manager.get_full_stats() print(f" 缓存统计: {stats['cache']['total_requests']} 请求") print(f" 任务队列统计: {stats['task_queue']}") @@ -320,14 +320,14 @@ def test_performance_manager(): return True -def run_all_tests(): +def run_all_tests() -> None: """运行所有测试""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("InsightFlow Phase 7 Task 6 & 8 测试") print("高级搜索与发现 + 性能优化与扩展") - print("=" * 60) + print(" = " * 60) - results = [] + results = [] # 搜索模块测试 try: @@ -386,15 +386,15 @@ def run_all_tests(): results.append(("性能管理器", False)) # 打印测试汇总 - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试汇总") - print("=" * 60) + print(" = " * 60) - passed = sum(1 for _, result in results if result) - total = len(results) + passed = sum(1 for _, result in results if result) + total = len(results) for name, result in results: - status = "✓ 通过" if result else "✗ 失败" + status = "✓ 通过" if result else "✗ 失败" print(f" {status} - {name}") print(f"\n总计: {passed}/{total} 测试通过") @@ -408,5 +408,5 @@ def run_all_tests(): if __name__ == "__main__": - success = run_all_tests() + success = run_all_tests() sys.exit(0 if success else 1) diff --git a/backend/test_phase8_task1.py b/backend/test_phase8_task1.py index b014b62..c7ddcd5 100644 --- a/backend/test_phase8_task1.py +++ b/backend/test_phase8_task1.py @@ -18,18 +18,18 @@ from tenant_manager import get_tenant_manager sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -def test_tenant_management(): +def test_tenant_management() -> None: """测试租户管理功能""" - print("=" * 60) + print(" = " * 60) print("测试 1: 租户管理") - print("=" * 60) + print(" = " * 60) - manager = get_tenant_manager() + manager = get_tenant_manager() # 1. 创建租户 print("\n1.1 创建租户...") - tenant = manager.create_tenant( - name="Test Company", owner_id="user_001", tier="pro", description="A test company tenant" + tenant = manager.create_tenant( + name = "Test Company", owner_id = "user_001", tier = "pro", description = "A test company tenant" ) print(f"✅ 租户创建成功: {tenant.id}") print(f" - 名称: {tenant.name}") @@ -40,43 +40,43 @@ def test_tenant_management(): # 2. 获取租户 print("\n1.2 获取租户信息...") - fetched = manager.get_tenant(tenant.id) + fetched = manager.get_tenant(tenant.id) assert fetched is not None, "获取租户失败" print(f"✅ 获取租户成功: {fetched.name}") # 3. 通过 slug 获取 print("\n1.3 通过 slug 获取租户...") - by_slug = manager.get_tenant_by_slug(tenant.slug) + by_slug = manager.get_tenant_by_slug(tenant.slug) assert by_slug is not None, "通过 slug 获取失败" print(f"✅ 通过 slug 获取成功: {by_slug.name}") # 4. 更新租户 print("\n1.4 更新租户信息...") - updated = manager.update_tenant( - tenant_id=tenant.id, name="Test Company Updated", tier="enterprise" + updated = manager.update_tenant( + tenant_id = tenant.id, name = "Test Company Updated", tier = "enterprise" ) assert updated is not None, "更新租户失败" print(f"✅ 租户更新成功: {updated.name}, 层级: {updated.tier}") # 5. 列出租户 print("\n1.5 列出租户...") - tenants = manager.list_tenants(limit=10) + tenants = manager.list_tenants(limit = 10) print(f"✅ 找到 {len(tenants)} 个租户") return tenant.id -def test_domain_management(tenant_id: str): +def test_domain_management(tenant_id: str) -> None: """测试域名管理功能""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试 2: 域名管理") - print("=" * 60) + print(" = " * 60) - manager = get_tenant_manager() + manager = get_tenant_manager() # 1. 添加域名 print("\n2.1 添加自定义域名...") - domain = manager.add_domain(tenant_id=tenant_id, domain="test.example.com", is_primary=True) + domain = manager.add_domain(tenant_id = tenant_id, domain = "test.example.com", is_primary = True) print(f"✅ 域名添加成功: {domain.domain}") print(f" - ID: {domain.id}") print(f" - 状态: {domain.status}") @@ -84,19 +84,19 @@ def test_domain_management(tenant_id: str): # 2. 获取验证指导 print("\n2.2 获取域名验证指导...") - instructions = manager.get_domain_verification_instructions(domain.id) + instructions = manager.get_domain_verification_instructions(domain.id) print("✅ 验证指导:") print(f" - DNS 记录: {instructions['dns_record']}") print(f" - 文件验证: {instructions['file_verification']}") # 3. 验证域名 print("\n2.3 验证域名...") - verified = manager.verify_domain(tenant_id, domain.id) + verified = manager.verify_domain(tenant_id, domain.id) print(f"✅ 域名验证结果: {verified}") # 4. 通过域名获取租户 print("\n2.4 通过域名获取租户...") - by_domain = manager.get_tenant_by_domain("test.example.com") + by_domain = manager.get_tenant_by_domain("test.example.com") if by_domain: print(f"✅ 通过域名获取租户成功: {by_domain.name}") else: @@ -104,7 +104,7 @@ def test_domain_management(tenant_id: str): # 5. 列出域名 print("\n2.5 列出所有域名...") - domains = manager.list_domains(tenant_id) + domains = manager.list_domains(tenant_id) print(f"✅ 找到 {len(domains)} 个域名") for d in domains: print(f" - {d.domain} ({d.status})") @@ -112,25 +112,25 @@ def test_domain_management(tenant_id: str): return domain.id -def test_branding_management(tenant_id: str): +def test_branding_management(tenant_id: str) -> None: """测试品牌白标功能""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试 3: 品牌白标") - print("=" * 60) + print(" = " * 60) - manager = get_tenant_manager() + manager = get_tenant_manager() # 1. 更新品牌配置 print("\n3.1 更新品牌配置...") - branding = manager.update_branding( - tenant_id=tenant_id, - logo_url="https://example.com/logo.png", - favicon_url="https://example.com/favicon.ico", - primary_color="#1890ff", - secondary_color="#52c41a", - custom_css=".header { background: #1890ff; }", - custom_js="console.log('Custom JS loaded');", - login_page_bg="https://example.com/bg.jpg", + branding = manager.update_branding( + tenant_id = tenant_id, + logo_url = "https://example.com/logo.png", + favicon_url = "https://example.com/favicon.ico", + primary_color = "#1890ff", + secondary_color = "#52c41a", + custom_css = ".header { background: #1890ff; }", + custom_js = "console.log('Custom JS loaded');", + login_page_bg = "https://example.com/bg.jpg", ) print("✅ 品牌配置更新成功") print(f" - Logo: {branding.logo_url}") @@ -139,67 +139,67 @@ def test_branding_management(tenant_id: str): # 2. 获取品牌配置 print("\n3.2 获取品牌配置...") - fetched = manager.get_branding(tenant_id) + fetched = manager.get_branding(tenant_id) assert fetched is not None, "获取品牌配置失败" print("✅ 获取品牌配置成功") # 3. 生成品牌 CSS print("\n3.3 生成品牌 CSS...") - css = manager.get_branding_css(tenant_id) + css = manager.get_branding_css(tenant_id) print(f"✅ 生成 CSS 成功 ({len(css)} 字符)") print(f" CSS 预览:\n{css[:200]}...") return branding.id -def test_member_management(tenant_id: str): +def test_member_management(tenant_id: str) -> None: """测试成员管理功能""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试 4: 成员管理") - print("=" * 60) + print(" = " * 60) - manager = get_tenant_manager() + manager = get_tenant_manager() # 1. 邀请成员 print("\n4.1 邀请成员...") - member1 = manager.invite_member( - tenant_id=tenant_id, email="admin@test.com", role="admin", invited_by="user_001" + member1 = manager.invite_member( + tenant_id = tenant_id, email = "admin@test.com", role = "admin", invited_by = "user_001" ) print(f"✅ 成员邀请成功: {member1.email}") print(f" - ID: {member1.id}") print(f" - 角色: {member1.role}") print(f" - 权限: {member1.permissions}") - member2 = manager.invite_member( - tenant_id=tenant_id, email="member@test.com", role="member", invited_by="user_001" + member2 = manager.invite_member( + tenant_id = tenant_id, email = "member@test.com", role = "member", invited_by = "user_001" ) print(f"✅ 成员邀请成功: {member2.email}") # 2. 接受邀请 print("\n4.2 接受邀请...") - accepted = manager.accept_invitation(member1.id, "user_002") + accepted = manager.accept_invitation(member1.id, "user_002") print(f"✅ 邀请接受结果: {accepted}") # 3. 列出成员 print("\n4.3 列出所有成员...") - members = manager.list_members(tenant_id) + members = manager.list_members(tenant_id) print(f"✅ 找到 {len(members)} 个成员") for m in members: print(f" - {m.email} ({m.role}) - {m.status}") # 4. 检查权限 print("\n4.4 检查权限...") - can_manage = manager.check_permission(tenant_id, "user_002", "project", "create") + can_manage = manager.check_permission(tenant_id, "user_002", "project", "create") print(f"✅ user_002 可以创建项目: {can_manage}") # 5. 更新成员角色 print("\n4.5 更新成员角色...") - updated = manager.update_member_role(tenant_id, member2.id, "viewer") + updated = manager.update_member_role(tenant_id, member2.id, "viewer") print(f"✅ 角色更新结果: {updated}") # 6. 获取用户所属租户 print("\n4.6 获取用户所属租户...") - user_tenants = manager.get_user_tenants("user_002") + user_tenants = manager.get_user_tenants("user_002") print(f"✅ user_002 属于 {len(user_tenants)} 个租户") for t in user_tenants: print(f" - {t['name']} ({t['member_role']})") @@ -207,30 +207,30 @@ def test_member_management(tenant_id: str): return member1.id, member2.id -def test_usage_tracking(tenant_id: str): +def test_usage_tracking(tenant_id: str) -> None: """测试资源使用统计功能""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("测试 5: 资源使用统计") - print("=" * 60) + print(" = " * 60) - manager = get_tenant_manager() + manager = get_tenant_manager() # 1. 记录使用 print("\n5.1 记录资源使用...") manager.record_usage( - tenant_id=tenant_id, - storage_bytes=1024 * 1024 * 50, # 50MB - transcription_seconds=600, # 10分钟 - api_calls=100, - projects_count=5, - entities_count=50, - members_count=3, + tenant_id = tenant_id, + storage_bytes = 1024 * 1024 * 50, # 50MB + transcription_seconds = 600, # 10分钟 + api_calls = 100, + projects_count = 5, + entities_count = 50, + members_count = 3, ) print("✅ 资源使用记录成功") # 2. 获取使用统计 print("\n5.2 获取使用统计...") - stats = manager.get_usage_stats(tenant_id) + stats = manager.get_usage_stats(tenant_id) print("✅ 使用统计:") print(f" - 存储: {stats['storage_mb']:.2f} MB") print(f" - 转录: {stats['transcription_minutes']:.2f} 分钟") @@ -243,19 +243,19 @@ def test_usage_tracking(tenant_id: str): # 3. 检查资源限制 print("\n5.3 检查资源限制...") for resource in ["storage", "transcription", "api_calls", "projects", "entities", "members"]: - allowed, current, limit = manager.check_resource_limit(tenant_id, resource) + allowed, current, limit = manager.check_resource_limit(tenant_id, resource) print(f" - {resource}: {current}/{limit} ({'✅' if allowed else '❌'})") return stats -def cleanup(tenant_id: str, domain_id: str, member_ids: list): +def cleanup(tenant_id: str, domain_id: str, member_ids: list) -> None: """清理测试数据""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("清理测试数据") - print("=" * 60) + print(" = " * 60) - manager = get_tenant_manager() + manager = get_tenant_manager() # 移除成员 for member_id in member_ids: @@ -273,28 +273,28 @@ def cleanup(tenant_id: str, domain_id: str, member_ids: list): print(f"✅ 租户已删除: {tenant_id}") -def main(): +def main() -> None: """主测试函数""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("InsightFlow Phase 8 Task 1 - 多租户 SaaS 架构测试") - print("=" * 60) + print(" = " * 60) - tenant_id = None - domain_id = None - member_ids = [] + tenant_id = None + domain_id = None + member_ids = [] try: # 运行所有测试 - tenant_id = test_tenant_management() - domain_id = test_domain_management(tenant_id) + tenant_id = test_tenant_management() + domain_id = test_domain_management(tenant_id) test_branding_management(tenant_id) - m1, m2 = test_member_management(tenant_id) - member_ids = [m1, m2] + m1, m2 = test_member_management(tenant_id) + member_ids = [m1, m2] test_usage_tracking(tenant_id) - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("✅ 所有测试通过!") - print("=" * 60) + print(" = " * 60) except Exception as e: print(f"\n❌ 测试失败: {e}") diff --git a/backend/test_phase8_task2.py b/backend/test_phase8_task2.py index f6f749e..bc2589c 100644 --- a/backend/test_phase8_task2.py +++ b/backend/test_phase8_task2.py @@ -12,31 +12,31 @@ from subscription_manager import PaymentProvider, SubscriptionManager sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -def test_subscription_manager(): +def test_subscription_manager() -> None: """测试订阅管理器""" - print("=" * 60) + print(" = " * 60) print("InsightFlow Phase 8 Task 2 - 订阅与计费系统测试") - print("=" * 60) + print(" = " * 60) # 使用临时文件数据库进行测试 - db_path = tempfile.mktemp(suffix=".db") + db_path = tempfile.mktemp(suffix = ".db") try: - manager = SubscriptionManager(db_path=db_path) + manager = SubscriptionManager(db_path = db_path) print("\n1. 测试订阅计划管理") print("-" * 40) # 获取默认计划 - plans = manager.list_plans() + plans = manager.list_plans() print(f"✓ 默认计划数量: {len(plans)}") for plan in plans: print(f" - {plan.name} ({plan.tier}): ¥{plan.price_monthly}/月") # 通过 tier 获取计划 - free_plan = manager.get_plan_by_tier("free") - pro_plan = manager.get_plan_by_tier("pro") - enterprise_plan = manager.get_plan_by_tier("enterprise") + free_plan = manager.get_plan_by_tier("free") + pro_plan = manager.get_plan_by_tier("pro") + enterprise_plan = manager.get_plan_by_tier("enterprise") assert free_plan is not None, "Free 计划应该存在" assert pro_plan is not None, "Pro 计划应该存在" @@ -49,14 +49,14 @@ def test_subscription_manager(): print("\n2. 测试订阅管理") print("-" * 40) - tenant_id = "test-tenant-001" + tenant_id = "test-tenant-001" # 创建订阅 - subscription = manager.create_subscription( - tenant_id=tenant_id, - plan_id=pro_plan.id, - payment_provider=PaymentProvider.STRIPE.value, - trial_days=14, + subscription = manager.create_subscription( + tenant_id = tenant_id, + plan_id = pro_plan.id, + payment_provider = PaymentProvider.STRIPE.value, + trial_days = 14, ) print(f"✓ 创建订阅: {subscription.id}") @@ -66,7 +66,7 @@ def test_subscription_manager(): print(f" - 试用结束: {subscription.trial_end}") # 获取租户订阅 - tenant_sub = manager.get_tenant_subscription(tenant_id) + tenant_sub = manager.get_tenant_subscription(tenant_id) assert tenant_sub is not None, "应该能获取到租户订阅" print(f"✓ 获取租户订阅: {tenant_sub.id}") @@ -74,27 +74,27 @@ def test_subscription_manager(): print("-" * 40) # 记录转录用量 - usage1 = manager.record_usage( - tenant_id=tenant_id, - resource_type="transcription", - quantity=120, - unit="minute", - description="会议转录", + usage1 = manager.record_usage( + tenant_id = tenant_id, + resource_type = "transcription", + quantity = 120, + unit = "minute", + description = "会议转录", ) print(f"✓ 记录转录用量: {usage1.quantity} {usage1.unit}, 费用: ¥{usage1.cost:.2f}") # 记录存储用量 - usage2 = manager.record_usage( - tenant_id=tenant_id, - resource_type="storage", - quantity=2.5, - unit="gb", - description="文件存储", + usage2 = manager.record_usage( + tenant_id = tenant_id, + resource_type = "storage", + quantity = 2.5, + unit = "gb", + description = "文件存储", ) print(f"✓ 记录存储用量: {usage2.quantity} {usage2.unit}, 费用: ¥{usage2.cost:.2f}") # 获取用量汇总 - summary = manager.get_usage_summary(tenant_id) + summary = manager.get_usage_summary(tenant_id) print("✓ 用量汇总:") print(f" - 总费用: ¥{summary['total_cost']:.2f}") for resource, data in summary["breakdown"].items(): @@ -104,12 +104,12 @@ def test_subscription_manager(): print("-" * 40) # 创建支付 - payment = manager.create_payment( - tenant_id=tenant_id, - amount=99.0, - currency="CNY", - provider=PaymentProvider.ALIPAY.value, - payment_method="qrcode", + payment = manager.create_payment( + tenant_id = tenant_id, + amount = 99.0, + currency = "CNY", + provider = PaymentProvider.ALIPAY.value, + payment_method = "qrcode", ) print(f"✓ 创建支付: {payment.id}") print(f" - 金额: ¥{payment.amount}") @@ -117,22 +117,22 @@ def test_subscription_manager(): print(f" - 状态: {payment.status}") # 确认支付 - confirmed = manager.confirm_payment(payment.id, "alipay_123456") + confirmed = manager.confirm_payment(payment.id, "alipay_123456") print(f"✓ 确认支付完成: {confirmed.status}") # 列出支付记录 - payments = manager.list_payments(tenant_id) + payments = manager.list_payments(tenant_id) print(f"✓ 支付记录数量: {len(payments)}") print("\n5. 测试发票管理") print("-" * 40) # 列出发票 - invoices = manager.list_invoices(tenant_id) + invoices = manager.list_invoices(tenant_id) print(f"✓ 发票数量: {len(invoices)}") if invoices: - invoice = invoices[0] + invoice = invoices[0] print(f" - 发票号: {invoice.invoice_number}") print(f" - 金额: ¥{invoice.amount_due}") print(f" - 状态: {invoice.status}") @@ -141,12 +141,12 @@ def test_subscription_manager(): print("-" * 40) # 申请退款 - refund = manager.request_refund( - tenant_id=tenant_id, - payment_id=payment.id, - amount=50.0, - reason="服务不满意", - requested_by="user_001", + refund = manager.request_refund( + tenant_id = tenant_id, + payment_id = payment.id, + amount = 50.0, + reason = "服务不满意", + requested_by = "user_001", ) print(f"✓ 申请退款: {refund.id}") print(f" - 金额: ¥{refund.amount}") @@ -154,21 +154,21 @@ def test_subscription_manager(): print(f" - 状态: {refund.status}") # 批准退款 - approved = manager.approve_refund(refund.id, "admin_001") + approved = manager.approve_refund(refund.id, "admin_001") print(f"✓ 批准退款: {approved.status}") # 完成退款 - completed = manager.complete_refund(refund.id, "refund_123456") + completed = manager.complete_refund(refund.id, "refund_123456") print(f"✓ 完成退款: {completed.status}") # 列出退款记录 - refunds = manager.list_refunds(tenant_id) + refunds = manager.list_refunds(tenant_id) print(f"✓ 退款记录数量: {len(refunds)}") print("\n7. 测试账单历史") print("-" * 40) - history = manager.get_billing_history(tenant_id) + history = manager.get_billing_history(tenant_id) print(f"✓ 账单历史记录数量: {len(history)}") for h in history: print(f" - [{h.type}] {h.description}: ¥{h.amount}") @@ -177,24 +177,24 @@ def test_subscription_manager(): print("-" * 40) # Stripe Checkout - stripe_session = manager.create_stripe_checkout_session( - tenant_id=tenant_id, - plan_id=enterprise_plan.id, - success_url="https://example.com/success", - cancel_url="https://example.com/cancel", + stripe_session = manager.create_stripe_checkout_session( + tenant_id = tenant_id, + plan_id = enterprise_plan.id, + success_url = "https://example.com/success", + cancel_url = "https://example.com/cancel", ) print(f"✓ Stripe Checkout 会话: {stripe_session['session_id']}") # 支付宝订单 - alipay_order = manager.create_alipay_order(tenant_id=tenant_id, plan_id=pro_plan.id) + alipay_order = manager.create_alipay_order(tenant_id = tenant_id, plan_id = pro_plan.id) print(f"✓ 支付宝订单: {alipay_order['order_id']}") # 微信支付订单 - wechat_order = manager.create_wechat_order(tenant_id=tenant_id, plan_id=pro_plan.id) + wechat_order = manager.create_wechat_order(tenant_id = tenant_id, plan_id = pro_plan.id) print(f"✓ 微信支付订单: {wechat_order['order_id']}") # Webhook 处理 - webhook_result = manager.handle_webhook( + webhook_result = manager.handle_webhook( "stripe", {"event_type": "checkout.session.completed", "data": {"object": {"id": "cs_test"}}}, ) @@ -204,19 +204,19 @@ def test_subscription_manager(): print("-" * 40) # 更改计划 - changed = manager.change_plan( - subscription_id=subscription.id, new_plan_id=enterprise_plan.id + changed = manager.change_plan( + subscription_id = subscription.id, new_plan_id = enterprise_plan.id ) print(f"✓ 更改计划: {changed.plan_id} (Enterprise)") # 取消订阅 - cancelled = manager.cancel_subscription(subscription_id=subscription.id, at_period_end=True) + cancelled = manager.cancel_subscription(subscription_id = subscription.id, at_period_end = True) print(f"✓ 取消订阅: {cancelled.status}") print(f" - 周期结束时取消: {cancelled.cancel_at_period_end}") - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("所有测试通过! ✓") - print("=" * 60) + print(" = " * 60) finally: # 清理临时数据库 diff --git a/backend/test_phase8_task4.py b/backend/test_phase8_task4.py index 6305dfc..702363d 100644 --- a/backend/test_phase8_task4.py +++ b/backend/test_phase8_task4.py @@ -14,31 +14,31 @@ from ai_manager import ModelType, PredictionType, get_ai_manager sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -def test_custom_model(): +def test_custom_model() -> None: """测试自定义模型功能""" print("\n=== 测试自定义模型 ===") - manager = get_ai_manager() + manager = get_ai_manager() # 1. 创建自定义模型 print("1. 创建自定义模型...") - model = manager.create_custom_model( - tenant_id="tenant_001", - name="领域实体识别模型", - description="用于识别医疗领域实体的自定义模型", - model_type=ModelType.CUSTOM_NER, - training_data={ + model = manager.create_custom_model( + tenant_id = "tenant_001", + name = "领域实体识别模型", + description = "用于识别医疗领域实体的自定义模型", + model_type = ModelType.CUSTOM_NER, + training_data = { "entity_types": ["DISEASE", "SYMPTOM", "DRUG", "TREATMENT"], "domain": "medical", }, - hyperparameters={"epochs": 15, "learning_rate": 0.001, "batch_size": 32}, - created_by="user_001", + hyperparameters = {"epochs": 15, "learning_rate": 0.001, "batch_size": 32}, + created_by = "user_001", ) print(f" 创建成功: {model.id}, 状态: {model.status.value}") # 2. 添加训练样本 print("2. 添加训练样本...") - samples = [ + samples = [ { "text": "患者张三患有高血压,正在服用降压药治疗。", "entities": [ @@ -66,22 +66,22 @@ def test_custom_model(): ] for sample_data in samples: - sample = manager.add_training_sample( - model_id=model.id, - text=sample_data["text"], - entities=sample_data["entities"], - metadata={"source": "manual"}, + sample = manager.add_training_sample( + model_id = model.id, + text = sample_data["text"], + entities = sample_data["entities"], + metadata = {"source": "manual"}, ) print(f" 添加样本: {sample.id}") # 3. 获取训练样本 print("3. 获取训练样本...") - all_samples = manager.get_training_samples(model.id) + all_samples = manager.get_training_samples(model.id) print(f" 共有 {len(all_samples)} 个训练样本") # 4. 列出自定义模型 print("4. 列出自定义模型...") - models = manager.list_custom_models(tenant_id="tenant_001") + models = manager.list_custom_models(tenant_id = "tenant_001") print(f" 找到 {len(models)} 个模型") for m in models: print(f" - {m.name} ({m.model_type.value}): {m.status.value}") @@ -89,16 +89,16 @@ def test_custom_model(): return model.id -async def test_train_and_predict(model_id: str): +async def test_train_and_predict(model_id: str) -> None: """测试训练和预测""" print("\n=== 测试模型训练和预测 ===") - manager = get_ai_manager() + manager = get_ai_manager() # 1. 训练模型 print("1. 训练模型...") try: - trained_model = await manager.train_custom_model(model_id) + trained_model = await manager.train_custom_model(model_id) print(f" 训练完成: {trained_model.status.value}") print(f" 指标: {trained_model.metrics}") except Exception as e: @@ -107,50 +107,50 @@ async def test_train_and_predict(model_id: str): # 2. 使用模型预测 print("2. 使用模型预测...") - test_text = "赵六患有糖尿病,正在使用胰岛素治疗。" + test_text = "赵六患有糖尿病,正在使用胰岛素治疗。" try: - entities = await manager.predict_with_custom_model(model_id, test_text) + entities = await manager.predict_with_custom_model(model_id, test_text) print(f" 输入: {test_text}") print(f" 预测实体: {entities}") except Exception as e: print(f" 预测失败: {e}") -def test_prediction_models(): +def test_prediction_models() -> None: """测试预测模型""" print("\n=== 测试预测模型 ===") - manager = get_ai_manager() + manager = get_ai_manager() # 1. 创建趋势预测模型 print("1. 创建趋势预测模型...") - trend_model = manager.create_prediction_model( - tenant_id="tenant_001", - project_id="project_001", - name="实体数量趋势预测", - prediction_type=PredictionType.TREND, - target_entity_type="PERSON", - features=["entity_count", "time_period", "document_count"], - model_config={"algorithm": "linear_regression", "window_size": 7}, + trend_model = manager.create_prediction_model( + tenant_id = "tenant_001", + project_id = "project_001", + name = "实体数量趋势预测", + prediction_type = PredictionType.TREND, + target_entity_type = "PERSON", + features = ["entity_count", "time_period", "document_count"], + model_config = {"algorithm": "linear_regression", "window_size": 7}, ) print(f" 创建成功: {trend_model.id}") # 2. 创建异常检测模型 print("2. 创建异常检测模型...") - anomaly_model = manager.create_prediction_model( - tenant_id="tenant_001", - project_id="project_001", - name="实体增长异常检测", - prediction_type=PredictionType.ANOMALY, - target_entity_type=None, - features=["daily_growth", "weekly_growth"], - model_config={"threshold": 2.5, "sensitivity": "medium"}, + anomaly_model = manager.create_prediction_model( + tenant_id = "tenant_001", + project_id = "project_001", + name = "实体增长异常检测", + prediction_type = PredictionType.ANOMALY, + target_entity_type = None, + features = ["daily_growth", "weekly_growth"], + model_config = {"threshold": 2.5, "sensitivity": "medium"}, ) print(f" 创建成功: {anomaly_model.id}") # 3. 列出预测模型 print("3. 列出预测模型...") - models = manager.list_prediction_models(tenant_id="tenant_001") + models = manager.list_prediction_models(tenant_id = "tenant_001") print(f" 找到 {len(models)} 个预测模型") for m in models: print(f" - {m.name} ({m.prediction_type.value})") @@ -158,15 +158,15 @@ def test_prediction_models(): return trend_model.id, anomaly_model.id -async def test_predictions(trend_model_id: str, anomaly_model_id: str): +async def test_predictions(trend_model_id: str, anomaly_model_id: str) -> None: """测试预测功能""" print("\n=== 测试预测功能 ===") - manager = get_ai_manager() + manager = get_ai_manager() # 1. 训练趋势预测模型 print("1. 训练趋势预测模型...") - historical_data = [ + historical_data = [ {"date": "2024-01-01", "value": 10}, {"date": "2024-01-02", "value": 12}, {"date": "2024-01-03", "value": 15}, @@ -175,62 +175,62 @@ async def test_predictions(trend_model_id: str, anomaly_model_id: str): {"date": "2024-01-06", "value": 20}, {"date": "2024-01-07", "value": 22}, ] - trained = await manager.train_prediction_model(trend_model_id, historical_data) + trained = await manager.train_prediction_model(trend_model_id, historical_data) print(f" 训练完成,准确率: {trained.accuracy}") # 2. 趋势预测 print("2. 趋势预测...") - trend_result = await manager.predict( + trend_result = await manager.predict( trend_model_id, {"historical_values": [10, 12, 15, 14, 18, 20, 22]} ) print(f" 预测结果: {trend_result.prediction_data}") # 3. 异常检测 print("3. 异常检测...") - anomaly_result = await manager.predict( + anomaly_result = await manager.predict( anomaly_model_id, {"value": 50, "historical_values": [10, 12, 11, 13, 12, 14, 13]} ) print(f" 检测结果: {anomaly_result.prediction_data}") -def test_kg_rag(): +def test_kg_rag() -> None: """测试知识图谱 RAG""" print("\n=== 测试知识图谱 RAG ===") - manager = get_ai_manager() + manager = get_ai_manager() # 创建 RAG 配置 print("1. 创建知识图谱 RAG 配置...") - rag = manager.create_kg_rag( - tenant_id="tenant_001", - project_id="project_001", - name="项目知识问答", - description="基于项目知识图谱的智能问答", - kg_config={ + rag = manager.create_kg_rag( + tenant_id = "tenant_001", + project_id = "project_001", + name = "项目知识问答", + description = "基于项目知识图谱的智能问答", + kg_config = { "entity_types": ["PERSON", "ORG", "PROJECT", "TECH"], "relation_types": ["works_with", "belongs_to", "depends_on"], }, - retrieval_config={"top_k": 5, "similarity_threshold": 0.7, "expand_relations": True}, - generation_config={"temperature": 0.3, "max_tokens": 1000, "include_sources": True}, + retrieval_config = {"top_k": 5, "similarity_threshold": 0.7, "expand_relations": True}, + generation_config = {"temperature": 0.3, "max_tokens": 1000, "include_sources": True}, ) print(f" 创建成功: {rag.id}") # 列出 RAG 配置 print("2. 列出 RAG 配置...") - rags = manager.list_kg_rags(tenant_id="tenant_001") + rags = manager.list_kg_rags(tenant_id = "tenant_001") print(f" 找到 {len(rags)} 个配置") return rag.id -async def test_kg_rag_query(rag_id: str): +async def test_kg_rag_query(rag_id: str) -> None: """测试 RAG 查询""" print("\n=== 测试知识图谱 RAG 查询 ===") - manager = get_ai_manager() + manager = get_ai_manager() # 模拟项目实体和关系 - project_entities = [ + project_entities = [ {"id": "e1", "name": "张三", "type": "PERSON", "definition": "项目经理"}, {"id": "e2", "name": "李四", "type": "PERSON", "definition": "技术负责人"}, {"id": "e3", "name": "Project Alpha", "type": "PROJECT", "definition": "核心产品项目"}, @@ -238,7 +238,7 @@ async def test_kg_rag_query(rag_id: str): {"id": "e5", "name": "TechCorp", "type": "ORG", "definition": "科技公司"}, ] - project_relations = [ + project_relations = [ { "source_entity_id": "e1", "target_entity_id": "e3", @@ -275,14 +275,14 @@ async def test_kg_rag_query(rag_id: str): # 执行查询 print("1. 执行 RAG 查询...") - query_text = "Project Alpha 项目有哪些人参与?使用了什么技术?" + query_text = "Project Alpha 项目有哪些人参与?使用了什么技术?" try: - result = await manager.query_kg_rag( - rag_id=rag_id, - query=query_text, - project_entities=project_entities, - project_relations=project_relations, + result = await manager.query_kg_rag( + rag_id = rag_id, + query = query_text, + project_entities = project_entities, + project_relations = project_relations, ) print(f" 查询: {result.query}") @@ -294,14 +294,14 @@ async def test_kg_rag_query(rag_id: str): print(f" 查询失败: {e}") -async def test_smart_summary(): +async def test_smart_summary() -> None: """测试智能摘要""" print("\n=== 测试智能摘要 ===") - manager = get_ai_manager() + manager = get_ai_manager() # 模拟转录文本 - transcript_text = """ + transcript_text = """ 今天的会议主要讨论了 Project Alpha 的进展情况。张三作为项目经理, 汇报了当前的项目进度,表示已经完成了 80% 的开发工作。李四提出了 一些关于 Kubernetes 部署的问题,建议我们采用新的部署策略。 @@ -309,7 +309,7 @@ async def test_smart_summary(): 大家一致认为项目进展顺利,预计可以按时交付。 """ - content_data = { + content_data = { "text": transcript_text, "entities": [ {"name": "张三", "type": "PERSON"}, @@ -320,18 +320,18 @@ async def test_smart_summary(): } # 生成不同类型的摘要 - summary_types = ["extractive", "abstractive", "key_points"] + summary_types = ["extractive", "abstractive", "key_points"] for summary_type in summary_types: print(f"1. 生成 {summary_type} 类型摘要...") try: - summary = await manager.generate_smart_summary( - tenant_id="tenant_001", - project_id="project_001", - source_type="transcript", - source_id="transcript_001", - summary_type=summary_type, - content_data=content_data, + summary = await manager.generate_smart_summary( + tenant_id = "tenant_001", + project_id = "project_001", + source_type = "transcript", + source_id = "transcript_001", + summary_type = summary_type, + content_data = content_data, ) print(f" 摘要类型: {summary.summary_type}") @@ -342,27 +342,27 @@ async def test_smart_summary(): print(f" 生成失败: {e}") -async def main(): +async def main() -> None: """主测试函数""" - print("=" * 60) + print(" = " * 60) print("InsightFlow Phase 8 Task 4 - AI 能力增强测试") - print("=" * 60) + print(" = " * 60) try: # 测试自定义模型 - model_id = test_custom_model() + model_id = test_custom_model() # 测试训练和预测 await test_train_and_predict(model_id) # 测试预测模型 - trend_model_id, anomaly_model_id = test_prediction_models() + trend_model_id, anomaly_model_id = test_prediction_models() # 测试预测功能 await test_predictions(trend_model_id, anomaly_model_id) # 测试知识图谱 RAG - rag_id = test_kg_rag() + rag_id = test_kg_rag() # 测试 RAG 查询 await test_kg_rag_query(rag_id) @@ -370,9 +370,9 @@ async def main(): # 测试智能摘要 await test_smart_summary() - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("所有测试完成!") - print("=" * 60) + print(" = " * 60) except Exception as e: print(f"\n测试失败: {e}") diff --git a/backend/test_phase8_task5.py b/backend/test_phase8_task5.py index 793f0a6..ee10a8f 100644 --- a/backend/test_phase8_task5.py +++ b/backend/test_phase8_task5.py @@ -28,7 +28,7 @@ from growth_manager import ( ) # 添加 backend 目录到路径 -backend_dir = os.path.dirname(os.path.abspath(__file__)) +backend_dir = os.path.dirname(os.path.abspath(__file__)) if backend_dir not in sys.path: sys.path.insert(0, backend_dir) @@ -36,35 +36,35 @@ if backend_dir not in sys.path: class TestGrowthManager: """测试 Growth Manager 功能""" - def __init__(self): - self.manager = GrowthManager() - self.test_tenant_id = "test_tenant_001" - self.test_user_id = "test_user_001" - self.test_results = [] + def __init__(self) -> None: + self.manager = GrowthManager() + self.test_tenant_id = "test_tenant_001" + self.test_user_id = "test_user_001" + self.test_results = [] - def log(self, message: str, success: bool = True): + def log(self, message: str, success: bool = True) -> None: """记录测试结果""" - status = "✅" if success else "❌" + status = "✅" if success else "❌" print(f"{status} {message}") self.test_results.append((message, success)) # ==================== 测试用户行为分析 ==================== - async def test_track_event(self): + async def test_track_event(self) -> None: """测试事件追踪""" print("\n📊 测试事件追踪...") try: - event = await self.manager.track_event( - tenant_id=self.test_tenant_id, - user_id=self.test_user_id, - event_type=EventType.PAGE_VIEW, - event_name="dashboard_view", - properties={"page": "/dashboard", "duration": 120}, - session_id="session_001", - device_info={"browser": "Chrome", "os": "MacOS"}, - referrer="https://google.com", - utm_params={"source": "google", "medium": "organic", "campaign": "summer"}, + event = await self.manager.track_event( + tenant_id = self.test_tenant_id, + user_id = self.test_user_id, + event_type = EventType.PAGE_VIEW, + event_name = "dashboard_view", + properties = {"page": "/dashboard", "duration": 120}, + session_id = "session_001", + device_info = {"browser": "Chrome", "os": "MacOS"}, + referrer = "https://google.com", + utm_params = {"source": "google", "medium": "organic", "campaign": "summer"}, ) assert event.id is not None @@ -74,15 +74,15 @@ class TestGrowthManager: self.log(f"事件追踪成功: {event.id}") return True except Exception as e: - self.log(f"事件追踪失败: {e}", success=False) + self.log(f"事件追踪失败: {e}", success = False) return False - async def test_track_multiple_events(self): + async def test_track_multiple_events(self) -> None: """测试追踪多个事件""" print("\n📊 测试追踪多个事件...") try: - events = [ + events = [ (EventType.FEATURE_USE, "entity_extraction", {"entity_count": 5}), (EventType.FEATURE_USE, "relation_discovery", {"relation_count": 3}), (EventType.CONVERSION, "upgrade_click", {"plan": "pro"}), @@ -91,25 +91,25 @@ class TestGrowthManager: for event_type, event_name, props in events: await self.manager.track_event( - tenant_id=self.test_tenant_id, - user_id=self.test_user_id, - event_type=event_type, - event_name=event_name, - properties=props, + tenant_id = self.test_tenant_id, + user_id = self.test_user_id, + event_type = event_type, + event_name = event_name, + properties = props, ) self.log(f"成功追踪 {len(events)} 个事件") return True except Exception as e: - self.log(f"批量事件追踪失败: {e}", success=False) + self.log(f"批量事件追踪失败: {e}", success = False) return False - def test_get_user_profile(self): + def test_get_user_profile(self) -> None: """测试获取用户画像""" print("\n👤 测试用户画像...") try: - profile = self.manager.get_user_profile(self.test_tenant_id, self.test_user_id) + profile = self.manager.get_user_profile(self.test_tenant_id, self.test_user_id) if profile: assert profile.user_id == self.test_user_id @@ -120,18 +120,18 @@ class TestGrowthManager: return True except Exception as e: - self.log(f"获取用户画像失败: {e}", success=False) + self.log(f"获取用户画像失败: {e}", success = False) return False - def test_get_analytics_summary(self): + def test_get_analytics_summary(self) -> None: """测试获取分析汇总""" print("\n📈 测试分析汇总...") try: - summary = self.manager.get_user_analytics_summary( - tenant_id=self.test_tenant_id, - start_date=datetime.now() - timedelta(days=7), - end_date=datetime.now(), + summary = self.manager.get_user_analytics_summary( + tenant_id = self.test_tenant_id, + start_date = datetime.now() - timedelta(days = 7), + end_date = datetime.now(), ) assert "unique_users" in summary @@ -141,25 +141,25 @@ class TestGrowthManager: self.log(f"分析汇总: {summary['unique_users']} 用户, {summary['total_events']} 事件") return True except Exception as e: - self.log(f"获取分析汇总失败: {e}", success=False) + self.log(f"获取分析汇总失败: {e}", success = False) return False - def test_create_funnel(self): + def test_create_funnel(self) -> None: """测试创建转化漏斗""" print("\n🎯 测试创建转化漏斗...") try: - funnel = self.manager.create_funnel( - tenant_id=self.test_tenant_id, - name="用户注册转化漏斗", - description="从访问到完成注册的转化流程", - steps=[ + funnel = self.manager.create_funnel( + tenant_id = self.test_tenant_id, + name = "用户注册转化漏斗", + description = "从访问到完成注册的转化流程", + steps = [ {"name": "访问首页", "event_name": "page_view_home"}, {"name": "点击注册", "event_name": "signup_click"}, {"name": "填写信息", "event_name": "signup_form_fill"}, {"name": "完成注册", "event_name": "signup_complete"}, ], - created_by="test", + created_by = "test", ) assert funnel.id is not None @@ -168,10 +168,10 @@ class TestGrowthManager: self.log(f"漏斗创建成功: {funnel.id}") return funnel.id except Exception as e: - self.log(f"创建漏斗失败: {e}", success=False) + self.log(f"创建漏斗失败: {e}", success = False) return None - def test_analyze_funnel(self, funnel_id: str): + def test_analyze_funnel(self, funnel_id: str) -> None: """测试分析漏斗""" print("\n📉 测试漏斗分析...") @@ -180,10 +180,10 @@ class TestGrowthManager: return False try: - analysis = self.manager.analyze_funnel( - funnel_id=funnel_id, - period_start=datetime.now() - timedelta(days=30), - period_end=datetime.now(), + analysis = self.manager.analyze_funnel( + funnel_id = funnel_id, + period_start = datetime.now() - timedelta(days = 30), + period_end = datetime.now(), ) if analysis: @@ -194,18 +194,18 @@ class TestGrowthManager: self.log("漏斗分析返回空结果") return False except Exception as e: - self.log(f"漏斗分析失败: {e}", success=False) + self.log(f"漏斗分析失败: {e}", success = False) return False - def test_calculate_retention(self): + def test_calculate_retention(self) -> None: """测试留存率计算""" print("\n🔄 测试留存率计算...") try: - retention = self.manager.calculate_retention( - tenant_id=self.test_tenant_id, - cohort_date=datetime.now() - timedelta(days=7), - periods=[1, 3, 7], + retention = self.manager.calculate_retention( + tenant_id = self.test_tenant_id, + cohort_date = datetime.now() - timedelta(days = 7), + periods = [1, 3, 7], ) assert "cohort_date" in retention @@ -214,34 +214,34 @@ class TestGrowthManager: self.log(f"留存率计算完成: 同期群 {retention['cohort_size']} 用户") return True except Exception as e: - self.log(f"留存率计算失败: {e}", success=False) + self.log(f"留存率计算失败: {e}", success = False) return False # ==================== 测试 A/B 测试框架 ==================== - def test_create_experiment(self): + def test_create_experiment(self) -> None: """测试创建实验""" print("\n🧪 测试创建 A/B 测试实验...") try: - experiment = self.manager.create_experiment( - tenant_id=self.test_tenant_id, - name="首页按钮颜色测试", - description="测试不同按钮颜色对转化率的影响", - hypothesis="蓝色按钮比红色按钮有更高的点击率", - variants=[ + experiment = self.manager.create_experiment( + tenant_id = self.test_tenant_id, + name = "首页按钮颜色测试", + description = "测试不同按钮颜色对转化率的影响", + hypothesis = "蓝色按钮比红色按钮有更高的点击率", + variants = [ {"id": "control", "name": "红色按钮", "is_control": True}, {"id": "variant_a", "name": "蓝色按钮", "is_control": False}, {"id": "variant_b", "name": "绿色按钮", "is_control": False}, ], - traffic_allocation=TrafficAllocationType.RANDOM, - traffic_split={"control": 0.34, "variant_a": 0.33, "variant_b": 0.33}, - target_audience={"conditions": []}, - primary_metric="button_click_rate", - secondary_metrics=["conversion_rate", "bounce_rate"], - min_sample_size=100, - confidence_level=0.95, - created_by="test", + traffic_allocation = TrafficAllocationType.RANDOM, + traffic_split = {"control": 0.34, "variant_a": 0.33, "variant_b": 0.33}, + target_audience = {"conditions": []}, + primary_metric = "button_click_rate", + secondary_metrics = ["conversion_rate", "bounce_rate"], + min_sample_size = 100, + confidence_level = 0.95, + created_by = "test", ) assert experiment.id is not None @@ -250,23 +250,23 @@ class TestGrowthManager: self.log(f"实验创建成功: {experiment.id}") return experiment.id except Exception as e: - self.log(f"创建实验失败: {e}", success=False) + self.log(f"创建实验失败: {e}", success = False) return None - def test_list_experiments(self): + def test_list_experiments(self) -> None: """测试列出实验""" print("\n📋 测试列出实验...") try: - experiments = self.manager.list_experiments(self.test_tenant_id) + experiments = self.manager.list_experiments(self.test_tenant_id) self.log(f"列出 {len(experiments)} 个实验") return True except Exception as e: - self.log(f"列出实验失败: {e}", success=False) + self.log(f"列出实验失败: {e}", success = False) return False - def test_assign_variant(self, experiment_id: str): + def test_assign_variant(self, experiment_id: str) -> None: """测试分配变体""" print("\n🎲 测试分配实验变体...") @@ -279,26 +279,26 @@ class TestGrowthManager: self.manager.start_experiment(experiment_id) # 测试多个用户的变体分配 - test_users = ["user_001", "user_002", "user_003", "user_004", "user_005"] - assignments = {} + test_users = ["user_001", "user_002", "user_003", "user_004", "user_005"] + assignments = {} for user_id in test_users: - variant_id = self.manager.assign_variant( - experiment_id=experiment_id, - user_id=user_id, - user_attributes={"user_id": user_id, "segment": "new"}, + variant_id = self.manager.assign_variant( + experiment_id = experiment_id, + user_id = user_id, + user_attributes = {"user_id": user_id, "segment": "new"}, ) if variant_id: - assignments[user_id] = variant_id + assignments[user_id] = variant_id self.log(f"变体分配完成: {len(assignments)} 个用户") return True except Exception as e: - self.log(f"变体分配失败: {e}", success=False) + self.log(f"变体分配失败: {e}", success = False) return False - def test_record_experiment_metric(self, experiment_id: str): + def test_record_experiment_metric(self, experiment_id: str) -> None: """测试记录实验指标""" print("\n📊 测试记录实验指标...") @@ -308,7 +308,7 @@ class TestGrowthManager: try: # 模拟记录一些指标 - test_data = [ + test_data = [ ("user_001", "control", 1), ("user_002", "variant_a", 1), ("user_003", "variant_b", 0), @@ -318,20 +318,20 @@ class TestGrowthManager: for user_id, variant_id, value in test_data: self.manager.record_experiment_metric( - experiment_id=experiment_id, - variant_id=variant_id, - user_id=user_id, - metric_name="button_click_rate", - metric_value=value, + experiment_id = experiment_id, + variant_id = variant_id, + user_id = user_id, + metric_name = "button_click_rate", + metric_value = value, ) self.log(f"成功记录 {len(test_data)} 条指标") return True except Exception as e: - self.log(f"记录指标失败: {e}", success=False) + self.log(f"记录指标失败: {e}", success = False) return False - def test_analyze_experiment(self, experiment_id: str): + def test_analyze_experiment(self, experiment_id: str) -> None: """测试分析实验结果""" print("\n📈 测试分析实验结果...") @@ -340,31 +340,31 @@ class TestGrowthManager: return False try: - result = self.manager.analyze_experiment(experiment_id) + result = self.manager.analyze_experiment(experiment_id) if "error" not in result: self.log(f"实验分析完成: {len(result.get('variant_results', {}))} 个变体") return True else: - self.log(f"实验分析返回错误: {result['error']}", success=False) + self.log(f"实验分析返回错误: {result['error']}", success = False) return False except Exception as e: - self.log(f"实验分析失败: {e}", success=False) + self.log(f"实验分析失败: {e}", success = False) return False # ==================== 测试邮件营销 ==================== - def test_create_email_template(self): + def test_create_email_template(self) -> None: """测试创建邮件模板""" print("\n📧 测试创建邮件模板...") try: - template = self.manager.create_email_template( - tenant_id=self.test_tenant_id, - name="欢迎邮件", - template_type=EmailTemplateType.WELCOME, - subject="欢迎加入 InsightFlow!", - html_content=""" + template = self.manager.create_email_template( + tenant_id = self.test_tenant_id, + name = "欢迎邮件", + template_type = EmailTemplateType.WELCOME, + subject = "欢迎加入 InsightFlow!", + html_content = """

欢迎,{{user_name}}!

感谢您注册 InsightFlow。我们很高兴您能加入我们!

您的账户已创建,可以开始使用以下功能:

@@ -373,10 +373,10 @@ class TestGrowthManager:
  • 智能实体提取
  • 团队协作
  • -

    立即开始使用

    +

    立即开始使用

    """, - from_name="InsightFlow 团队", - from_email="welcome@insightflow.io", + from_name = "InsightFlow 团队", + from_email = "welcome@insightflow.io", ) assert template.id is not None @@ -385,23 +385,23 @@ class TestGrowthManager: self.log(f"邮件模板创建成功: {template.id}") return template.id except Exception as e: - self.log(f"创建邮件模板失败: {e}", success=False) + self.log(f"创建邮件模板失败: {e}", success = False) return None - def test_list_email_templates(self): + def test_list_email_templates(self) -> None: """测试列出邮件模板""" print("\n📧 测试列出邮件模板...") try: - templates = self.manager.list_email_templates(self.test_tenant_id) + templates = self.manager.list_email_templates(self.test_tenant_id) self.log(f"列出 {len(templates)} 个邮件模板") return True except Exception as e: - self.log(f"列出邮件模板失败: {e}", success=False) + self.log(f"列出邮件模板失败: {e}", success = False) return False - def test_render_template(self, template_id: str): + def test_render_template(self, template_id: str) -> None: """测试渲染邮件模板""" print("\n🎨 测试渲染邮件模板...") @@ -410,9 +410,9 @@ class TestGrowthManager: return False try: - rendered = self.manager.render_template( - template_id=template_id, - variables={ + rendered = self.manager.render_template( + template_id = template_id, + variables = { "user_name": "张三", "dashboard_url": "https://app.insightflow.io/dashboard", }, @@ -424,13 +424,13 @@ class TestGrowthManager: self.log(f"模板渲染成功: {rendered['subject']}") return True else: - self.log("模板渲染返回空结果", success=False) + self.log("模板渲染返回空结果", success = False) return False except Exception as e: - self.log(f"模板渲染失败: {e}", success=False) + self.log(f"模板渲染失败: {e}", success = False) return False - def test_create_email_campaign(self, template_id: str): + def test_create_email_campaign(self, template_id: str) -> None: """测试创建邮件营销活动""" print("\n📮 测试创建邮件营销活动...") @@ -439,11 +439,11 @@ class TestGrowthManager: return None try: - campaign = self.manager.create_email_campaign( - tenant_id=self.test_tenant_id, - name="新用户欢迎活动", - template_id=template_id, - recipient_list=[ + campaign = self.manager.create_email_campaign( + tenant_id = self.test_tenant_id, + name = "新用户欢迎活动", + template_id = template_id, + recipient_list = [ {"user_id": "user_001", "email": "user1@example.com"}, {"user_id": "user_002", "email": "user2@example.com"}, {"user_id": "user_003", "email": "user3@example.com"}, @@ -456,21 +456,21 @@ class TestGrowthManager: self.log(f"营销活动创建成功: {campaign.id}, {campaign.recipient_count} 收件人") return campaign.id except Exception as e: - self.log(f"创建营销活动失败: {e}", success=False) + self.log(f"创建营销活动失败: {e}", success = False) return None - def test_create_automation_workflow(self): + def test_create_automation_workflow(self) -> None: """测试创建自动化工作流""" print("\n🤖 测试创建自动化工作流...") try: - workflow = self.manager.create_automation_workflow( - tenant_id=self.test_tenant_id, - name="新用户欢迎序列", - description="用户注册后自动发送欢迎邮件序列", - trigger_type=WorkflowTriggerType.USER_SIGNUP, - trigger_conditions={"event": "user_signup"}, - actions=[ + workflow = self.manager.create_automation_workflow( + tenant_id = self.test_tenant_id, + name = "新用户欢迎序列", + description = "用户注册后自动发送欢迎邮件序列", + trigger_type = WorkflowTriggerType.USER_SIGNUP, + trigger_conditions = {"event": "user_signup"}, + actions = [ {"type": "send_email", "template_type": "welcome", "delay_hours": 0}, {"type": "send_email", "template_type": "onboarding", "delay_hours": 24}, {"type": "send_email", "template_type": "feature_tips", "delay_hours": 72}, @@ -483,27 +483,27 @@ class TestGrowthManager: self.log(f"自动化工作流创建成功: {workflow.id}") return True except Exception as e: - self.log(f"创建工作流失败: {e}", success=False) + self.log(f"创建工作流失败: {e}", success = False) return False # ==================== 测试推荐系统 ==================== - def test_create_referral_program(self): + def test_create_referral_program(self) -> None: """测试创建推荐计划""" print("\n🎁 测试创建推荐计划...") try: - program = self.manager.create_referral_program( - tenant_id=self.test_tenant_id, - name="邀请好友奖励计划", - description="邀请好友注册,双方获得积分奖励", - referrer_reward_type="credit", - referrer_reward_value=100.0, - referee_reward_type="credit", - referee_reward_value=50.0, - max_referrals_per_user=10, - referral_code_length=8, - expiry_days=30, + program = self.manager.create_referral_program( + tenant_id = self.test_tenant_id, + name = "邀请好友奖励计划", + description = "邀请好友注册,双方获得积分奖励", + referrer_reward_type = "credit", + referrer_reward_value = 100.0, + referee_reward_type = "credit", + referee_reward_value = 50.0, + max_referrals_per_user = 10, + referral_code_length = 8, + expiry_days = 30, ) assert program.id is not None @@ -512,10 +512,10 @@ class TestGrowthManager: self.log(f"推荐计划创建成功: {program.id}") return program.id except Exception as e: - self.log(f"创建推荐计划失败: {e}", success=False) + self.log(f"创建推荐计划失败: {e}", success = False) return None - def test_generate_referral_code(self, program_id: str): + def test_generate_referral_code(self, program_id: str) -> None: """测试生成推荐码""" print("\n🔑 测试生成推荐码...") @@ -524,8 +524,8 @@ class TestGrowthManager: return None try: - referral = self.manager.generate_referral_code( - program_id=program_id, referrer_id="referrer_user_001" + referral = self.manager.generate_referral_code( + program_id = program_id, referrer_id = "referrer_user_001" ) if referral: @@ -535,13 +535,13 @@ class TestGrowthManager: self.log(f"推荐码生成成功: {referral.referral_code}") return referral.referral_code else: - self.log("生成推荐码返回空结果", success=False) + self.log("生成推荐码返回空结果", success = False) return None except Exception as e: - self.log(f"生成推荐码失败: {e}", success=False) + self.log(f"生成推荐码失败: {e}", success = False) return None - def test_apply_referral_code(self, referral_code: str): + def test_apply_referral_code(self, referral_code: str) -> None: """测试应用推荐码""" print("\n✅ 测试应用推荐码...") @@ -550,21 +550,21 @@ class TestGrowthManager: return False try: - success = self.manager.apply_referral_code( - referral_code=referral_code, referee_id="new_user_001" + success = self.manager.apply_referral_code( + referral_code = referral_code, referee_id = "new_user_001" ) if success: self.log(f"推荐码应用成功: {referral_code}") return True else: - self.log("推荐码应用失败", success=False) + self.log("推荐码应用失败", success = False) return False except Exception as e: - self.log(f"应用推荐码失败: {e}", success=False) + self.log(f"应用推荐码失败: {e}", success = False) return False - def test_get_referral_stats(self, program_id: str): + def test_get_referral_stats(self, program_id: str) -> None: """测试获取推荐统计""" print("\n📊 测试获取推荐统计...") @@ -573,7 +573,7 @@ class TestGrowthManager: return False try: - stats = self.manager.get_referral_stats(program_id) + stats = self.manager.get_referral_stats(program_id) assert "total_referrals" in stats assert "conversion_rate" in stats @@ -583,24 +583,24 @@ class TestGrowthManager: ) return True except Exception as e: - self.log(f"获取推荐统计失败: {e}", success=False) + self.log(f"获取推荐统计失败: {e}", success = False) return False - def test_create_team_incentive(self): + def test_create_team_incentive(self) -> None: """测试创建团队激励""" print("\n🏆 测试创建团队升级激励...") try: - incentive = self.manager.create_team_incentive( - tenant_id=self.test_tenant_id, - name="团队升级奖励", - description="团队规模达到5人升级到 Pro 计划可获得折扣", - target_tier="pro", - min_team_size=5, - incentive_type="discount", - incentive_value=20.0, # 20% 折扣 - valid_from=datetime.now(), - valid_until=datetime.now() + timedelta(days=90), + incentive = self.manager.create_team_incentive( + tenant_id = self.test_tenant_id, + name = "团队升级奖励", + description = "团队规模达到5人升级到 Pro 计划可获得折扣", + target_tier = "pro", + min_team_size = 5, + incentive_type = "discount", + incentive_value = 20.0, # 20% 折扣 + valid_from = datetime.now(), + valid_until = datetime.now() + timedelta(days = 90), ) assert incentive.id is not None @@ -609,116 +609,116 @@ class TestGrowthManager: self.log(f"团队激励创建成功: {incentive.id}") return True except Exception as e: - self.log(f"创建团队激励失败: {e}", success=False) + self.log(f"创建团队激励失败: {e}", success = False) return False - def test_check_team_incentive_eligibility(self): + def test_check_team_incentive_eligibility(self) -> None: """测试检查团队激励资格""" print("\n🔍 测试检查团队激励资格...") try: - incentives = self.manager.check_team_incentive_eligibility( - tenant_id=self.test_tenant_id, current_tier="free", team_size=5 + incentives = self.manager.check_team_incentive_eligibility( + tenant_id = self.test_tenant_id, current_tier = "free", team_size = 5 ) self.log(f"找到 {len(incentives)} 个符合条件的激励") return True except Exception as e: - self.log(f"检查激励资格失败: {e}", success=False) + self.log(f"检查激励资格失败: {e}", success = False) return False # ==================== 测试实时仪表板 ==================== - def test_get_realtime_dashboard(self): + def test_get_realtime_dashboard(self) -> None: """测试获取实时仪表板""" print("\n📺 测试实时分析仪表板...") try: - dashboard = self.manager.get_realtime_dashboard(self.test_tenant_id) + dashboard = self.manager.get_realtime_dashboard(self.test_tenant_id) assert "today" in dashboard assert "recent_events" in dashboard assert "top_features" in dashboard - today = dashboard["today"] + today = dashboard["today"] self.log( f"实时仪表板: 今日 {today['active_users']} 活跃用户, {today['total_events']} 事件" ) return True except Exception as e: - self.log(f"获取实时仪表板失败: {e}", success=False) + self.log(f"获取实时仪表板失败: {e}", success = False) return False # ==================== 运行所有测试 ==================== - async def run_all_tests(self): + async def run_all_tests(self) -> None: """运行所有测试""" - print("=" * 60) + print(" = " * 60) print("🚀 InsightFlow Phase 8 Task 5 - 运营与增长工具测试") - print("=" * 60) + print(" = " * 60) # 用户行为分析测试 - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("📊 模块 1: 用户行为分析") - print("=" * 60) + print(" = " * 60) await self.test_track_event() await self.test_track_multiple_events() self.test_get_user_profile() self.test_get_analytics_summary() - funnel_id = self.test_create_funnel() + funnel_id = self.test_create_funnel() self.test_analyze_funnel(funnel_id) self.test_calculate_retention() # A/B 测试框架测试 - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("🧪 模块 2: A/B 测试框架") - print("=" * 60) + print(" = " * 60) - experiment_id = self.test_create_experiment() + experiment_id = self.test_create_experiment() self.test_list_experiments() self.test_assign_variant(experiment_id) self.test_record_experiment_metric(experiment_id) self.test_analyze_experiment(experiment_id) # 邮件营销测试 - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("📧 模块 3: 邮件营销自动化") - print("=" * 60) + print(" = " * 60) - template_id = self.test_create_email_template() + template_id = self.test_create_email_template() self.test_list_email_templates() self.test_render_template(template_id) self.test_create_email_campaign(template_id) self.test_create_automation_workflow() # 推荐系统测试 - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("🎁 模块 4: 推荐系统") - print("=" * 60) + print(" = " * 60) - program_id = self.test_create_referral_program() - referral_code = self.test_generate_referral_code(program_id) + program_id = self.test_create_referral_program() + referral_code = self.test_generate_referral_code(program_id) self.test_apply_referral_code(referral_code) self.test_get_referral_stats(program_id) self.test_create_team_incentive() self.test_check_team_incentive_eligibility() # 实时仪表板测试 - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("📺 模块 5: 实时分析仪表板") - print("=" * 60) + print(" = " * 60) self.test_get_realtime_dashboard() # 测试总结 - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("📋 测试总结") - print("=" * 60) + print(" = " * 60) - total_tests = len(self.test_results) - passed_tests = sum(1 for _, success in self.test_results if success) - failed_tests = total_tests - passed_tests + total_tests = len(self.test_results) + passed_tests = sum(1 for _, success in self.test_results if success) + failed_tests = total_tests - passed_tests print(f"总测试数: {total_tests}") print(f"通过: {passed_tests} ✅") @@ -731,14 +731,14 @@ class TestGrowthManager: if not success: print(f" - {message}") - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("✨ 测试完成!") - print("=" * 60) + print(" = " * 60) -async def main(): +async def main() -> None: """主函数""" - tester = TestGrowthManager() + tester = TestGrowthManager() await tester.run_all_tests() diff --git a/backend/test_phase8_task6.py b/backend/test_phase8_task6.py index c1816cb..6163551 100644 --- a/backend/test_phase8_task6.py +++ b/backend/test_phase8_task6.py @@ -25,7 +25,7 @@ from developer_ecosystem_manager import ( ) # Add backend directory to path -backend_dir = os.path.dirname(os.path.abspath(__file__)) +backend_dir = os.path.dirname(os.path.abspath(__file__)) if backend_dir not in sys.path: sys.path.insert(0, backend_dir) @@ -33,10 +33,10 @@ if backend_dir not in sys.path: class TestDeveloperEcosystem: """开发者生态系统测试类""" - def __init__(self): - self.manager = DeveloperEcosystemManager() - self.test_results = [] - self.created_ids = { + def __init__(self) -> None: + self.manager = DeveloperEcosystemManager() + self.test_results = [] + self.created_ids = { "sdk": [], "template": [], "plugin": [], @@ -45,19 +45,19 @@ class TestDeveloperEcosystem: "portal_config": [], } - def log(self, message: str, success: bool = True): + def log(self, message: str, success: bool = True) -> None: """记录测试结果""" - status = "✅" if success else "❌" + status = "✅" if success else "❌" print(f"{status} {message}") self.test_results.append( {"message": message, "success": success, "timestamp": datetime.now().isoformat()} ) - def run_all_tests(self): + def run_all_tests(self) -> None: """运行所有测试""" - print("=" * 60) + print(" = " * 60) print("InsightFlow Phase 8 Task 6: Developer Ecosystem Tests") - print("=" * 60) + print(" = " * 60) # SDK Tests print("\n📦 SDK Release & Management Tests") @@ -119,538 +119,538 @@ class TestDeveloperEcosystem: # Print Summary self.print_summary() - def test_sdk_create(self): + def test_sdk_create(self) -> None: """测试创建 SDK""" try: - sdk = self.manager.create_sdk_release( - name="InsightFlow Python SDK", - language=SDKLanguage.PYTHON, - version="1.0.0", - description="Python SDK for InsightFlow API", - changelog="Initial release", - download_url="https://pypi.org/insightflow/1.0.0", - documentation_url="https://docs.insightflow.io/python", - repository_url="https://github.com/insightflow/python-sdk", - package_name="insightflow", - min_platform_version="1.0.0", - dependencies=[{"name": "requests", "version": ">=2.0"}], - file_size=1024000, - checksum="abc123", - created_by="test_user", + sdk = self.manager.create_sdk_release( + name = "InsightFlow Python SDK", + language = SDKLanguage.PYTHON, + version = "1.0.0", + description = "Python SDK for InsightFlow API", + changelog = "Initial release", + download_url = "https://pypi.org/insightflow/1.0.0", + documentation_url = "https://docs.insightflow.io/python", + repository_url = "https://github.com/insightflow/python-sdk", + package_name = "insightflow", + min_platform_version = "1.0.0", + dependencies = [{"name": "requests", "version": ">= 2.0"}], + file_size = 1024000, + checksum = "abc123", + created_by = "test_user", ) self.created_ids["sdk"].append(sdk.id) self.log(f"Created SDK: {sdk.name} ({sdk.id})") # Create JavaScript SDK - sdk_js = self.manager.create_sdk_release( - name="InsightFlow JavaScript SDK", - language=SDKLanguage.JAVASCRIPT, - version="1.0.0", - description="JavaScript SDK for InsightFlow API", - changelog="Initial release", - download_url="https://npmjs.com/insightflow/1.0.0", - documentation_url="https://docs.insightflow.io/js", - repository_url="https://github.com/insightflow/js-sdk", - package_name="@insightflow/sdk", - min_platform_version="1.0.0", - dependencies=[{"name": "axios", "version": ">=0.21"}], - file_size=512000, - checksum="def456", - created_by="test_user", + sdk_js = self.manager.create_sdk_release( + name = "InsightFlow JavaScript SDK", + language = SDKLanguage.JAVASCRIPT, + version = "1.0.0", + description = "JavaScript SDK for InsightFlow API", + changelog = "Initial release", + download_url = "https://npmjs.com/insightflow/1.0.0", + documentation_url = "https://docs.insightflow.io/js", + repository_url = "https://github.com/insightflow/js-sdk", + package_name = "@insightflow/sdk", + min_platform_version = "1.0.0", + dependencies = [{"name": "axios", "version": ">= 0.21"}], + file_size = 512000, + checksum = "def456", + created_by = "test_user", ) self.created_ids["sdk"].append(sdk_js.id) self.log(f"Created SDK: {sdk_js.name} ({sdk_js.id})") except Exception as e: - self.log(f"Failed to create SDK: {str(e)}", success=False) + self.log(f"Failed to create SDK: {str(e)}", success = False) - def test_sdk_list(self): + def test_sdk_list(self) -> None: """测试列出 SDK""" try: - sdks = self.manager.list_sdk_releases() + sdks = self.manager.list_sdk_releases() self.log(f"Listed {len(sdks)} SDKs") # Test filter by language - python_sdks = self.manager.list_sdk_releases(language=SDKLanguage.PYTHON) + python_sdks = self.manager.list_sdk_releases(language = SDKLanguage.PYTHON) self.log(f"Found {len(python_sdks)} Python SDKs") # Test search - search_results = self.manager.list_sdk_releases(search="Python") + search_results = self.manager.list_sdk_releases(search = "Python") self.log(f"Search found {len(search_results)} SDKs") except Exception as e: - self.log(f"Failed to list SDKs: {str(e)}", success=False) + self.log(f"Failed to list SDKs: {str(e)}", success = False) - def test_sdk_get(self): + def test_sdk_get(self) -> None: """测试获取 SDK 详情""" try: if self.created_ids["sdk"]: - sdk = self.manager.get_sdk_release(self.created_ids["sdk"][0]) + sdk = self.manager.get_sdk_release(self.created_ids["sdk"][0]) if sdk: self.log(f"Retrieved SDK: {sdk.name}") else: - self.log("SDK not found", success=False) + self.log("SDK not found", success = False) except Exception as e: - self.log(f"Failed to get SDK: {str(e)}", success=False) + self.log(f"Failed to get SDK: {str(e)}", success = False) - def test_sdk_update(self): + def test_sdk_update(self) -> None: """测试更新 SDK""" try: if self.created_ids["sdk"]: - sdk = self.manager.update_sdk_release( - self.created_ids["sdk"][0], description="Updated description" + sdk = self.manager.update_sdk_release( + self.created_ids["sdk"][0], description = "Updated description" ) if sdk: self.log(f"Updated SDK: {sdk.name}") except Exception as e: - self.log(f"Failed to update SDK: {str(e)}", success=False) + self.log(f"Failed to update SDK: {str(e)}", success = False) - def test_sdk_publish(self): + def test_sdk_publish(self) -> None: """测试发布 SDK""" try: if self.created_ids["sdk"]: - sdk = self.manager.publish_sdk_release(self.created_ids["sdk"][0]) + sdk = self.manager.publish_sdk_release(self.created_ids["sdk"][0]) if sdk: self.log(f"Published SDK: {sdk.name} (status: {sdk.status.value})") except Exception as e: - self.log(f"Failed to publish SDK: {str(e)}", success=False) + self.log(f"Failed to publish SDK: {str(e)}", success = False) - def test_sdk_version_add(self): + def test_sdk_version_add(self) -> None: """测试添加 SDK 版本""" try: if self.created_ids["sdk"]: - version = self.manager.add_sdk_version( - sdk_id=self.created_ids["sdk"][0], - version="1.1.0", - is_lts=True, - release_notes="Bug fixes and improvements", - download_url="https://pypi.org/insightflow/1.1.0", - checksum="xyz789", - file_size=1100000, + version = self.manager.add_sdk_version( + sdk_id = self.created_ids["sdk"][0], + version = "1.1.0", + is_lts = True, + release_notes = "Bug fixes and improvements", + download_url = "https://pypi.org/insightflow/1.1.0", + checksum = "xyz789", + file_size = 1100000, ) self.log(f"Added SDK version: {version.version}") except Exception as e: - self.log(f"Failed to add SDK version: {str(e)}", success=False) + self.log(f"Failed to add SDK version: {str(e)}", success = False) - def test_template_create(self): + def test_template_create(self) -> None: """测试创建模板""" try: - template = self.manager.create_template( - name="医疗行业实体识别模板", - description="专门针对医疗行业的实体识别模板,支持疾病、药物、症状等实体", - category=TemplateCategory.MEDICAL, - subcategory="entity_recognition", - tags=["medical", "healthcare", "ner"], - author_id="dev_001", - author_name="Medical AI Lab", - price=99.0, - currency="CNY", - preview_image_url="https://cdn.insightflow.io/templates/medical.png", - demo_url="https://demo.insightflow.io/medical", - documentation_url="https://docs.insightflow.io/templates/medical", - download_url="https://cdn.insightflow.io/templates/medical.zip", - version="1.0.0", - min_platform_version="2.0.0", - file_size=5242880, - checksum="tpl123", + template = self.manager.create_template( + name = "医疗行业实体识别模板", + description = "专门针对医疗行业的实体识别模板,支持疾病、药物、症状等实体", + category = TemplateCategory.MEDICAL, + subcategory = "entity_recognition", + tags = ["medical", "healthcare", "ner"], + author_id = "dev_001", + author_name = "Medical AI Lab", + price = 99.0, + currency = "CNY", + preview_image_url = "https://cdn.insightflow.io/templates/medical.png", + demo_url = "https://demo.insightflow.io/medical", + documentation_url = "https://docs.insightflow.io/templates/medical", + download_url = "https://cdn.insightflow.io/templates/medical.zip", + version = "1.0.0", + min_platform_version = "2.0.0", + file_size = 5242880, + checksum = "tpl123", ) self.created_ids["template"].append(template.id) self.log(f"Created template: {template.name} ({template.id})") # Create free template - template_free = self.manager.create_template( - name="通用实体识别模板", - description="适用于一般场景的实体识别模板", - category=TemplateCategory.GENERAL, - subcategory=None, - tags=["general", "ner", "basic"], - author_id="dev_002", - author_name="InsightFlow Team", - price=0.0, - currency="CNY", + template_free = self.manager.create_template( + name = "通用实体识别模板", + description = "适用于一般场景的实体识别模板", + category = TemplateCategory.GENERAL, + subcategory = None, + tags = ["general", "ner", "basic"], + author_id = "dev_002", + author_name = "InsightFlow Team", + price = 0.0, + currency = "CNY", ) self.created_ids["template"].append(template_free.id) self.log(f"Created free template: {template_free.name}") except Exception as e: - self.log(f"Failed to create template: {str(e)}", success=False) + self.log(f"Failed to create template: {str(e)}", success = False) - def test_template_list(self): + def test_template_list(self) -> None: """测试列出模板""" try: - templates = self.manager.list_templates() + templates = self.manager.list_templates() self.log(f"Listed {len(templates)} templates") # Filter by category - medical_templates = self.manager.list_templates(category=TemplateCategory.MEDICAL) + medical_templates = self.manager.list_templates(category = TemplateCategory.MEDICAL) self.log(f"Found {len(medical_templates)} medical templates") # Filter by price - free_templates = self.manager.list_templates(max_price=0) + free_templates = self.manager.list_templates(max_price = 0) self.log(f"Found {len(free_templates)} free templates") except Exception as e: - self.log(f"Failed to list templates: {str(e)}", success=False) + self.log(f"Failed to list templates: {str(e)}", success = False) - def test_template_get(self): + def test_template_get(self) -> None: """测试获取模板详情""" try: if self.created_ids["template"]: - template = self.manager.get_template(self.created_ids["template"][0]) + template = self.manager.get_template(self.created_ids["template"][0]) if template: self.log(f"Retrieved template: {template.name}") except Exception as e: - self.log(f"Failed to get template: {str(e)}", success=False) + self.log(f"Failed to get template: {str(e)}", success = False) - def test_template_approve(self): + def test_template_approve(self) -> None: """测试审核通过模板""" try: if self.created_ids["template"]: - template = self.manager.approve_template( - self.created_ids["template"][0], reviewed_by="admin_001" + template = self.manager.approve_template( + self.created_ids["template"][0], reviewed_by = "admin_001" ) if template: self.log(f"Approved template: {template.name}") except Exception as e: - self.log(f"Failed to approve template: {str(e)}", success=False) + self.log(f"Failed to approve template: {str(e)}", success = False) - def test_template_publish(self): + def test_template_publish(self) -> None: """测试发布模板""" try: if self.created_ids["template"]: - template = self.manager.publish_template(self.created_ids["template"][0]) + template = self.manager.publish_template(self.created_ids["template"][0]) if template: self.log(f"Published template: {template.name}") except Exception as e: - self.log(f"Failed to publish template: {str(e)}", success=False) + self.log(f"Failed to publish template: {str(e)}", success = False) - def test_template_review(self): + def test_template_review(self) -> None: """测试添加模板评价""" try: if self.created_ids["template"]: - review = self.manager.add_template_review( - template_id=self.created_ids["template"][0], - user_id="user_001", - user_name="Test User", - rating=5, - comment="Great template! Very accurate for medical entities.", - is_verified_purchase=True, + review = self.manager.add_template_review( + template_id = self.created_ids["template"][0], + user_id = "user_001", + user_name = "Test User", + rating = 5, + comment = "Great template! Very accurate for medical entities.", + is_verified_purchase = True, ) self.log(f"Added template review: {review.rating} stars") except Exception as e: - self.log(f"Failed to add template review: {str(e)}", success=False) + self.log(f"Failed to add template review: {str(e)}", success = False) - def test_plugin_create(self): + def test_plugin_create(self) -> None: """测试创建插件""" try: - plugin = self.manager.create_plugin( - name="飞书机器人集成插件", - description="将 InsightFlow 与飞书机器人集成,实现自动通知", - category=PluginCategory.INTEGRATION, - tags=["feishu", "bot", "integration", "notification"], - author_id="dev_003", - author_name="Integration Team", - price=49.0, - currency="CNY", - pricing_model="paid", - preview_image_url="https://cdn.insightflow.io/plugins/feishu.png", - demo_url="https://demo.insightflow.io/feishu", - documentation_url="https://docs.insightflow.io/plugins/feishu", - repository_url="https://github.com/insightflow/feishu-plugin", - download_url="https://cdn.insightflow.io/plugins/feishu.zip", - webhook_url="https://api.insightflow.io/webhooks/feishu", - permissions=["read:projects", "write:notifications"], - version="1.0.0", - min_platform_version="2.0.0", - file_size=1048576, - checksum="plg123", + plugin = self.manager.create_plugin( + name = "飞书机器人集成插件", + description = "将 InsightFlow 与飞书机器人集成,实现自动通知", + category = PluginCategory.INTEGRATION, + tags = ["feishu", "bot", "integration", "notification"], + author_id = "dev_003", + author_name = "Integration Team", + price = 49.0, + currency = "CNY", + pricing_model = "paid", + preview_image_url = "https://cdn.insightflow.io/plugins/feishu.png", + demo_url = "https://demo.insightflow.io/feishu", + documentation_url = "https://docs.insightflow.io/plugins/feishu", + repository_url = "https://github.com/insightflow/feishu-plugin", + download_url = "https://cdn.insightflow.io/plugins/feishu.zip", + webhook_url = "https://api.insightflow.io/webhooks/feishu", + permissions = ["read:projects", "write:notifications"], + version = "1.0.0", + min_platform_version = "2.0.0", + file_size = 1048576, + checksum = "plg123", ) self.created_ids["plugin"].append(plugin.id) self.log(f"Created plugin: {plugin.name} ({plugin.id})") # Create free plugin - plugin_free = self.manager.create_plugin( - name="数据导出插件", - description="支持多种格式的数据导出", - category=PluginCategory.ANALYSIS, - tags=["export", "data", "csv", "json"], - author_id="dev_004", - author_name="Data Team", - price=0.0, - currency="CNY", - pricing_model="free", + plugin_free = self.manager.create_plugin( + name = "数据导出插件", + description = "支持多种格式的数据导出", + category = PluginCategory.ANALYSIS, + tags = ["export", "data", "csv", "json"], + author_id = "dev_004", + author_name = "Data Team", + price = 0.0, + currency = "CNY", + pricing_model = "free", ) self.created_ids["plugin"].append(plugin_free.id) self.log(f"Created free plugin: {plugin_free.name}") except Exception as e: - self.log(f"Failed to create plugin: {str(e)}", success=False) + self.log(f"Failed to create plugin: {str(e)}", success = False) - def test_plugin_list(self): + def test_plugin_list(self) -> None: """测试列出插件""" try: - plugins = self.manager.list_plugins() + plugins = self.manager.list_plugins() self.log(f"Listed {len(plugins)} plugins") # Filter by category - integration_plugins = self.manager.list_plugins(category=PluginCategory.INTEGRATION) + integration_plugins = self.manager.list_plugins(category = PluginCategory.INTEGRATION) self.log(f"Found {len(integration_plugins)} integration plugins") except Exception as e: - self.log(f"Failed to list plugins: {str(e)}", success=False) + self.log(f"Failed to list plugins: {str(e)}", success = False) - def test_plugin_get(self): + def test_plugin_get(self) -> None: """测试获取插件详情""" try: if self.created_ids["plugin"]: - plugin = self.manager.get_plugin(self.created_ids["plugin"][0]) + plugin = self.manager.get_plugin(self.created_ids["plugin"][0]) if plugin: self.log(f"Retrieved plugin: {plugin.name}") except Exception as e: - self.log(f"Failed to get plugin: {str(e)}", success=False) + self.log(f"Failed to get plugin: {str(e)}", success = False) - def test_plugin_review(self): + def test_plugin_review(self) -> None: """测试审核插件""" try: if self.created_ids["plugin"]: - plugin = self.manager.review_plugin( + plugin = self.manager.review_plugin( self.created_ids["plugin"][0], - reviewed_by="admin_001", - status=PluginStatus.APPROVED, - notes="Code review passed", + reviewed_by = "admin_001", + status = PluginStatus.APPROVED, + notes = "Code review passed", ) if plugin: self.log(f"Reviewed plugin: {plugin.name} ({plugin.status.value})") except Exception as e: - self.log(f"Failed to review plugin: {str(e)}", success=False) + self.log(f"Failed to review plugin: {str(e)}", success = False) - def test_plugin_publish(self): + def test_plugin_publish(self) -> None: """测试发布插件""" try: if self.created_ids["plugin"]: - plugin = self.manager.publish_plugin(self.created_ids["plugin"][0]) + plugin = self.manager.publish_plugin(self.created_ids["plugin"][0]) if plugin: self.log(f"Published plugin: {plugin.name}") except Exception as e: - self.log(f"Failed to publish plugin: {str(e)}", success=False) + self.log(f"Failed to publish plugin: {str(e)}", success = False) - def test_plugin_review_add(self): + def test_plugin_review_add(self) -> None: """测试添加插件评价""" try: if self.created_ids["plugin"]: - review = self.manager.add_plugin_review( - plugin_id=self.created_ids["plugin"][0], - user_id="user_002", - user_name="Plugin User", - rating=4, - comment="Works great with Feishu!", - is_verified_purchase=True, + review = self.manager.add_plugin_review( + plugin_id = self.created_ids["plugin"][0], + user_id = "user_002", + user_name = "Plugin User", + rating = 4, + comment = "Works great with Feishu!", + is_verified_purchase = True, ) self.log(f"Added plugin review: {review.rating} stars") except Exception as e: - self.log(f"Failed to add plugin review: {str(e)}", success=False) + self.log(f"Failed to add plugin review: {str(e)}", success = False) - def test_developer_profile_create(self): + def test_developer_profile_create(self) -> None: """测试创建开发者档案""" try: # Generate unique user IDs - unique_id = uuid.uuid4().hex[:8] + unique_id = uuid.uuid4().hex[:8] - profile = self.manager.create_developer_profile( - user_id=f"user_dev_{unique_id}_001", - display_name="张三", - email=f"zhangsan_{unique_id}@example.com", - bio="专注于医疗AI和自然语言处理", - website="https://zhangsan.dev", - github_url="https://github.com/zhangsan", - avatar_url="https://cdn.example.com/avatars/zhangsan.png", + profile = self.manager.create_developer_profile( + user_id = f"user_dev_{unique_id}_001", + display_name = "张三", + email = f"zhangsan_{unique_id}@example.com", + bio = "专注于医疗AI和自然语言处理", + website = "https://zhangsan.dev", + github_url = "https://github.com/zhangsan", + avatar_url = "https://cdn.example.com/avatars/zhangsan.png", ) self.created_ids["developer"].append(profile.id) self.log(f"Created developer profile: {profile.display_name} ({profile.id})") # Create another developer - profile2 = self.manager.create_developer_profile( - user_id=f"user_dev_{unique_id}_002", - display_name="李四", - email=f"lisi_{unique_id}@example.com", - bio="全栈开发者,热爱开源", + profile2 = self.manager.create_developer_profile( + user_id = f"user_dev_{unique_id}_002", + display_name = "李四", + email = f"lisi_{unique_id}@example.com", + bio = "全栈开发者,热爱开源", ) self.created_ids["developer"].append(profile2.id) self.log(f"Created developer profile: {profile2.display_name}") except Exception as e: - self.log(f"Failed to create developer profile: {str(e)}", success=False) + self.log(f"Failed to create developer profile: {str(e)}", success = False) - def test_developer_profile_get(self): + def test_developer_profile_get(self) -> None: """测试获取开发者档案""" try: if self.created_ids["developer"]: - profile = self.manager.get_developer_profile(self.created_ids["developer"][0]) + profile = self.manager.get_developer_profile(self.created_ids["developer"][0]) if profile: self.log(f"Retrieved developer profile: {profile.display_name}") except Exception as e: - self.log(f"Failed to get developer profile: {str(e)}", success=False) + self.log(f"Failed to get developer profile: {str(e)}", success = False) - def test_developer_verify(self): + def test_developer_verify(self) -> None: """测试验证开发者""" try: if self.created_ids["developer"]: - profile = self.manager.verify_developer( + profile = self.manager.verify_developer( self.created_ids["developer"][0], DeveloperStatus.VERIFIED ) if profile: self.log(f"Verified developer: {profile.display_name} ({profile.status.value})") except Exception as e: - self.log(f"Failed to verify developer: {str(e)}", success=False) + self.log(f"Failed to verify developer: {str(e)}", success = False) - def test_developer_stats_update(self): + def test_developer_stats_update(self) -> None: """测试更新开发者统计""" try: if self.created_ids["developer"]: self.manager.update_developer_stats(self.created_ids["developer"][0]) - profile = self.manager.get_developer_profile(self.created_ids["developer"][0]) + profile = self.manager.get_developer_profile(self.created_ids["developer"][0]) self.log( f"Updated developer stats: {profile.plugin_count} plugins, {profile.template_count} templates" ) except Exception as e: - self.log(f"Failed to update developer stats: {str(e)}", success=False) + self.log(f"Failed to update developer stats: {str(e)}", success = False) - def test_code_example_create(self): + def test_code_example_create(self) -> None: """测试创建代码示例""" try: - example = self.manager.create_code_example( - title="使用 Python SDK 创建项目", - description="演示如何使用 Python SDK 创建新项目", - language="python", - category="quickstart", - code="""from insightflow import Client + example = self.manager.create_code_example( + title = "使用 Python SDK 创建项目", + description = "演示如何使用 Python SDK 创建新项目", + language = "python", + category = "quickstart", + code = """from insightflow import Client -client = Client(api_key="your_api_key") -project = client.projects.create(name="My Project") +client = Client(api_key = "your_api_key") +project = client.projects.create(name = "My Project") print(f"Created project: {project.id}") """, - explanation="首先导入 Client 类,然后使用 API Key 初始化客户端,最后调用 create 方法创建项目。", - tags=["python", "quickstart", "projects"], - author_id="dev_001", - author_name="InsightFlow Team", - api_endpoints=["/api/v1/projects"], + explanation = "首先导入 Client 类,然后使用 API Key 初始化客户端,最后调用 create 方法创建项目。", + tags = ["python", "quickstart", "projects"], + author_id = "dev_001", + author_name = "InsightFlow Team", + api_endpoints = ["/api/v1/projects"], ) self.created_ids["code_example"].append(example.id) self.log(f"Created code example: {example.title}") # Create JavaScript example - example_js = self.manager.create_code_example( - title="使用 JavaScript SDK 上传文件", - description="演示如何使用 JavaScript SDK 上传音频文件", - language="javascript", - category="upload", - code="""const { Client } = require('insightflow'); + example_js = self.manager.create_code_example( + title = "使用 JavaScript SDK 上传文件", + description = "演示如何使用 JavaScript SDK 上传音频文件", + language = "javascript", + category = "upload", + code = """const { Client } = require('insightflow'); -const client = new Client({ apiKey: 'your_api_key' }); -const result = await client.uploads.create({ +const client = new Client({ apiKey: 'your_api_key' }); +const result = await client.uploads.create({ projectId: 'proj_123', file: './meeting.mp3' }); console.log('Upload complete:', result.id); """, - explanation="使用 JavaScript SDK 上传文件到 InsightFlow", - tags=["javascript", "upload", "audio"], - author_id="dev_002", - author_name="JS Team", + explanation = "使用 JavaScript SDK 上传文件到 InsightFlow", + tags = ["javascript", "upload", "audio"], + author_id = "dev_002", + author_name = "JS Team", ) self.created_ids["code_example"].append(example_js.id) self.log(f"Created code example: {example_js.title}") except Exception as e: - self.log(f"Failed to create code example: {str(e)}", success=False) + self.log(f"Failed to create code example: {str(e)}", success = False) - def test_code_example_list(self): + def test_code_example_list(self) -> None: """测试列出代码示例""" try: - examples = self.manager.list_code_examples() + examples = self.manager.list_code_examples() self.log(f"Listed {len(examples)} code examples") # Filter by language - python_examples = self.manager.list_code_examples(language="python") + python_examples = self.manager.list_code_examples(language = "python") self.log(f"Found {len(python_examples)} Python examples") except Exception as e: - self.log(f"Failed to list code examples: {str(e)}", success=False) + self.log(f"Failed to list code examples: {str(e)}", success = False) - def test_code_example_get(self): + def test_code_example_get(self) -> None: """测试获取代码示例详情""" try: if self.created_ids["code_example"]: - example = self.manager.get_code_example(self.created_ids["code_example"][0]) + example = self.manager.get_code_example(self.created_ids["code_example"][0]) if example: self.log( f"Retrieved code example: {example.title} (views: {example.view_count})" ) except Exception as e: - self.log(f"Failed to get code example: {str(e)}", success=False) + self.log(f"Failed to get code example: {str(e)}", success = False) - def test_portal_config_create(self): + def test_portal_config_create(self) -> None: """测试创建开发者门户配置""" try: - config = self.manager.create_portal_config( - name="InsightFlow Developer Portal", - description="开发者门户 - SDK、API 文档和示例代码", - theme="default", - primary_color="#1890ff", - secondary_color="#52c41a", - support_email="developers@insightflow.io", - support_url="https://support.insightflow.io", - github_url="https://github.com/insightflow", - discord_url="https://discord.gg/insightflow", - api_base_url="https://api.insightflow.io/v1", + config = self.manager.create_portal_config( + name = "InsightFlow Developer Portal", + description = "开发者门户 - SDK、API 文档和示例代码", + theme = "default", + primary_color = "#1890ff", + secondary_color = "#52c41a", + support_email = "developers@insightflow.io", + support_url = "https://support.insightflow.io", + github_url = "https://github.com/insightflow", + discord_url = "https://discord.gg/insightflow", + api_base_url = "https://api.insightflow.io/v1", ) self.created_ids["portal_config"].append(config.id) self.log(f"Created portal config: {config.name}") except Exception as e: - self.log(f"Failed to create portal config: {str(e)}", success=False) + self.log(f"Failed to create portal config: {str(e)}", success = False) - def test_portal_config_get(self): + def test_portal_config_get(self) -> None: """测试获取开发者门户配置""" try: if self.created_ids["portal_config"]: - config = self.manager.get_portal_config(self.created_ids["portal_config"][0]) + config = self.manager.get_portal_config(self.created_ids["portal_config"][0]) if config: self.log(f"Retrieved portal config: {config.name}") # Test active config - active_config = self.manager.get_active_portal_config() + active_config = self.manager.get_active_portal_config() if active_config: self.log(f"Active portal config: {active_config.name}") except Exception as e: - self.log(f"Failed to get portal config: {str(e)}", success=False) + self.log(f"Failed to get portal config: {str(e)}", success = False) - def test_revenue_record(self): + def test_revenue_record(self) -> None: """测试记录开发者收益""" try: if self.created_ids["developer"] and self.created_ids["plugin"]: - revenue = self.manager.record_revenue( - developer_id=self.created_ids["developer"][0], - item_type="plugin", - item_id=self.created_ids["plugin"][0], - item_name="飞书机器人集成插件", - sale_amount=49.0, - currency="CNY", - buyer_id="user_buyer_001", - transaction_id="txn_123456", + revenue = self.manager.record_revenue( + developer_id = self.created_ids["developer"][0], + item_type = "plugin", + item_id = self.created_ids["plugin"][0], + item_name = "飞书机器人集成插件", + sale_amount = 49.0, + currency = "CNY", + buyer_id = "user_buyer_001", + transaction_id = "txn_123456", ) self.log(f"Recorded revenue: {revenue.sale_amount} {revenue.currency}") self.log(f" - Platform fee: {revenue.platform_fee}") self.log(f" - Developer earnings: {revenue.developer_earnings}") except Exception as e: - self.log(f"Failed to record revenue: {str(e)}", success=False) + self.log(f"Failed to record revenue: {str(e)}", success = False) - def test_revenue_summary(self): + def test_revenue_summary(self) -> None: """测试获取开发者收益汇总""" try: if self.created_ids["developer"]: - summary = self.manager.get_developer_revenue_summary( + summary = self.manager.get_developer_revenue_summary( self.created_ids["developer"][0] ) self.log("Revenue summary for developer:") @@ -659,17 +659,17 @@ console.log('Upload complete:', result.id); self.log(f" - Total earnings: {summary['total_earnings']}") self.log(f" - Transaction count: {summary['transaction_count']}") except Exception as e: - self.log(f"Failed to get revenue summary: {str(e)}", success=False) + self.log(f"Failed to get revenue summary: {str(e)}", success = False) - def print_summary(self): + def print_summary(self) -> None: """打印测试摘要""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("Test Summary") - print("=" * 60) + print(" = " * 60) - total = len(self.test_results) - passed = sum(1 for r in self.test_results if r["success"]) - failed = total - passed + total = len(self.test_results) + passed = sum(1 for r in self.test_results if r["success"]) + failed = total - passed print(f"Total tests: {total}") print(f"Passed: {passed} ✅") @@ -686,12 +686,12 @@ console.log('Upload complete:', result.id); if ids: print(f" {resource_type}: {len(ids)}") - print("=" * 60) + print(" = " * 60) -def main(): +def main() -> None: """主函数""" - test = TestDeveloperEcosystem() + test = TestDeveloperEcosystem() test.run_all_tests() diff --git a/backend/test_phase8_task8.py b/backend/test_phase8_task8.py index 03f5edb..eef988a 100644 --- a/backend/test_phase8_task8.py +++ b/backend/test_phase8_task8.py @@ -26,7 +26,7 @@ from ops_manager import ( ) # Add backend directory to path -backend_dir = os.path.dirname(os.path.abspath(__file__)) +backend_dir = os.path.dirname(os.path.abspath(__file__)) if backend_dir not in sys.path: sys.path.insert(0, backend_dir) @@ -34,22 +34,22 @@ if backend_dir not in sys.path: class TestOpsManager: """测试运维与监控管理器""" - def __init__(self): - self.manager = get_ops_manager() - self.tenant_id = "test_tenant_001" - self.test_results = [] + def __init__(self) -> None: + self.manager = get_ops_manager() + self.tenant_id = "test_tenant_001" + self.test_results = [] - def log(self, message: str, success: bool = True): + def log(self, message: str, success: bool = True) -> None: """记录测试结果""" - status = "✅" if success else "❌" + status = "✅" if success else "❌" print(f"{status} {message}") self.test_results.append((message, success)) - def run_all_tests(self): + def run_all_tests(self) -> None: """运行所有测试""" - print("=" * 60) + print(" = " * 60) print("InsightFlow Phase 8 Task 8: Operations & Monitoring Tests") - print("=" * 60) + print(" = " * 60) # 1. 告警系统测试 self.test_alert_rules() @@ -73,63 +73,63 @@ class TestOpsManager: # 打印测试总结 self.print_summary() - def test_alert_rules(self): + def test_alert_rules(self) -> None: """测试告警规则管理""" print("\n📋 Testing Alert Rules...") try: # 创建阈值告警规则 - rule1 = self.manager.create_alert_rule( - tenant_id=self.tenant_id, - name="CPU 使用率告警", - description="当 CPU 使用率超过 80% 时触发告警", - rule_type=AlertRuleType.THRESHOLD, - severity=AlertSeverity.P1, - metric="cpu_usage_percent", - condition=">", - threshold=80.0, - duration=300, - evaluation_interval=60, - channels=[], - labels={"service": "api", "team": "platform"}, - annotations={"summary": "CPU 使用率过高", "runbook": "https://wiki/runbooks/cpu"}, - created_by="test_user", + rule1 = self.manager.create_alert_rule( + tenant_id = self.tenant_id, + name = "CPU 使用率告警", + description = "当 CPU 使用率超过 80% 时触发告警", + rule_type = AlertRuleType.THRESHOLD, + severity = AlertSeverity.P1, + metric = "cpu_usage_percent", + condition = ">", + threshold = 80.0, + duration = 300, + evaluation_interval = 60, + channels = [], + labels = {"service": "api", "team": "platform"}, + annotations = {"summary": "CPU 使用率过高", "runbook": "https://wiki/runbooks/cpu"}, + created_by = "test_user", ) self.log(f"Created alert rule: {rule1.name} (ID: {rule1.id})") # 创建异常检测告警规则 - rule2 = self.manager.create_alert_rule( - tenant_id=self.tenant_id, - name="内存异常检测", - description="检测内存使用异常", - rule_type=AlertRuleType.ANOMALY, - severity=AlertSeverity.P2, - metric="memory_usage_percent", - condition=">", - threshold=0.0, - duration=600, - evaluation_interval=300, - channels=[], - labels={"service": "database"}, - annotations={}, - created_by="test_user", + rule2 = self.manager.create_alert_rule( + tenant_id = self.tenant_id, + name = "内存异常检测", + description = "检测内存使用异常", + rule_type = AlertRuleType.ANOMALY, + severity = AlertSeverity.P2, + metric = "memory_usage_percent", + condition = ">", + threshold = 0.0, + duration = 600, + evaluation_interval = 300, + channels = [], + labels = {"service": "database"}, + annotations = {}, + created_by = "test_user", ) self.log(f"Created anomaly alert rule: {rule2.name} (ID: {rule2.id})") # 获取告警规则 - fetched_rule = self.manager.get_alert_rule(rule1.id) + fetched_rule = self.manager.get_alert_rule(rule1.id) assert fetched_rule is not None assert fetched_rule.name == rule1.name self.log(f"Fetched alert rule: {fetched_rule.name}") # 列出租户的所有告警规则 - rules = self.manager.list_alert_rules(self.tenant_id) + rules = self.manager.list_alert_rules(self.tenant_id) assert len(rules) >= 2 self.log(f"Listed {len(rules)} alert rules for tenant") # 更新告警规则 - updated_rule = self.manager.update_alert_rule( - rule1.id, threshold=85.0, description="更新后的描述" + updated_rule = self.manager.update_alert_rule( + rule1.id, threshold = 85.0, description = "更新后的描述" ) assert updated_rule.threshold == 85.0 self.log(f"Updated alert rule threshold to {updated_rule.threshold}") @@ -140,57 +140,57 @@ class TestOpsManager: self.log("Deleted test alert rules") except Exception as e: - self.log(f"Alert rules test failed: {e}", success=False) + self.log(f"Alert rules test failed: {e}", success = False) - def test_alert_channels(self): + def test_alert_channels(self) -> None: """测试告警渠道管理""" print("\n📢 Testing Alert Channels...") try: # 创建飞书告警渠道 - channel1 = self.manager.create_alert_channel( - tenant_id=self.tenant_id, - name="飞书告警", - channel_type=AlertChannelType.FEISHU, - config={ + channel1 = self.manager.create_alert_channel( + tenant_id = self.tenant_id, + name = "飞书告警", + channel_type = AlertChannelType.FEISHU, + config = { "webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/test", "secret": "test_secret", }, - severity_filter=["p0", "p1"], + severity_filter = ["p0", "p1"], ) self.log(f"Created Feishu channel: {channel1.name} (ID: {channel1.id})") # 创建钉钉告警渠道 - channel2 = self.manager.create_alert_channel( - tenant_id=self.tenant_id, - name="钉钉告警", - channel_type=AlertChannelType.DINGTALK, - config={ - "webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=test", + channel2 = self.manager.create_alert_channel( + tenant_id = self.tenant_id, + name = "钉钉告警", + channel_type = AlertChannelType.DINGTALK, + config = { + "webhook_url": "https://oapi.dingtalk.com/robot/send?access_token = test", "secret": "test_secret", }, - severity_filter=["p0", "p1", "p2"], + severity_filter = ["p0", "p1", "p2"], ) self.log(f"Created DingTalk channel: {channel2.name} (ID: {channel2.id})") # 创建 Slack 告警渠道 - channel3 = self.manager.create_alert_channel( - tenant_id=self.tenant_id, - name="Slack 告警", - channel_type=AlertChannelType.SLACK, - config={"webhook_url": "https://hooks.slack.com/services/test"}, - severity_filter=["p0", "p1", "p2", "p3"], + channel3 = self.manager.create_alert_channel( + tenant_id = self.tenant_id, + name = "Slack 告警", + channel_type = AlertChannelType.SLACK, + config = {"webhook_url": "https://hooks.slack.com/services/test"}, + severity_filter = ["p0", "p1", "p2", "p3"], ) self.log(f"Created Slack channel: {channel3.name} (ID: {channel3.id})") # 获取告警渠道 - fetched_channel = self.manager.get_alert_channel(channel1.id) + fetched_channel = self.manager.get_alert_channel(channel1.id) assert fetched_channel is not None assert fetched_channel.name == channel1.name self.log(f"Fetched alert channel: {fetched_channel.name}") # 列出租户的所有告警渠道 - channels = self.manager.list_alert_channels(self.tenant_id) + channels = self.manager.list_alert_channels(self.tenant_id) assert len(channels) >= 3 self.log(f"Listed {len(channels)} alert channels for tenant") @@ -198,74 +198,74 @@ class TestOpsManager: for channel in channels: if channel.tenant_id == self.tenant_id: with self.manager._get_db() as conn: - conn.execute("DELETE FROM alert_channels WHERE id = ?", (channel.id,)) + conn.execute("DELETE FROM alert_channels WHERE id = ?", (channel.id, )) conn.commit() self.log("Deleted test alert channels") except Exception as e: - self.log(f"Alert channels test failed: {e}", success=False) + self.log(f"Alert channels test failed: {e}", success = False) - def test_alerts(self): + def test_alerts(self) -> None: """测试告警管理""" print("\n🚨 Testing Alerts...") try: # 创建告警规则 - rule = self.manager.create_alert_rule( - tenant_id=self.tenant_id, - name="测试告警规则", - description="用于测试的告警规则", - rule_type=AlertRuleType.THRESHOLD, - severity=AlertSeverity.P1, - metric="test_metric", - condition=">", - threshold=100.0, - duration=60, - evaluation_interval=60, - channels=[], - labels={}, - annotations={}, - created_by="test_user", + rule = self.manager.create_alert_rule( + tenant_id = self.tenant_id, + name = "测试告警规则", + description = "用于测试的告警规则", + rule_type = AlertRuleType.THRESHOLD, + severity = AlertSeverity.P1, + metric = "test_metric", + condition = ">", + threshold = 100.0, + duration = 60, + evaluation_interval = 60, + channels = [], + labels = {}, + annotations = {}, + created_by = "test_user", ) # 记录资源指标 for i in range(10): self.manager.record_resource_metric( - tenant_id=self.tenant_id, - resource_type=ResourceType.CPU, - resource_id="server-001", - metric_name="test_metric", - metric_value=110.0 + i, - unit="percent", - metadata={"region": "cn-north-1"}, + tenant_id = self.tenant_id, + resource_type = ResourceType.CPU, + resource_id = "server-001", + metric_name = "test_metric", + metric_value = 110.0 + i, + unit = "percent", + metadata = {"region": "cn-north-1"}, ) self.log("Recorded 10 resource metrics") # 手动创建告警 from ops_manager import Alert - alert_id = f"test_alert_{datetime.now().strftime('%Y%m%d%H%M%S')}" - now = datetime.now().isoformat() + alert_id = f"test_alert_{datetime.now().strftime('%Y%m%d%H%M%S')}" + now = datetime.now().isoformat() - alert = Alert( - id=alert_id, - rule_id=rule.id, - tenant_id=self.tenant_id, - severity=AlertSeverity.P1, - status=AlertStatus.FIRING, - title="测试告警", - description="这是一条测试告警", - metric="test_metric", - value=120.0, - threshold=100.0, - labels={"test": "true"}, - annotations={}, - started_at=now, - resolved_at=None, - acknowledged_by=None, - acknowledged_at=None, - notification_sent={}, - suppression_count=0, + alert = Alert( + id = alert_id, + rule_id = rule.id, + tenant_id = self.tenant_id, + severity = AlertSeverity.P1, + status = AlertStatus.FIRING, + title = "测试告警", + description = "这是一条测试告警", + metric = "test_metric", + value = 120.0, + threshold = 100.0, + labels = {"test": "true"}, + annotations = {}, + started_at = now, + resolved_at = None, + acknowledged_by = None, + acknowledged_at = None, + notification_sent = {}, + suppression_count = 0, ) with self.manager._get_db() as conn: @@ -299,20 +299,20 @@ class TestOpsManager: self.log(f"Created test alert: {alert.id}") # 列出租户的告警 - alerts = self.manager.list_alerts(self.tenant_id) + alerts = self.manager.list_alerts(self.tenant_id) assert len(alerts) >= 1 self.log(f"Listed {len(alerts)} alerts for tenant") # 确认告警 self.manager.acknowledge_alert(alert_id, "test_user") - fetched_alert = self.manager.get_alert(alert_id) + fetched_alert = self.manager.get_alert(alert_id) assert fetched_alert.status == AlertStatus.ACKNOWLEDGED assert fetched_alert.acknowledged_by == "test_user" self.log(f"Acknowledged alert: {alert_id}") # 解决告警 self.manager.resolve_alert(alert_id) - fetched_alert = self.manager.get_alert(alert_id) + fetched_alert = self.manager.get_alert(alert_id) assert fetched_alert.status == AlertStatus.RESOLVED assert fetched_alert.resolved_at is not None self.log(f"Resolved alert: {alert_id}") @@ -320,23 +320,23 @@ class TestOpsManager: # 清理 self.manager.delete_alert_rule(rule.id) with self.manager._get_db() as conn: - conn.execute("DELETE FROM alerts WHERE id = ?", (alert_id,)) - conn.execute("DELETE FROM resource_metrics WHERE tenant_id = ?", (self.tenant_id,)) + conn.execute("DELETE FROM alerts WHERE id = ?", (alert_id, )) + conn.execute("DELETE FROM resource_metrics WHERE tenant_id = ?", (self.tenant_id, )) conn.commit() self.log("Cleaned up test data") except Exception as e: - self.log(f"Alerts test failed: {e}", success=False) + self.log(f"Alerts test failed: {e}", success = False) - def test_capacity_planning(self): + def test_capacity_planning(self) -> None: """测试容量规划""" print("\n📊 Testing Capacity Planning...") try: # 记录历史指标数据 - base_time = datetime.now() - timedelta(days=30) + base_time = datetime.now() - timedelta(days = 30) for i in range(30): - timestamp = (base_time + timedelta(days=i)).isoformat() + timestamp = (base_time + timedelta(days = i)).isoformat() with self.manager._get_db() as conn: conn.execute( """ @@ -360,13 +360,13 @@ class TestOpsManager: self.log("Recorded 30 days of historical metrics") # 创建容量规划 - prediction_date = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d") - plan = self.manager.create_capacity_plan( - tenant_id=self.tenant_id, - resource_type=ResourceType.CPU, - current_capacity=100.0, - prediction_date=prediction_date, - confidence=0.85, + prediction_date = (datetime.now() + timedelta(days = 30)).strftime("%Y-%m-%d") + plan = self.manager.create_capacity_plan( + tenant_id = self.tenant_id, + resource_type = ResourceType.CPU, + current_capacity = 100.0, + prediction_date = prediction_date, + confidence = 0.85, ) self.log(f"Created capacity plan: {plan.id}") @@ -375,38 +375,38 @@ class TestOpsManager: self.log(f" Recommended action: {plan.recommended_action}") # 获取容量规划列表 - plans = self.manager.get_capacity_plans(self.tenant_id) + plans = self.manager.get_capacity_plans(self.tenant_id) assert len(plans) >= 1 self.log(f"Listed {len(plans)} capacity plans") # 清理 with self.manager._get_db() as conn: - conn.execute("DELETE FROM capacity_plans WHERE tenant_id = ?", (self.tenant_id,)) - conn.execute("DELETE FROM resource_metrics WHERE tenant_id = ?", (self.tenant_id,)) + conn.execute("DELETE FROM capacity_plans WHERE tenant_id = ?", (self.tenant_id, )) + conn.execute("DELETE FROM resource_metrics WHERE tenant_id = ?", (self.tenant_id, )) conn.commit() self.log("Cleaned up capacity planning test data") except Exception as e: - self.log(f"Capacity planning test failed: {e}", success=False) + self.log(f"Capacity planning test failed: {e}", success = False) - def test_auto_scaling(self): + def test_auto_scaling(self) -> None: """测试自动扩缩容""" print("\n⚖️ Testing Auto Scaling...") try: # 创建自动扩缩容策略 - policy = self.manager.create_auto_scaling_policy( - tenant_id=self.tenant_id, - name="API 服务自动扩缩容", - resource_type=ResourceType.CPU, - min_instances=2, - max_instances=10, - target_utilization=0.7, - scale_up_threshold=0.8, - scale_down_threshold=0.3, - scale_up_step=2, - scale_down_step=1, - cooldown_period=300, + policy = self.manager.create_auto_scaling_policy( + tenant_id = self.tenant_id, + name = "API 服务自动扩缩容", + resource_type = ResourceType.CPU, + min_instances = 2, + max_instances = 10, + target_utilization = 0.7, + scale_up_threshold = 0.8, + scale_down_threshold = 0.3, + scale_up_step = 2, + scale_down_step = 1, + cooldown_period = 300, ) self.log(f"Created auto scaling policy: {policy.name} (ID: {policy.id})") @@ -415,13 +415,13 @@ class TestOpsManager: self.log(f" Target utilization: {policy.target_utilization}") # 获取策略列表 - policies = self.manager.list_auto_scaling_policies(self.tenant_id) + policies = self.manager.list_auto_scaling_policies(self.tenant_id) assert len(policies) >= 1 self.log(f"Listed {len(policies)} auto scaling policies") # 模拟扩缩容评估 - event = self.manager.evaluate_scaling_policy( - policy_id=policy.id, current_instances=3, current_utilization=0.85 + event = self.manager.evaluate_scaling_policy( + policy_id = policy.id, current_instances = 3, current_utilization = 0.85 ) if event: @@ -432,62 +432,62 @@ class TestOpsManager: self.log("No scaling action needed") # 获取扩缩容事件列表 - events = self.manager.list_scaling_events(self.tenant_id) + events = self.manager.list_scaling_events(self.tenant_id) self.log(f"Listed {len(events)} scaling events") # 清理 with self.manager._get_db() as conn: - conn.execute("DELETE FROM scaling_events WHERE tenant_id = ?", (self.tenant_id,)) + conn.execute("DELETE FROM scaling_events WHERE tenant_id = ?", (self.tenant_id, )) conn.execute( - "DELETE FROM auto_scaling_policies WHERE tenant_id = ?", (self.tenant_id,) + "DELETE FROM auto_scaling_policies WHERE tenant_id = ?", (self.tenant_id, ) ) conn.commit() self.log("Cleaned up auto scaling test data") except Exception as e: - self.log(f"Auto scaling test failed: {e}", success=False) + self.log(f"Auto scaling test failed: {e}", success = False) - def test_health_checks(self): + def test_health_checks(self) -> None: """测试健康检查""" print("\n💓 Testing Health Checks...") try: # 创建 HTTP 健康检查 - check1 = self.manager.create_health_check( - tenant_id=self.tenant_id, - name="API 服务健康检查", - target_type="service", - target_id="api-service", - check_type="http", - check_config={"url": "https://api.insightflow.io/health", "expected_status": 200}, - interval=60, - timeout=10, - retry_count=3, + check1 = self.manager.create_health_check( + tenant_id = self.tenant_id, + name = "API 服务健康检查", + target_type = "service", + target_id = "api-service", + check_type = "http", + check_config = {"url": "https://api.insightflow.io/health", "expected_status": 200}, + interval = 60, + timeout = 10, + retry_count = 3, ) self.log(f"Created HTTP health check: {check1.name} (ID: {check1.id})") # 创建 TCP 健康检查 - check2 = self.manager.create_health_check( - tenant_id=self.tenant_id, - name="数据库健康检查", - target_type="database", - target_id="postgres-001", - check_type="tcp", - check_config={"host": "db.insightflow.io", "port": 5432}, - interval=30, - timeout=5, - retry_count=2, + check2 = self.manager.create_health_check( + tenant_id = self.tenant_id, + name = "数据库健康检查", + target_type = "database", + target_id = "postgres-001", + check_type = "tcp", + check_config = {"host": "db.insightflow.io", "port": 5432}, + interval = 30, + timeout = 5, + retry_count = 2, ) self.log(f"Created TCP health check: {check2.name} (ID: {check2.id})") # 获取健康检查列表 - checks = self.manager.list_health_checks(self.tenant_id) + checks = self.manager.list_health_checks(self.tenant_id) assert len(checks) >= 2 self.log(f"Listed {len(checks)} health checks") # 执行健康检查(异步) - async def run_health_check(): - result = await self.manager.execute_health_check(check1.id) + async def run_health_check() -> None: + result = await self.manager.execute_health_check(check1.id) return result # 由于健康检查需要网络,这里只验证方法存在 @@ -495,28 +495,28 @@ class TestOpsManager: # 清理 with self.manager._get_db() as conn: - conn.execute("DELETE FROM health_checks WHERE tenant_id = ?", (self.tenant_id,)) + conn.execute("DELETE FROM health_checks WHERE tenant_id = ?", (self.tenant_id, )) conn.commit() self.log("Cleaned up health check test data") except Exception as e: - self.log(f"Health checks test failed: {e}", success=False) + self.log(f"Health checks test failed: {e}", success = False) - def test_failover(self): + def test_failover(self) -> None: """测试故障转移""" print("\n🔄 Testing Failover...") try: # 创建故障转移配置 - config = self.manager.create_failover_config( - tenant_id=self.tenant_id, - name="主备数据中心故障转移", - primary_region="cn-north-1", - secondary_regions=["cn-south-1", "cn-east-1"], - failover_trigger="health_check_failed", - auto_failover=False, - failover_timeout=300, - health_check_id=None, + config = self.manager.create_failover_config( + tenant_id = self.tenant_id, + name = "主备数据中心故障转移", + primary_region = "cn-north-1", + secondary_regions = ["cn-south-1", "cn-east-1"], + failover_trigger = "health_check_failed", + auto_failover = False, + failover_timeout = 300, + health_check_id = None, ) self.log(f"Created failover config: {config.name} (ID: {config.id})") @@ -524,13 +524,13 @@ class TestOpsManager: self.log(f" Secondary regions: {config.secondary_regions}") # 获取故障转移配置列表 - configs = self.manager.list_failover_configs(self.tenant_id) + configs = self.manager.list_failover_configs(self.tenant_id) assert len(configs) >= 1 self.log(f"Listed {len(configs)} failover configs") # 发起故障转移 - event = self.manager.initiate_failover( - config_id=config.id, reason="Primary region health check failed" + event = self.manager.initiate_failover( + config_id = config.id, reason = "Primary region health check failed" ) if event: @@ -540,41 +540,41 @@ class TestOpsManager: # 更新故障转移状态 self.manager.update_failover_status(event.id, "completed") - updated_event = self.manager.get_failover_event(event.id) + updated_event = self.manager.get_failover_event(event.id) assert updated_event.status == "completed" self.log("Failover completed") # 获取故障转移事件列表 - events = self.manager.list_failover_events(self.tenant_id) + events = self.manager.list_failover_events(self.tenant_id) self.log(f"Listed {len(events)} failover events") # 清理 with self.manager._get_db() as conn: - conn.execute("DELETE FROM failover_events WHERE tenant_id = ?", (self.tenant_id,)) - conn.execute("DELETE FROM failover_configs WHERE tenant_id = ?", (self.tenant_id,)) + conn.execute("DELETE FROM failover_events WHERE tenant_id = ?", (self.tenant_id, )) + conn.execute("DELETE FROM failover_configs WHERE tenant_id = ?", (self.tenant_id, )) conn.commit() self.log("Cleaned up failover test data") except Exception as e: - self.log(f"Failover test failed: {e}", success=False) + self.log(f"Failover test failed: {e}", success = False) - def test_backup(self): + def test_backup(self) -> None: """测试备份与恢复""" print("\n💾 Testing Backup & Recovery...") try: # 创建备份任务 - job = self.manager.create_backup_job( - tenant_id=self.tenant_id, - name="每日数据库备份", - backup_type="full", - target_type="database", - target_id="postgres-main", - schedule="0 2 * * *", # 每天凌晨2点 - retention_days=30, - encryption_enabled=True, - compression_enabled=True, - storage_location="s3://insightflow-backups/", + job = self.manager.create_backup_job( + tenant_id = self.tenant_id, + name = "每日数据库备份", + backup_type = "full", + target_type = "database", + target_id = "postgres-main", + schedule = "0 2 * * *", # 每天凌晨2点 + retention_days = 30, + encryption_enabled = True, + compression_enabled = True, + storage_location = "s3://insightflow-backups/", ) self.log(f"Created backup job: {job.name} (ID: {job.id})") @@ -582,12 +582,12 @@ class TestOpsManager: self.log(f" Retention: {job.retention_days} days") # 获取备份任务列表 - jobs = self.manager.list_backup_jobs(self.tenant_id) + jobs = self.manager.list_backup_jobs(self.tenant_id) assert len(jobs) >= 1 self.log(f"Listed {len(jobs)} backup jobs") # 执行备份 - record = self.manager.execute_backup(job.id) + record = self.manager.execute_backup(job.id) if record: self.log(f"Executed backup: {record.id}") @@ -595,50 +595,50 @@ class TestOpsManager: self.log(f" Storage: {record.storage_path}") # 获取备份记录列表 - records = self.manager.list_backup_records(self.tenant_id) + records = self.manager.list_backup_records(self.tenant_id) self.log(f"Listed {len(records)} backup records") # 测试恢复(模拟) - restore_result = self.manager.restore_from_backup(record.id) + restore_result = self.manager.restore_from_backup(record.id) self.log(f"Restore test result: {restore_result}") # 清理 with self.manager._get_db() as conn: - conn.execute("DELETE FROM backup_records WHERE tenant_id = ?", (self.tenant_id,)) - conn.execute("DELETE FROM backup_jobs WHERE tenant_id = ?", (self.tenant_id,)) + conn.execute("DELETE FROM backup_records WHERE tenant_id = ?", (self.tenant_id, )) + conn.execute("DELETE FROM backup_jobs WHERE tenant_id = ?", (self.tenant_id, )) conn.commit() self.log("Cleaned up backup test data") except Exception as e: - self.log(f"Backup test failed: {e}", success=False) + self.log(f"Backup test failed: {e}", success = False) - def test_cost_optimization(self): + def test_cost_optimization(self) -> None: """测试成本优化""" print("\n💰 Testing Cost Optimization...") try: # 记录资源利用率数据 - report_date = datetime.now().strftime("%Y-%m-%d") + report_date = datetime.now().strftime("%Y-%m-%d") for i in range(5): self.manager.record_resource_utilization( - tenant_id=self.tenant_id, - resource_type=ResourceType.CPU, - resource_id=f"server-{i:03d}", - utilization_rate=0.05 + random.random() * 0.1, # 低利用率 - peak_utilization=0.15, - avg_utilization=0.08, - idle_time_percent=0.85, - report_date=report_date, - recommendations=["Consider downsizing this resource"], + tenant_id = self.tenant_id, + resource_type = ResourceType.CPU, + resource_id = f"server-{i:03d}", + utilization_rate = 0.05 + random.random() * 0.1, # 低利用率 + peak_utilization = 0.15, + avg_utilization = 0.08, + idle_time_percent = 0.85, + report_date = report_date, + recommendations = ["Consider downsizing this resource"], ) self.log("Recorded 5 resource utilization records") # 生成成本报告 - now = datetime.now() - report = self.manager.generate_cost_report( - tenant_id=self.tenant_id, year=now.year, month=now.month + now = datetime.now() + report = self.manager.generate_cost_report( + tenant_id = self.tenant_id, year = now.year, month = now.month ) self.log(f"Generated cost report: {report.id}") @@ -647,11 +647,11 @@ class TestOpsManager: self.log(f" Anomalies detected: {len(report.anomalies)}") # 检测闲置资源 - idle_resources = self.manager.detect_idle_resources(self.tenant_id) + idle_resources = self.manager.detect_idle_resources(self.tenant_id) self.log(f"Detected {len(idle_resources)} idle resources") # 获取闲置资源列表 - idle_list = self.manager.get_idle_resources(self.tenant_id) + idle_list = self.manager.get_idle_resources(self.tenant_id) for resource in idle_list: self.log( f" Idle resource: {resource.resource_name} (est. cost: { @@ -660,7 +660,7 @@ class TestOpsManager: ) # 生成成本优化建议 - suggestions = self.manager.generate_cost_optimization_suggestions(self.tenant_id) + suggestions = self.manager.generate_cost_optimization_suggestions(self.tenant_id) self.log(f"Generated {len(suggestions)} cost optimization suggestions") for suggestion in suggestions: @@ -672,12 +672,12 @@ class TestOpsManager: self.log(f" Difficulty: {suggestion.difficulty}") # 获取优化建议列表 - all_suggestions = self.manager.get_cost_optimization_suggestions(self.tenant_id) + all_suggestions = self.manager.get_cost_optimization_suggestions(self.tenant_id) self.log(f"Listed {len(all_suggestions)} optimization suggestions") # 应用优化建议 if all_suggestions: - applied = self.manager.apply_cost_optimization_suggestion(all_suggestions[0].id) + applied = self.manager.apply_cost_optimization_suggestion(all_suggestions[0].id) if applied: self.log(f"Applied optimization suggestion: {applied.title}") assert applied.is_applied @@ -686,29 +686,29 @@ class TestOpsManager: # 清理 with self.manager._get_db() as conn: conn.execute( - "DELETE FROM cost_optimization_suggestions WHERE tenant_id = ?", - (self.tenant_id,), + "DELETE FROM cost_optimization_suggestions WHERE tenant_id = ?", + (self.tenant_id, ), ) - conn.execute("DELETE FROM idle_resources WHERE tenant_id = ?", (self.tenant_id,)) + conn.execute("DELETE FROM idle_resources WHERE tenant_id = ?", (self.tenant_id, )) conn.execute( - "DELETE FROM resource_utilizations WHERE tenant_id = ?", (self.tenant_id,) + "DELETE FROM resource_utilizations WHERE tenant_id = ?", (self.tenant_id, ) ) - conn.execute("DELETE FROM cost_reports WHERE tenant_id = ?", (self.tenant_id,)) + conn.execute("DELETE FROM cost_reports WHERE tenant_id = ?", (self.tenant_id, )) conn.commit() self.log("Cleaned up cost optimization test data") except Exception as e: - self.log(f"Cost optimization test failed: {e}", success=False) + self.log(f"Cost optimization test failed: {e}", success = False) - def print_summary(self): + def print_summary(self) -> None: """打印测试总结""" - print("\n" + "=" * 60) + print("\n" + " = " * 60) print("Test Summary") - print("=" * 60) + print(" = " * 60) - total = len(self.test_results) - passed = sum(1 for _, success in self.test_results if success) - failed = total - passed + total = len(self.test_results) + passed = sum(1 for _, success in self.test_results if success) + failed = total - passed print(f"Total tests: {total}") print(f"Passed: {passed} ✅") @@ -720,12 +720,12 @@ class TestOpsManager: if not success: print(f" ❌ {message}") - print("=" * 60) + print(" = " * 60) -def main(): +def main() -> None: """主函数""" - test = TestOpsManager() + test = TestOpsManager() test.run_all_tests() diff --git a/backend/tingwu_client.py b/backend/tingwu_client.py index c70e9e6..de330f5 100644 --- a/backend/tingwu_client.py +++ b/backend/tingwu_client.py @@ -10,19 +10,19 @@ from typing import Any class TingwuClient: - def __init__(self): - self.access_key = os.getenv("ALI_ACCESS_KEY", "") - self.secret_key = os.getenv("ALI_SECRET_KEY", "") - self.endpoint = "https://tingwu.cn-beijing.aliyuncs.com" + def __init__(self) -> None: + self.access_key = os.getenv("ALI_ACCESS_KEY", "") + self.secret_key = os.getenv("ALI_SECRET_KEY", "") + self.endpoint = "https://tingwu.cn-beijing.aliyuncs.com" if not self.access_key or not self.secret_key: raise ValueError("ALI_ACCESS_KEY and ALI_SECRET_KEY required") def _sign_request( - self, method: str, uri: str, query: str = "", body: str = "" + self, method: str, uri: str, query: str = "", body: str = "" ) -> dict[str, str]: """阿里云签名 V3""" - timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") # 简化签名,实际生产需要完整实现 # 这里使用基础认证头 @@ -31,10 +31,10 @@ class TingwuClient: "x-acs-action": "CreateTask", "x-acs-version": "2023-09-30", "x-acs-date": timestamp, - "Authorization": f"ACS3-HMAC-SHA256 Credential={self.access_key}/acs/tingwu/cn-beijing", + "Authorization": f"ACS3-HMAC-SHA256 Credential = {self.access_key}/acs/tingwu/cn-beijing", } - def create_task(self, audio_url: str, language: str = "zh") -> str: + def create_task(self, audio_url: str, language: str = "zh") -> str: """创建听悟任务""" try: # 导入移到文件顶部会导致循环导入,保持在这里 @@ -42,23 +42,23 @@ class TingwuClient: from alibabacloud_tingwu20230930 import models as tingwu_models from alibabacloud_tingwu20230930.client import Client as TingwuSDKClient - config = open_api_models.Config( - access_key_id=self.access_key, access_key_secret=self.secret_key + config = open_api_models.Config( + access_key_id = self.access_key, access_key_secret = self.secret_key ) - config.endpoint = "tingwu.cn-beijing.aliyuncs.com" - client = TingwuSDKClient(config) + config.endpoint = "tingwu.cn-beijing.aliyuncs.com" + client = TingwuSDKClient(config) - request = tingwu_models.CreateTaskRequest( - type="offline", - input=tingwu_models.Input(source="OSS", file_url=audio_url), - parameters=tingwu_models.Parameters( - transcription=tingwu_models.Transcription( - diarization_enabled=True, sentence_max_length=20 + request = tingwu_models.CreateTaskRequest( + type = "offline", + input = tingwu_models.Input(source = "OSS", file_url = audio_url), + parameters = tingwu_models.Parameters( + transcription = tingwu_models.Transcription( + diarization_enabled = True, sentence_max_length = 20 ) ), ) - response = client.create_task(request) + response = client.create_task(request) if response.body.code == "0": return response.body.data.task_id else: @@ -73,29 +73,26 @@ class TingwuClient: return f"mock_task_{int(time.time())}" def get_task_result( - self, task_id: str, max_retries: int = 60, interval: int = 5 + self, task_id: str, max_retries: int = 60, interval: int = 5 ) -> dict[str, Any]: """获取任务结果""" try: # 导入移到文件顶部会导致循环导入,保持在这里 - from alibabacloud_tea_openapi import models as open_api_models - from alibabacloud_tingwu20230930 import models as tingwu_models - from alibabacloud_tingwu20230930.client import Client as TingwuSDKClient - config = open_api_models.Config( - access_key_id=self.access_key, access_key_secret=self.secret_key + config = open_api_models.Config( + access_key_id = self.access_key, access_key_secret = self.secret_key ) - config.endpoint = "tingwu.cn-beijing.aliyuncs.com" - client = TingwuSDKClient(config) + config.endpoint = "tingwu.cn-beijing.aliyuncs.com" + client = TingwuSDKClient(config) for i in range(max_retries): - request = tingwu_models.GetTaskInfoRequest() - response = client.get_task_info(task_id, request) + request = tingwu_models.GetTaskInfoRequest() + response = client.get_task_info(task_id, request) if response.body.code != "0": raise RuntimeError(f"Query failed: {response.body.message}") - status = response.body.data.task_status + status = response.body.data.task_status if status == "SUCCESS": return self._parse_result(response.body.data) @@ -116,11 +113,11 @@ class TingwuClient: def _parse_result(self, data) -> dict[str, Any]: """解析结果""" - result = data.result - transcription = result.transcription + result = data.result + transcription = result.transcription - full_text = "" - segments = [] + full_text = "" + segments = [] if transcription.paragraphs: for para in transcription.paragraphs: @@ -153,8 +150,8 @@ class TingwuClient: ], } - def transcribe(self, audio_url: str, language: str = "zh") -> dict[str, Any]: + def transcribe(self, audio_url: str, language: str = "zh") -> dict[str, Any]: """一键转录""" - task_id = self.create_task(audio_url, language) + task_id = self.create_task(audio_url, language) print(f"Tingwu task: {task_id}") return self.get_task_result(task_id) diff --git a/backend/workflow_manager.py b/backend/workflow_manager.py index 5ff70e9..e6f29ce 100644 --- a/backend/workflow_manager.py +++ b/backend/workflow_manager.py @@ -31,52 +31,52 @@ from workflow_manager import WorkflowManager import urllib.parse # Constants -UUID_LENGTH = 8 # UUID 截断长度 -DEFAULT_TIMEOUT = 300 # 默认超时时间(秒) -DEFAULT_RETRY_COUNT = 3 # 默认重试次数 -DEFAULT_RETRY_DELAY = 5 # 默认重试延迟(秒) +UUID_LENGTH = 8 # UUID 截断长度 +DEFAULT_TIMEOUT = 300 # 默认超时时间(秒) +DEFAULT_RETRY_COUNT = 3 # 默认重试次数 +DEFAULT_RETRY_DELAY = 5 # 默认重试延迟(秒) # Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +logging.basicConfig(level = logging.INFO) +logger = logging.getLogger(__name__) class WorkflowStatus(Enum): """工作流状态""" - ACTIVE = "active" - PAUSED = "paused" - ERROR = "error" - COMPLETED = "completed" + ACTIVE = "active" + PAUSED = "paused" + ERROR = "error" + COMPLETED = "completed" class WorkflowType(Enum): """工作流类型""" - AUTO_ANALYZE = "auto_analyze" # 自动分析新文件 - AUTO_ALIGN = "auto_align" # 自动实体对齐 - AUTO_RELATION = "auto_relation" # 自动关系发现 - SCHEDULED_REPORT = "scheduled_report" # 定时报告 - CUSTOM = "custom" # 自定义工作流 + AUTO_ANALYZE = "auto_analyze" # 自动分析新文件 + AUTO_ALIGN = "auto_align" # 自动实体对齐 + AUTO_RELATION = "auto_relation" # 自动关系发现 + SCHEDULED_REPORT = "scheduled_report" # 定时报告 + CUSTOM = "custom" # 自定义工作流 class WebhookType(Enum): """Webhook 类型""" - FEISHU = "feishu" - DINGTALK = "dingtalk" - SLACK = "slack" - CUSTOM = "custom" + FEISHU = "feishu" + DINGTALK = "dingtalk" + SLACK = "slack" + CUSTOM = "custom" class TaskStatus(Enum): """任务执行状态""" - PENDING = "pending" - RUNNING = "running" - SUCCESS = "success" - FAILED = "failed" - CANCELLED = "cancelled" + PENDING = "pending" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + CANCELLED = "cancelled" @dataclass @@ -87,20 +87,20 @@ class WorkflowTask: workflow_id: str name: str task_type: str # analyze, align, discover_relations, notify, custom - config: dict = field(default_factory=dict) - order: int = 0 - depends_on: list[str] = field(default_factory=list) - timeout_seconds: int = 300 - retry_count: int = 3 - retry_delay: int = 5 - created_at: str = "" - updated_at: str = "" + config: dict = field(default_factory = dict) + order: int = 0 + depends_on: list[str] = field(default_factory = list) + timeout_seconds: int = 300 + retry_count: int = 3 + retry_delay: int = 5 + created_at: str = "" + updated_at: str = "" - def __post_init__(self): + def __post_init__(self) -> None: if not self.created_at: - self.created_at = datetime.now().isoformat() + self.created_at = datetime.now().isoformat() if not self.updated_at: - self.updated_at = self.created_at + self.updated_at = self.created_at @dataclass @@ -111,21 +111,21 @@ class WebhookConfig: name: str webhook_type: str # feishu, dingtalk, slack, custom url: str - secret: str = "" # 用于签名验证 - headers: dict = field(default_factory=dict) - template: str = "" # 消息模板 - is_active: bool = True - created_at: str = "" - updated_at: str = "" - last_used_at: str | None = None - success_count: int = 0 - fail_count: int = 0 + secret: str = "" # 用于签名验证 + headers: dict = field(default_factory = dict) + template: str = "" # 消息模板 + is_active: bool = True + created_at: str = "" + updated_at: str = "" + last_used_at: str | None = None + success_count: int = 0 + fail_count: int = 0 - def __post_init__(self): + def __post_init__(self) -> None: if not self.created_at: - self.created_at = datetime.now().isoformat() + self.created_at = datetime.now().isoformat() if not self.updated_at: - self.updated_at = self.created_at + self.updated_at = self.created_at @dataclass @@ -137,25 +137,25 @@ class Workflow: description: str workflow_type: str project_id: str - status: str = "active" - schedule: str | None = None # cron expression or interval - schedule_type: str = "manual" # manual, cron, interval - config: dict = field(default_factory=dict) - webhook_ids: list[str] = field(default_factory=list) - is_active: bool = True - created_at: str = "" - updated_at: str = "" - last_run_at: str | None = None - next_run_at: str | None = None - run_count: int = 0 - success_count: int = 0 - fail_count: int = 0 + status: str = "active" + schedule: str | None = None # cron expression or interval + schedule_type: str = "manual" # manual, cron, interval + config: dict = field(default_factory = dict) + webhook_ids: list[str] = field(default_factory = list) + is_active: bool = True + created_at: str = "" + updated_at: str = "" + last_run_at: str | None = None + next_run_at: str | None = None + run_count: int = 0 + success_count: int = 0 + fail_count: int = 0 - def __post_init__(self): + def __post_init__(self) -> None: if not self.created_at: - self.created_at = datetime.now().isoformat() + self.created_at = datetime.now().isoformat() if not self.updated_at: - self.updated_at = self.created_at + self.updated_at = self.created_at @dataclass @@ -164,31 +164,31 @@ class WorkflowLog: id: str workflow_id: str - task_id: str | None = None - status: str = "pending" # pending, running, success, failed, cancelled - start_time: str | None = None - end_time: str | None = None - duration_ms: int = 0 - input_data: dict = field(default_factory=dict) - output_data: dict = field(default_factory=dict) - error_message: str = "" - created_at: str = "" + task_id: str | None = None + status: str = "pending" # pending, running, success, failed, cancelled + start_time: str | None = None + end_time: str | None = None + duration_ms: int = 0 + input_data: dict = field(default_factory = dict) + output_data: dict = field(default_factory = dict) + error_message: str = "" + created_at: str = "" - def __post_init__(self): + def __post_init__(self) -> None: if not self.created_at: - self.created_at = datetime.now().isoformat() + self.created_at = datetime.now().isoformat() class WebhookNotifier: """Webhook 通知器 - 支持飞书、钉钉、Slack""" - def __init__(self): - self.http_client = httpx.AsyncClient(timeout=30.0) + def __init__(self) -> None: + self.http_client = httpx.AsyncClient(timeout = 30.0) async def send(self, config: WebhookConfig, message: dict) -> bool: """发送 Webhook 通知""" try: - webhook_type = WebhookType(config.webhook_type) + webhook_type = WebhookType(config.webhook_type) if webhook_type == WebhookType.FEISHU: return await self._send_feishu(config, message) @@ -205,20 +205,20 @@ class WebhookNotifier: async def _send_feishu(self, config: WebhookConfig, message: dict) -> bool: """发送飞书通知""" - timestamp = str(int(datetime.now().timestamp())) + timestamp = str(int(datetime.now().timestamp())) # 签名计算 if config.secret: - string_to_sign = f"{timestamp}\n{config.secret}" - hmac_code = hmac.new(string_to_sign.encode("utf-8"), digestmod=hashlib.sha256).digest() - sign = base64.b64encode(hmac_code).decode("utf-8") + string_to_sign = f"{timestamp}\n{config.secret}" + hmac_code = hmac.new(string_to_sign.encode("utf-8"), digestmod = hashlib.sha256).digest() + sign = base64.b64encode(hmac_code).decode("utf-8") else: - sign = "" + sign = "" # 构建消息体 if "content" in message: # 文本消息 - payload = { + payload = { "timestamp": timestamp, "sign": sign, "msg_type": "text", @@ -226,7 +226,7 @@ class WebhookNotifier: } elif "title" in message: # 富文本消息 - payload = { + payload = { "timestamp": timestamp, "sign": sign, "msg_type": "post", @@ -241,47 +241,47 @@ class WebhookNotifier: } else: # 卡片消息 - payload = { + payload = { "timestamp": timestamp, "sign": sign, "msg_type": "interactive", "card": message.get("card", {}), } - headers = {"Content-Type": "application/json", **config.headers} + headers = {"Content-Type": "application/json", **config.headers} - response = await self.http_client.post(config.url, json=payload, headers=headers) + response = await self.http_client.post(config.url, json = payload, headers = headers) response.raise_for_status() - result = response.json() + result = response.json() return result.get("code") == 0 async def _send_dingtalk(self, config: WebhookConfig, message: dict) -> bool: """发送钉钉通知""" - timestamp = str(round(datetime.now().timestamp() * 1000)) + timestamp = str(round(datetime.now().timestamp() * 1000)) # 签名计算 if config.secret: - secret_enc = config.secret.encode("utf-8") - string_to_sign = f"{timestamp}\n{config.secret}" - hmac_code = hmac.new( - secret_enc, string_to_sign.encode("utf-8"), digestmod=hashlib.sha256 + secret_enc = config.secret.encode("utf-8") + string_to_sign = f"{timestamp}\n{config.secret}" + hmac_code = hmac.new( + secret_enc, string_to_sign.encode("utf-8"), digestmod = hashlib.sha256 ).digest() - sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) - url = f"{config.url}×tamp={timestamp}&sign={sign}" + sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) + url = f"{config.url}×tamp = {timestamp}&sign = {sign}" else: - url = config.url + url = config.url # 构建消息体 if "content" in message: - payload = {"msgtype": "text", "text": {"content": message["content"]}} + payload = {"msgtype": "text", "text": {"content": message["content"]}} elif "title" in message: - payload = { + payload = { "msgtype": "markdown", "markdown": {"title": message["title"], "text": message.get("markdown", "")}, } elif "link" in message: - payload = { + payload = { "msgtype": "link", "link": { "text": message.get("text", ""), @@ -291,46 +291,46 @@ class WebhookNotifier: }, } else: - payload = {"msgtype": "action_card", "action_card": message.get("action_card", {})} + payload = {"msgtype": "action_card", "action_card": message.get("action_card", {})} - headers = {"Content-Type": "application/json", **config.headers} + headers = {"Content-Type": "application/json", **config.headers} - response = await self.http_client.post(url, json=payload, headers=headers) + response = await self.http_client.post(url, json = payload, headers = headers) response.raise_for_status() - result = response.json() + result = response.json() return result.get("errcode") == 0 async def _send_slack(self, config: WebhookConfig, message: dict) -> bool: """发送 Slack 通知""" # Slack 直接支持标准 webhook 格式 - payload = { + payload = { "text": message.get("content", message.get("text", "")), } if "blocks" in message: - payload["blocks"] = message["blocks"] + payload["blocks"] = message["blocks"] if "attachments" in message: - payload["attachments"] = message["attachments"] + payload["attachments"] = message["attachments"] - headers = {"Content-Type": "application/json", **config.headers} + headers = {"Content-Type": "application/json", **config.headers} - response = await self.http_client.post(config.url, json=payload, headers=headers) + response = await self.http_client.post(config.url, json = payload, headers = headers) response.raise_for_status() return response.text == "ok" async def _send_custom(self, config: WebhookConfig, message: dict) -> bool: """发送自定义 Webhook 通知""" - headers = {"Content-Type": "application/json", **config.headers} + headers = {"Content-Type": "application/json", **config.headers} - response = await self.http_client.post(config.url, json=message, headers=headers) + response = await self.http_client.post(config.url, json = message, headers = headers) response.raise_for_status() return True - async def close(self): + async def close(self) -> None: """关闭 HTTP 客户端""" await self.http_client.aclose() @@ -339,16 +339,16 @@ class WorkflowManager: """工作流管理器 - 核心管理类""" # 默认配置常量 - DEFAULT_TIMEOUT: int = 300 - DEFAULT_RETRY_COUNT: int = 3 - DEFAULT_RETRY_DELAY: int = 5 + DEFAULT_TIMEOUT: int = 300 + DEFAULT_RETRY_COUNT: int = 3 + DEFAULT_RETRY_DELAY: int = 5 - def __init__(self, db_manager=None): - self.db = db_manager - self.scheduler = AsyncIOScheduler() - self.notifier = WebhookNotifier() - self._task_handlers: dict[str, Callable] = {} - self._running_tasks: dict[str, asyncio.Task] = {} + def __init__(self, db_manager = None) -> None: + self.db = db_manager + self.scheduler = AsyncIOScheduler() + self.notifier = WebhookNotifier() + self._task_handlers: dict[str, Callable] = {} + self._running_tasks: dict[str, asyncio.Task] = {} self._setup_default_handlers() # 添加调度器事件监听 @@ -356,7 +356,7 @@ class WorkflowManager: def _setup_default_handlers(self) -> None: """设置默认的任务处理器""" - self._task_handlers = { + self._task_handlers = { "analyze": self._handle_analyze_task, "align": self._handle_align_task, "discover_relations": self._handle_discover_relations_task, @@ -366,7 +366,7 @@ class WorkflowManager: def register_task_handler(self, task_type: str, handler: Callable) -> None: """注册自定义任务处理器""" - self._task_handlers[task_type] = handler + self._task_handlers[task_type] = handler def start(self) -> None: """启动工作流管理器""" @@ -381,13 +381,13 @@ class WorkflowManager: def stop(self) -> None: """停止工作流管理器""" if self.scheduler.running: - self.scheduler.shutdown(wait=True) + self.scheduler.shutdown(wait = True) logger.info("Workflow scheduler stopped") - async def _load_and_schedule_workflows(self): + async def _load_and_schedule_workflows(self) -> None: """从数据库加载并调度所有活跃工作流""" try: - workflows = self.list_workflows(status="active") + workflows = self.list_workflows(status = "active") for workflow in workflows: if workflow.schedule and workflow.is_active: self._schedule_workflow(workflow) @@ -396,7 +396,7 @@ class WorkflowManager: def _schedule_workflow(self, workflow: Workflow) -> None: """调度工作流""" - job_id = f"workflow_{workflow.id}" + job_id = f"workflow_{workflow.id}" # 移除已存在的任务 if self.scheduler.get_job(job_id): @@ -404,29 +404,29 @@ class WorkflowManager: if workflow.schedule_type == "cron": # Cron 表达式调度 - trigger = CronTrigger.from_crontab(workflow.schedule) + trigger = CronTrigger.from_crontab(workflow.schedule) elif workflow.schedule_type == "interval": # 间隔调度 - interval_minutes = int(workflow.schedule) - trigger = IntervalTrigger(minutes=interval_minutes) + interval_minutes = int(workflow.schedule) + trigger = IntervalTrigger(minutes = interval_minutes) else: return self.scheduler.add_job( - func=self._execute_workflow_job, - trigger=trigger, - id=job_id, - args=[workflow.id], - replace_existing=True, - max_instances=1, - coalesce=True, + func = self._execute_workflow_job, + trigger = trigger, + id = job_id, + args = [workflow.id], + replace_existing = True, + max_instances = 1, + coalesce = True, ) logger.info( f"Scheduled workflow {workflow.id} ({workflow.name}) with {workflow.schedule_type}" ) - async def _execute_workflow_job(self, workflow_id: str): + async def _execute_workflow_job(self, workflow_id: str) -> None: """调度器调用的工作流执行函数""" try: await self.execute_workflow(workflow_id) @@ -444,7 +444,7 @@ class WorkflowManager: def create_workflow(self, workflow: Workflow) -> Workflow: """创建工作流""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: conn.execute( """INSERT INTO workflows @@ -486,9 +486,9 @@ class WorkflowManager: def get_workflow(self, workflow_id: str) -> Workflow | None: """获取工作流""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - row = conn.execute("SELECT * FROM workflows WHERE id = ?", (workflow_id,)).fetchone() + row = conn.execute("SELECT * FROM workflows WHERE id = ?", (workflow_id, )).fetchone() if not row: return None @@ -498,27 +498,27 @@ class WorkflowManager: conn.close() def list_workflows( - self, project_id: str = None, status: str = None, workflow_type: str = None + self, project_id: str = None, status: str = None, workflow_type: str = None ) -> list[Workflow]: """列出工作流""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - conditions = [] - params = [] + conditions = [] + params = [] if project_id: - conditions.append("project_id = ?") + conditions.append("project_id = ?") params.append(project_id) if status: - conditions.append("status = ?") + conditions.append("status = ?") params.append(status) if workflow_type: - conditions.append("workflow_type = ?") + conditions.append("workflow_type = ?") params.append(workflow_type) - where_clause = " AND ".join(conditions) if conditions else "1=1" + where_clause = " AND ".join(conditions) if conditions else "1 = 1" - rows = conn.execute( + rows = conn.execute( f"SELECT * FROM workflows WHERE {where_clause} ORDER BY created_at DESC", params ).fetchall() @@ -528,9 +528,9 @@ class WorkflowManager: def update_workflow(self, workflow_id: str, **kwargs) -> Workflow | None: """更新工作流""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - allowed_fields = [ + allowed_fields = [ "name", "description", "status", @@ -540,12 +540,12 @@ class WorkflowManager: "config", "webhook_ids", ] - updates = [] - values = [] + updates = [] + values = [] for f in allowed_fields: if f in kwargs: - updates.append(f"{f} = ?") + updates.append(f"{f} = ?") if f in ["config", "webhook_ids"]: values.append(json.dumps(kwargs[f])) else: @@ -554,20 +554,20 @@ class WorkflowManager: if not updates: return self.get_workflow(workflow_id) - updates.append("updated_at = ?") + updates.append("updated_at = ?") values.append(datetime.now().isoformat()) values.append(workflow_id) - query = f"UPDATE workflows SET {', '.join(updates)} WHERE id = ?" + query = f"UPDATE workflows SET {', '.join(updates)} WHERE id = ?" conn.execute(query, values) conn.commit() # 重新调度 - workflow = self.get_workflow(workflow_id) + workflow = self.get_workflow(workflow_id) if workflow and workflow.schedule and workflow.is_active: self._schedule_workflow(workflow) elif workflow and not workflow.is_active: - job_id = f"workflow_{workflow_id}" + job_id = f"workflow_{workflow_id}" if self.scheduler.get_job(job_id): self.scheduler.remove_job(job_id) @@ -577,18 +577,18 @@ class WorkflowManager: def delete_workflow(self, workflow_id: str) -> bool: """删除工作流""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: # 移除调度 - job_id = f"workflow_{workflow_id}" + job_id = f"workflow_{workflow_id}" if self.scheduler.get_job(job_id): self.scheduler.remove_job(job_id) # 删除相关任务 - conn.execute("DELETE FROM workflow_tasks WHERE workflow_id = ?", (workflow_id,)) + conn.execute("DELETE FROM workflow_tasks WHERE workflow_id = ?", (workflow_id, )) # 删除工作流 - conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,)) + conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id, )) conn.commit() return True @@ -598,31 +598,31 @@ class WorkflowManager: def _row_to_workflow(self, row) -> Workflow: """将数据库行转换为 Workflow 对象""" return Workflow( - id=row["id"], - name=row["name"], - description=row["description"] or "", - workflow_type=row["workflow_type"], - project_id=row["project_id"], - status=row["status"], - schedule=row["schedule"], - schedule_type=row["schedule_type"], - config=json.loads(row["config"]) if row["config"] else {}, - webhook_ids=json.loads(row["webhook_ids"]) if row["webhook_ids"] else [], - is_active=bool(row["is_active"]), - created_at=row["created_at"], - updated_at=row["updated_at"], - last_run_at=row["last_run_at"], - next_run_at=row["next_run_at"], - run_count=row["run_count"] or 0, - success_count=row["success_count"] or 0, - fail_count=row["fail_count"] or 0, + id = row["id"], + name = row["name"], + description = row["description"] or "", + workflow_type = row["workflow_type"], + project_id = row["project_id"], + status = row["status"], + schedule = row["schedule"], + schedule_type = row["schedule_type"], + config = json.loads(row["config"]) if row["config"] else {}, + webhook_ids = json.loads(row["webhook_ids"]) if row["webhook_ids"] else [], + is_active = bool(row["is_active"]), + created_at = row["created_at"], + updated_at = row["updated_at"], + last_run_at = row["last_run_at"], + next_run_at = row["next_run_at"], + run_count = row["run_count"] or 0, + success_count = row["success_count"] or 0, + fail_count = row["fail_count"] or 0, ) # ==================== Workflow Task CRUD ==================== def create_task(self, task: WorkflowTask) -> WorkflowTask: """创建工作流任务""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: conn.execute( """INSERT INTO workflow_tasks @@ -652,9 +652,9 @@ class WorkflowManager: def get_task(self, task_id: str) -> WorkflowTask | None: """获取任务""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - row = conn.execute("SELECT * FROM workflow_tasks WHERE id = ?", (task_id,)).fetchone() + row = conn.execute("SELECT * FROM workflow_tasks WHERE id = ?", (task_id, )).fetchone() if not row: return None @@ -665,11 +665,11 @@ class WorkflowManager: def list_tasks(self, workflow_id: str) -> list[WorkflowTask]: """列出工作流的所有任务""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - rows = conn.execute( - "SELECT * FROM workflow_tasks WHERE workflow_id = ? ORDER BY task_order", - (workflow_id,), + rows = conn.execute( + "SELECT * FROM workflow_tasks WHERE workflow_id = ? ORDER BY task_order", + (workflow_id, ), ).fetchall() return [self._row_to_task(row) for row in rows] @@ -678,9 +678,9 @@ class WorkflowManager: def update_task(self, task_id: str, **kwargs) -> WorkflowTask | None: """更新任务""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - allowed_fields = [ + allowed_fields = [ "name", "task_type", "config", @@ -690,12 +690,12 @@ class WorkflowManager: "retry_count", "retry_delay", ] - updates = [] - values = [] + updates = [] + values = [] for f in allowed_fields: if f in kwargs: - updates.append(f"{f} = ?") + updates.append(f"{f} = ?") if f in ["config", "depends_on"]: values.append(json.dumps(kwargs[f])) else: @@ -704,11 +704,11 @@ class WorkflowManager: if not updates: return self.get_task(task_id) - updates.append("updated_at = ?") + updates.append("updated_at = ?") values.append(datetime.now().isoformat()) values.append(task_id) - query = f"UPDATE workflow_tasks SET {', '.join(updates)} WHERE id = ?" + query = f"UPDATE workflow_tasks SET {', '.join(updates)} WHERE id = ?" conn.execute(query, values) conn.commit() @@ -718,9 +718,9 @@ class WorkflowManager: def delete_task(self, task_id: str) -> bool: """删除任务""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - conn.execute("DELETE FROM workflow_tasks WHERE id = ?", (task_id,)) + conn.execute("DELETE FROM workflow_tasks WHERE id = ?", (task_id, )) conn.commit() return True finally: @@ -729,25 +729,25 @@ class WorkflowManager: def _row_to_task(self, row) -> WorkflowTask: """将数据库行转换为 WorkflowTask 对象""" return WorkflowTask( - id=row["id"], - workflow_id=row["workflow_id"], - name=row["name"], - task_type=row["task_type"], - config=json.loads(row["config"]) if row["config"] else {}, - order=row["task_order"] or 0, - depends_on=json.loads(row["depends_on"]) if row["depends_on"] else [], - timeout_seconds=row["timeout_seconds"] or 300, - retry_count=row["retry_count"] or 3, - retry_delay=row["retry_delay"] or 5, - created_at=row["created_at"], - updated_at=row["updated_at"], + id = row["id"], + workflow_id = row["workflow_id"], + name = row["name"], + task_type = row["task_type"], + config = json.loads(row["config"]) if row["config"] else {}, + order = row["task_order"] or 0, + depends_on = json.loads(row["depends_on"]) if row["depends_on"] else [], + timeout_seconds = row["timeout_seconds"] or 300, + retry_count = row["retry_count"] or 3, + retry_delay = row["retry_delay"] or 5, + created_at = row["created_at"], + updated_at = row["updated_at"], ) # ==================== Webhook Config CRUD ==================== def create_webhook(self, webhook: WebhookConfig) -> WebhookConfig: """创建 Webhook 配置""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: conn.execute( """INSERT INTO webhook_configs @@ -778,10 +778,10 @@ class WorkflowManager: def get_webhook(self, webhook_id: str) -> WebhookConfig | None: """获取 Webhook 配置""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - row = conn.execute( - "SELECT * FROM webhook_configs WHERE id = ?", (webhook_id,) + row = conn.execute( + "SELECT * FROM webhook_configs WHERE id = ?", (webhook_id, ) ).fetchone() if not row: @@ -793,9 +793,9 @@ class WorkflowManager: def list_webhooks(self) -> list[WebhookConfig]: """列出所有 Webhook 配置""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - rows = conn.execute("SELECT * FROM webhook_configs ORDER BY created_at DESC").fetchall() + rows = conn.execute("SELECT * FROM webhook_configs ORDER BY created_at DESC").fetchall() return [self._row_to_webhook(row) for row in rows] finally: @@ -803,9 +803,9 @@ class WorkflowManager: def update_webhook(self, webhook_id: str, **kwargs) -> WebhookConfig | None: """更新 Webhook 配置""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - allowed_fields = [ + allowed_fields = [ "name", "webhook_type", "url", @@ -814,12 +814,12 @@ class WorkflowManager: "template", "is_active", ] - updates = [] - values = [] + updates = [] + values = [] for f in allowed_fields: if f in kwargs: - updates.append(f"{f} = ?") + updates.append(f"{f} = ?") if f == "headers": values.append(json.dumps(kwargs[f])) else: @@ -828,11 +828,11 @@ class WorkflowManager: if not updates: return self.get_webhook(webhook_id) - updates.append("updated_at = ?") + updates.append("updated_at = ?") values.append(datetime.now().isoformat()) values.append(webhook_id) - query = f"UPDATE webhook_configs SET {', '.join(updates)} WHERE id = ?" + query = f"UPDATE webhook_configs SET {', '.join(updates)} WHERE id = ?" conn.execute(query, values) conn.commit() @@ -842,9 +842,9 @@ class WorkflowManager: def delete_webhook(self, webhook_id: str) -> bool: """删除 Webhook 配置""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - conn.execute("DELETE FROM webhook_configs WHERE id = ?", (webhook_id,)) + conn.execute("DELETE FROM webhook_configs WHERE id = ?", (webhook_id, )) conn.commit() return True finally: @@ -852,20 +852,20 @@ class WorkflowManager: def update_webhook_stats(self, webhook_id: str, success: bool) -> None: """更新 Webhook 统计""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: if success: conn.execute( """UPDATE webhook_configs - SET success_count = success_count + 1, last_used_at = ? - WHERE id = ?""", + SET success_count = success_count + 1, last_used_at = ? + WHERE id = ?""", (datetime.now().isoformat(), webhook_id), ) else: conn.execute( """UPDATE webhook_configs - SET fail_count = fail_count + 1, last_used_at = ? - WHERE id = ?""", + SET fail_count = fail_count + 1, last_used_at = ? + WHERE id = ?""", (datetime.now().isoformat(), webhook_id), ) conn.commit() @@ -875,26 +875,26 @@ class WorkflowManager: def _row_to_webhook(self, row) -> WebhookConfig: """将数据库行转换为 WebhookConfig 对象""" return WebhookConfig( - id=row["id"], - name=row["name"], - webhook_type=row["webhook_type"], - url=row["url"], - secret=row["secret"] or "", - headers=json.loads(row["headers"]) if row["headers"] else {}, - template=row["template"] or "", - is_active=bool(row["is_active"]), - created_at=row["created_at"], - updated_at=row["updated_at"], - last_used_at=row["last_used_at"], - success_count=row["success_count"] or 0, - fail_count=row["fail_count"] or 0, + id = row["id"], + name = row["name"], + webhook_type = row["webhook_type"], + url = row["url"], + secret = row["secret"] or "", + headers = json.loads(row["headers"]) if row["headers"] else {}, + template = row["template"] or "", + is_active = bool(row["is_active"]), + created_at = row["created_at"], + updated_at = row["updated_at"], + last_used_at = row["last_used_at"], + success_count = row["success_count"] or 0, + fail_count = row["fail_count"] or 0, ) # ==================== Workflow Log ==================== def create_log(self, log: WorkflowLog) -> WorkflowLog: """创建工作流日志""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: conn.execute( """INSERT INTO workflow_logs @@ -922,15 +922,15 @@ class WorkflowManager: def update_log(self, log_id: str, **kwargs) -> WorkflowLog | None: """更新工作流日志""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - allowed_fields = ["status", "end_time", "duration_ms", "output_data", "error_message"] - updates = [] - values = [] + allowed_fields = ["status", "end_time", "duration_ms", "output_data", "error_message"] + updates = [] + values = [] for f in allowed_fields: if f in kwargs: - updates.append(f"{f} = ?") + updates.append(f"{f} = ?") if f == "output_data": values.append(json.dumps(kwargs[f])) else: @@ -940,7 +940,7 @@ class WorkflowManager: return None values.append(log_id) - query = f"UPDATE workflow_logs SET {', '.join(updates)} WHERE id = ?" + query = f"UPDATE workflow_logs SET {', '.join(updates)} WHERE id = ?" conn.execute(query, values) conn.commit() @@ -950,9 +950,9 @@ class WorkflowManager: def get_log(self, log_id: str) -> WorkflowLog | None: """获取日志""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - row = conn.execute("SELECT * FROM workflow_logs WHERE id = ?", (log_id,)).fetchone() + row = conn.execute("SELECT * FROM workflow_logs WHERE id = ?", (log_id, )).fetchone() if not row: return None @@ -963,31 +963,31 @@ class WorkflowManager: def list_logs( self, - workflow_id: str = None, - task_id: str = None, - status: str = None, - limit: int = 100, - offset: int = 0, + workflow_id: str = None, + task_id: str = None, + status: str = None, + limit: int = 100, + offset: int = 0, ) -> list[WorkflowLog]: """列出工作流日志""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - conditions = [] - params = [] + conditions = [] + params = [] if workflow_id: - conditions.append("workflow_id = ?") + conditions.append("workflow_id = ?") params.append(workflow_id) if task_id: - conditions.append("task_id = ?") + conditions.append("task_id = ?") params.append(task_id) if status: - conditions.append("status = ?") + conditions.append("status = ?") params.append(status) - where_clause = " AND ".join(conditions) if conditions else "1=1" + where_clause = " AND ".join(conditions) if conditions else "1 = 1" - rows = conn.execute( + rows = conn.execute( f"""SELECT * FROM workflow_logs WHERE {where_clause} ORDER BY created_at DESC @@ -999,46 +999,46 @@ class WorkflowManager: finally: conn.close() - def get_workflow_stats(self, workflow_id: str, days: int = 30) -> dict: + def get_workflow_stats(self, workflow_id: str, days: int = 30) -> dict: """获取工作流统计""" - conn = self.db.get_conn() + conn = self.db.get_conn() try: - since = (datetime.now() - timedelta(days=days)).isoformat() + since = (datetime.now() - timedelta(days = days)).isoformat() # 总执行次数 - total = conn.execute( - "SELECT COUNT(*) FROM workflow_logs WHERE workflow_id = ? AND created_at > ?", + total = conn.execute( + "SELECT COUNT(*) FROM workflow_logs WHERE workflow_id = ? AND created_at > ?", (workflow_id, since), ).fetchone()[0] # 成功次数 - success = conn.execute( - "SELECT COUNT(*) FROM workflow_logs WHERE workflow_id = ? AND status = 'success' AND created_at > ?", + success = conn.execute( + "SELECT COUNT(*) FROM workflow_logs WHERE workflow_id = ? AND status = 'success' AND created_at > ?", (workflow_id, since), ).fetchone()[0] # 失败次数 - failed = conn.execute( - "SELECT COUNT(*) FROM workflow_logs WHERE workflow_id = ? AND status = 'failed' AND created_at > ?", + failed = conn.execute( + "SELECT COUNT(*) FROM workflow_logs WHERE workflow_id = ? AND status = 'failed' AND created_at > ?", (workflow_id, since), ).fetchone()[0] # 平均执行时间 - avg_duration = ( + avg_duration = ( conn.execute( - "SELECT AVG(duration_ms) FROM workflow_logs WHERE workflow_id = ? AND created_at > ?", + "SELECT AVG(duration_ms) FROM workflow_logs WHERE workflow_id = ? AND created_at > ?", (workflow_id, since), ).fetchone()[0] or 0 ) # 每日统计 - daily = conn.execute( + daily = conn.execute( """SELECT DATE(created_at) as date, COUNT(*) as count, - SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success FROM workflow_logs - WHERE workflow_id = ? AND created_at > ? + WHERE workflow_id = ? AND created_at > ? GROUP BY DATE(created_at) ORDER BY date""", (workflow_id, since), @@ -1060,24 +1060,24 @@ class WorkflowManager: def _row_to_log(self, row) -> WorkflowLog: """将数据库行转换为 WorkflowLog 对象""" return WorkflowLog( - id=row["id"], - workflow_id=row["workflow_id"], - task_id=row["task_id"], - status=row["status"], - start_time=row["start_time"], - end_time=row["end_time"], - duration_ms=row["duration_ms"] or 0, - input_data=json.loads(row["input_data"]) if row["input_data"] else {}, - output_data=json.loads(row["output_data"]) if row["output_data"] else {}, - error_message=row["error_message"] or "", - created_at=row["created_at"], + id = row["id"], + workflow_id = row["workflow_id"], + task_id = row["task_id"], + status = row["status"], + start_time = row["start_time"], + end_time = row["end_time"], + duration_ms = row["duration_ms"] or 0, + input_data = json.loads(row["input_data"]) if row["input_data"] else {}, + output_data = json.loads(row["output_data"]) if row["output_data"] else {}, + error_message = row["error_message"] or "", + created_at = row["created_at"], ) # ==================== Workflow Execution ==================== - async def execute_workflow(self, workflow_id: str, input_data: dict = None) -> dict: + async def execute_workflow(self, workflow_id: str, input_data: dict = None) -> dict: """执行工作流""" - workflow = self.get_workflow(workflow_id) + workflow = self.get_workflow(workflow_id) if not workflow: raise ValueError(f"Workflow {workflow_id} not found") @@ -1085,49 +1085,49 @@ class WorkflowManager: raise ValueError(f"Workflow {workflow_id} is not active") # 更新最后运行时间 - now = datetime.now().isoformat() - self.update_workflow(workflow_id, last_run_at=now, run_count=workflow.run_count + 1) + now = datetime.now().isoformat() + self.update_workflow(workflow_id, last_run_at = now, run_count = workflow.run_count + 1) # 创建工作流执行日志 - log = WorkflowLog( - id=str(uuid.uuid4())[:UUID_LENGTH], - workflow_id=workflow_id, - status=TaskStatus.RUNNING.value, - start_time=now, - input_data=input_data or {}, + log = WorkflowLog( + id = str(uuid.uuid4())[:UUID_LENGTH], + workflow_id = workflow_id, + status = TaskStatus.RUNNING.value, + start_time = now, + input_data = input_data or {}, ) self.create_log(log) - start_time = datetime.now() - results = {} + start_time = datetime.now() + results = {} try: # 获取所有任务 - tasks = self.list_tasks(workflow_id) + tasks = self.list_tasks(workflow_id) if not tasks: # 没有任务时执行默认行为 - results = await self._execute_default_workflow(workflow, input_data) + results = await self._execute_default_workflow(workflow, input_data) else: # 按依赖顺序执行任务 - results = await self._execute_tasks_with_deps(tasks, input_data, log.id) + results = await self._execute_tasks_with_deps(tasks, input_data, log.id) # 发送通知 - await self._send_workflow_notification(workflow, results, success=True) + await self._send_workflow_notification(workflow, results, success = True) # 更新日志为成功 - end_time = datetime.now() - duration = int((end_time - start_time).total_seconds() * 1000) + end_time = datetime.now() + duration = int((end_time - start_time).total_seconds() * 1000) self.update_log( log.id, - status=TaskStatus.SUCCESS.value, - end_time=end_time.isoformat(), - duration_ms=duration, - output_data=results, + status = TaskStatus.SUCCESS.value, + end_time = end_time.isoformat(), + duration_ms = duration, + output_data = results, ) # 更新成功计数 - self.update_workflow(workflow_id, success_count=workflow.success_count + 1) + self.update_workflow(workflow_id, success_count = workflow.success_count + 1) return { "success": True, @@ -1141,21 +1141,21 @@ class WorkflowManager: logger.error(f"Workflow {workflow_id} execution failed: {e}") # 更新日志为失败 - end_time = datetime.now() - duration = int((end_time - start_time).total_seconds() * 1000) + end_time = datetime.now() + duration = int((end_time - start_time).total_seconds() * 1000) self.update_log( log.id, - status=TaskStatus.FAILED.value, - end_time=end_time.isoformat(), - duration_ms=duration, - error_message=str(e), + status = TaskStatus.FAILED.value, + end_time = end_time.isoformat(), + duration_ms = duration, + error_message = str(e), ) # 更新失败计数 - self.update_workflow(workflow_id, fail_count=workflow.fail_count + 1) + self.update_workflow(workflow_id, fail_count = workflow.fail_count + 1) # 发送失败通知 - await self._send_workflow_notification(workflow, {"error": str(e)}, success=False) + await self._send_workflow_notification(workflow, {"error": str(e)}, success = False) raise @@ -1163,12 +1163,12 @@ class WorkflowManager: self, tasks: list[WorkflowTask], input_data: dict, log_id: str ) -> dict: """按依赖顺序执行任务""" - results = {} - completed_tasks = set() + results = {} + completed_tasks = set() while len(completed_tasks) < len(tasks): # 找到可以执行的任务(依赖已完成) - ready_tasks = [ + ready_tasks = [ t for t in tasks if t.id not in completed_tasks @@ -1180,12 +1180,12 @@ class WorkflowManager: raise ValueError("Circular dependency detected or tasks cannot be resolved") # 并行执行就绪的任务 - task_coros = [] + task_coros = [] for task in ready_tasks: - task_input = {**input_data, **results} + task_input = {**input_data, **results} task_coros.append(self._execute_single_task(task, task_input, log_id)) - task_results = await asyncio.gather(*task_coros, return_exceptions=True) + task_results = await asyncio.gather(*task_coros, return_exceptions = True) for task, result in zip(ready_tasks, task_results): if isinstance(result, Exception): @@ -1195,7 +1195,7 @@ class WorkflowManager: for attempt in range(task.retry_count): await asyncio.sleep(task.retry_delay) try: - result = await self._execute_single_task(task, task_input, log_id) + result = await self._execute_single_task(task, task_input, log_id) break except (TimeoutError, httpx.HTTPError) as e: logger.error(f"Task {task.id} retry {attempt + 1} failed: {e}") @@ -1204,38 +1204,38 @@ class WorkflowManager: else: raise result - results[task.name] = result + results[task.name] = result completed_tasks.add(task.id) return results async def _execute_single_task(self, task: WorkflowTask, input_data: dict, log_id: str) -> Any: """执行单个任务""" - handler = self._task_handlers.get(task.task_type) + handler = self._task_handlers.get(task.task_type) if not handler: raise ValueError(f"No handler for task type: {task.task_type}") # 创建任务日志 - task_log = WorkflowLog( - id=str(uuid.uuid4())[:UUID_LENGTH], - workflow_id=task.workflow_id, - task_id=task.id, - status=TaskStatus.RUNNING.value, - start_time=datetime.now().isoformat(), - input_data=input_data, + task_log = WorkflowLog( + id = str(uuid.uuid4())[:UUID_LENGTH], + workflow_id = task.workflow_id, + task_id = task.id, + status = TaskStatus.RUNNING.value, + start_time = datetime.now().isoformat(), + input_data = input_data, ) self.create_log(task_log) try: # 设置超时 - result = await asyncio.wait_for(handler(task, input_data), timeout=task.timeout_seconds) + result = await asyncio.wait_for(handler(task, input_data), timeout = task.timeout_seconds) # 更新任务日志为成功 self.update_log( task_log.id, - status=TaskStatus.SUCCESS.value, - end_time=datetime.now().isoformat(), - output_data={"result": result} if not isinstance(result, dict) else result, + status = TaskStatus.SUCCESS.value, + end_time = datetime.now().isoformat(), + output_data = {"result": result} if not isinstance(result, dict) else result, ) return result @@ -1243,24 +1243,24 @@ class WorkflowManager: except TimeoutError: self.update_log( task_log.id, - status=TaskStatus.FAILED.value, - end_time=datetime.now().isoformat(), - error_message="Task timeout", + status = TaskStatus.FAILED.value, + end_time = datetime.now().isoformat(), + error_message = "Task timeout", ) raise TimeoutError(f"Task {task.id} timed out after {task.timeout_seconds}s") except Exception as e: self.update_log( task_log.id, - status=TaskStatus.FAILED.value, - end_time=datetime.now().isoformat(), - error_message=str(e), + status = TaskStatus.FAILED.value, + end_time = datetime.now().isoformat(), + error_message = str(e), ) raise async def _execute_default_workflow(self, workflow: Workflow, input_data: dict) -> dict: """执行默认工作流(根据类型)""" - workflow_type = WorkflowType(workflow.workflow_type) + workflow_type = WorkflowType(workflow.workflow_type) if workflow_type == WorkflowType.AUTO_ANALYZE: return await self._auto_analyze_files(workflow, input_data) @@ -1277,8 +1277,8 @@ class WorkflowManager: async def _handle_analyze_task(self, task: WorkflowTask, input_data: dict) -> dict: """处理分析任务""" - project_id = input_data.get("project_id") - file_ids = input_data.get("file_ids", []) + project_id = input_data.get("project_id") + file_ids = input_data.get("file_ids", []) if not project_id: raise ValueError("project_id required for analyze task") @@ -1294,8 +1294,8 @@ class WorkflowManager: async def _handle_align_task(self, task: WorkflowTask, input_data: dict) -> dict: """处理实体对齐任务""" - project_id = input_data.get("project_id") - threshold = task.config.get("threshold", 0.85) + project_id = input_data.get("project_id") + threshold = task.config.get("threshold", 0.85) if not project_id: raise ValueError("project_id required for align task") @@ -1311,7 +1311,7 @@ class WorkflowManager: async def _handle_discover_relations_task(self, task: WorkflowTask, input_data: dict) -> dict: """处理关系发现任务""" - project_id = input_data.get("project_id") + project_id = input_data.get("project_id") if not project_id: raise ValueError("project_id required for discover_relations task") @@ -1326,24 +1326,24 @@ class WorkflowManager: async def _handle_notify_task(self, task: WorkflowTask, input_data: dict) -> dict: """处理通知任务""" - webhook_id = task.config.get("webhook_id") - message = task.config.get("message", {}) + webhook_id = task.config.get("webhook_id") + message = task.config.get("message", {}) if not webhook_id: raise ValueError("webhook_id required for notify task") - webhook = self.get_webhook(webhook_id) + webhook = self.get_webhook(webhook_id) if not webhook: raise ValueError(f"Webhook {webhook_id} not found") # 替换模板变量 if webhook.template: try: - message = json.loads(webhook.template.format(**input_data)) + message = json.loads(webhook.template.format(**input_data)) except (json.JSONDecodeError, KeyError, ValueError): pass - success = await self.notifier.send(webhook, message) + success = await self.notifier.send(webhook, message) self.update_webhook_stats(webhook_id, success) return {"task": "notify", "webhook_id": webhook_id, "success": success} @@ -1362,7 +1362,7 @@ class WorkflowManager: async def _auto_analyze_files(self, workflow: Workflow, input_data: dict) -> dict: """自动分析新上传的文件""" - project_id = workflow.project_id + project_id = workflow.project_id # 获取未分析的文件(实际实现需要查询数据库) # 这里是一个示例实现 @@ -1377,8 +1377,8 @@ class WorkflowManager: async def _auto_align_entities(self, workflow: Workflow, input_data: dict) -> dict: """自动实体对齐""" - project_id = workflow.project_id - threshold = workflow.config.get("threshold", 0.85) + project_id = workflow.project_id + threshold = workflow.config.get("threshold", 0.85) return { "workflow_type": "auto_align", @@ -1390,7 +1390,7 @@ class WorkflowManager: async def _auto_discover_relations(self, workflow: Workflow, input_data: dict) -> dict: """自动关系发现""" - project_id = workflow.project_id + project_id = workflow.project_id return { "workflow_type": "auto_relation", @@ -1401,8 +1401,8 @@ class WorkflowManager: async def _generate_scheduled_report(self, workflow: Workflow, input_data: dict) -> dict: """生成定时报告""" - project_id = workflow.project_id - report_type = workflow.config.get("report_type", "summary") + project_id = workflow.project_id + report_type = workflow.config.get("report_type", "summary") return { "workflow_type": "scheduled_report", @@ -1414,26 +1414,26 @@ class WorkflowManager: # ==================== Notification ==================== async def _send_workflow_notification( - self, workflow: Workflow, results: dict, success: bool = True - ): + self, workflow: Workflow, results: dict, success: bool = True + ) -> None: """发送工作流执行通知""" if not workflow.webhook_ids: return for webhook_id in workflow.webhook_ids: - webhook = self.get_webhook(webhook_id) + webhook = self.get_webhook(webhook_id) if not webhook or not webhook.is_active: continue # 构建通知消息 if webhook.webhook_type == WebhookType.FEISHU.value: - message = self._build_feishu_message(workflow, results, success) + message = self._build_feishu_message(workflow, results, success) elif webhook.webhook_type == WebhookType.DINGTALK.value: - message = self._build_dingtalk_message(workflow, results, success) + message = self._build_dingtalk_message(workflow, results, success) elif webhook.webhook_type == WebhookType.SLACK.value: - message = self._build_slack_message(workflow, results, success) + message = self._build_slack_message(workflow, results, success) else: - message = { + message = { "workflow_id": workflow.id, "workflow_name": workflow.name, "status": "success" if success else "failed", @@ -1442,14 +1442,14 @@ class WorkflowManager: } try: - result = await self.notifier.send(webhook, message) + result = await self.notifier.send(webhook, message) self.update_webhook_stats(webhook_id, result) except (TimeoutError, httpx.HTTPError) as e: logger.error(f"Failed to send notification to {webhook_id}: {e}") def _build_feishu_message(self, workflow: Workflow, results: dict, success: bool) -> dict: """构建飞书消息""" - status_text = "✅ 成功" if success else "❌ 失败" + status_text = "✅ 成功" if success else "❌ 失败" return { "title": f"工作流执行通知: {workflow.name}", @@ -1462,7 +1462,7 @@ class WorkflowManager: def _build_dingtalk_message(self, workflow: Workflow, results: dict, success: bool) -> dict: """构建钉钉消息""" - status_text = "✅ 成功" if success else "❌ 失败" + status_text = "✅ 成功" if success else "❌ 失败" return { "title": f"工作流执行通知: {workflow.name}", @@ -1476,15 +1476,15 @@ class WorkflowManager: **结果:** ```json -{json.dumps(results, ensure_ascii=False, indent=2)} +{json.dumps(results, ensure_ascii = False, indent = 2)} ``` """, } def _build_slack_message(self, workflow: Workflow, results: dict, success: bool) -> dict: """构建 Slack 消息""" - color = "#36a64f" if success else "#ff0000" - status_text = "Success" if success else "Failed" + color = "#36a64f" if success else "#ff0000" + status_text = "Success" if success else "Failed" return { "attachments": [ @@ -1507,12 +1507,12 @@ class WorkflowManager: # Singleton instance -_workflow_manager = None +_workflow_manager = None -def get_workflow_manager(db_manager=None) -> WorkflowManager: +def get_workflow_manager(db_manager = None) -> WorkflowManager: """获取 WorkflowManager 单例""" global _workflow_manager if _workflow_manager is None: - _workflow_manager = WorkflowManager(db_manager) + _workflow_manager = WorkflowManager(db_manager) return _workflow_manager diff --git a/code_review_fixer.py b/code_review_fixer.py index 4d2b639..e84141e 100644 --- a/code_review_fixer.py +++ b/code_review_fixer.py @@ -11,10 +11,10 @@ from pathlib import Path from typing import Any # 项目路径 -PROJECT_PATH = Path("/root/.openclaw/workspace/projects/insightflow") +PROJECT_PATH = Path("/root/.openclaw/workspace/projects/insightflow") # 修复报告 -report = { +report = { "fixed": [], "manual_review": [], "errors": [] @@ -22,7 +22,7 @@ report = { def find_python_files() -> list[Path]: """查找所有 Python 文件""" - py_files = [] + py_files = [] for py_file in PROJECT_PATH.rglob("*.py"): if "__pycache__" not in str(py_file): py_files.append(py_file) @@ -30,12 +30,12 @@ def find_python_files() -> list[Path]: def check_duplicate_imports(content: str, file_path: Path) -> list[dict]: """检查重复导入""" - issues = [] - lines = content.split('\n') - imports = {} - + issues = [] + lines = content.split('\n') + imports = {} + for i, line in enumerate(lines, 1): - line_stripped = line.strip() + line_stripped = line.strip() if line_stripped.startswith('import ') or line_stripped.startswith('from '): if line_stripped in imports: issues.append({ @@ -45,17 +45,17 @@ def check_duplicate_imports(content: str, file_path: Path) -> list[dict]: "original_line": imports[line_stripped] }) else: - imports[line_stripped] = i + imports[line_stripped] = i return issues def check_bare_excepts(content: str, file_path: Path) -> list[dict]: """检查裸异常捕获""" - issues = [] - lines = content.split('\n') - + issues = [] + lines = content.split('\n') + for i, line in enumerate(lines, 1): - stripped = line.strip() - # 检查 except Exception: 或 except : + stripped = line.strip() + # 检查 except Exception: 或 except Exception: if re.match(r'^except\s*:', stripped): issues.append({ "line": i, @@ -66,9 +66,9 @@ def check_bare_excepts(content: str, file_path: Path) -> list[dict]: def check_line_length(content: str, file_path: Path) -> list[dict]: """检查行长度(PEP8: 79字符,这里放宽到 100)""" - issues = [] - lines = content.split('\n') - + issues = [] + lines = content.split('\n') + for i, line in enumerate(lines, 1): if len(line) > 100: issues.append({ @@ -81,24 +81,24 @@ def check_line_length(content: str, file_path: Path) -> list[dict]: def check_unused_imports(content: str, file_path: Path) -> list[dict]: """检查未使用的导入""" - issues = [] + issues = [] try: - tree = ast.parse(content) - imports = {} - used_names = set() - + tree = ast.parse(content) + imports = {} + used_names = set() + for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: - imports[alias.asname or alias.name] = node + imports[alias.asname or alias.name] = node elif isinstance(node, ast.ImportFrom): for alias in node.names: - name = alias.asname or alias.name + name = alias.asname or alias.name if name != '*': - imports[name] = node + imports[name] = node elif isinstance(node, ast.Name): used_names.add(node.id) - + for name, node in imports.items(): if name not in used_names and not name.startswith('_'): issues.append({ @@ -112,9 +112,9 @@ def check_unused_imports(content: str, file_path: Path) -> list[dict]: def check_string_formatting(content: str, file_path: Path) -> list[dict]: """检查混合字符串格式化(建议使用 f-string)""" - issues = [] - lines = content.split('\n') - + issues = [] + lines = content.split('\n') + for i, line in enumerate(lines, 1): # 检查 % 格式化 if re.search(r'["\'].*%\s*\w+', line) and '%' in line: @@ -136,18 +136,18 @@ def check_string_formatting(content: str, file_path: Path) -> list[dict]: def check_magic_numbers(content: str, file_path: Path) -> list[dict]: """检查魔法数字""" - issues = [] - lines = content.split('\n') - + issues = [] + lines = content.split('\n') + # 常见魔法数字模式(排除常见索引和简单值) - magic_pattern = re.compile(r'(? list[dict]: def check_sql_injection(content: str, file_path: Path) -> list[dict]: """检查 SQL 注入风险""" - issues = [] - lines = content.split('\n') - + issues = [] + lines = content.split('\n') + for i, line in enumerate(lines, 1): # 检查字符串拼接的 SQL if 'execute(' in line or 'executescript(' in line or 'executemany(' in line: @@ -179,9 +179,9 @@ def check_sql_injection(content: str, file_path: Path) -> list[dict]: def check_cors_config(content: str, file_path: Path) -> list[dict]: """检查 CORS 配置""" - issues = [] - lines = content.split('\n') - + issues = [] + lines = content.split('\n') + for i, line in enumerate(lines, 1): if 'allow_origins' in line and '["*"]' in line: issues.append({ @@ -194,48 +194,48 @@ def check_cors_config(content: str, file_path: Path) -> list[dict]: def fix_bare_excepts(content: str) -> str: """修复裸异常捕获""" - lines = content.split('\n') - new_lines = [] - + lines = content.split('\n') + new_lines = [] + for line in lines: - stripped = line.strip() + stripped = line.strip() if re.match(r'^except\s*:', stripped): # 替换为具体异常 - indent = len(line) - len(line.lstrip()) - new_line = ' ' * indent + 'except (RuntimeError, ValueError, TypeError):' + indent = len(line) - len(line.lstrip()) + new_line = ' ' * indent + 'except (RuntimeError, ValueError, TypeError):' new_lines.append(new_line) else: new_lines.append(line) - + return '\n'.join(new_lines) def fix_line_length(content: str) -> str: """修复行长度问题(简单折行)""" - lines = content.split('\n') - new_lines = [] - + lines = content.split('\n') + new_lines = [] + for line in lines: if len(line) > 100: # 尝试在逗号或运算符处折行 - if ',' in line[80:]: + if ', ' in line[80:]: # 简单处理:截断并添加续行 - indent = len(line) - len(line.lstrip()) + indent = len(line) - len(line.lstrip()) new_lines.append(line) else: new_lines.append(line) else: new_lines.append(line) - + return '\n'.join(new_lines) def analyze_file(file_path: Path) -> dict: """分析单个文件""" try: - content = file_path.read_text(encoding='utf-8') + content = file_path.read_text(encoding = 'utf-8') except Exception as e: return {"error": str(e)} - - issues = { + + issues = { "duplicate_imports": check_duplicate_imports(content, file_path), "bare_excepts": check_bare_excepts(content, file_path), "line_length": check_line_length(content, file_path), @@ -245,22 +245,22 @@ def analyze_file(file_path: Path) -> dict: "sql_injection": check_sql_injection(content, file_path), "cors_config": check_cors_config(content, file_path), } - + return issues def fix_file(file_path: Path, issues: dict) -> bool: """自动修复文件问题""" try: - content = file_path.read_text(encoding='utf-8') - original_content = content - + content = file_path.read_text(encoding = 'utf-8') + original_content = content + # 修复裸异常 if issues.get("bare_excepts"): - content = fix_bare_excepts(content) - + content = fix_bare_excepts(content) + # 如果有修改,写回文件 if content != original_content: - file_path.write_text(content, encoding='utf-8') + file_path.write_text(content, encoding = 'utf-8') return True return False except Exception as e: @@ -269,88 +269,88 @@ def fix_file(file_path: Path, issues: dict) -> bool: def generate_report(all_issues: dict) -> str: """生成修复报告""" - lines = [] + lines = [] lines.append("# InsightFlow 代码审查报告") lines.append(f"\n生成时间: {__import__('datetime').datetime.now().isoformat()}") lines.append("\n## 自动修复的问题\n") - - total_fixed = 0 + + total_fixed = 0 for file_path, issues in all_issues.items(): - fixed_count = 0 + fixed_count = 0 for issue_type, issue_list in issues.items(): if issue_type in ["bare_excepts"] and issue_list: fixed_count += len(issue_list) - + if fixed_count > 0: lines.append(f"### {file_path}") lines.append(f"- 修复裸异常捕获: {fixed_count} 处") total_fixed += fixed_count - + if total_fixed == 0: lines.append("未发现需要自动修复的问题。") - + lines.append(f"\n**总计自动修复: {total_fixed} 处**") - + lines.append("\n## 需要人工确认的问题\n") - - total_manual = 0 + + total_manual = 0 for file_path, issues in all_issues.items(): - manual_issues = [] - + manual_issues = [] + if issues.get("sql_injection"): manual_issues.extend(issues["sql_injection"]) if issues.get("cors_config"): manual_issues.extend(issues["cors_config"]) - + if manual_issues: lines.append(f"### {file_path}") for issue in manual_issues: lines.append(f"- **{issue['type']}** (第 {issue['line']} 行): {issue.get('content', '')}") total_manual += len(manual_issues) - + if total_manual == 0: lines.append("未发现需要人工确认的问题。") - + lines.append(f"\n**总计待确认: {total_manual} 处**") - + lines.append("\n## 代码风格建议\n") - + for file_path, issues in all_issues.items(): - style_issues = [] + style_issues = [] if issues.get("line_length"): style_issues.extend(issues["line_length"]) if issues.get("string_formatting"): style_issues.extend(issues["string_formatting"]) if issues.get("magic_numbers"): style_issues.extend(issues["magic_numbers"]) - + if style_issues: lines.append(f"### {file_path}") for issue in style_issues[:5]: # 只显示前5个 lines.append(f"- 第 {issue['line']} 行: {issue['type']}") if len(style_issues) > 5: lines.append(f"- ... 还有 {len(style_issues) - 5} 个类似问题") - + return '\n'.join(lines) -def git_commit_and_push(): +def git_commit_and_push() -> None: """提交并推送代码""" try: os.chdir(PROJECT_PATH) - + # 检查是否有修改 - result = subprocess.run( + result = subprocess.run( ["git", "status", "--porcelain"], - capture_output=True, - text=True + capture_output = True, + text = True ) - + if not result.stdout.strip(): return "没有需要提交的更改" - + # 添加所有修改 - subprocess.run(["git", "add", "-A"], check=True) - + subprocess.run(["git", "add", "-A"], check = True) + # 提交 subprocess.run( ["git", "commit", "-m", """fix: auto-fix code issues (cron) @@ -359,52 +359,52 @@ def git_commit_and_push(): - 修复异常处理 - 修复PEP8格式问题 - 添加类型注解"""], - check=True + check = True ) - + # 推送 - subprocess.run(["git", "push"], check=True) - + subprocess.run(["git", "push"], check = True) + return "✅ 提交并推送成功" except subprocess.CalledProcessError as e: return f"❌ Git 操作失败: {e}" except Exception as e: return f"❌ 错误: {e}" -def main(): +def main() -> None: """主函数""" print("🔍 开始代码审查...") - - py_files = find_python_files() + + py_files = find_python_files() print(f"📁 找到 {len(py_files)} 个 Python 文件") - - all_issues = {} - + + all_issues = {} + for py_file in py_files: print(f" 分析: {py_file.name}") - issues = analyze_file(py_file) - all_issues[py_file] = issues - + issues = analyze_file(py_file) + all_issues[py_file] = issues + # 自动修复 if fix_file(py_file, issues): report["fixed"].append(str(py_file)) - + # 生成报告 - report_content = generate_report(all_issues) - report_path = PROJECT_PATH / "AUTO_CODE_REVIEW_REPORT.md" - report_path.write_text(report_content, encoding='utf-8') - + report_content = generate_report(all_issues) + report_path = PROJECT_PATH / "AUTO_CODE_REVIEW_REPORT.md" + report_path.write_text(report_content, encoding = 'utf-8') + print("\n📄 报告已生成:", report_path) - + # Git 提交 print("\n🚀 提交代码...") - git_result = git_commit_and_push() + git_result = git_commit_and_push() print(git_result) - + # 追加提交结果到报告 - with open(report_path, 'a', encoding='utf-8') as f: + with open(report_path, 'a', encoding = 'utf-8') as f: f.write(f"\n\n## Git 提交结果\n\n{git_result}\n") - + print("\n✅ 代码审查完成!") return report_content diff --git a/code_reviewer.py b/code_reviewer.py index 251d0d4..90f84bd 100644 --- a/code_reviewer.py +++ b/code_reviewer.py @@ -15,25 +15,25 @@ class CodeIssue: line_no: int, issue_type: str, message: str, - severity: str = "info", - ): - self.file_path = file_path - self.line_no = line_no - self.issue_type = issue_type - self.message = message - self.severity = severity # info, warning, error - self.fixed = False + severity: str = "info", + ) -> None: + self.file_path = file_path + self.line_no = line_no + self.issue_type = issue_type + self.message = message + self.severity = severity # info, warning, error + self.fixed = False - def __repr__(self): + def __repr__(self) -> None: return f"{self.severity.upper()}: {self.file_path}:{self.line_no} - {self.issue_type}: {self.message}" class CodeReviewer: - def __init__(self, base_path: str): - self.base_path = Path(base_path) - self.issues: list[CodeIssue] = [] - self.fixed_issues: list[CodeIssue] = [] - self.manual_review_issues: list[CodeIssue] = [] + def __init__(self, base_path: str) -> None: + self.base_path = Path(base_path) + self.issues: list[CodeIssue] = [] + self.fixed_issues: list[CodeIssue] = [] + self.manual_review_issues: list[CodeIssue] = [] def scan_all(self) -> None: """扫描所有 Python 文件""" @@ -45,14 +45,14 @@ class CodeReviewer: def scan_file(self, file_path: Path) -> None: """扫描单个文件""" try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - lines = content.split("\n") + with open(file_path, "r", encoding = "utf-8") as f: + content = f.read() + lines = content.split("\n") except Exception as e: print(f"Error reading {file_path}: {e}") return - rel_path = str(file_path.relative_to(self.base_path)) + rel_path = str(file_path.relative_to(self.base_path)) # 1. 检查裸异常捕获 self._check_bare_exceptions(content, lines, rel_path) @@ -92,7 +92,7 @@ class CodeReviewer: # 跳过有注释说明的情况 if "# noqa" in line or "# intentional" in line.lower(): continue - issue = CodeIssue( + issue = CodeIssue( file_path, i, "bare_exception", @@ -105,17 +105,17 @@ class CodeReviewer: self, content: str, lines: list[str], file_path: str ) -> None: """检查重复导入""" - imports = {} + imports = {} for i, line in enumerate(lines, 1): - match = re.match(r"^(?:from\s+(\S+)\s+)?import\s+(.+)$", line.strip()) + match = re.match(r"^(?:from\s+(\S+)\s+)?import\s+(.+)$", line.strip()) if match: - module = match.group(1) or "" - names = match.group(2).split(",") + module = match.group(1) or "" + names = match.group(2).split(", ") for name in names: - name = name.strip().split()[0] # 处理 'as' 别名 - key = f"{module}.{name}" if module else name + name = name.strip().split()[0] # 处理 'as' 别名 + key = f"{module}.{name}" if module else name if key in imports: - issue = CodeIssue( + issue = CodeIssue( file_path, i, "duplicate_import", @@ -123,7 +123,7 @@ class CodeReviewer: "warning", ) self.issues.append(issue) - imports[key] = i + imports[key] = i def _check_pep8_issues( self, content: str, lines: list[str], file_path: str @@ -132,7 +132,7 @@ class CodeReviewer: for i, line in enumerate(lines, 1): # 行长度超过 120 if len(line) > 120: - issue = CodeIssue( + issue = CodeIssue( file_path, i, "line_too_long", @@ -143,7 +143,7 @@ class CodeReviewer: # 行尾空格 if line.rstrip() != line: - issue = CodeIssue( + issue = CodeIssue( file_path, i, "trailing_whitespace", "行尾有空格", "info" ) self.issues.append(issue) @@ -151,7 +151,7 @@ class CodeReviewer: # 多余的空行 if i > 1 and line.strip() == "" and lines[i - 2].strip() == "": if i < len(lines) and lines[i].strip() == "": - issue = CodeIssue( + issue = CodeIssue( file_path, i, "extra_blank_line", "多余的空行", "info" ) self.issues.append(issue) @@ -161,23 +161,23 @@ class CodeReviewer: ) -> None: """检查未使用的导入""" try: - tree = ast.parse(content) + tree = ast.parse(content) except SyntaxError: return - imported_names = {} - used_names = set() + imported_names = {} + used_names = set() for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: - name = alias.asname if alias.asname else alias.name - imported_names[name] = node.lineno + name = alias.asname if alias.asname else alias.name + imported_names[name] = node.lineno elif isinstance(node, ast.ImportFrom): for alias in node.names: - name = alias.asname if alias.asname else alias.name + name = alias.asname if alias.asname else alias.name if name != "*": - imported_names[name] = node.lineno + imported_names[name] = node.lineno elif isinstance(node, ast.Name): used_names.add(node.id) @@ -186,7 +186,7 @@ class CodeReviewer: # 排除一些常见例外 if name in ["annotations", "TYPE_CHECKING"]: continue - issue = CodeIssue( + issue = CodeIssue( file_path, lineno, "unused_import", f"未使用的导入: {name}", "info" ) self.issues.append(issue) @@ -195,20 +195,20 @@ class CodeReviewer: self, content: str, lines: list[str], file_path: str ) -> None: """检查混合字符串格式化""" - has_fstring = False - has_percent = False - has_format = False + has_fstring = False + has_percent = False + has_format = False for i, line in enumerate(lines, 1): if re.search(r'f["\']', line): - has_fstring = True + has_fstring = True if re.search(r"%[sdfr]", line) and not re.search(r"\d+%", line): - has_percent = True + has_percent = True if ".format(" in line: - has_format = True + has_format = True if has_fstring and (has_percent or has_format): - issue = CodeIssue( + issue = CodeIssue( file_path, 0, "mixed_formatting", @@ -222,25 +222,25 @@ class CodeReviewer: ) -> None: """检查魔法数字""" # 常见的魔法数字模式 - magic_patterns = [ - (r"=\s*(\d{3,})\s*[^:]", "可能的魔法数字"), - (r"timeout\s*=\s*(\d+)", "timeout 魔法数字"), - (r"limit\s*=\s*(\d+)", "limit 魔法数字"), - (r"port\s*=\s*(\d+)", "port 魔法数字"), + magic_patterns = [ + (r" = \s*(\d{3, })\s*[^:]", "可能的魔法数字"), + (r"timeout\s* = \s*(\d+)", "timeout 魔法数字"), + (r"limit\s* = \s*(\d+)", "limit 魔法数字"), + (r"port\s* = \s*(\d+)", "port 魔法数字"), ] for i, line in enumerate(lines, 1): # 跳过注释和字符串 - code_part = line.split("#")[0] + code_part = line.split("#")[0] if not code_part.strip(): continue for pattern, msg in magic_patterns: if re.search(pattern, code_part, re.IGNORECASE): # 排除常见的合理数字 - match = re.search(r"(\d{3,})", code_part) + match = re.search(r"(\d{3, })", code_part) if match: - num = int(match.group(1)) + num = int(match.group(1)) if num in [ 200, 404, @@ -257,7 +257,7 @@ class CodeReviewer: 8000, ]: continue - issue = CodeIssue( + issue = CodeIssue( file_path, i, "magic_number", f"{msg}: {num}", "info" ) self.issues.append(issue) @@ -272,7 +272,7 @@ class CodeReviewer: r'execute\s*\(\s*f["\']', line ): if "?" not in line and "%s" in line: - issue = CodeIssue( + issue = CodeIssue( file_path, i, "sql_injection_risk", @@ -287,7 +287,7 @@ class CodeReviewer: """检查 CORS 配置""" for i, line in enumerate(lines, 1): if "allow_origins" in line and '["*"]' in line: - issue = CodeIssue( + issue = CodeIssue( file_path, i, "cors_wildcard", @@ -303,7 +303,7 @@ class CodeReviewer: for i, line in enumerate(lines, 1): # 检查硬编码密钥 if re.search( - r'(password|secret|key|token)\s*=\s*["\'][^"\']+["\']', + r'(password|secret|key|token)\s* = \s*["\'][^"\']+["\']', line, re.IGNORECASE, ): @@ -316,7 +316,7 @@ class CodeReviewer: if not re.search(r'["\']\*+["\']', line) and not re.search( r'["\']<[^"\']*>["\']', line ): - issue = CodeIssue( + issue = CodeIssue( file_path, i, "hardcoded_secret", @@ -328,62 +328,62 @@ class CodeReviewer: def auto_fix(self) -> None: """自动修复问题""" # 按文件分组问题 - issues_by_file: dict[str, list[CodeIssue]] = {} + issues_by_file: dict[str, list[CodeIssue]] = {} for issue in self.issues: if issue.file_path not in issues_by_file: - issues_by_file[issue.file_path] = [] + issues_by_file[issue.file_path] = [] issues_by_file[issue.file_path].append(issue) for file_path, issues in issues_by_file.items(): - full_path = self.base_path / file_path + full_path = self.base_path / file_path if not full_path.exists(): continue try: - with open(full_path, "r", encoding="utf-8") as f: - content = f.read() - lines = content.split("\n") + with open(full_path, "r", encoding = "utf-8") as f: + content = f.read() + lines = content.split("\n") except Exception as e: print(f"Error reading {full_path}: {e}") continue - original_lines = lines.copy() + original_lines = lines.copy() # 修复行尾空格 for issue in issues: if issue.issue_type == "trailing_whitespace": - idx = issue.line_no - 1 + idx = issue.line_no - 1 if 0 <= idx < len(lines): - lines[idx] = lines[idx].rstrip() - issue.fixed = True + lines[idx] = lines[idx].rstrip() + issue.fixed = True # 修复裸异常 for issue in issues: if issue.issue_type == "bare_exception": - idx = issue.line_no - 1 + idx = issue.line_no - 1 if 0 <= idx < len(lines): - line = lines[idx] - # 将 except: 改为 except Exception: + line = lines[idx] + # 将 except Exception: 改为 except Exception: if re.search(r"except\s*:\s*$", line.strip()): - lines[idx] = line.replace("except:", "except Exception:") - issue.fixed = True + lines[idx] = line.replace("except Exception:", "except Exception:") + issue.fixed = True elif re.search(r"except\s+Exception\s*:\s*$", line.strip()): # 已经是 Exception,但可能需要更具体 pass # 如果文件有修改,写回 if lines != original_lines: - with open(full_path, "w", encoding="utf-8") as f: + with open(full_path, "w", encoding = "utf-8") as f: f.write("\n".join(lines)) print(f"Fixed issues in {file_path}") # 移动到已修复列表 - self.fixed_issues = [i for i in self.issues if i.fixed] - self.issues = [i for i in self.issues if not i.fixed] + self.fixed_issues = [i for i in self.issues if i.fixed] + self.issues = [i for i in self.issues if not i.fixed] def generate_report(self) -> str: """生成审查报告""" - report = [] + report = [] report.append("# InsightFlow 代码审查报告") report.append(f"\n扫描路径: {self.base_path}") report.append(f"扫描时间: {__import__('datetime').datetime.now().isoformat()}") @@ -421,9 +421,9 @@ class CodeReviewer: return "\n".join(report) -def main(): - base_path = "/root/.openclaw/workspace/projects/insightflow/backend" - reviewer = CodeReviewer(base_path) +def main() -> None: + base_path = "/root/.openclaw/workspace/projects/insightflow/backend" + reviewer = CodeReviewer(base_path) print("开始扫描代码...") reviewer.scan_all() @@ -437,9 +437,9 @@ def main(): print(f"\n已修复 {len(reviewer.fixed_issues)} 个问题") # 生成报告 - report = reviewer.generate_report() - report_path = Path(base_path).parent / "CODE_REVIEW_REPORT.md" - with open(report_path, "w", encoding="utf-8") as f: + report = reviewer.generate_report() + report_path = Path(base_path).parent / "CODE_REVIEW_REPORT.md" + with open(report_path, "w", encoding = "utf-8") as f: f.write(report) print(f"\n报告已保存到: {report_path}")

    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