diff --git a/backend/python/app/api/chat_context.py b/backend/python/app/api/chat_context.py index 30fbc5d..4c378ac 100644 --- a/backend/python/app/api/chat_context.py +++ b/backend/python/app/api/chat_context.py @@ -1,2819 +1,2906 @@ -# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). - -# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. - - - -from fastapi import APIRouter, Depends, HTTPException, Query, Response - -from sqlalchemy.orm import Session - -from sqlalchemy import and_, desc, asc, func - -from pydantic import BaseModel, Field - -from typing import Optional - -import uuid - -from datetime import datetime, timezone, timedelta - -import re - -import json - -import asyncio - -import math - -import httpx - -import os - -import logging - -from ..database import get_db, SessionLocal - -from ..models import ( - - - - - - - - User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, ConversationSummary, - - - - - - - - KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, - - - - - - - - EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, - - - - - - - - MCPServer, - - - - - - - - gen_uuid - - - - - - - -) - -from ..auth import get_current_user - -from ..platform_knowledge import get_platform_knowledge - -from ..mcp_client import list_mcp_tools, call_mcp_tool - -from ..middleware.log_service import LogService - -from ..capability_registry import resolve_auto_model - -from app.api.chat_skill import (_bm25_search) - -from app.kb_router import kb_router_search - -from app.api.chat_utils import (logger) - - - -async def _retrieve_kb_context(db: Session, kb_ids: list, query: str, max_tokens: int = 1000) -> str: - - - - - - - - """ - - - - - - - - Stage C: 增强版知识库检索 — 使用BM25排序替代朴素拼接 - - - - - - - - 1. 获取每个KB的文档 - - - - - - - - 2. 构建文档列表(content+title+metadata) - - - - - - - - 3. BM25检索最相关的段落 - - - - - - - - 4. 拼接为上下文注入system prompt - - - - - - - - 同时叠加 Dify 外部知识库(RAG)检索结果。 - - - - - - - - """ - - - - - - - - local_context = "" - - - - - - - - - - - - - - - - if kb_ids: - - - - - - - - kbs = db.query(KnowledgeBase).filter(KnowledgeBase.id.in_(kb_ids)).all() - - - - - - - - if kbs: - - - - - - - - # 收集所有文档段落 - - - - - - - - # Stage C.1: 优先使用 KnowledgeDocumentChunk 表的预分段数据 - - - - - - - - all_segments = [] - - - - - - - - for kb in kbs: - - - - - - - - # 先查chunk表 - - - - - - - - chunks = db.query(KnowledgeDocumentChunk).filter( - - - - - - - - KnowledgeDocumentChunk.knowledge_base_id == kb.id - - - - - - - - ).order_by(KnowledgeDocumentChunk.chunk_index).limit(30).all() - - - - - - - - - - - - - - - - if chunks: - - - - - - - - # 使用预分段数据 - - - - - - - - for chunk in chunks: - - - - - - - - if chunk.content and len(chunk.content.strip()) >= 20: - - - - - - - - all_segments.append({ - - - - - - - - "content": "[{}/{}/段{}]: {}".format( - - - - - - - - kb.name, chunk.document_id[:8], chunk.chunk_index, chunk.content[:500] - - - - - - - - ), - - - - - - - - "raw_content": chunk.content, - - - - - - - - "kb_name": kb.name, - - - - - - - - }) - - - - - - - - else: - - - - - - - - # 退化: 使用原始文档分段 - - - - - - - - docs = db.query(KnowledgeDocument).filter( - - - - - - - - KnowledgeDocument.knowledge_base_id == kb.id, - - - - - - - - KnowledgeDocument.status == "completed" - - - - - - - - ).order_by(KnowledgeDocument.updated_at.desc()).limit(20).all() - - - - - - - - - - - - - - - - for doc in docs: - - - - - - - - content = doc.content or "" - - - - - - - - if not content: - - - - - - - - continue - - - - - - - - - - - - - - - - # 如果文档较长, 分段处理 (每段~800字) - - - - - - - - if len(content) > 800: - - - - - - - - paragraphs = re.split(r'\n\n+', content) - - - - - - - - for pi, para in enumerate(paragraphs): - - - - - - - - if len(para.strip()) >= 20: - - - - - - - - all_segments.append({ - - - - - - - - "content": "[{}/{}/段{}]: {}".format( - - - - - - - - kb.name, doc.name, pi+1, para.strip()[:500] - - - - - - - - ), - - - - - - - - "raw_content": para.strip(), - - - - - - - - "kb_name": kb.name, - - - - - - - - "doc_name": doc.name, - - - - - - - - }) - - - - - - - - else: - - - - - - - - all_segments.append({ - - - - - - - - "content": "[{}/{}]: {}".format(kb.name, doc.name, content[:500]), - - - - - - - - "raw_content": content, - - - - - - - - "kb_name": kb.name, - - - - - - - - "doc_name": doc.name, - - - - - - - - }) - - - - - - - - - - - - - - - - if not all_segments: - - - - - - - - # 退化: 使用原始朴素拼接 - - - - - - - - kb_names = ", ".join([k.name for k in kbs]) - - - - - - - - local_context = f"参考知识库: {kb_names}" - - - - - - - - else: - - - - - - - - # BM25检索最相关的段落 - - - - - - - - # 混合检索:BM25(关键词) + 向量语义 + RRF 融合;失败回退 BM25 - bm25_segs = _bm25_search(query, all_segments, top_k=8) - bm25_for_router = [ - {"content": seg.get("raw_content", seg.get("content", "")), - "metadata": {"label": seg.get("content", "")}} - for seg in bm25_segs - ] - merged = None - try: - merged = await kb_router_search( - query, kb_ids=kb_ids, top_k=8, - bm25_results=bm25_for_router, db_session=db, - use_vector=True, use_external=False, - ) - except Exception as e: - logger.warning(f"[kb] 混合检索失败, 回退 BM25: {e}") - if merged: - ctx_parts = [] - used = 0 - for seg in merged: - label = (seg.get("metadata") or {}).get("label") - c = label if label else seg.get("content", "") - t = _estimate_tokens(c) - if ctx_parts and used + t > max_tokens: - break - ctx_parts.append(c) - used += t - local_context = "\n".join(ctx_parts) - else: - ctx_parts = [seg["content"] for seg in bm25_segs] - joined = "\n".join(ctx_parts) - if _estimate_tokens(joined) > max_tokens: - trunc, u = [], 0 - for c in ctx_parts: - if u + _estimate_tokens(c) > max_tokens: - break - trunc.append(c) - u += _estimate_tokens(c) - joined = "\n".join(trunc) + "\n...(内容截断)" - local_context = joined - - - - - - - - - - - - - - - - # 叠加 Dify 外部知识库(RAG)检索 - - - - - - - - dify_context = "" - - - - - - - - try: - - - - - - - - from app.dify_kb import get_dify_kb_context - - - - - - - - dify_context = get_dify_kb_context(db, query, top_k=5) - - - - - - - - except Exception as e: - - - - - - - - logger.warning(f"Dify KB context retrieval failed: {e}") - - - - - - - - - - - - - - - - parts = [] - - - - - - - - if local_context: - - - - - - - - parts.append(local_context) - - - - - - - - if dify_context: - - - - - - - - parts.append("【Dify 知识库检索结果】\n" + dify_context) - - - - - - - - return "\n\n".join(parts) - - - -async def _ingest_answer_to_dify(conv_id: str, msg_id: str, content: str): - - - - - - - - """后台任务:将助手回答入库到 Dify 知识库(量化后可供 RAG 检索复用)。""" - - - - - - - - try: - - - - - - - - from app.dify_kb import aingest_text - - - - - - - - from app.database import SessionLocal - - - - - - - - from sqlalchemy import text as _t - - - - - - - - db = SessionLocal() - - - - - - - - try: - - - - - - - - row = db.execute(_t( - - - - - - - - "SELECT api_url, api_key, config FROM external_kb_services " - - - - - - - - "WHERE service_type='dify' AND is_enabled=1 LIMIT 1" - - - - - - - - )).fetchone() - - - - - - - - finally: - - - - - - - - db.close() - - - - - - - - if not row: - - - - - - - - return - - - - - - - - api_url, api_key, config_raw = row - - - - - - - - dataset_name = None - - - - - - - - if config_raw: - - - - - - - - try: - - - - - - - - import json - - - - - - - - cfg = config_raw if isinstance(config_raw, dict) else json.loads(config_raw) - - - - - - - - dataset_name = cfg.get("dataset_name") - - - - - - - - except Exception: - - - - - - - - pass - - - - - - - - await aingest_text(api_url, api_key, content, name=f"chat_{conv_id}_{msg_id}", dataset_name=dataset_name) - - - - - - - - except Exception as e: - - - - - - - - logger.warning(f"后台入库 Dify 失败: {e}") - - - -def _build_system_prompt( - - - - - - - - skills: list, - - - - - - - - agents: list, - - - - - - - - kb_context: str, - - - - - - - - user_content: str, - - - - - - - - mcp: list = None, - - - - mcp_functions: list = None, - - - - auto_discovered: bool = False, - - all_mcp: list = None, - - - -) -> tuple: - - - - - - - - """ - - - - - - - - 动态组装system prompt + function definitions。 - - - - - - - - 返回: (system_prompt_str, function_definitions_list) - - - - - - - - """ - - - - - - - - system_parts = ["你是AIFlow平台的AI助手,具备专业的知识储备和工具调用能力。"] - - - - - - - - - - - - - - - - # 平台知识库注入(始终开启):确保AI优先使用平台已有能力 - - - - - - - - try: - - - - - - - - pk = get_platform_knowledge() - - - - - - - - if pk: - - - - - - - - system_parts.append(f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}") - - - - - - - - except Exception: - - - - - - - - pass - - - - - - - - - - - - - - - - # 回复格式要求:强制使用Markdown - - - - - - - - system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 ``` 代码块包裹。避免冗余空行,保持紧凑、美观、易读。") - - - - - - - - - - - - - - - - function_definitions = [] - - - - - - - - - - - - - - - - # Stage B.1: Agent角色注入(Issue 2:让用户询问 agent 能力时,AI 能“看到”该 agent 资料) - - - - - - - - if agents: - - - - - - - - for agent in agents: - - - - - - - - name = agent.get("name", "") - - - - - - - - role = agent.get("role_prompt", "") - - - - - - - - instructions = agent.get("instructions", "") - - - - - - - - desc = agent.get("description", "") - - - - - - - - block = [] - - - - - - - - if name: - - - - - - - - block.append( - - - - - - - - f"用户已选择智能体「{name}」。当用户询问该智能体的功能、能力、用法或相关问题时," - - - - - - - - f"请基于以下资料准确作答,不要说“不知道这个智能体”。" - - - - - - - - ) - - - - - - - - if role: - - - - - - - - block.append(f"该智能体的角色设定: {role}") - - - - - - - - if desc: - - - - - - - - block.append(f"该智能体的简介: {desc}") - - - - - - - - if instructions: - - - - - - - - block.append(f"该智能体的行为指引: {instructions}") - - - - - - - - if block: - - - - - - - - system_parts.append("\n".join(block)) - - - - - - - - - - - - - - - - # Stage A.2: Skill工具注入 - - - - - - - - if skills: - - - - - - - - skill_descs = ", ".join([f"{s['name']}({s.get('category', 'general')})" for s in skills]) - - - - - - - - system_parts.append(f"可用技能工具: {skill_descs}") - - - - - - - - if auto_discovered: - - - - - - - - # Issue 8:每轮默认先寻找本账号已安装且平台已部署的技能,多个适用时 AI 自行判断选 1 个最优 - - - - - - - - system_parts.append( - - - - - - - - "重要规则: 以上技能为本账号已安装且平台已部署的技能。每轮对话请先判断用户请求是否可由其中某个技能处理;" - - - - - - - - "若多个技能都适用,请自行判断并选择最贴切的 1 个优先调用,不要一次性全部调用。" - - - - - - - - "调用对应工具后将获得执行结果,基于结果生成最终回复。若这些技能均不适用,则直接基于你的知识回答。" - - - - - - - - ) - - - - - - - - else: - - - - - - - - system_parts.append( - - "重要规则:\n" - - "1) 当用户请求需要由该技能【执行】某项任务时,优先通过 function calling 调用对应工具;调用后基于工具返回结果生成回复。\n" - - "2) 当用户询问所选技能【本身的介绍、功能、用途、用法、特点】(例如“介绍一下这个技能”“它是什么”“怎么用”“能做什么”)时," - - "必须基于下方【已选技能资料】中的名称/描述/指令直接作答,不要泛泛介绍平台整体,也不要说“不知道这个技能”。\n" - - "3) 若用户问题既不执行也不询问该技能本身,则按常规方式回答。" - - ) - - - - - - - - - - - - - - - - # 为每个skill构建function definition - - - - - - - - for s in skills: - - - - - - - - # 从skill_content提取指令注入system prompt - - - - - - - - instructions = s.get("instructions", "") - - - - - - - - name = s.get("name", "") - - if not auto_discovered: - - # 显式选中技能:注入可引用的技能资料,便于“介绍/说明”类问题据此作答 - - ref_parts = [f"【已选技能资料】名称:{name}"] - - desc = s.get("description", "") - - if desc: - - ref_parts.append(f"简介:{desc}") - - if instructions and len(instructions) < 1200: - - ref_parts.append(f"使用指令:{instructions}") - - system_parts.append("\n".join(ref_parts)) - - elif instructions and len(instructions) < 800: - - system_parts.append(f"技能「{name}」的详细指令: {instructions}") - - - - - - - - - - - - - - - - # 构建参数schema - - - - - - - - params = s.get("parameters", {}) - - - - - - - - if isinstance(params, dict) and len(params) > 0: - - - - - - - - properties = {} - - - - - - - - required = [] - - - - - - - - for pname, pdef in params.items(): - - - - - - - - if isinstance(pdef, dict): - - - - - - - - properties[pname] = { - - - - - - - - "type": pdef.get("type", "string"), - - - - - - - - "description": pdef.get("description", f"传递给{s['name']}的{pname}参数"), - - - - - - - - } - - - - - - - - if pdef.get("required", True): - - - - - - - - required.append(pname) - - - - - - - - else: - - - - - - - - properties[pname] = {"type": "string", "description": f"传递给{s['name']}的{pname}参数"} - - - - - - - - required.append(pname) - - - - - - - - - - - - - - - - if not properties: - - - - - - - - properties = {"input": {"type": "string", "description": f"需要传递给{s['name']}的完整用户输入"}} - - - - - - - - required = ["input"] - - - - - - - - else: - - - - - - - - properties = {"input": {"type": "string", "description": f"需要传递给{s['name']}的完整用户输入"}} - - - - - - - - required = ["input"] - - - - - - - - - - - - - - - - desc = s.get("description", "") - - - - - - - - category = s.get("category", "general") - - - - - - - - func_desc = f"调用技能「{s['name']}」处理用户请求。使用场景: {desc}。当用户请求涉及{category}类任务时优先调用此工具。" - - - - - - - - - - - - - - - - function_definitions.append({ - - - - - - - - "name": s["func_name"], - - - - - - - - "description": func_desc, - - - - - - - - "parameters": { - - - - - - - - "type": "object", - - - - - - - - "properties": properties, - - - - - - - - "required": required, - - - - - - - - }, - - - - - - - - }) - - - - - - - - - - - - - - - - # KB context injection - - - - - - - - if kb_context: - - - - - - - - system_parts.append(f"参考以下知识库内容回答用户问题:\n{kb_context}") - - - - - - - - - - - - - - - - # 平台全量 MCP 清单(Issue:让用户询问"平台有哪些 MCP"时 AI 能准确列举,而非凭空编造) - - if all_mcp: - - _mcp_inventory = "\n".join([f"- {m.get('name','')}: {m.get('description','') or '(未填写描述)'}" for m in all_mcp]) - - system_parts.append( - - f"【平台已安装的 MCP 应用清单(共 {len(all_mcp)} 个,均为平台真实接入并启用)】\n{_mcp_inventory}\n" - - "当用户询问“平台有哪些 MCP / 接入了哪些 MCP / 有哪些工具服务”时,请严格依据以上清单准确列举名称与功能," - - "不要编造或遗漏,也不要把未列出的服务说成已接入。" - - ) - - - - # MCP 应用注入(Issue 3:让用户询问 MCP 能力时,AI 能“看到”该 MCP 的资料与地址) - - - - - - - - if mcp: - - - - - - - - mcp_lines = [] - - - - - - - - for m in mcp: - - - - - - - - name = m.get("name", "") - - - - - - - - desc = m.get("description", "") - - - - - - - - transport = m.get("transport", "stdio") - - - - - - - - endpoint = m.get("endpoint", "") - - - - - - - - line = f"用户已选择 MCP 应用「{name}」(传输方式: {transport})" - - - - - - - - if desc: - - - - - - - - line += f",功能简介: {desc}" - - - - - - - - if endpoint: - - - - - - - - line += f",服务地址: {endpoint}" - - - - - - - - mcp_lines.append(line) - - - - - - - - system_parts.append("\n".join(mcp_lines)) - - - - - - - - system_parts.append( - - - - - - - - "用户已选择上述 MCP 应用。当请求需要借助外部工具/系统(如查询数据、调用 API、操作第三方服务)时," - - - - - - - - "请优先参考这些 MCP 应用的能力来回答;若平台已接入对应工具,优先通过工具调用完成;" - - - - - - - - "不要说“不了解该 MCP”。" - - - - - - - - ) - - - - # 高优先级覆盖规则:显式选中工具时,回答必须基于该工具资料 - - if (skills and not auto_discovered) or (mcp): - - system_parts.append( - - "【最高优先级覆盖规则】\n" - - "用户在本轮已明确选择了工具(见上文【已选技能资料】或【用户已选择 MCP 应用】)。\n" - - "如果用户的问题是在询问这些已选工具/技能/应用本身的介绍、功能、能做什么、怎么用、特点或用法," - - "你必须只基于上文提供的该工具具体资料作答;\n" - - "不要引用《平台知识库》中关于技能市场、MCP 协议或平台整体能力的通用描述;" - - "不要列举其它未选中的技能或 MCP 作为示例;也不要说“不知道”或“无法回答”。\n" - - "若用户问题与已选工具本身无关,则按常规方式回答。" - - ) - - - - - - - - - - - - - - - - - - system_prompt = "\n".join(system_parts) - - - - - - - - # MCP function definitions 注入(让 LLM 可通过 function calling 真实调用 MCP 工具) - - - - if mcp_functions: - - - - function_definitions.extend(mcp_functions) - - - - - - - - return system_prompt, function_definitions - - - -def _get_conversation_context(db: Session, conv_id: str) -> dict: - - - - - - - - """ - - - - - - - - Stage B.2: 获取对话的持久化上下文 - - - - - - - - 对话级别的agent/skill/kb选择保存在Conversation字段中。 - - - - - - - - """ - - - - - - - - conv = db.query(Conversation).filter(Conversation.id == conv_id).first() - - - - - - - - if not conv: - - - - - - - - return {} - - - - - - - - - - - - - - - - # Conversation目前没有存储agent/skill/kb的JSON字段 - - - - - - - - # 我们从第一条消息中获取(如果存在), 或返回空 - - - - - - - - # 后续可通过数据库迁移添加Conversation.context字段 - - - - - - - - - - - - - - - - # 查询该对话中最早一条含有skill_ids/agent_ids/knowledge_ids的消息 - - - - - - - - first_tool_msg = db.query(Message).filter( - - - - - - - - Message.conversation_id == conv_id, - - - - - - - - Message.role == "user", - - - - - - - - ).order_by(asc(Message.created_at)).first() - - - - - - - - - - - - - - - - context = {} - - - - - - - - if first_tool_msg: - - - - - - - - if first_tool_msg.skill_ids: - - - - - - - - context["skill_ids"] = first_tool_msg.skill_ids - - - - - - - - if first_tool_msg.agent_ids: - - - - - - - - context["agent_ids"] = first_tool_msg.agent_ids - - - - - - - - if first_tool_msg.knowledge_ids: - - - - - - - - context["knowledge_base_ids"] = first_tool_msg.knowledge_ids - - - - - - - - if getattr(first_tool_msg, "mcp_ids", None): - - - - - - - - context["mcp_ids"] = first_tool_msg.mcp_ids - - - - - - - - - - - - - - - - return context - - - - - - - -# ===== Phase 2 上下文引擎升级:分层记忆 + 压缩 + 隔离 ===== - -logger = logging.getLogger(__name__) - - - - - -_tiktoken_enc = None - -def _estimate_tokens(text): - - """精确 token 估算:优先 tiktoken(cl100k_base),失败回退 2 字符/token。""" - - if not text: - - return 0 - - global _tiktoken_enc - - if _tiktoken_enc is None: - - try: - - import tiktoken - - _tiktoken_enc = tiktoken.get_encoding("cl100k_base") - - except Exception: - - _tiktoken_enc = False - - if _tiktoken_enc: - - try: - - return len(_tiktoken_enc.encode(text)) - - except Exception: - - pass - - return max(1, len(text) // 2) - - - - - -def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40, - - exclude_latest_user=True, include_summary=True): - - """会话层上下文引擎:预算感知的历史组装 + 可选压缩摘要前置。 - - - - - 若 conversation_summaries 存在摘要,以 system 角色「对话摘要」前置(压缩层)。 - - - 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。 - - - exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。 - - """ - - summary_block = None - - if include_summary: - - try: - - summ = db.query(ConversationSummary).filter( - - ConversationSummary.conversation_id == conv_id - - ).first() - - if summ and summ.summary_text: - - summary_block = { - - "role": "system", - - "content": "【对话历史摘要,请据此理解用户此前的意图与已确认结论】\n" + summ.summary_text, - - } - - except Exception: - - pass - - rows = db.query(Message).filter( - - Message.conversation_id == conv_id, - - Message.role.in_(["user", "assistant"]), - - ).order_by(desc(Message.created_at)).limit(max_messages).all() - - if exclude_latest_user: - - for i, m in enumerate(rows): - - if m.role == "user": - - rows = rows[:i] + rows[i + 1:] - - break - - kept = [] - - used = 0 - - for m in rows: # 新->旧 - - t = _estimate_tokens(m.content or "") - - if kept and used + t > max_tokens: - - break - - kept.append({"role": m.role, "content": m.content}) - - used += t - - kept.reverse() # 时间正序 - - out = [] - - if summary_block: - - out.append(summary_block) - - out.extend(kept) - - return out - - - - - -async def _summarize_via_llm(prompt): - - """调用默认文本模型生成对话摘要;失败返回 None(压缩为可选项,不阻塞)。""" - - try: - - from ..capability_registry import resolve_auto_model - - from app.api.chat_utils import _proxy_for_url - - sdb = SessionLocal() - - try: - - ai_model = resolve_auto_model("text", sdb) - - if not ai_model: - - return None - - provider = sdb.query(ModelProvider).filter( - - ModelProvider.id == ai_model.provider_id - - ).first() - - api_key = ai_model.api_key or (provider.api_key if provider else None) - - if not provider or not provider.api_base_url or not api_key: - - return None - - api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" - - body = { - - "model": ai_model.name, - - "messages": [ - - {"role": "system", "content": "你是专业的对话摘要助手,输出简洁中文摘要。"}, - - {"role": "user", "content": prompt}, - - ], - - "max_tokens": 512, - - "temperature": 0.2, - - } - - async with httpx.AsyncClient(proxy=_proxy_for_url(api_url), trust_env=False, timeout=60) as client: - - resp = await client.post( - - api_url, - - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, - - json=body, - - ) - - if resp.status_code != 200: - - return None - - data = resp.json() - - return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip() - - finally: - - sdb.close() - - except Exception as e: - - logger.warning(f"[context] summarize llm failed: {e}") - - return None - - - - - -async def _maybe_compress_history(conv_id): - - """后台异步压缩:将受保护窗口外的旧消息摘要落库 conversation_summaries。 - - - - 失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。 - - """ - - sdb = None - - try: - - sdb = SessionLocal() - - all_msgs = sdb.query(Message).filter( - - Message.conversation_id == conv_id, - - Message.role.in_(["user", "assistant"]), - - ).order_by(asc(Message.created_at)).all() - - PROTECTED = 20 - - if len(all_msgs) <= PROTECTED: - - return - - compressible = all_msgs[: len(all_msgs) - PROTECTED] - - if len(compressible) < 8: - - return - - chunk = compressible[:30] - - text = "\n".join(f"{m.role}: {m.content}" for m in chunk) - - existing = sdb.query(ConversationSummary).filter( - - ConversationSummary.conversation_id == conv_id - - ).first() - - prior = existing.summary_text if existing else "" - - prompt = ( - - "请把以下对话片段浓缩为简明摘要,保留用户核心诉求、已确认结论、关键事实与待办。" - - "若提供『已有摘要』,请合并而非重复。\n\n" - - f"已有摘要:\n{prior}\n\n待摘要片段:\n{text}" - - ) - - summary = await _summarize_via_llm(prompt) - - if not summary: - - return - - if existing: - - existing.summary_text = summary - - existing.token_count = _estimate_tokens(summary) - - existing.updated_at = datetime.now() - - else: - - sdb.add(ConversationSummary( - - conversation_id=conv_id, - - summary_text=summary, - - token_count=_estimate_tokens(summary), - - updated_at=datetime.now(), - - )) - - sdb.commit() - - except Exception as e: - - logger.warning(f"[context] compress history failed conv={conv_id}: {e}") - - if sdb is not None: - - try: - - sdb.rollback() - - except Exception: - - pass - - finally: - - if sdb is not None: - - try: - - sdb.close() - - except Exception: - - pass - +# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). + +# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. + + + +from fastapi import APIRouter, Depends, HTTPException, Query, Response + +from sqlalchemy.orm import Session + +from sqlalchemy import and_, desc, asc, func + +from pydantic import BaseModel, Field + +from typing import Optional + +import uuid + +from datetime import datetime, timezone, timedelta + +import re + +import json + +import asyncio + +import math + +import httpx + +import os + +import logging + +from ..database import get_db, SessionLocal + +from ..models import ( + + + + + + + + User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, ConversationSummary, + + + + + + + + KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, + + + + + + + + EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, + + + + + + + + MCPServer, + + + + + + + + gen_uuid + + + + + + + +) + +from ..auth import get_current_user + +from ..platform_knowledge import get_platform_knowledge + +from ..mcp_client import list_mcp_tools, call_mcp_tool + +from ..middleware.log_service import LogService + +from ..capability_registry import resolve_auto_model + +from app.api.chat_skill import (_bm25_search) + +from app.kb_router import kb_router_search + +from app.api.chat_utils import (logger) + + + +async def _retrieve_kb_context(db: Session, kb_ids: list, query: str, max_tokens: int = 1000) -> str: + + + + + + + + """ + + + + + + + + Stage C: 增强版知识库检索 — 使用BM25排序替代朴素拼接 + + + + + + + + 1. 获取每个KB的文档 + + + + + + + + 2. 构建文档列表(content+title+metadata) + + + + + + + + 3. BM25检索最相关的段落 + + + + + + + + 4. 拼接为上下文注入system prompt + + + + + + + + 同时叠加 Dify 外部知识库(RAG)检索结果。 + + + + + + + + """ + + + + + + + + local_context = "" + + + + + + + + + + + + + + + + if kb_ids: + + + + + + + + kbs = db.query(KnowledgeBase).filter(KnowledgeBase.id.in_(kb_ids)).all() + + + + + + + + if kbs: + + + + + + + + # 收集所有文档段落 + + + + + + + + # Stage C.1: 优先使用 KnowledgeDocumentChunk 表的预分段数据 + + + + + + + + all_segments = [] + + + + + + + + for kb in kbs: + + + + + + + + # 先查chunk表 + + + + + + + + chunks = db.query(KnowledgeDocumentChunk).filter( + + + + + + + + KnowledgeDocumentChunk.knowledge_base_id == kb.id + + + + + + + + ).order_by(KnowledgeDocumentChunk.chunk_index).limit(30).all() + + + + + + + + + + + + + + + + if chunks: + + + + + + + + # 使用预分段数据 + + + + + + + + for chunk in chunks: + + + + + + + + if chunk.content and len(chunk.content.strip()) >= 20: + + + + + + + + all_segments.append({ + + + + + + + + "content": "[{}/{}/段{}]: {}".format( + + + + + + + + kb.name, chunk.document_id[:8], chunk.chunk_index, chunk.content[:500] + + + + + + + + ), + + + + + + + + "raw_content": chunk.content, + + + + + + + + "kb_name": kb.name, + + + + + + + + }) + + + + + + + + else: + + + + + + + + # 退化: 使用原始文档分段 + + + + + + + + docs = db.query(KnowledgeDocument).filter( + + + + + + + + KnowledgeDocument.knowledge_base_id == kb.id, + + + + + + + + KnowledgeDocument.status == "completed" + + + + + + + + ).order_by(KnowledgeDocument.updated_at.desc()).limit(20).all() + + + + + + + + + + + + + + + + for doc in docs: + + + + + + + + content = doc.content or "" + + + + + + + + if not content: + + + + + + + + continue + + + + + + + + + + + + + + + + # 如果文档较长, 分段处理 (每段~800字) + + + + + + + + if len(content) > 800: + + + + + + + + paragraphs = re.split(r'\n\n+', content) + + + + + + + + for pi, para in enumerate(paragraphs): + + + + + + + + if len(para.strip()) >= 20: + + + + + + + + all_segments.append({ + + + + + + + + "content": "[{}/{}/段{}]: {}".format( + + + + + + + + kb.name, doc.name, pi+1, para.strip()[:500] + + + + + + + + ), + + + + + + + + "raw_content": para.strip(), + + + + + + + + "kb_name": kb.name, + + + + + + + + "doc_name": doc.name, + + + + + + + + }) + + + + + + + + else: + + + + + + + + all_segments.append({ + + + + + + + + "content": "[{}/{}]: {}".format(kb.name, doc.name, content[:500]), + + + + + + + + "raw_content": content, + + + + + + + + "kb_name": kb.name, + + + + + + + + "doc_name": doc.name, + + + + + + + + }) + + + + + + + + + + + + + + + + if not all_segments: + + + + + + + + # 退化: 使用原始朴素拼接 + + + + + + + + kb_names = ", ".join([k.name for k in kbs]) + + + + + + + + local_context = f"参考知识库: {kb_names}" + + + + + + + + else: + + + + + + + + # BM25检索最相关的段落 + + + + + + + + # 混合检索:BM25(关键词) + 向量语义 + RRF 融合;失败回退 BM25 + bm25_segs = _bm25_search(query, all_segments, top_k=8) + bm25_for_router = [ + {"content": seg.get("raw_content", seg.get("content", "")), + "metadata": {"label": seg.get("content", "")}} + for seg in bm25_segs + ] + merged = None + try: + merged = await kb_router_search( + query, kb_ids=kb_ids, top_k=8, + bm25_results=bm25_for_router, db_session=db, + use_vector=True, use_external=False, + ) + except Exception as e: + logger.warning(f"[kb] 混合检索失败, 回退 BM25: {e}") + if merged: + ctx_parts = [] + used = 0 + for seg in merged: + label = (seg.get("metadata") or {}).get("label") + c = label if label else seg.get("content", "") + t = _estimate_tokens(c) + if ctx_parts and used + t > max_tokens: + break + ctx_parts.append(c) + used += t + local_context = "\n".join(ctx_parts) + else: + ctx_parts = [seg["content"] for seg in bm25_segs] + joined = "\n".join(ctx_parts) + if _estimate_tokens(joined) > max_tokens: + trunc, u = [], 0 + for c in ctx_parts: + if u + _estimate_tokens(c) > max_tokens: + break + trunc.append(c) + u += _estimate_tokens(c) + joined = "\n".join(trunc) + "\n...(内容截断)" + local_context = joined + + + + + + + + + + + + + + + + # 叠加 Dify 外部知识库(RAG)检索 + + + + + + + + dify_context = "" + + + + + + + + try: + + + + + + + + from app.dify_kb import get_dify_kb_context + + + + + + + + dify_context = get_dify_kb_context(db, query, top_k=5) + + + + + + + + except Exception as e: + + + + + + + + logger.warning(f"Dify KB context retrieval failed: {e}") + + + + + + + + + + + + + + + + parts = [] + + + + + + + + if local_context: + + + + + + + + parts.append(local_context) + + + + + + + + if dify_context: + + + + + + + + parts.append("【Dify 知识库检索结果】\n" + dify_context) + + + + + + + + return "\n\n".join(parts) + + + +async def _ingest_answer_to_dify(conv_id: str, msg_id: str, content: str): + + + + + + + + """后台任务:将助手回答入库到 Dify 知识库(量化后可供 RAG 检索复用)。""" + + + + + + + + try: + + + + + + + + from app.dify_kb import aingest_text + + + + + + + + from app.database import SessionLocal + + + + + + + + from sqlalchemy import text as _t + + + + + + + + db = SessionLocal() + + + + + + + + try: + + + + + + + + row = db.execute(_t( + + + + + + + + "SELECT api_url, api_key, config FROM external_kb_services " + + + + + + + + "WHERE service_type='dify' AND is_enabled=1 LIMIT 1" + + + + + + + + )).fetchone() + + + + + + + + finally: + + + + + + + + db.close() + + + + + + + + if not row: + + + + + + + + return + + + + + + + + api_url, api_key, config_raw = row + + + + + + + + dataset_name = None + + + + + + + + if config_raw: + + + + + + + + try: + + + + + + + + import json + + + + + + + + cfg = config_raw if isinstance(config_raw, dict) else json.loads(config_raw) + + + + + + + + dataset_name = cfg.get("dataset_name") + + + + + + + + except Exception: + + + + + + + + pass + + + + + + + + await aingest_text(api_url, api_key, content, name=f"chat_{conv_id}_{msg_id}", dataset_name=dataset_name) + + + + + + + + except Exception as e: + + + + + + + + logger.warning(f"后台入库 Dify 失败: {e}") + + + +def _build_system_prompt( + + + + + + + + skills: list, + + + + + + + + agents: list, + + + + + + + + kb_context: str, + + + + + + + + user_content: str, + + + + + + + + mcp: list = None, + + + + mcp_functions: list = None, + + + + auto_discovered: bool = False, + + all_mcp: list = None, + + + +) -> tuple: + + + + + + + + """ + + + + + + + + 动态组装system prompt + function definitions。 + + + + + + + + 返回: (system_prompt_str, function_definitions_list) + + + + + + + + """ + + + + + + + + system_parts = ["你是AIFlow平台的AI助手,具备专业的知识储备和工具调用能力。"] + + + + + + + + + + + + + + + + # 平台知识库注入(始终开启):确保AI优先使用平台已有能力 + + + + + + + + try: + + + + + + + + pk = get_platform_knowledge() + + + + + + + + if pk: + + + + + + + + system_parts.append(f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}") + + + + + + + + except Exception: + + + + + + + + pass + + + + + + + + + + + + + + + + # 回复格式要求:强制使用Markdown + + + + + + + + system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 ``` 代码块包裹。避免冗余空行,保持紧凑、美观、易读。") + + + + + + + + + + + + + + + + function_definitions = [] + + + + + + + + + + + + + + + + # Stage B.1: Agent角色注入(Issue 2:让用户询问 agent 能力时,AI 能“看到”该 agent 资料) + + + + + + + + if agents: + + + + + + + + for agent in agents: + + + + + + + + name = agent.get("name", "") + + + + + + + + role = agent.get("role_prompt", "") + + + + + + + + instructions = agent.get("instructions", "") + + + + + + + + desc = agent.get("description", "") + + + + + + + + block = [] + + + + + + + + if name: + + + + + + + + block.append( + + + + + + + + f"用户已选择智能体「{name}」。当用户询问该智能体的功能、能力、用法或相关问题时," + + + + + + + + f"请基于以下资料准确作答,不要说“不知道这个智能体”。" + + + + + + + + ) + + + + + + + + if role: + + + + + + + + block.append(f"该智能体的角色设定: {role}") + + + + + + + + if desc: + + + + + + + + block.append(f"该智能体的简介: {desc}") + + + + + + + + if instructions: + + + + + + + + block.append(f"该智能体的行为指引: {instructions}") + + + + + + + + if block: + + + + + + + + system_parts.append("\n".join(block)) + + + + + + + + + + + + + + + + # Stage A.2: Skill工具注入 + + + + + + + + if skills: + + + + + + + + skill_descs = ", ".join([f"{s['name']}({s.get('category', 'general')})" for s in skills]) + + + + + + + + system_parts.append(f"可用技能工具: {skill_descs}") + + + + + + + + if auto_discovered: + + + + + + + + # Issue 8:每轮默认先寻找本账号已安装且平台已部署的技能,多个适用时 AI 自行判断选 1 个最优 + + + + + + + + system_parts.append( + + + + + + + + "重要规则: 以上技能为本账号已安装且平台已部署的技能。每轮对话请先判断用户请求是否可由其中某个技能处理;" + + + + + + + + "若多个技能都适用,请自行判断并选择最贴切的 1 个优先调用,不要一次性全部调用。" + + + + + + + + "调用对应工具后将获得执行结果,基于结果生成最终回复。若这些技能均不适用,则直接基于你的知识回答。" + + + + + + + + ) + + + + + + + + else: + + + + + + + + system_parts.append( + + "重要规则:\n" + + "1) 当用户请求需要由该技能【执行】某项任务时,优先通过 function calling 调用对应工具;调用后基于工具返回结果生成回复。\n" + + "2) 当用户询问所选技能【本身的介绍、功能、用途、用法、特点】(例如“介绍一下这个技能”“它是什么”“怎么用”“能做什么”)时," + + "必须基于下方【已选技能资料】中的名称/描述/指令直接作答,不要泛泛介绍平台整体,也不要说“不知道这个技能”。\n" + + "3) 若用户问题既不执行也不询问该技能本身,则按常规方式回答。" + + ) + + + + + + + + + + + + + + + + # 为每个skill构建function definition + + + + + + + + for s in skills: + + + + + + + + # 从skill_content提取指令注入system prompt + + + + + + + + instructions = s.get("instructions", "") + + + + + + + + name = s.get("name", "") + + if not auto_discovered: + + # 显式选中技能:注入可引用的技能资料,便于“介绍/说明”类问题据此作答 + + ref_parts = [f"【已选技能资料】名称:{name}"] + + desc = s.get("description", "") + + if desc: + + ref_parts.append(f"简介:{desc}") + + if instructions and len(instructions) < 1200: + + ref_parts.append(f"使用指令:{instructions}") + + system_parts.append("\n".join(ref_parts)) + + elif instructions and len(instructions) < 800: + + system_parts.append(f"技能「{name}」的详细指令: {instructions}") + + + + + + + + + + + + + + + + # 构建参数schema + + + + + + + + params = s.get("parameters", {}) + + + + + + + + if isinstance(params, dict) and len(params) > 0: + + + + + + + + properties = {} + + + + + + + + required = [] + + + + + + + + for pname, pdef in params.items(): + + + + + + + + if isinstance(pdef, dict): + + + + + + + + properties[pname] = { + + + + + + + + "type": pdef.get("type", "string"), + + + + + + + + "description": pdef.get("description", f"传递给{s['name']}的{pname}参数"), + + + + + + + + } + + + + + + + + if pdef.get("required", True): + + + + + + + + required.append(pname) + + + + + + + + else: + + + + + + + + properties[pname] = {"type": "string", "description": f"传递给{s['name']}的{pname}参数"} + + + + + + + + required.append(pname) + + + + + + + + + + + + + + + + if not properties: + + + + + + + + properties = {"input": {"type": "string", "description": f"需要传递给{s['name']}的完整用户输入"}} + + + + + + + + required = ["input"] + + + + + + + + else: + + + + + + + + properties = {"input": {"type": "string", "description": f"需要传递给{s['name']}的完整用户输入"}} + + + + + + + + required = ["input"] + + + + + + + + + + + + + + + + desc = s.get("description", "") + + + + + + + + category = s.get("category", "general") + + + + + + + + func_desc = f"调用技能「{s['name']}」处理用户请求。使用场景: {desc}。当用户请求涉及{category}类任务时优先调用此工具。" + + + + + + + + + + + + + + + + function_definitions.append({ + + + + + + + + "name": s["func_name"], + + + + + + + + "description": func_desc, + + + + + + + + "parameters": { + + + + + + + + "type": "object", + + + + + + + + "properties": properties, + + + + + + + + "required": required, + + + + + + + + }, + + + + + + + + }) + + + + + + + + + + + + + + + + # KB context injection + + + + + + + + if kb_context: + + + + + + + + system_parts.append(f"参考以下知识库内容回答用户问题:\n{kb_context}") + + + + + + + + + + + + + + + + # 平台全量 MCP 清单(Issue:让用户询问"平台有哪些 MCP"时 AI 能准确列举,而非凭空编造) + + if all_mcp: + + _mcp_inventory = "\n".join([f"- {m.get('name','')}: {m.get('description','') or '(未填写描述)'}" for m in all_mcp]) + + system_parts.append( + + f"【平台已安装的 MCP 应用清单(共 {len(all_mcp)} 个,均为平台真实接入并启用)】\n{_mcp_inventory}\n" + + "当用户询问“平台有哪些 MCP / 接入了哪些 MCP / 有哪些工具服务”时,请严格依据以上清单准确列举名称与功能," + + "不要编造或遗漏,也不要把未列出的服务说成已接入。" + + ) + + + + # MCP 应用注入(Issue 3:让用户询问 MCP 能力时,AI 能“看到”该 MCP 的资料与地址) + + + + + + + + if mcp: + + + + + + + + mcp_lines = [] + + + + + + + + for m in mcp: + + + + + + + + name = m.get("name", "") + + + + + + + + desc = m.get("description", "") + + + + + + + + transport = m.get("transport", "stdio") + + + + + + + + endpoint = m.get("endpoint", "") + + + + + + + + line = f"用户已选择 MCP 应用「{name}」(传输方式: {transport})" + + + + + + + + if desc: + + + + + + + + line += f",功能简介: {desc}" + + + + + + + + if endpoint: + + + + + + + + line += f",服务地址: {endpoint}" + + + + + + + + mcp_lines.append(line) + + + + + + + + system_parts.append("\n".join(mcp_lines)) + + + + + + + + system_parts.append( + + + + + + + + "用户已选择上述 MCP 应用。当请求需要借助外部工具/系统(如查询数据、调用 API、操作第三方服务)时," + + + + + + + + "请优先参考这些 MCP 应用的能力来回答;若平台已接入对应工具,优先通过工具调用完成;" + + + + + + + + "不要说“不了解该 MCP”。" + + + + + + + + ) + + + + # 高优先级覆盖规则:显式选中工具时,回答必须基于该工具资料 + + if (skills and not auto_discovered) or (mcp): + + system_parts.append( + + "【最高优先级覆盖规则】\n" + + "用户在本轮已明确选择了工具(见上文【已选技能资料】或【用户已选择 MCP 应用】)。\n" + + "如果用户的问题是在询问这些已选工具/技能/应用本身的介绍、功能、能做什么、怎么用、特点或用法," + + "你必须只基于上文提供的该工具具体资料作答;\n" + + "不要引用《平台知识库》中关于技能市场、MCP 协议或平台整体能力的通用描述;" + + "不要列举其它未选中的技能或 MCP 作为示例;也不要说“不知道”或“无法回答”。\n" + + "若用户问题与已选工具本身无关,则按常规方式回答。" + + ) + + + + + + + + + + + + + + + + + + system_prompt = "\n".join(system_parts) + + + + + + + + # MCP function definitions 注入(让 LLM 可通过 function calling 真实调用 MCP 工具) + + + + if mcp_functions: + + + + function_definitions.extend(mcp_functions) + + + + + + + + return system_prompt, function_definitions + + + +def _get_conversation_context(db: Session, conv_id: str) -> dict: + + + + + + + + """ + + + + + + + + Stage B.2: 获取对话的持久化上下文 + + + + + + + + 对话级别的agent/skill/kb选择保存在Conversation字段中。 + + + + + + + + """ + + + + + + + + conv = db.query(Conversation).filter(Conversation.id == conv_id).first() + + + + + + + + if not conv: + + + + + + + + return {} + + + + + + + + + + + + + + + + # Conversation目前没有存储agent/skill/kb的JSON字段 + + + + + + + + # 我们从第一条消息中获取(如果存在), 或返回空 + + + + + + + + # 后续可通过数据库迁移添加Conversation.context字段 + + + + + + + + + + + + + + + + # 查询该对话中最早一条含有skill_ids/agent_ids/knowledge_ids的消息 + + + + + + + + first_tool_msg = db.query(Message).filter( + + + + + + + + Message.conversation_id == conv_id, + + + + + + + + Message.role == "user", + + + + + + + + ).order_by(asc(Message.created_at)).first() + + + + + + + + + + + + + + + + context = {} + + + + + + + + if first_tool_msg: + + + + + + + + if first_tool_msg.skill_ids: + + + + + + + + context["skill_ids"] = first_tool_msg.skill_ids + + + + + + + + if first_tool_msg.agent_ids: + + + + + + + + context["agent_ids"] = first_tool_msg.agent_ids + + + + + + + + if first_tool_msg.knowledge_ids: + + + + + + + + context["knowledge_base_ids"] = first_tool_msg.knowledge_ids + + + + + + + + if getattr(first_tool_msg, "mcp_ids", None): + + + + + + + + context["mcp_ids"] = first_tool_msg.mcp_ids + + + + + + + + + + + + + + + + return context + + + + + + + +# ===== Phase 2 上下文引擎升级:分层记忆 + 压缩 + 隔离 ===== + +logger = logging.getLogger(__name__) + + + + + +_tiktoken_enc = None + +def _estimate_tokens(text): + + """精确 token 估算:优先 tiktoken(cl100k_base),失败回退 2 字符/token。""" + + if not text: + + return 0 + + global _tiktoken_enc + + if _tiktoken_enc is None: + + try: + + import tiktoken + + _tiktoken_enc = tiktoken.get_encoding("cl100k_base") + + except Exception: + + _tiktoken_enc = False + + if _tiktoken_enc: + + try: + + return len(_tiktoken_enc.encode(text)) + + except Exception: + + pass + + return max(1, len(text) // 2) + + + + + +def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40, + exclude_latest_user=True, include_summary=True): + """会话层上下文引擎:预算感知的历史组装 + 压缩摘要/结构化长期记忆前置。 + + - 若 conversation_summaries 存在结构化长期记忆(structured_memory),以紧凑 + 结构化区块前置(Semantica 式:实体/事实/偏好/决策/待办),比扁平摘要更省 + token 且更便于模型检索关键信息。 + - 否则回退到扁平 summary_text 前置。 + - 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。 + - exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。 + """ + ensure_structured_column() + summary_block = None + if include_summary: + try: + summ = db.query(ConversationSummary).filter( + ConversationSummary.conversation_id == conv_id + ).first() + if summ and summ.structured_memory and isinstance(summ.structured_memory, dict): + block = _format_structured_memory(summ.structured_memory) + if block: + summary_block = { + "role": "system", + "content": "【长期记忆·结构化】请据此理解用户此前的偏好、已确认决策与待办:\n" + block, + } + elif summ and summ.summary_text: + summary_block = { + "role": "system", + "content": "【对话历史摘要,请据此理解用户此前的意图与已确认结论】\n" + summ.summary_text, + } + except Exception: + pass + + rows = db.query(Message).filter( + Message.conversation_id == conv_id, + Message.role.in_(["user", "assistant"]), + ).order_by(desc(Message.created_at)).limit(max_messages).all() + if exclude_latest_user: + for i, m in enumerate(rows): + if m.role == "user": + rows = rows[:i] + rows[i + 1:] + break + + kept = [] + used = 0 + for m in rows: # 新->旧 + t = _estimate_tokens(m.content or "") + if kept and used + t > max_tokens: + break + kept.append({"role": m.role, "content": m.content}) + used += t + + kept.reverse() # 时间正序 + + out = [] + if summary_block: + out.append(summary_block) + out.extend(kept) + return out + + +def _format_structured_memory(mem: dict) -> str: + """将结构化记忆格式化为紧凑可读区块。""" + labels = { + "entities": "实体", + "facts": "事实", + "preferences": "偏好", + "decisions": "决策", + "open_tasks": "待办", + } + lines = [] + for key, label in labels.items(): + items = mem.get(key) + if not items: + continue + if isinstance(items, list): + for it in items: + if it: + lines.append(f"· {label}: {it}") + elif isinstance(items, str): + lines.append(f"· {label}: {items}") + return "\n".join(lines) +async def _summarize_via_llm(prompt): + + """调用默认文本模型生成对话摘要;失败返回 None(压缩为可选项,不阻塞)。""" + + try: + + from ..capability_registry import resolve_auto_model + + from app.api.chat_utils import _proxy_for_url + + sdb = SessionLocal() + + try: + + ai_model = resolve_auto_model("text", sdb) + + if not ai_model: + + return None + + provider = sdb.query(ModelProvider).filter( + + ModelProvider.id == ai_model.provider_id + + ).first() + + api_key = ai_model.api_key or (provider.api_key if provider else None) + + if not provider or not provider.api_base_url or not api_key: + + return None + + api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" + + body = { + + "model": ai_model.name, + + "messages": [ + + {"role": "system", "content": "你是专业的对话摘要助手,输出简洁中文摘要。"}, + + {"role": "user", "content": prompt}, + + ], + + "max_tokens": 512, + + "temperature": 0.2, + + } + + async with httpx.AsyncClient(proxy=_proxy_for_url(api_url), trust_env=False, timeout=60) as client: + + resp = await client.post( + + api_url, + + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + + json=body, + + ) + + if resp.status_code != 200: + + return None + + data = resp.json() + + return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip() + + finally: + + sdb.close() + + except Exception as e: + + logger.warning(f"[context] summarize llm failed: {e}") + + return None + + + + + +_STRUCT_COL_READY = False + + +def ensure_structured_column(): + """幂等地为 conversation_summaries 增加 structured_memory 列(MariaDB 安全)。""" + global _STRUCT_COL_READY + if _STRUCT_COL_READY: + return True + try: + from app.database import engine + from sqlalchemy import text + with engine.connect() as conn: + exists = conn.execute(text( + "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_schema = DATABASE() AND table_name='conversation_summaries' " + "AND column_name='structured_memory'" + )).scalar() + if not exists: + conn.execute(text( + "ALTER TABLE conversation_summaries ADD COLUMN structured_memory JSON DEFAULT NULL" + )) + conn.commit() + _STRUCT_COL_READY = True + return True + except Exception as e: + logger.warning(f"[context] ensure structured column failed: {e}") + return False + + +async def _recursive_compress(text, depth=0, max_depth=3, budget=700): + """递归再压缩:若摘要仍超过 token 预算,则进一步浓缩,直至收敛或达最大深度。""" + if not text: + return text + if _estimate_tokens(text) <= budget or depth >= max_depth: + return text + s = await _summarize_via_llm("请将以下摘要进一步浓缩,保留全部关键信息,篇幅更短:\n" + text) + if not s: + return text + return await _recursive_compress(s, depth + 1, max_depth, budget) + + +async def _extract_structured_memory(text): + """Semantica 式结构化长期记忆抽取:将对话内容抽取为类型化结构。 + + 模型对抽取类提示偶发返回空,故做最多 3 次重试;并兼容有标签与无标签两种输出。 + """ + if not text or len(text.strip()) < 10: + return None + prompt = ( + "提取要点,按 实体/事实/偏好/决策/待办 五类,用分号分隔输出:" + text[:6000] + ) + raw = None + for _ in range(3): + r = await _summarize_via_llm(prompt) + if r and r.strip(): + raw = r.strip() + break + if not raw: + return None + # JSON 回退 + if "{" in raw: + try: + s = raw.find("{") + e = raw.rfind("}") + data = json.loads(raw[s:e + 1]) + if isinstance(data, dict): + return _normalize_structured(data) + except Exception: + pass + norm = raw.replace(":", ":").replace(";", ";").replace(",", ",").replace("。", "") + result = _parse_structured_segments(norm) + if result: + return result + # 无标签回退:按分号切分,按顺序映射到五类 + segs = [s.strip() for s in norm.replace(chr(10), ";").split(";") if s.strip()] + if segs: + cats = ["entities", "facts", "preferences", "decisions", "open_tasks"] + fb = {} + for i, s in enumerate(segs[:5]): + fb[cats[i]] = [s] + return fb + return None + + +def _parse_structured_segments(norm): + """解析『类别: 内容』格式(兼容全角冒号/分号与单行多类别)。""" + result = {} + for seg in norm.replace(chr(10), ";").split(";"): + if ":" not in seg: + continue + key, _, val = seg.partition(":") + key = key.strip() + val = val.strip().strip(",") + if not val or val in ("无", "none", "None"): + continue + mapped = { + "实体": "entities", "事实": "facts", "偏好": "preferences", + "决策": "decisions", "待办": "open_tasks", + }.get(key) + if mapped: + items = [x.strip() for x in val.split(",") if x.strip()] + if items: + result[mapped] = items + return result + + +def _normalize_structured(data): + """将模型的 JSON 输出规范化为结构化记忆字典。""" + keys = ["entities", "facts", "preferences", "decisions", "open_tasks"] + out = {} + for k in keys: + v = data.get(k) + if isinstance(v, list): + out[k] = [str(x) for x in v if x] + elif isinstance(v, str) and v: + out[k] = [v] + return out + + +def _merge_structured_memory(old, new): + """合并新旧结构化记忆,按字符串去重并控制规模。""" + keys = ["entities", "facts", "preferences", "decisions", "open_tasks"] + merged = {} + for k in keys: + a = old.get(k) if isinstance(old, dict) else None + b = new.get(k) if isinstance(new, dict) else None + items = [] + for x in (a or []) + (b or []): + if x and x not in items: + items.append(x) + merged[k] = items[:50] + return merged + + +async def _maybe_compress_history(conv_id): + """后台异步压缩:将受保护窗口外的旧消息递归再压缩,并抽取结构化长期记忆。 + + 失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。 + """ + ensure_structured_column() + sdb = None + try: + sdb = SessionLocal() + all_msgs = sdb.query(Message).filter( + Message.conversation_id == conv_id, + Message.role.in_(["user", "assistant"]), + ).order_by(asc(Message.created_at)).all() + PROTECTED = 20 + if len(all_msgs) <= PROTECTED: + return + compressible = all_msgs[: len(all_msgs) - PROTECTED] + if len(compressible) < 8: + return + + # 1) 分块摘要:覆盖全部可压缩消息(而非仅前 30 条) + CHUNK = 30 + chunk_summaries = [] + for i in range(0, len(compressible), CHUNK): + piece = compressible[i:i + CHUNK] + text = "\n".join(f"{m.role}: {m.content}" for m in piece) + s = await _summarize_via_llm("请将以下对话片段浓缩为简明要点(保留关键事实、诉求与结论):\n" + text) + if s: + chunk_summaries.append(s) + + existing = sdb.query(ConversationSummary).filter( + ConversationSummary.conversation_id == conv_id + ).first() + prior = existing.summary_text if existing else "" + + combined = "\n".join(chunk_summaries) + if prior: + combined = prior + "\n" + combined + + # 2) 递归再压缩:合并摘要超预算时逐层再压缩直到收敛 + final_summary = await _recursive_compress(combined) + + # 3) 结构化长期记忆抽取(Semantica 式),并与既有记忆合并 + structured = await _extract_structured_memory(combined) + if structured is None: + structured = {} + if existing and existing.structured_memory and isinstance(existing.structured_memory, dict): + structured = _merge_structured_memory(existing.structured_memory, structured) + + now = datetime.now() + if existing: + existing.summary_text = final_summary + existing.structured_memory = structured + existing.token_count = _estimate_tokens(final_summary) + existing.updated_at = now + else: + sdb.add(ConversationSummary( + conversation_id=conv_id, + summary_text=final_summary, + structured_memory=structured, + token_count=_estimate_tokens(final_summary), + updated_at=now, + )) + sdb.commit() + except Exception as e: + logger.warning(f"[context] compress history failed conv={conv_id}: {e}") + if sdb is not None: + try: + sdb.rollback() + except Exception: + pass + finally: + if sdb is not None: + try: + sdb.close() + except Exception: + pass \ No newline at end of file diff --git a/backend/python/app/models.py b/backend/python/app/models.py index 5e362eb..a20fdc5 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -1,1159 +1,1160 @@ -""" -AIFlow 数据库模型定义 - SQLAlchemy ORM -所有用户数据查询必须带 user_id 过滤实现物理隔离 -""" -import uuid -from datetime import datetime, date -from sqlalchemy import ( - Text, - Column, String, Integer, BigInteger, Float, Boolean, - DateTime, Date, Text, Enum, ForeignKey, Index, JSON, - DECIMAL, UniqueConstraint -) -from sqlalchemy.orm import relationship -from .database import Base - - -def gen_uuid() -> str: - return str(uuid.uuid4()) - - -# ============================================================ -# 用户与权限 -# ============================================================ - -class User(Base): - __tablename__ = "users" - - id = Column(String(36), primary_key=True, default=gen_uuid) - username = Column(String(50), unique=True, nullable=False) - email = Column(String(100), default=None) - phone = Column(String(20), default=None) - password_hash = Column(String(255), nullable=False) - avatar = Column(String(500), default=None) - role = Column(String(10), nullable=False, default="user") # user / admin - status = Column(String(10), nullable=False, default="active") # active / banned - real_name_status = Column(String(20), nullable=False, default="unverified") - real_name = Column(String(50), default=None) - id_card = Column(String(20), default=None) - membership_plan_id = Column(String(36), default=None) - membership_level = Column(Integer, default=0) - membership_expire_at = Column(DateTime, default=None) - auto_renew = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class LoginLog(Base): - __tablename__ = "login_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - ip = Column(String(45), default=None) - user_agent = Column(String(500), default=None) - login_at = Column(DateTime, default=datetime.now) - - -class ApiKey(Base): - __tablename__ = "api_keys" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - api_key = Column(String(64), unique=True, nullable=False) - secret = Column(String(128), nullable=False) - name = Column(String(100), default=None) - status = Column(String(10), nullable=False, default="active") # active / revoked - last_used_at = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - expired_at = Column(DateTime, default=None) - description = Column(String(500), default=None, comment="密钥描述") - rate_limit = Column(Integer, default=60, comment="速率限制(请求/分钟)") - total_calls = Column(BigInteger, default=0, comment="总调用次数") - - -# ============================================================ -# AI聊天与频道 -# ============================================================ - -class Conversation(Base): - __tablename__ = "conversations" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - title = Column(String(200), nullable=False, default="新对话") - model_id = Column(String(36), default=None) - status = Column(String(10), nullable=False, default="active") # active / archived - pinned = Column(Boolean, default=False) - knowledge_graph_count = Column(Integer, default=0) - knowledge_graph_base_id = Column(String(64), default=None, comment="已生成的知识图谱知识库ID") - source = Column(String(32), default=None, comment="对话来源,如 wechat_bot") - ext_agent_conversation_id = Column(String(128), default=None, comment="外部Agent(Dify/Coze)会话ID,用于维持多轮上下文") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class Message(Base): - __tablename__ = "messages" - - id = Column(String(36), primary_key=True, default=gen_uuid) - conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False) - role = Column(String(10), nullable=False) # user / assistant / system - content = Column(Text, nullable=False) - token_used = Column(Integer, default=0) - model_id = Column(String(36), default=None) - knowledge_ids = Column(JSON, default=None) - agent_ids = Column(JSON, default=None) - skill_ids = Column(JSON, default=None) - mcp_ids = Column(JSON, default=None, comment="该轮对话选择的MCP应用ID列表") - attachments = Column(JSON, default=None, comment="该轮对话附带的文件附件列表") - used_skills = Column(JSON, default=None, comment="该轮对话调用的进化技能名列表") - used_mcp = Column(JSON, default=None, comment="该轮对话调用的MCP应用名列表") - created_at = Column(DateTime, default=datetime.now) - - -class Channel(Base): - __tablename__ = "channels" - - id = Column(String(36), primary_key=True, default=gen_uuid) - owner_id = Column(String(36), ForeignKey("users.id"), nullable=False) - name = Column(String(100), nullable=False) - description = Column(Text, default=None) - platform = Column(String(20), nullable=False) - webhook_url = Column(String(500), default=None) - credential = Column(JSON, default=None) - oauth_state = Column(String(500), default=None) - settings = Column(JSON, default=None) - account_name = Column(String(100), default=None) - default_agent_id = Column(String(36), default=None) - default_skill_id = Column(String(36), default=None) - knowledge_base_id = Column(String(36), default=None) - bound_model_id = Column(String(36), default=None) - features = Column(JSON, default=None) - status = Column(String(20), nullable=False, default="pending") - is_active = Column(Boolean, default=True) - last_active = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class ChannelMember(Base): - __tablename__ = "channel_members" - __table_args__ = (UniqueConstraint("channel_id", "user_id"),) - - id = Column(String(36), primary_key=True, default=gen_uuid) - channel_id = Column(String(36), ForeignKey("channels.id"), nullable=False) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - role = Column(String(10), nullable=False, default="member") # owner / member - joined_at = Column(DateTime, default=datetime.now) - - -class ChannelMessage(Base): - __tablename__ = "channel_messages" - - id = Column(String(36), primary_key=True, default=gen_uuid) - channel_id = Column(String(36), ForeignKey("channels.id"), nullable=False) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - role = Column(String(10), nullable=False) - content = Column(Text, nullable=False) - token_used = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.now) - - -# ============================================================ -# 知识库 -# ============================================================ - -class KnowledgeBase(Base): - __tablename__ = "knowledge_bases" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - name = Column(String(200), nullable=False) - description = Column(Text, default=None) - directory_path = Column(String(500), default="/") - document_count = Column(Integer, default=0) - knowledge_graph_count = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - is_official = Column(Boolean, default=False) - status = Column(String(20), default="active") - external_service_id = Column(String(36), default=None) - external_id = Column(String(100), default=None) - external_sync_status = Column(String(20), default="none") - external_config = Column(JSON, default=None) - - -class KnowledgeDirectory(Base): - __tablename__ = "knowledge_directories" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), default=None) - parent_id = Column(String(36), default=None) - name = Column(String(200), nullable=False) - created_at = Column(DateTime, default=datetime.now) - - -class KnowledgeDocument(Base): - __tablename__ = "knowledge_documents" - - id = Column(String(36), primary_key=True, default=gen_uuid) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) - directory_id = Column(String(36), ForeignKey("knowledge_directories.id"), default=None) - filename = Column(String(255), nullable=False) - file_type = Column(String(20), nullable=False) - file_size = Column(BigInteger, default=0) - file_path = Column(String(500), default=None) - status = Column(String(20), nullable=False, default="uploading") - chunk_count = Column(Integer, default=0) - error_message = Column(Text, default=None) - content = Column(Text, default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - - - -class KnowledgeDocumentChunk(Base): - """知识库文档分段表 — Stage C.1""" - __tablename__ = "knowledge_document_chunks" - - id = Column(String(36), primary_key=True, default=gen_uuid) - document_id = Column(String(36), ForeignKey("knowledge_documents.id"), nullable=False) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) - chunk_index = Column(Integer, nullable=False, default=0, comment="分段序号") - content = Column(Text, nullable=False, comment="分段文本内容") - start_char = Column(Integer, default=0, comment="起始字符位置") - end_char = Column(Integer, default=0, comment="结束字符位置") - chunk_metadata = Column(JSON, default=None, comment="分段元数据(标题、关键词等)") - token_count = Column(Integer, default=0, comment="分段token数估算") - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - Index("idx_kdc_doc_id", "document_id"), - Index("idx_kdc_kb_id", "knowledge_base_id"), - ) - -class KnowledgeGraphNode(Base): - __tablename__ = "knowledge_graph_nodes" - - id = Column(String(36), primary_key=True, default=gen_uuid) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) - source_doc_id = Column(String(36), ForeignKey("knowledge_documents.id"), default=None) - label = Column(String(500), nullable=False) - type = Column(String(50), nullable=False, comment="entity/attribute/concept/event") - properties = Column(JSON, default=None) - description = Column(Text, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class KnowledgeGraphEdge(Base): - __tablename__ = "knowledge_graph_edges" - - id = Column(String(36), primary_key=True, default=gen_uuid) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) - source_node_id = Column(String(36), ForeignKey("knowledge_graph_nodes.id"), nullable=False) - target_node_id = Column(String(36), ForeignKey("knowledge_graph_nodes.id"), nullable=False) - relation = Column(String(200), nullable=False) - weight = Column(Float, default=1.0) - description = Column(Text, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class KnowledgeGraphBuildLog(Base): - __tablename__ = "knowledge_graph_build_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) - document_id = Column(String(36), ForeignKey("knowledge_documents.id"), nullable=False) - node_count = Column(Integer, default=0) - edge_count = Column(Integer, default=0) - status = Column(String(20), default="completed") - created_at = Column(DateTime, default=datetime.now) - - -# ============================================================ -# 应用市场 -# ============================================================ - -class MarketApp(Base): - __tablename__ = "market_apps" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(200), nullable=False) - type = Column(String(10), nullable=False) # skill / agent / mcp - source = Column(String(10), nullable=False, default="user") # official / user - publisher_id = Column(String(36), ForeignKey("users.id"), default=None) - publisher_name = Column(String(100), default=None) - description = Column(Text, default=None) - icon = Column(String(500), default=None) - tutorial = Column(Text, default=None) - price = Column(DECIMAL(10, 2), nullable=False, default=0.00) - is_member_free = Column(Boolean, default=False) - join_promotion = Column(Boolean, default=False) - status = Column(String(10), nullable=False, default="pending") # pending / published / removed - rating_avg = Column(DECIMAL(3, 2), default=0.00) - review_count = Column(Integer, default=0) - install_count = Column(Integer, default=0) - skill_content = Column(Text, default=None) - mcp_transport = Column(String(20), default=None) - platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze") - capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video") - agent_endpoint = Column(String(500), default=None) - agent_api_key = Column(Text, default=None, comment="Agent 接入 API Key(3.2 起 Fernet 加密存储)") - tool_policy = Column(JSON, default=None, comment="Agent 工具/MCP 权限沙箱策略(3.1)") - orchestration = Column(JSON, default=None, comment="Agent 多Agent编排图(3.3)") - # Phase 4.1: 市场安检 + 版本化 + 绿标 - version = Column(String(20), default="1.0.0", comment="应用版本号(4.1)") - security_scan = Column(JSON, default=None, comment="安全扫描结果(4.1)") - dependencies = Column(JSON, default=None, comment="依赖声明(4.1)") - verified = Column(Boolean, default=False, comment="绿标/官方安检通过(4.1)") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class MarketAppVersion(Base): - """Phase 4.1: 应用版本快照表,支持灰度回滚。""" - __tablename__ = "market_app_versions" - - id = Column(String(36), primary_key=True, default=gen_uuid) - app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False, index=True) - version = Column(String(20), nullable=False) - type = Column(String(10), nullable=False) - config = Column(JSON, default=None, comment="该版本完整配置快照(4.1)") - security_scan = Column(JSON, default=None, comment="该版本安检结果(4.1)") - dependencies = Column(JSON, default=None, comment="该版本依赖声明(4.1)") - created_by = Column(String(36), default=None) - created_at = Column(DateTime, default=datetime.now) - - -class AppPurchase(Base): - __tablename__ = "app_purchases" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) - price = Column(DECIMAL(10, 2), nullable=False, default=0.00) - order_id = Column(String(36), default=None) - status = Column(String(20), nullable=False, default="purchased") # purchased / installed - installed_at = Column(DateTime, default=None) - purchased_at = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class AppReview(Base): - __tablename__ = "app_reviews" - __table_args__ = (UniqueConstraint("user_id", "app_id"),) - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) - rating = Column(Integer, nullable=False) - content = Column(Text, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class AppInstallation(Base): - __tablename__ = "app_installations" - __table_args__ = (UniqueConstraint("user_id", "app_id"),) - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) - installed_at = Column(DateTime, default=datetime.now) - - -# ============================================================ -# 付费与订单 -# ============================================================ - -class MembershipPlan(Base): - __tablename__ = "membership_plans" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(100), nullable=False) - level = Column(Integer, nullable=False, default=0) - duration_type = Column(String(10), nullable=False) # month / quarter / year - price = Column(DECIMAL(10, 2), nullable=False) - daily_free_tokens = Column(Integer, default=0) - features = Column(JSON, default=None) - free_app_ids = Column(JSON, default=None) - free_model_ids = Column(JSON, default=None) - # 配额字段 - max_agents = Column(Integer, default=5) - max_workflows = Column(Integer, default=3) - max_knowledge_bases = Column(Integer, default=2) - max_storage_mb = Column(Integer, default=1024) - api_calls_per_day = Column(Integer, default=100) - api_calls_per_min = Column(Integer, default=10) - # 权限字段 - can_publish_apps = Column(Boolean, default=False) - can_create_api_keys = Column(Boolean, default=False) - can_use_workflow = Column(Boolean, default=False) - can_use_mcp = Column(Boolean, default=False) - can_use_code_exec = Column(Boolean, default=False) - can_use_privacy_mode = Column(Boolean, default=False) - can_use_dag = Column(Boolean, default=False) - allowed_model_tiers = Column(JSON, default=None) - permissions = Column(JSON, default=None) - is_active = Column(Boolean, default=True) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class MembershipSubscription(Base): - __tablename__ = "membership_subscriptions" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - plan_id = Column(String(36), ForeignKey("membership_plans.id"), nullable=False) - order_id = Column(String(36), default=None) - start_at = Column(DateTime, nullable=False) - end_at = Column(DateTime, nullable=False) - auto_renew = Column(Boolean, default=False) - status = Column(String(15), nullable=False, default="active") # active / expired / downgraded - created_at = Column(DateTime, default=datetime.now) - - -class Order(Base): - __tablename__ = "orders" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - order_type = Column(String(15), nullable=False) # membership / service / app - item_id = Column(String(36), default=None) - item_name = Column(String(200), default=None) - amount = Column(DECIMAL(10, 2), nullable=False) - coupon_id = Column(String(36), default=None) - discount_amount = Column(DECIMAL(10, 2), default=0.00) - payment_method = Column(String(10), default=None) # wechat / alipay - payment_id = Column(String(200), default=None) - status = Column(String(15), nullable=False, default="pending") # pending / paid / refunded / cancelled - paid_at = Column(DateTime, default=None) - receipt_number = Column(String(100), default=None) - receipt_type = Column(String(20), default=None) - receipt_title = Column(String(200), default=None) - receipt_tax_number = Column(String(50), default=None) - receipt_issued_at = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class Coupon(Base): - __tablename__ = "coupons" - - id = Column(String(36), primary_key=True, default=gen_uuid) - code = Column(String(50), unique=True, nullable=False) - type = Column(String(10), nullable=False) # fixed / percent - value = Column(DECIMAL(10, 2), nullable=False) - min_amount = Column(DECIMAL(10, 2), default=0.00) - valid_from = Column(DateTime, nullable=False) - valid_to = Column(DateTime, nullable=False) - total_count = Column(Integer, default=None) - used_count = Column(Integer, default=0) - status = Column(String(10), nullable=False, default="active") # active / expired / disabled - created_at = Column(DateTime, default=datetime.now) - - -class UserCoupon(Base): - __tablename__ = "user_coupons" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - coupon_id = Column(String(36), ForeignKey("coupons.id"), nullable=False) - status = Column(String(10), nullable=False, default="unused") # unused / used / expired - used_at = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class ValueAddedService(Base): - __tablename__ = "value_added_services" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(200), nullable=False) - description = Column(Text, default=None) - price = Column(DECIMAL(10, 2), nullable=False) - type = Column(String(20), nullable=False) # app_custom / platform_proxy / data_custom / private_project - features = Column(JSON, default=None) # 服务特性 - status = Column(String(10), nullable=False, default="active") # active / inactive / deleted - assigned_agent_id = Column(String(36), default=None) # 应用定制服务绑定的Agent(市场应用ID) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class CustomServiceOrder(Base): - __tablename__ = "custom_service_orders" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - service_id = Column(String(36), default=None) - service_type = Column(String(10), nullable=False) - description = Column(Text, default=None) - status = Column(String(15), nullable=False, default="pending") - price = Column(DECIMAL(10, 2), default=0.00) - order_id = Column(String(36), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class CustomServiceRequest(Base): - """增值服务工单表 - 用于数据定制服务和私有化项目服务""" - __tablename__ = "custom_service_requests" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - service_id = Column(String(36), ForeignKey("value_added_services.id"), nullable=False) - request_type = Column(String(20), nullable=False) # data_custom / private_project - title = Column(String(200), nullable=False) - description = Column(Text, default=None) - requirements = Column(JSON, default=None) # 需求详情JSON - status = Column(String(20), nullable=False, default="pending") # pending / processing / completed / rejected - assigned_agent_id = Column(String(36), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# Token与计费 -# ============================================================ - -class ModelProvider(Base): - __tablename__ = "model_providers" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(100), nullable=False) - protocol = Column(String(30), nullable=False, default="openai_compatible") - api_key = Column(String(255), default=None) - api_base_url = Column(String(500), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class AIModel(Base): - __tablename__ = "models" - - id = Column(String(36), primary_key=True, default=gen_uuid) - provider_id = Column(String(36), ForeignKey("model_providers.id"), nullable=False) - name = Column(String(100), nullable=False) - display_name = Column(String(200), default=None, comment="display name") - api_key = Column(String(255), default=None) - endpoint = Column(String(500), default=None) - capabilities = Column(JSON, default=None) - timeout = Column(Integer, default=60) - status = Column(String(10), nullable=False, default="paid") # free / paid - price_per_million_tokens = Column(DECIMAL(10, 4), nullable=False, default=0.0000) - is_enabled = Column(Boolean, default=True) - is_default = Column(Boolean, default=False) # 全局默认模型 - total_calls = Column(BigInteger, default=0) - total_tokens = Column(BigInteger, default=0) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class TokenUsageLog(Base): - __tablename__ = "token_usage_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - model_id = Column(String(36), default=None) - model_name = Column(String(100), default=None) - app_id = Column(String(36), default=None) - app_name = Column(String(200), default=None) - conversation_id = Column(String(36), default=None) - trace_id = Column(String(64), default=None) - prompt_tokens = Column(Integer, default=0) - completion_tokens = Column(Integer, default=0) - total_tokens = Column(Integer, default=0) - cost = Column(DECIMAL(10, 6), default=0.000000) - created_at = Column(DateTime, default=datetime.now) - - -class TokenBalance(Base): - __tablename__ = "token_balances" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), unique=True, nullable=False) - total_consumed = Column(BigInteger, default=0) - balance = Column(BigInteger, default=0) - daily_free_used = Column(BigInteger, default=0) - daily_free_date = Column(Date, default=None) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# 通知与日志 -# ============================================================ - -class Notification(Base): - __tablename__ = "notifications" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - type = Column(String(15), nullable=False) - title = Column(String(200), nullable=False) - content = Column(Text, default=None) - is_read = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.now) - - -class OperationLog(Base): - __tablename__ = "operation_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), nullable=False) - username = Column(String(50), default=None) - action = Column(String(100), nullable=False) - target = Column(String(100), default=None) - target_id = Column(String(36), default=None) - detail = Column(Text, default=None) - ip = Column(String(45), default=None) - created_at = Column(DateTime, default=datetime.now) - - -class AuditLog(Base): - __tablename__ = "audit_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - admin_id = Column(String(36), nullable=False) - admin_name = Column(String(50), default=None) - action = Column(String(100), nullable=False) - target_type = Column(String(50), default=None) - target_id = Column(String(36), default=None) - detail = Column(Text, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class RiskRule(Base): - __tablename__ = "risk_rules" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(100), nullable=False) - type = Column(String(15), nullable=False) - threshold = Column(Integer, default=0) - duration = Column(Integer, default=3600) - is_enabled = Column(Boolean, default=True) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# 系统配置 -# ============================================================ - -class SiteSetting(Base): - __tablename__ = "site_settings" - - id = Column(String(36), primary_key=True, default=gen_uuid) - key = Column(String(100), unique=True, nullable=False) - value = Column(Text, default=None) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class EmailConfig(Base): - __tablename__ = "email_configs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - smtp_host = Column(String(200), nullable=False) - smtp_port = Column(Integer, nullable=False, default=587) - username = Column(String(200), nullable=False) - password = Column(String(200), nullable=False) - from_address = Column(String(200), nullable=False) - use_tls = Column(Boolean, default=True) - use_ssl = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class SmsConfig(Base): - __tablename__ = "sms_configs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - provider = Column(String(50), nullable=False) - api_key = Column(String(200), nullable=False) - api_secret = Column(String(200), nullable=False) - sign_name = Column(String(100), default=None) - template_code = Column(String(100), default=None) - # 短信宝 (SMSBao) 字段 - username = Column(String(100), default=None) # 短信宝用户名 u - password = Column(String(200), default=None) # 短信宝密码(明文存储,发送时 MD5 32位) - goods_id = Column(String(100), default=None) # 短信宝专属通道产品 ID g - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class PaymentConfig(Base): - __tablename__ = "payment_configs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - type = Column(String(10), nullable=False) # wechat / alipay - app_id = Column(String(100), nullable=False) - merchant_id = Column(String(100), default=None) # 微信: 商户号 - api_key = Column(String(255), nullable=False) # 微信: APIv2密钥 / 支付宝: 应用私钥 - api_v3_key = Column(String(255), default=None) # 微信: APIv3密钥 - cert_path = Column(String(500), default=None) # 微信: 证书路径 - cert_serial_no = Column(String(200), default=None) # 微信: 证书序列号 - alipay_public_key = Column(Text, default=None) # 支付宝: 支付宝公钥 - app_private_key = Column(Text, default=None) # 支付宝: 应用私钥 - gateway_url = Column(String(500), default=None) # 支付宝: 网关地址 - notify_url = Column(String(500), default=None) # 支付回调通知地址 - return_url = Column(String(500), default=None) # 支付宝: 同步跳转地址 - status = Column(String(10), nullable=False, default="inactive") # active / inactive - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class RegisterSetting(Base): - __tablename__ = "register_settings" - - id = Column(String(36), primary_key=True, default=gen_uuid) - phone_required = Column(Boolean, default=True) - phone_sms_required = Column(Boolean, default=True) - email_required = Column(Boolean, default=True) - email_verify_required = Column(Boolean, default=True) - real_name_required = Column(Boolean, default=False) - aliyun_access_key = Column(String(200), default=None) - aliyun_secret_key = Column(String(200), default=None) - aliyun_endpoint = Column(String(200), default=None) - sms_sign_name = Column(String(100), default=None) - sms_template_code = Column(String(50), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# 广告位 -# ============================================================ - -class AdBanner(Base): - __tablename__ = "ad_banners" - - id = Column(String(36), primary_key=True, default=gen_uuid) - title = Column(String(200), nullable=False) - image_url = Column(String(500), default=None) - link_app_id = Column(String(36), default=None) - link_app_name = Column(String(200), default=None) - sort_order = Column(Integer, default=0) - click_count = Column(Integer, default=0) - status = Column(String(10), nullable=False, default="active") # active / inactive - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# 资产管理 -# ============================================================ - -class AssetCategory(Base): - __tablename__ = "asset_categories" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(100), nullable=False) - type = Column(String(10), nullable=False) - icon = Column(String(200), default=None) - color = Column(String(20), default=None) - is_default = Column(Boolean, default=False) - user_id = Column(String(36), default=None) - created_at = Column(DateTime, default=datetime.now) - - -class Asset(Base): - __tablename__ = "assets" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - name = Column(String(200), nullable=False) - category = Column(String(10), nullable=False) - sub_category = Column(String(50), default=None) - file_type = Column(String(50), default=None) - size = Column(BigInteger, default=0) - description = Column(Text, default=None) - cover_url = Column(String(500), default=None) - url = Column(String(500), default=None) - content = Column(Text, default=None) - novel_meta = Column(JSON, default=None) - status = Column(String(10), nullable=False, default="active") # active / deleted - knowledge_doc_id = Column(String(36), default=None, comment="关联的知识库文档ID(由知识库同步资产时写入)") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class NovelChapter(Base): - __tablename__ = "novel_chapters" - - id = Column(String(36), primary_key=True, default=gen_uuid) - asset_id = Column(String(36), ForeignKey("assets.id"), nullable=False) - title = Column(String(200), nullable=False) - word_count = Column(Integer, default=0) - sort_order = Column(Integer, default=0) - content = Column(Text(length=4294967295), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# 预留扩展 -# ============================================================ - -class Team(Base): - __tablename__ = "teams" - - id = Column(String(36), primary_key=True, default=gen_uuid) - owner_id = Column(String(36), ForeignKey("users.id"), nullable=False) - name = Column(String(100), nullable=False) - description = Column(Text, default=None) - status = Column(String(10), nullable=False, default="active") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class Referral(Base): - __tablename__ = "referrals" - - id = Column(String(36), primary_key=True, default=gen_uuid) - referrer_id = Column(String(36), ForeignKey("users.id"), nullable=False) - referee_id = Column(String(36), ForeignKey("users.id"), nullable=False) - referral_code = Column(String(50), default=None) - status = Column(String(10), nullable=False, default="pending") - commission_rate = Column(DECIMAL(5, 2), default=0.00) - created_at = Column(DateTime, default=datetime.now) - - -class OpenApiClient(Base): - __tablename__ = "open_api_clients" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - name = Column(String(100), nullable=False) - client_id = Column(String(64), unique=True, nullable=False) - client_secret = Column(String(128), nullable=False) - allowed_models = Column(JSON, default=None) - rate_limit = Column(Integer, default=60) - status = Column(String(10), nullable=False, default="active") - created_at = Column(DateTime, default=datetime.now) - total_calls = Column(BigInteger, default=0, comment="总调用次数") - last_used_at = Column(DateTime, default=None, comment="最后使用时间") - - -# ============================================================ -# API调用统计 -# ============================================================ - -class ApiCallStat(Base): - """API调用统计表 - 按端点聚合""" - __tablename__ = "api_call_stats" - - id = Column(String(36), primary_key=True, default=gen_uuid) - endpoint = Column(String(200), nullable=False) - method = Column(String(10), nullable=False) - call_count = Column(BigInteger, default=0) - total_response_time = Column(Float, default=0.0) - error_count = Column(BigInteger, default=0) - last_called_at = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - __table_args__ = ( - UniqueConstraint("endpoint", "method", name="uk_endpoint_method"), - ) - - -class SlowRequestLog(Base): - """慢请求记录表""" - __tablename__ = "slow_request_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - request_id = Column(String(64), default=None) - method = Column(String(10), nullable=False) - endpoint = Column(String(500), nullable=False) - status_code = Column(Integer, default=0) - response_time = Column(Float, default=0.0) - threshold = Column(Float, default=3.0) - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - Index("idx_srl_created", "created_at"), - ) - - -# ============================================================ -# AI自进化引擎 - 用户进化配置 -# ============================================================ -class UserEvolutionConfig(Base): - __tablename__ = "user_evolution_config" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), unique=True, nullable=False, index=True) - skill_dir_path = Column(String(500), nullable=True, comment="用户Skill仓库根目录") - trajectory_dir_path = Column(String(500), nullable=True, comment="用户对话轨迹目录") - watcher_status = Column(String(20), default="stopped", comment="运行中/已停止/错误") - watcher_error = Column(String(500), nullable=True, comment="watcher异常信息") - pipeline_status = Column(String(20), default="idle", comment="流水线状态: running/idle/error") - pipeline_processed = Column(Integer, default=0, comment="已处理轨迹数") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class EvolutionSkill(Base): - __tablename__ = "evolution_skills" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) - skill_name = Column(String(200), nullable=False, comment="技能名称") - description = Column(Text, nullable=True) - tags = Column(JSON, nullable=True, comment="标签列表") - category = Column(String(100), nullable=True, comment="技能分类") - state = Column(String(20), default="baby", comment="baby/main/staging/frozen") - use_count = Column(Integer, default=0, comment="使用次数") - version = Column(Integer, default=1) - skill_md_path = Column(String(500), nullable=True, comment="SKILL.md文件路径") - git_commit_sha = Column(String(40), nullable=True) - last_used_at = Column(DateTime, nullable=True) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class EvolutionEvent(Base): - __tablename__ = "evolution_events" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) - skill_name = Column(String(200), nullable=False) - event_type = Column(String(50), nullable=False, comment="created/promoted/rejected/frozen/unfrozen/edited/deleted/milestone") - description = Column(Text, nullable=True) - version_before = Column(Integer, nullable=True) - version_after = Column(Integer, nullable=True) - created_at = Column(DateTime, default=datetime.now) - - -class EvolutionTrajectory(Base): - __tablename__ = "evolution_trajectories" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) - conversation_id = Column(String(36), nullable=True, comment="关联的对话ID") - file_path = Column(String(500), nullable=True, comment="轨迹文件路径") - status = Column(String(20), default="discovered", comment="discovered/splitting/split_done/indexed/clustering/done/error/filtered") - atom_count = Column(Integer, default=0) - token_used = Column(Integer, default=0) - error_msg = Column(String(500), nullable=True, comment="轨迹处理失败的错误信息") - retry_count = Column(Integer, default=0, comment="失败重试次数") - tasks_extracted = Column(Integer, default=0, comment="拆分的AtomTask数量") - created_at = Column(DateTime, default=datetime.now) - processed_at = Column(DateTime, nullable=True) - - -class SkillTrajectoryLink(Base): - """技能-轨迹关联表:记录技能与对话轨迹的关联关系""" - __tablename__ = "skill_trajectory_links" - id = Column(String(36), primary_key=True, default=gen_uuid) - skill_name = Column(String(200), nullable=False, index=True, comment="关联的技能名称") - user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) - trajectory_id = Column(String(36), nullable=True, comment="关联的轨迹ID") - atom_id = Column(String(128), nullable=True, comment="原子任务ID(形如 trajectory_id_atom_N,需容纳 UUID+后缀)") - weightscore = Column(Integer, default=0, comment="关联权重分") - created_at = Column(DateTime, default=datetime.now) - - -# ============================================================ -# AI自进化v2 - 新增表 -# ============================================================ - -class SkillVersion(Base): - """技能版本历史表:存储每个版本的SKILL.md完整内容,支持回滚""" - __tablename__ = "skill_versions" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - skill_name = Column(String(200), nullable=False) - version = Column(Integer, nullable=False, default=1) - skill_md_content = Column(Text(4294967295), nullable=True, comment="该版本的SKILL.md完整内容") - git_commit_sha = Column(String(40), nullable=True) - description = Column(Text, nullable=True, comment="版本变更说明") - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - Index("idx_sv_user_skill", "user_id", "skill_name"), - Index("idx_sv_version", "version"), - ) - - -class EvolutionReport(Base): - """进化报告表:存储周报/月报""" - __tablename__ = "evolution_reports" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - report_type = Column(String(20), nullable=False, comment="weekly / monthly") - period_start = Column(Date, nullable=False) - period_end = Column(Date, nullable=False) - report_data = Column(JSON, nullable=True, comment="报告详细数据(JSON)") - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - Index("idx_er_user_type", "user_id", "report_type"), - Index("idx_er_period", "period_start", "period_end"), - ) - - -class SkillPublishRecord(Base): - """技能市场发布记录表""" - __tablename__ = "skill_publish_records" - id = Column(String(36), primary_key=True, default=gen_uuid) - skill_id = Column(String(36), nullable=False, comment="关联evolution_skills.id") - skill_name = Column(String(200), nullable=False) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - market_app_id = Column(String(36), nullable=True, comment="关联market_apps.id") - price = Column(DECIMAL(10, 2), default=0.00) - market_description = Column(Text, nullable=True) - status = Column(String(20), nullable=False, default="published", comment="published/unpublished") - published_at = Column(DateTime, default=datetime.now) - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - Index("idx_spr_skill_id", "skill_id"), - Index("idx_spr_user_id", "user_id"), - Index("idx_spr_status", "status"), - ) - - -class TeamSharedSkill(Base): - """团队共享技能表""" - __tablename__ = "team_shared_skills" - id = Column(String(36), primary_key=True, default=gen_uuid) - team_id = Column(String(36), ForeignKey("teams.id"), nullable=False) - skill_id = Column(String(36), nullable=False, comment="关联evolution_skills.id") - skill_name = Column(String(200), nullable=False) - shared_by = Column(String(36), ForeignKey("users.id"), nullable=False) - shared_by_name = Column(String(100), nullable=True, comment="分享者昵称") - is_public = Column(Boolean, default=True, comment="团队内是否可见") - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - UniqueConstraint("team_id", "skill_id", name="uk_tss_team_skill"), - ) - - -class ExternalKBService(Base): - """外部知识库服务配置""" - __tablename__ = "external_kb_services" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(200), nullable=False) - service_type = Column(String(20), nullable=False) - api_url = Column(String(500), nullable=False) - api_key = Column(String(255), default="") - config = Column(JSON, default=None) - is_enabled = Column(Boolean, default=True) - status = Column(String(20), default="disconnected") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - -class OSSStorageConfig(Base): - __tablename__ = "oss_storage_configs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(200), nullable=False) - provider = Column(String(20), nullable=False) - endpoint = Column(String(500), nullable=False) - access_key = Column(String(255), nullable=False) - secret_key = Column(String(255), nullable=False) - bucket = Column(String(200), nullable=False) - region = Column(String(100), default="") - base_path = Column(String(300), default="") - is_default = Column(Boolean, default=False) - is_enabled = Column(Boolean, default=True) - status = Column(String(20), default="disconnected") - config = Column(JSON, default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - - -class UserStorageQuota(Base): - __tablename__ = "user_storage_quotas" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), nullable=False, unique=True) - quota_bytes = Column(BigInteger, nullable=False, default=1073741824) - used_bytes = Column(BigInteger, nullable=False, default=0) - quota_type = Column(String(20), nullable=False, default="membership") - membership_level = Column(Integer, default=0) - plan_id = Column(String(36), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class AutomatedTask(Base): - __tablename__ = "automated_tasks" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(200), nullable=False) - description = Column(Text, default="") - trigger_type = Column(String(20), nullable=False) - cron_expression = Column(String(100), default=None) - event_condition = Column(JSON, default=None) - action_type = Column(String(30), nullable=False) - action_config = Column(JSON, default=None) - is_enabled = Column(Boolean, default=True) - status = Column(String(20), default="idle") - max_retries = Column(Integer, default=3) - timeout_seconds = Column(Integer, default=300) - last_run_at = Column(DateTime, default=None) - next_run_at = Column(DateTime, default=None) - total_runs = Column(Integer, default=0) - success_runs = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - -class TaskExecutionLog(Base): - __tablename__ = "task_execution_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - task_id = Column(String(36), nullable=False) - task_name = Column(String(200), default="") - status = Column(String(20), default="running") - trigger_type = Column(String(20), default="manual") - started_at = Column(DateTime, default=None) - finished_at = Column(DateTime, default=None) - duration_ms = Column(Integer, default=0) - result_summary = Column(Text, default="") - error_message = Column(Text, default="") - created_at = Column(DateTime, default=datetime.now) - - - -class MCPServer(Base): - __tablename__ = "mcp_servers" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(100), nullable=False) - description = Column(Text, default="") - transport_type = Column(String(20), default="stdio") - command = Column(String(500), default="") - args = Column(Text, default="") - env_vars = Column(Text, default="") - url = Column(String(500), default="") - headers = Column(Text, default="") - is_enabled = Column(Boolean, default=True) - status = Column(String(20), default="disconnected") - last_connected_at = Column(DateTime, default=None) - tools_count = Column(Integer, default=0) - resources_count = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class SmsVerificationCode(Base): - __tablename__ = "sms_verification_codes" - - id = Column(String(36), primary_key=True, default=gen_uuid) - phone = Column(String(20), nullable=False, index=True) - code = Column(String(10), nullable=False) - purpose = Column(String(30), nullable=False) # register / reset_password - expires_at = Column(DateTime, nullable=False) - used = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.now) - - - -class ConversationSummary(Base): - __tablename__ = "conversation_summaries" - - conversation_id = Column(String(64), primary_key=True) - summary_text = Column(Text, nullable=False, default="") - token_count = Column(Integer, default=0) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) +""" +AIFlow 数据库模型定义 - SQLAlchemy ORM +所有用户数据查询必须带 user_id 过滤实现物理隔离 +""" +import uuid +from datetime import datetime, date +from sqlalchemy import ( + Text, + Column, String, Integer, BigInteger, Float, Boolean, + DateTime, Date, Text, Enum, ForeignKey, Index, JSON, + DECIMAL, UniqueConstraint +) +from sqlalchemy.orm import relationship +from .database import Base + + +def gen_uuid() -> str: + return str(uuid.uuid4()) + + +# ============================================================ +# 用户与权限 +# ============================================================ + +class User(Base): + __tablename__ = "users" + + id = Column(String(36), primary_key=True, default=gen_uuid) + username = Column(String(50), unique=True, nullable=False) + email = Column(String(100), default=None) + phone = Column(String(20), default=None) + password_hash = Column(String(255), nullable=False) + avatar = Column(String(500), default=None) + role = Column(String(10), nullable=False, default="user") # user / admin + status = Column(String(10), nullable=False, default="active") # active / banned + real_name_status = Column(String(20), nullable=False, default="unverified") + real_name = Column(String(50), default=None) + id_card = Column(String(20), default=None) + membership_plan_id = Column(String(36), default=None) + membership_level = Column(Integer, default=0) + membership_expire_at = Column(DateTime, default=None) + auto_renew = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class LoginLog(Base): + __tablename__ = "login_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + ip = Column(String(45), default=None) + user_agent = Column(String(500), default=None) + login_at = Column(DateTime, default=datetime.now) + + +class ApiKey(Base): + __tablename__ = "api_keys" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + api_key = Column(String(64), unique=True, nullable=False) + secret = Column(String(128), nullable=False) + name = Column(String(100), default=None) + status = Column(String(10), nullable=False, default="active") # active / revoked + last_used_at = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + expired_at = Column(DateTime, default=None) + description = Column(String(500), default=None, comment="密钥描述") + rate_limit = Column(Integer, default=60, comment="速率限制(请求/分钟)") + total_calls = Column(BigInteger, default=0, comment="总调用次数") + + +# ============================================================ +# AI聊天与频道 +# ============================================================ + +class Conversation(Base): + __tablename__ = "conversations" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + title = Column(String(200), nullable=False, default="新对话") + model_id = Column(String(36), default=None) + status = Column(String(10), nullable=False, default="active") # active / archived + pinned = Column(Boolean, default=False) + knowledge_graph_count = Column(Integer, default=0) + knowledge_graph_base_id = Column(String(64), default=None, comment="已生成的知识图谱知识库ID") + source = Column(String(32), default=None, comment="对话来源,如 wechat_bot") + ext_agent_conversation_id = Column(String(128), default=None, comment="外部Agent(Dify/Coze)会话ID,用于维持多轮上下文") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class Message(Base): + __tablename__ = "messages" + + id = Column(String(36), primary_key=True, default=gen_uuid) + conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False) + role = Column(String(10), nullable=False) # user / assistant / system + content = Column(Text, nullable=False) + token_used = Column(Integer, default=0) + model_id = Column(String(36), default=None) + knowledge_ids = Column(JSON, default=None) + agent_ids = Column(JSON, default=None) + skill_ids = Column(JSON, default=None) + mcp_ids = Column(JSON, default=None, comment="该轮对话选择的MCP应用ID列表") + attachments = Column(JSON, default=None, comment="该轮对话附带的文件附件列表") + used_skills = Column(JSON, default=None, comment="该轮对话调用的进化技能名列表") + used_mcp = Column(JSON, default=None, comment="该轮对话调用的MCP应用名列表") + created_at = Column(DateTime, default=datetime.now) + + +class Channel(Base): + __tablename__ = "channels" + + id = Column(String(36), primary_key=True, default=gen_uuid) + owner_id = Column(String(36), ForeignKey("users.id"), nullable=False) + name = Column(String(100), nullable=False) + description = Column(Text, default=None) + platform = Column(String(20), nullable=False) + webhook_url = Column(String(500), default=None) + credential = Column(JSON, default=None) + oauth_state = Column(String(500), default=None) + settings = Column(JSON, default=None) + account_name = Column(String(100), default=None) + default_agent_id = Column(String(36), default=None) + default_skill_id = Column(String(36), default=None) + knowledge_base_id = Column(String(36), default=None) + bound_model_id = Column(String(36), default=None) + features = Column(JSON, default=None) + status = Column(String(20), nullable=False, default="pending") + is_active = Column(Boolean, default=True) + last_active = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class ChannelMember(Base): + __tablename__ = "channel_members" + __table_args__ = (UniqueConstraint("channel_id", "user_id"),) + + id = Column(String(36), primary_key=True, default=gen_uuid) + channel_id = Column(String(36), ForeignKey("channels.id"), nullable=False) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + role = Column(String(10), nullable=False, default="member") # owner / member + joined_at = Column(DateTime, default=datetime.now) + + +class ChannelMessage(Base): + __tablename__ = "channel_messages" + + id = Column(String(36), primary_key=True, default=gen_uuid) + channel_id = Column(String(36), ForeignKey("channels.id"), nullable=False) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + role = Column(String(10), nullable=False) + content = Column(Text, nullable=False) + token_used = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.now) + + +# ============================================================ +# 知识库 +# ============================================================ + +class KnowledgeBase(Base): + __tablename__ = "knowledge_bases" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + name = Column(String(200), nullable=False) + description = Column(Text, default=None) + directory_path = Column(String(500), default="/") + document_count = Column(Integer, default=0) + knowledge_graph_count = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + is_official = Column(Boolean, default=False) + status = Column(String(20), default="active") + external_service_id = Column(String(36), default=None) + external_id = Column(String(100), default=None) + external_sync_status = Column(String(20), default="none") + external_config = Column(JSON, default=None) + + +class KnowledgeDirectory(Base): + __tablename__ = "knowledge_directories" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), default=None) + parent_id = Column(String(36), default=None) + name = Column(String(200), nullable=False) + created_at = Column(DateTime, default=datetime.now) + + +class KnowledgeDocument(Base): + __tablename__ = "knowledge_documents" + + id = Column(String(36), primary_key=True, default=gen_uuid) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) + directory_id = Column(String(36), ForeignKey("knowledge_directories.id"), default=None) + filename = Column(String(255), nullable=False) + file_type = Column(String(20), nullable=False) + file_size = Column(BigInteger, default=0) + file_path = Column(String(500), default=None) + status = Column(String(20), nullable=False, default="uploading") + chunk_count = Column(Integer, default=0) + error_message = Column(Text, default=None) + content = Column(Text, default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + + + +class KnowledgeDocumentChunk(Base): + """知识库文档分段表 — Stage C.1""" + __tablename__ = "knowledge_document_chunks" + + id = Column(String(36), primary_key=True, default=gen_uuid) + document_id = Column(String(36), ForeignKey("knowledge_documents.id"), nullable=False) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) + chunk_index = Column(Integer, nullable=False, default=0, comment="分段序号") + content = Column(Text, nullable=False, comment="分段文本内容") + start_char = Column(Integer, default=0, comment="起始字符位置") + end_char = Column(Integer, default=0, comment="结束字符位置") + chunk_metadata = Column(JSON, default=None, comment="分段元数据(标题、关键词等)") + token_count = Column(Integer, default=0, comment="分段token数估算") + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index("idx_kdc_doc_id", "document_id"), + Index("idx_kdc_kb_id", "knowledge_base_id"), + ) + +class KnowledgeGraphNode(Base): + __tablename__ = "knowledge_graph_nodes" + + id = Column(String(36), primary_key=True, default=gen_uuid) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) + source_doc_id = Column(String(36), ForeignKey("knowledge_documents.id"), default=None) + label = Column(String(500), nullable=False) + type = Column(String(50), nullable=False, comment="entity/attribute/concept/event") + properties = Column(JSON, default=None) + description = Column(Text, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class KnowledgeGraphEdge(Base): + __tablename__ = "knowledge_graph_edges" + + id = Column(String(36), primary_key=True, default=gen_uuid) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) + source_node_id = Column(String(36), ForeignKey("knowledge_graph_nodes.id"), nullable=False) + target_node_id = Column(String(36), ForeignKey("knowledge_graph_nodes.id"), nullable=False) + relation = Column(String(200), nullable=False) + weight = Column(Float, default=1.0) + description = Column(Text, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class KnowledgeGraphBuildLog(Base): + __tablename__ = "knowledge_graph_build_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) + document_id = Column(String(36), ForeignKey("knowledge_documents.id"), nullable=False) + node_count = Column(Integer, default=0) + edge_count = Column(Integer, default=0) + status = Column(String(20), default="completed") + created_at = Column(DateTime, default=datetime.now) + + +# ============================================================ +# 应用市场 +# ============================================================ + +class MarketApp(Base): + __tablename__ = "market_apps" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(200), nullable=False) + type = Column(String(10), nullable=False) # skill / agent / mcp + source = Column(String(10), nullable=False, default="user") # official / user + publisher_id = Column(String(36), ForeignKey("users.id"), default=None) + publisher_name = Column(String(100), default=None) + description = Column(Text, default=None) + icon = Column(String(500), default=None) + tutorial = Column(Text, default=None) + price = Column(DECIMAL(10, 2), nullable=False, default=0.00) + is_member_free = Column(Boolean, default=False) + join_promotion = Column(Boolean, default=False) + status = Column(String(10), nullable=False, default="pending") # pending / published / removed + rating_avg = Column(DECIMAL(3, 2), default=0.00) + review_count = Column(Integer, default=0) + install_count = Column(Integer, default=0) + skill_content = Column(Text, default=None) + mcp_transport = Column(String(20), default=None) + platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze") + capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video") + agent_endpoint = Column(String(500), default=None) + agent_api_key = Column(Text, default=None, comment="Agent 接入 API Key(3.2 起 Fernet 加密存储)") + tool_policy = Column(JSON, default=None, comment="Agent 工具/MCP 权限沙箱策略(3.1)") + orchestration = Column(JSON, default=None, comment="Agent 多Agent编排图(3.3)") + # Phase 4.1: 市场安检 + 版本化 + 绿标 + version = Column(String(20), default="1.0.0", comment="应用版本号(4.1)") + security_scan = Column(JSON, default=None, comment="安全扫描结果(4.1)") + dependencies = Column(JSON, default=None, comment="依赖声明(4.1)") + verified = Column(Boolean, default=False, comment="绿标/官方安检通过(4.1)") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class MarketAppVersion(Base): + """Phase 4.1: 应用版本快照表,支持灰度回滚。""" + __tablename__ = "market_app_versions" + + id = Column(String(36), primary_key=True, default=gen_uuid) + app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False, index=True) + version = Column(String(20), nullable=False) + type = Column(String(10), nullable=False) + config = Column(JSON, default=None, comment="该版本完整配置快照(4.1)") + security_scan = Column(JSON, default=None, comment="该版本安检结果(4.1)") + dependencies = Column(JSON, default=None, comment="该版本依赖声明(4.1)") + created_by = Column(String(36), default=None) + created_at = Column(DateTime, default=datetime.now) + + +class AppPurchase(Base): + __tablename__ = "app_purchases" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) + price = Column(DECIMAL(10, 2), nullable=False, default=0.00) + order_id = Column(String(36), default=None) + status = Column(String(20), nullable=False, default="purchased") # purchased / installed + installed_at = Column(DateTime, default=None) + purchased_at = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class AppReview(Base): + __tablename__ = "app_reviews" + __table_args__ = (UniqueConstraint("user_id", "app_id"),) + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) + rating = Column(Integer, nullable=False) + content = Column(Text, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class AppInstallation(Base): + __tablename__ = "app_installations" + __table_args__ = (UniqueConstraint("user_id", "app_id"),) + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) + installed_at = Column(DateTime, default=datetime.now) + + +# ============================================================ +# 付费与订单 +# ============================================================ + +class MembershipPlan(Base): + __tablename__ = "membership_plans" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + level = Column(Integer, nullable=False, default=0) + duration_type = Column(String(10), nullable=False) # month / quarter / year + price = Column(DECIMAL(10, 2), nullable=False) + daily_free_tokens = Column(Integer, default=0) + features = Column(JSON, default=None) + free_app_ids = Column(JSON, default=None) + free_model_ids = Column(JSON, default=None) + # 配额字段 + max_agents = Column(Integer, default=5) + max_workflows = Column(Integer, default=3) + max_knowledge_bases = Column(Integer, default=2) + max_storage_mb = Column(Integer, default=1024) + api_calls_per_day = Column(Integer, default=100) + api_calls_per_min = Column(Integer, default=10) + # 权限字段 + can_publish_apps = Column(Boolean, default=False) + can_create_api_keys = Column(Boolean, default=False) + can_use_workflow = Column(Boolean, default=False) + can_use_mcp = Column(Boolean, default=False) + can_use_code_exec = Column(Boolean, default=False) + can_use_privacy_mode = Column(Boolean, default=False) + can_use_dag = Column(Boolean, default=False) + allowed_model_tiers = Column(JSON, default=None) + permissions = Column(JSON, default=None) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class MembershipSubscription(Base): + __tablename__ = "membership_subscriptions" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + plan_id = Column(String(36), ForeignKey("membership_plans.id"), nullable=False) + order_id = Column(String(36), default=None) + start_at = Column(DateTime, nullable=False) + end_at = Column(DateTime, nullable=False) + auto_renew = Column(Boolean, default=False) + status = Column(String(15), nullable=False, default="active") # active / expired / downgraded + created_at = Column(DateTime, default=datetime.now) + + +class Order(Base): + __tablename__ = "orders" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + order_type = Column(String(15), nullable=False) # membership / service / app + item_id = Column(String(36), default=None) + item_name = Column(String(200), default=None) + amount = Column(DECIMAL(10, 2), nullable=False) + coupon_id = Column(String(36), default=None) + discount_amount = Column(DECIMAL(10, 2), default=0.00) + payment_method = Column(String(10), default=None) # wechat / alipay + payment_id = Column(String(200), default=None) + status = Column(String(15), nullable=False, default="pending") # pending / paid / refunded / cancelled + paid_at = Column(DateTime, default=None) + receipt_number = Column(String(100), default=None) + receipt_type = Column(String(20), default=None) + receipt_title = Column(String(200), default=None) + receipt_tax_number = Column(String(50), default=None) + receipt_issued_at = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class Coupon(Base): + __tablename__ = "coupons" + + id = Column(String(36), primary_key=True, default=gen_uuid) + code = Column(String(50), unique=True, nullable=False) + type = Column(String(10), nullable=False) # fixed / percent + value = Column(DECIMAL(10, 2), nullable=False) + min_amount = Column(DECIMAL(10, 2), default=0.00) + valid_from = Column(DateTime, nullable=False) + valid_to = Column(DateTime, nullable=False) + total_count = Column(Integer, default=None) + used_count = Column(Integer, default=0) + status = Column(String(10), nullable=False, default="active") # active / expired / disabled + created_at = Column(DateTime, default=datetime.now) + + +class UserCoupon(Base): + __tablename__ = "user_coupons" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + coupon_id = Column(String(36), ForeignKey("coupons.id"), nullable=False) + status = Column(String(10), nullable=False, default="unused") # unused / used / expired + used_at = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class ValueAddedService(Base): + __tablename__ = "value_added_services" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(200), nullable=False) + description = Column(Text, default=None) + price = Column(DECIMAL(10, 2), nullable=False) + type = Column(String(20), nullable=False) # app_custom / platform_proxy / data_custom / private_project + features = Column(JSON, default=None) # 服务特性 + status = Column(String(10), nullable=False, default="active") # active / inactive / deleted + assigned_agent_id = Column(String(36), default=None) # 应用定制服务绑定的Agent(市场应用ID) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class CustomServiceOrder(Base): + __tablename__ = "custom_service_orders" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + service_id = Column(String(36), default=None) + service_type = Column(String(10), nullable=False) + description = Column(Text, default=None) + status = Column(String(15), nullable=False, default="pending") + price = Column(DECIMAL(10, 2), default=0.00) + order_id = Column(String(36), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class CustomServiceRequest(Base): + """增值服务工单表 - 用于数据定制服务和私有化项目服务""" + __tablename__ = "custom_service_requests" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + service_id = Column(String(36), ForeignKey("value_added_services.id"), nullable=False) + request_type = Column(String(20), nullable=False) # data_custom / private_project + title = Column(String(200), nullable=False) + description = Column(Text, default=None) + requirements = Column(JSON, default=None) # 需求详情JSON + status = Column(String(20), nullable=False, default="pending") # pending / processing / completed / rejected + assigned_agent_id = Column(String(36), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# Token与计费 +# ============================================================ + +class ModelProvider(Base): + __tablename__ = "model_providers" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + protocol = Column(String(30), nullable=False, default="openai_compatible") + api_key = Column(String(255), default=None) + api_base_url = Column(String(500), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class AIModel(Base): + __tablename__ = "models" + + id = Column(String(36), primary_key=True, default=gen_uuid) + provider_id = Column(String(36), ForeignKey("model_providers.id"), nullable=False) + name = Column(String(100), nullable=False) + display_name = Column(String(200), default=None, comment="display name") + api_key = Column(String(255), default=None) + endpoint = Column(String(500), default=None) + capabilities = Column(JSON, default=None) + timeout = Column(Integer, default=60) + status = Column(String(10), nullable=False, default="paid") # free / paid + price_per_million_tokens = Column(DECIMAL(10, 4), nullable=False, default=0.0000) + is_enabled = Column(Boolean, default=True) + is_default = Column(Boolean, default=False) # 全局默认模型 + total_calls = Column(BigInteger, default=0) + total_tokens = Column(BigInteger, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class TokenUsageLog(Base): + __tablename__ = "token_usage_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + model_id = Column(String(36), default=None) + model_name = Column(String(100), default=None) + app_id = Column(String(36), default=None) + app_name = Column(String(200), default=None) + conversation_id = Column(String(36), default=None) + trace_id = Column(String(64), default=None) + prompt_tokens = Column(Integer, default=0) + completion_tokens = Column(Integer, default=0) + total_tokens = Column(Integer, default=0) + cost = Column(DECIMAL(10, 6), default=0.000000) + created_at = Column(DateTime, default=datetime.now) + + +class TokenBalance(Base): + __tablename__ = "token_balances" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), unique=True, nullable=False) + total_consumed = Column(BigInteger, default=0) + balance = Column(BigInteger, default=0) + daily_free_used = Column(BigInteger, default=0) + daily_free_date = Column(Date, default=None) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# 通知与日志 +# ============================================================ + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + type = Column(String(15), nullable=False) + title = Column(String(200), nullable=False) + content = Column(Text, default=None) + is_read = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.now) + + +class OperationLog(Base): + __tablename__ = "operation_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), nullable=False) + username = Column(String(50), default=None) + action = Column(String(100), nullable=False) + target = Column(String(100), default=None) + target_id = Column(String(36), default=None) + detail = Column(Text, default=None) + ip = Column(String(45), default=None) + created_at = Column(DateTime, default=datetime.now) + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + admin_id = Column(String(36), nullable=False) + admin_name = Column(String(50), default=None) + action = Column(String(100), nullable=False) + target_type = Column(String(50), default=None) + target_id = Column(String(36), default=None) + detail = Column(Text, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class RiskRule(Base): + __tablename__ = "risk_rules" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + type = Column(String(15), nullable=False) + threshold = Column(Integer, default=0) + duration = Column(Integer, default=3600) + is_enabled = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# 系统配置 +# ============================================================ + +class SiteSetting(Base): + __tablename__ = "site_settings" + + id = Column(String(36), primary_key=True, default=gen_uuid) + key = Column(String(100), unique=True, nullable=False) + value = Column(Text, default=None) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class EmailConfig(Base): + __tablename__ = "email_configs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + smtp_host = Column(String(200), nullable=False) + smtp_port = Column(Integer, nullable=False, default=587) + username = Column(String(200), nullable=False) + password = Column(String(200), nullable=False) + from_address = Column(String(200), nullable=False) + use_tls = Column(Boolean, default=True) + use_ssl = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class SmsConfig(Base): + __tablename__ = "sms_configs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + provider = Column(String(50), nullable=False) + api_key = Column(String(200), nullable=False) + api_secret = Column(String(200), nullable=False) + sign_name = Column(String(100), default=None) + template_code = Column(String(100), default=None) + # 短信宝 (SMSBao) 字段 + username = Column(String(100), default=None) # 短信宝用户名 u + password = Column(String(200), default=None) # 短信宝密码(明文存储,发送时 MD5 32位) + goods_id = Column(String(100), default=None) # 短信宝专属通道产品 ID g + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class PaymentConfig(Base): + __tablename__ = "payment_configs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + type = Column(String(10), nullable=False) # wechat / alipay + app_id = Column(String(100), nullable=False) + merchant_id = Column(String(100), default=None) # 微信: 商户号 + api_key = Column(String(255), nullable=False) # 微信: APIv2密钥 / 支付宝: 应用私钥 + api_v3_key = Column(String(255), default=None) # 微信: APIv3密钥 + cert_path = Column(String(500), default=None) # 微信: 证书路径 + cert_serial_no = Column(String(200), default=None) # 微信: 证书序列号 + alipay_public_key = Column(Text, default=None) # 支付宝: 支付宝公钥 + app_private_key = Column(Text, default=None) # 支付宝: 应用私钥 + gateway_url = Column(String(500), default=None) # 支付宝: 网关地址 + notify_url = Column(String(500), default=None) # 支付回调通知地址 + return_url = Column(String(500), default=None) # 支付宝: 同步跳转地址 + status = Column(String(10), nullable=False, default="inactive") # active / inactive + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class RegisterSetting(Base): + __tablename__ = "register_settings" + + id = Column(String(36), primary_key=True, default=gen_uuid) + phone_required = Column(Boolean, default=True) + phone_sms_required = Column(Boolean, default=True) + email_required = Column(Boolean, default=True) + email_verify_required = Column(Boolean, default=True) + real_name_required = Column(Boolean, default=False) + aliyun_access_key = Column(String(200), default=None) + aliyun_secret_key = Column(String(200), default=None) + aliyun_endpoint = Column(String(200), default=None) + sms_sign_name = Column(String(100), default=None) + sms_template_code = Column(String(50), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# 广告位 +# ============================================================ + +class AdBanner(Base): + __tablename__ = "ad_banners" + + id = Column(String(36), primary_key=True, default=gen_uuid) + title = Column(String(200), nullable=False) + image_url = Column(String(500), default=None) + link_app_id = Column(String(36), default=None) + link_app_name = Column(String(200), default=None) + sort_order = Column(Integer, default=0) + click_count = Column(Integer, default=0) + status = Column(String(10), nullable=False, default="active") # active / inactive + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# 资产管理 +# ============================================================ + +class AssetCategory(Base): + __tablename__ = "asset_categories" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + type = Column(String(10), nullable=False) + icon = Column(String(200), default=None) + color = Column(String(20), default=None) + is_default = Column(Boolean, default=False) + user_id = Column(String(36), default=None) + created_at = Column(DateTime, default=datetime.now) + + +class Asset(Base): + __tablename__ = "assets" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + name = Column(String(200), nullable=False) + category = Column(String(10), nullable=False) + sub_category = Column(String(50), default=None) + file_type = Column(String(50), default=None) + size = Column(BigInteger, default=0) + description = Column(Text, default=None) + cover_url = Column(String(500), default=None) + url = Column(String(500), default=None) + content = Column(Text, default=None) + novel_meta = Column(JSON, default=None) + status = Column(String(10), nullable=False, default="active") # active / deleted + knowledge_doc_id = Column(String(36), default=None, comment="关联的知识库文档ID(由知识库同步资产时写入)") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class NovelChapter(Base): + __tablename__ = "novel_chapters" + + id = Column(String(36), primary_key=True, default=gen_uuid) + asset_id = Column(String(36), ForeignKey("assets.id"), nullable=False) + title = Column(String(200), nullable=False) + word_count = Column(Integer, default=0) + sort_order = Column(Integer, default=0) + content = Column(Text(length=4294967295), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# 预留扩展 +# ============================================================ + +class Team(Base): + __tablename__ = "teams" + + id = Column(String(36), primary_key=True, default=gen_uuid) + owner_id = Column(String(36), ForeignKey("users.id"), nullable=False) + name = Column(String(100), nullable=False) + description = Column(Text, default=None) + status = Column(String(10), nullable=False, default="active") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class Referral(Base): + __tablename__ = "referrals" + + id = Column(String(36), primary_key=True, default=gen_uuid) + referrer_id = Column(String(36), ForeignKey("users.id"), nullable=False) + referee_id = Column(String(36), ForeignKey("users.id"), nullable=False) + referral_code = Column(String(50), default=None) + status = Column(String(10), nullable=False, default="pending") + commission_rate = Column(DECIMAL(5, 2), default=0.00) + created_at = Column(DateTime, default=datetime.now) + + +class OpenApiClient(Base): + __tablename__ = "open_api_clients" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + name = Column(String(100), nullable=False) + client_id = Column(String(64), unique=True, nullable=False) + client_secret = Column(String(128), nullable=False) + allowed_models = Column(JSON, default=None) + rate_limit = Column(Integer, default=60) + status = Column(String(10), nullable=False, default="active") + created_at = Column(DateTime, default=datetime.now) + total_calls = Column(BigInteger, default=0, comment="总调用次数") + last_used_at = Column(DateTime, default=None, comment="最后使用时间") + + +# ============================================================ +# API调用统计 +# ============================================================ + +class ApiCallStat(Base): + """API调用统计表 - 按端点聚合""" + __tablename__ = "api_call_stats" + + id = Column(String(36), primary_key=True, default=gen_uuid) + endpoint = Column(String(200), nullable=False) + method = Column(String(10), nullable=False) + call_count = Column(BigInteger, default=0) + total_response_time = Column(Float, default=0.0) + error_count = Column(BigInteger, default=0) + last_called_at = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + __table_args__ = ( + UniqueConstraint("endpoint", "method", name="uk_endpoint_method"), + ) + + +class SlowRequestLog(Base): + """慢请求记录表""" + __tablename__ = "slow_request_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + request_id = Column(String(64), default=None) + method = Column(String(10), nullable=False) + endpoint = Column(String(500), nullable=False) + status_code = Column(Integer, default=0) + response_time = Column(Float, default=0.0) + threshold = Column(Float, default=3.0) + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index("idx_srl_created", "created_at"), + ) + + +# ============================================================ +# AI自进化引擎 - 用户进化配置 +# ============================================================ +class UserEvolutionConfig(Base): + __tablename__ = "user_evolution_config" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), unique=True, nullable=False, index=True) + skill_dir_path = Column(String(500), nullable=True, comment="用户Skill仓库根目录") + trajectory_dir_path = Column(String(500), nullable=True, comment="用户对话轨迹目录") + watcher_status = Column(String(20), default="stopped", comment="运行中/已停止/错误") + watcher_error = Column(String(500), nullable=True, comment="watcher异常信息") + pipeline_status = Column(String(20), default="idle", comment="流水线状态: running/idle/error") + pipeline_processed = Column(Integer, default=0, comment="已处理轨迹数") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class EvolutionSkill(Base): + __tablename__ = "evolution_skills" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + skill_name = Column(String(200), nullable=False, comment="技能名称") + description = Column(Text, nullable=True) + tags = Column(JSON, nullable=True, comment="标签列表") + category = Column(String(100), nullable=True, comment="技能分类") + state = Column(String(20), default="baby", comment="baby/main/staging/frozen") + use_count = Column(Integer, default=0, comment="使用次数") + version = Column(Integer, default=1) + skill_md_path = Column(String(500), nullable=True, comment="SKILL.md文件路径") + git_commit_sha = Column(String(40), nullable=True) + last_used_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class EvolutionEvent(Base): + __tablename__ = "evolution_events" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + skill_name = Column(String(200), nullable=False) + event_type = Column(String(50), nullable=False, comment="created/promoted/rejected/frozen/unfrozen/edited/deleted/milestone") + description = Column(Text, nullable=True) + version_before = Column(Integer, nullable=True) + version_after = Column(Integer, nullable=True) + created_at = Column(DateTime, default=datetime.now) + + +class EvolutionTrajectory(Base): + __tablename__ = "evolution_trajectories" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + conversation_id = Column(String(36), nullable=True, comment="关联的对话ID") + file_path = Column(String(500), nullable=True, comment="轨迹文件路径") + status = Column(String(20), default="discovered", comment="discovered/splitting/split_done/indexed/clustering/done/error/filtered") + atom_count = Column(Integer, default=0) + token_used = Column(Integer, default=0) + error_msg = Column(String(500), nullable=True, comment="轨迹处理失败的错误信息") + retry_count = Column(Integer, default=0, comment="失败重试次数") + tasks_extracted = Column(Integer, default=0, comment="拆分的AtomTask数量") + created_at = Column(DateTime, default=datetime.now) + processed_at = Column(DateTime, nullable=True) + + +class SkillTrajectoryLink(Base): + """技能-轨迹关联表:记录技能与对话轨迹的关联关系""" + __tablename__ = "skill_trajectory_links" + id = Column(String(36), primary_key=True, default=gen_uuid) + skill_name = Column(String(200), nullable=False, index=True, comment="关联的技能名称") + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + trajectory_id = Column(String(36), nullable=True, comment="关联的轨迹ID") + atom_id = Column(String(128), nullable=True, comment="原子任务ID(形如 trajectory_id_atom_N,需容纳 UUID+后缀)") + weightscore = Column(Integer, default=0, comment="关联权重分") + created_at = Column(DateTime, default=datetime.now) + + +# ============================================================ +# AI自进化v2 - 新增表 +# ============================================================ + +class SkillVersion(Base): + """技能版本历史表:存储每个版本的SKILL.md完整内容,支持回滚""" + __tablename__ = "skill_versions" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + skill_name = Column(String(200), nullable=False) + version = Column(Integer, nullable=False, default=1) + skill_md_content = Column(Text(4294967295), nullable=True, comment="该版本的SKILL.md完整内容") + git_commit_sha = Column(String(40), nullable=True) + description = Column(Text, nullable=True, comment="版本变更说明") + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index("idx_sv_user_skill", "user_id", "skill_name"), + Index("idx_sv_version", "version"), + ) + + +class EvolutionReport(Base): + """进化报告表:存储周报/月报""" + __tablename__ = "evolution_reports" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + report_type = Column(String(20), nullable=False, comment="weekly / monthly") + period_start = Column(Date, nullable=False) + period_end = Column(Date, nullable=False) + report_data = Column(JSON, nullable=True, comment="报告详细数据(JSON)") + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index("idx_er_user_type", "user_id", "report_type"), + Index("idx_er_period", "period_start", "period_end"), + ) + + +class SkillPublishRecord(Base): + """技能市场发布记录表""" + __tablename__ = "skill_publish_records" + id = Column(String(36), primary_key=True, default=gen_uuid) + skill_id = Column(String(36), nullable=False, comment="关联evolution_skills.id") + skill_name = Column(String(200), nullable=False) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + market_app_id = Column(String(36), nullable=True, comment="关联market_apps.id") + price = Column(DECIMAL(10, 2), default=0.00) + market_description = Column(Text, nullable=True) + status = Column(String(20), nullable=False, default="published", comment="published/unpublished") + published_at = Column(DateTime, default=datetime.now) + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index("idx_spr_skill_id", "skill_id"), + Index("idx_spr_user_id", "user_id"), + Index("idx_spr_status", "status"), + ) + + +class TeamSharedSkill(Base): + """团队共享技能表""" + __tablename__ = "team_shared_skills" + id = Column(String(36), primary_key=True, default=gen_uuid) + team_id = Column(String(36), ForeignKey("teams.id"), nullable=False) + skill_id = Column(String(36), nullable=False, comment="关联evolution_skills.id") + skill_name = Column(String(200), nullable=False) + shared_by = Column(String(36), ForeignKey("users.id"), nullable=False) + shared_by_name = Column(String(100), nullable=True, comment="分享者昵称") + is_public = Column(Boolean, default=True, comment="团队内是否可见") + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + UniqueConstraint("team_id", "skill_id", name="uk_tss_team_skill"), + ) + + +class ExternalKBService(Base): + """外部知识库服务配置""" + __tablename__ = "external_kb_services" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(200), nullable=False) + service_type = Column(String(20), nullable=False) + api_url = Column(String(500), nullable=False) + api_key = Column(String(255), default="") + config = Column(JSON, default=None) + is_enabled = Column(Boolean, default=True) + status = Column(String(20), default="disconnected") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + +class OSSStorageConfig(Base): + __tablename__ = "oss_storage_configs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(200), nullable=False) + provider = Column(String(20), nullable=False) + endpoint = Column(String(500), nullable=False) + access_key = Column(String(255), nullable=False) + secret_key = Column(String(255), nullable=False) + bucket = Column(String(200), nullable=False) + region = Column(String(100), default="") + base_path = Column(String(300), default="") + is_default = Column(Boolean, default=False) + is_enabled = Column(Boolean, default=True) + status = Column(String(20), default="disconnected") + config = Column(JSON, default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + + +class UserStorageQuota(Base): + __tablename__ = "user_storage_quotas" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), nullable=False, unique=True) + quota_bytes = Column(BigInteger, nullable=False, default=1073741824) + used_bytes = Column(BigInteger, nullable=False, default=0) + quota_type = Column(String(20), nullable=False, default="membership") + membership_level = Column(Integer, default=0) + plan_id = Column(String(36), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class AutomatedTask(Base): + __tablename__ = "automated_tasks" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(200), nullable=False) + description = Column(Text, default="") + trigger_type = Column(String(20), nullable=False) + cron_expression = Column(String(100), default=None) + event_condition = Column(JSON, default=None) + action_type = Column(String(30), nullable=False) + action_config = Column(JSON, default=None) + is_enabled = Column(Boolean, default=True) + status = Column(String(20), default="idle") + max_retries = Column(Integer, default=3) + timeout_seconds = Column(Integer, default=300) + last_run_at = Column(DateTime, default=None) + next_run_at = Column(DateTime, default=None) + total_runs = Column(Integer, default=0) + success_runs = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + +class TaskExecutionLog(Base): + __tablename__ = "task_execution_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + task_id = Column(String(36), nullable=False) + task_name = Column(String(200), default="") + status = Column(String(20), default="running") + trigger_type = Column(String(20), default="manual") + started_at = Column(DateTime, default=None) + finished_at = Column(DateTime, default=None) + duration_ms = Column(Integer, default=0) + result_summary = Column(Text, default="") + error_message = Column(Text, default="") + created_at = Column(DateTime, default=datetime.now) + + + +class MCPServer(Base): + __tablename__ = "mcp_servers" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + description = Column(Text, default="") + transport_type = Column(String(20), default="stdio") + command = Column(String(500), default="") + args = Column(Text, default="") + env_vars = Column(Text, default="") + url = Column(String(500), default="") + headers = Column(Text, default="") + is_enabled = Column(Boolean, default=True) + status = Column(String(20), default="disconnected") + last_connected_at = Column(DateTime, default=None) + tools_count = Column(Integer, default=0) + resources_count = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class SmsVerificationCode(Base): + __tablename__ = "sms_verification_codes" + + id = Column(String(36), primary_key=True, default=gen_uuid) + phone = Column(String(20), nullable=False, index=True) + code = Column(String(10), nullable=False) + purpose = Column(String(30), nullable=False) # register / reset_password + expires_at = Column(DateTime, nullable=False) + used = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.now) + + + +class ConversationSummary(Base): + __tablename__ = "conversation_summaries" + + conversation_id = Column(String(64), primary_key=True) + summary_text = Column(Text, nullable=False, default="") + token_count = Column(Integer, default=0) + structured_memory = Column(JSON, default=None) # T2: Semantica 式结构化长期记忆 + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)