Compare commits

..

2 Commits

Author SHA1 Message Date
yingzhao
9edf5c9bd6 Merge pull request #915 from SuanmoSuanyangTechnology/fix/userinfo_zy
fix(web): userinfo
2026-04-16 11:11:54 +08:00
zhaoying
2f0c4300df fix(web): userinfo 2026-04-16 11:10:38 +08:00
187 changed files with 2001 additions and 7166 deletions

View File

@@ -121,8 +121,6 @@ jobs:
AUTHOR: ${{ github.event.pull_request.user.login }} AUTHOR: ${{ github.event.pull_request.user.login }}
PR_TITLE: ${{ github.event.pull_request.title }} PR_TITLE: ${{ github.event.pull_request.title }}
PR_URL: ${{ github.event.pull_request.html_url }} PR_URL: ${{ github.event.pull_request.html_url }}
PR_NUMBER: ${{ github.event.pull_request.number }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
SOURCERY_FOUND: ${{ steps.sourcery.outputs.found }} SOURCERY_FOUND: ${{ steps.sourcery.outputs.found }}
SOURCERY_SUMMARY: ${{ steps.sourcery.outputs.summary }} SOURCERY_SUMMARY: ${{ steps.sourcery.outputs.summary }}
QWEN_SUMMARY: ${{ steps.qwen.outputs.summary }} QWEN_SUMMARY: ${{ steps.qwen.outputs.summary }}
@@ -137,16 +135,11 @@ jobs:
label = "AI变更摘要" label = "AI变更摘要"
summary = os.environ.get("QWEN_SUMMARY", "AI 摘要生成失败") summary = os.environ.get("QWEN_SUMMARY", "AI 摘要生成失败")
pr_number = os.environ.get("PR_NUMBER", "")
short_sha = os.environ.get("MERGE_SHA", "")[:7]
content = ( content = (
"## 🚀 Release 发布通知\n" "## 🚀 Release 发布通知\n"
"> <EFBFBD> **分支**: " + os.environ["BRANCH"] + "\n" "> 📦 **分支**: " + os.environ["BRANCH"] + "\n"
"> 👤 **提交人**: " + os.environ["AUTHOR"] + "\n" "> 👤 **提交人**: " + os.environ["AUTHOR"] + "\n"
"> 📝 **标题**: " + os.environ["PR_TITLE"] + "\n" "> 📝 **标题**: " + os.environ["PR_TITLE"] + "\n\n"
"> 🔢 **PR编号**: #" + pr_number + "\n"
"> 🔖 **Commit**: " + short_sha + "\n\n"
"### 🧠 " + label + "\n" + "### 🧠 " + label + "\n" +
summary + "\n\n" summary + "\n\n"
"---\n" "---\n"

View File

@@ -116,12 +116,9 @@ celery_app.conf.update(
# Document tasks → document_tasks queue (prefork worker) # Document tasks → document_tasks queue (prefork worker)
'app.core.rag.tasks.parse_document': {'queue': 'document_tasks'}, 'app.core.rag.tasks.parse_document': {'queue': 'document_tasks'},
'app.core.rag.tasks.build_graphrag_for_kb': {'queue': 'document_tasks'},
'app.core.rag.tasks.sync_knowledge_for_kb': {'queue': 'document_tasks'}, 'app.core.rag.tasks.sync_knowledge_for_kb': {'queue': 'document_tasks'},
# GraphRAG tasks → graphrag_tasks queue (独立队列,避免阻塞文档解析)
'app.core.rag.tasks.build_graphrag_for_kb': {'queue': 'graphrag_tasks'},
'app.core.rag.tasks.build_graphrag_for_document': {'queue': 'graphrag_tasks'},
# Beat/periodic tasks → periodic_tasks queue (dedicated periodic worker) # Beat/periodic tasks → periodic_tasks queue (dedicated periodic worker)
'app.tasks.workspace_reflection_task': {'queue': 'periodic_tasks'}, 'app.tasks.workspace_reflection_task': {'queue': 'periodic_tasks'},
'app.tasks.regenerate_memory_cache': {'queue': 'periodic_tasks'}, 'app.tasks.regenerate_memory_cache': {'queue': 'periodic_tasks'},

View File

@@ -2,8 +2,6 @@
Celery Worker 入口点 Celery Worker 入口点
用于启动 Celery Worker: celery -A app.celery_worker worker --loglevel=info 用于启动 Celery Worker: celery -A app.celery_worker worker --loglevel=info
""" """
from celery.signals import worker_process_init
from app.celery_app import celery_app from app.celery_app import celery_app
from app.core.logging_config import LoggingConfig, get_logger from app.core.logging_config import LoggingConfig, get_logger
@@ -15,39 +13,4 @@ logger.info("Celery worker logging initialized")
# 导入任务模块以注册任务 # 导入任务模块以注册任务
import app.tasks import app.tasks
@worker_process_init.connect
def _reinit_db_pool(**kwargs):
"""
prefork 子进程启动时重建被 fork 污染的资源。
fork() 后子进程继承了父进程的:
1. SQLAlchemy 连接池 — 多进程共享 TCP socket 导致 DB 连接损坏
2. ThreadPoolExecutor — fork 后线程状态不确定,第二个任务会死锁
"""
# 重建 DB 连接池
from app.db import engine
engine.dispose()
logger.info("DB connection pool disposed for forked worker process")
# 重建模块级 ThreadPoolExecutorfork 后线程池不可用)
try:
from app.core.rag.deepdoc.parser import figure_parser
from concurrent.futures import ThreadPoolExecutor
figure_parser.shared_executor = ThreadPoolExecutor(max_workers=10)
logger.info("figure_parser.shared_executor recreated")
except Exception as e:
logger.warning(f"Failed to recreate figure_parser.shared_executor: {e}")
try:
from app.core.rag.utils import libre_office
from concurrent.futures import ThreadPoolExecutor
import os
max_workers = os.cpu_count() * 2 if os.cpu_count() else 4
libre_office.executor = ThreadPoolExecutor(max_workers=max_workers)
logger.info("libre_office.executor recreated")
except Exception as e:
logger.warning(f"Failed to recreate libre_office.executor: {e}")
__all__ = ['celery_app'] __all__ = ['celery_app']

View File

@@ -1,77 +0,0 @@
"""
社区版默认免费套餐配置
当无法从 SaaS 版获取 premium 模块时,使用此配置作为兜底
可通过环境变量覆盖配额配置格式QUOTA_<QUOTA_NAME>
例如QUOTA_END_USER_QUOTA=100
"""
import os
def _get_quota_from_env():
"""从环境变量获取配额配置"""
quota_keys = [
"workspace_quota",
"skill_quota",
"app_quota",
"knowledge_capacity_quota",
"memory_engine_quota",
"end_user_quota",
"ontology_project_quota",
"model_quota",
"api_ops_rate_limit",
]
quotas = {}
for key in quota_keys:
env_key = f"QUOTA_{key.upper()}"
env_value = os.getenv(env_key)
if env_value is not None:
try:
quotas[key] = float(env_value) if '.' in env_value else int(env_value)
except ValueError:
pass
return quotas
def _build_default_free_plan():
"""构建默认免费套餐配置"""
base = {
"name": "记忆体验版",
"name_en": "Memory Experience",
"category": "saas_personal",
"tier_level": 0,
"version": "1.0",
"status": True,
"price": 0,
"billing_cycle": "permanent_free",
"core_value": "感受永久记忆",
"core_value_en": "Experience Permanent Memory",
"tech_support": "社群交流",
"tech_support_en": "Community Support",
"sla_compliance": "",
"sla_compliance_en": "None",
"page_customization": "",
"page_customization_en": "None",
"theme_color": "#64748B",
"quotas": {
"workspace_quota": 1,
"skill_quota": 5,
"app_quota": 2,
"knowledge_capacity_quota": 0.3,
"memory_engine_quota": 1,
"end_user_quota": 10,
"ontology_project_quota": 3,
"model_quota": 1,
"api_ops_rate_limit": 50,
},
}
env_quotas = _get_quota_from_env()
if env_quotas:
base["quotas"].update(env_quotas)
return base
DEFAULT_FREE_PLAN = _build_default_free_plan()

View File

@@ -47,8 +47,7 @@ from . import (
user_memory_controllers, user_memory_controllers,
workspace_controller, workspace_controller,
ontology_controller, ontology_controller,
skill_controller, skill_controller
tenant_subscription_controller,
) )
# 创建管理端 API 路由器 # 创建管理端 API 路由器
@@ -99,7 +98,5 @@ manager_router.include_router(file_storage_controller.router)
manager_router.include_router(ontology_controller.router) manager_router.include_router(ontology_controller.router)
manager_router.include_router(skill_controller.router) manager_router.include_router(skill_controller.router)
manager_router.include_router(i18n_controller.router) manager_router.include_router(i18n_controller.router)
manager_router.include_router(tenant_subscription_controller.router)
manager_router.include_router(tenant_subscription_controller.public_router)
__all__ = ["manager_router"] __all__ = ["manager_router"]

View File

@@ -167,8 +167,6 @@ def update_api_key(
return success(data=api_key_schema.ApiKey.model_validate(api_key), msg="API Key 更新成功") return success(data=api_key_schema.ApiKey.model_validate(api_key), msg="API Key 更新成功")
except BusinessException:
raise
except Exception as e: except Exception as e:
logger.error(f"未知错误: {str(e)}", extra={ logger.error(f"未知错误: {str(e)}", extra={
"api_key_id": str(api_key_id), "api_key_id": str(api_key_id),

View File

@@ -28,7 +28,6 @@ from app.services.app_statistics_service import AppStatisticsService
from app.services.workflow_import_service import WorkflowImportService from app.services.workflow_import_service import WorkflowImportService
from app.services.workflow_service import WorkflowService, get_workflow_service from app.services.workflow_service import WorkflowService, get_workflow_service
from app.services.app_dsl_service import AppDslService from app.services.app_dsl_service import AppDslService
from app.core.quota_stub import check_app_quota
router = APIRouter(prefix="/apps", tags=["Apps"]) router = APIRouter(prefix="/apps", tags=["Apps"])
logger = get_business_logger() logger = get_business_logger()
@@ -36,7 +35,6 @@ logger = get_business_logger()
@router.post("", summary="创建应用(可选创建 Agent 配置)") @router.post("", summary="创建应用(可选创建 Agent 配置)")
@cur_workspace_access_guard() @cur_workspace_access_guard()
@check_app_quota
def create_app( def create_app(
payload: app_schema.AppCreate, payload: app_schema.AppCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -219,7 +217,6 @@ def delete_app(
@router.post("/{app_id}/copy", summary="复制应用") @router.post("/{app_id}/copy", summary="复制应用")
@cur_workspace_access_guard() @cur_workspace_access_guard()
@check_app_quota
def copy_app( def copy_app(
app_id: uuid.UUID, app_id: uuid.UUID,
new_name: Optional[str] = None, new_name: Optional[str] = None,
@@ -272,19 +269,6 @@ def update_agent_config(
return success(data=app_schema.AgentConfig.model_validate(cfg)) return success(data=app_schema.AgentConfig.model_validate(cfg))
@router.get("/{app_id}/model/parameters/default", summary="获取 Agent 模型参数默认配置")
@cur_workspace_access_guard()
def get_agent_model_parameters(
app_id: uuid.UUID,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
workspace_id = current_user.current_workspace_id
service = AppService(db)
model_parameters = service.get_default_model_parameters(app_id=app_id)
return success(data=model_parameters, msg="获取 Agent 模型参数默认配置")
@router.get("/{app_id}/config", summary="获取 Agent 配置") @router.get("/{app_id}/config", summary="获取 Agent 配置")
@cur_workspace_access_guard() @cur_workspace_access_guard()
def get_agent_config( def get_agent_config(
@@ -1145,7 +1129,6 @@ async def import_workflow_config(
@router.post("/workflow/import/save") @router.post("/workflow/import/save")
@cur_workspace_access_guard() @cur_workspace_access_guard()
@check_app_quota
async def save_workflow_import( async def save_workflow_import(
data: WorkflowImportSave, data: WorkflowImportSave,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -1267,11 +1250,9 @@ async def export_app(
async def import_app( async def import_app(
file: UploadFile = File(...), file: UploadFile = File(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user)
app_id: Optional[str] = Form(None),
): ):
"""从 YAML 文件导入 agent / multi_agent / workflow 应用。 """从 YAML 文件导入 agent / multi_agent / workflow 应用。
传入 app_id 时覆盖该应用的配置(类型必须一致),否则创建新应用。
跨空间/跨租户导入时,模型/工具/知识库会按名称匹配,匹配不到则置空并返回 warnings。 跨空间/跨租户导入时,模型/工具/知识库会按名称匹配,匹配不到则置空并返回 warnings。
""" """
if not file.filename.lower().endswith((".yaml", ".yml")): if not file.filename.lower().endswith((".yaml", ".yml")):
@@ -1282,19 +1263,13 @@ async def import_app(
if not dsl or "app" not in dsl: if not dsl or "app" not in dsl:
return fail(msg="YAML 格式无效,缺少 app 字段", code=BizCode.BAD_REQUEST) return fail(msg="YAML 格式无效,缺少 app 字段", code=BizCode.BAD_REQUEST)
target_app_id = uuid.UUID(app_id) if app_id else None new_app, warnings = AppDslService(db).import_dsl(
# 仅新建应用时检查配额,覆盖已有应用时跳过
if target_app_id is None:
from app.core.quota_manager import _check_quota
_check_quota(db, current_user.tenant_id, "app_quota", "app", workspace_id=current_user.current_workspace_id)
result_app, warnings = AppDslService(db).import_dsl(
dsl=dsl, dsl=dsl,
workspace_id=current_user.current_workspace_id, workspace_id=current_user.current_workspace_id,
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
user_id=current_user.id, user_id=current_user.id,
app_id=target_app_id,
) )
return success( return success(
data={"app": app_schema.App.model_validate(result_app), "warnings": warnings}, data={"app": app_schema.App.model_validate(new_app), "warnings": warnings},
msg="应用导入成功" + (",但部分资源需手动配置" if warnings else "") msg="应用导入成功" + (",但部分资源需手动配置" if warnings else "")
) )

View File

@@ -443,10 +443,10 @@ async def retrieve_chunks(
match retrieve_data.retrieve_type: match retrieve_data.retrieve_type:
case chunk_schema.RetrieveType.PARTICIPLE: case chunk_schema.RetrieveType.PARTICIPLE:
rs = vector_service.search_by_full_text(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.similarity_threshold, file_names_filter=retrieve_data.file_names_filter) rs = vector_service.search_by_full_text(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.similarity_threshold, file_names_filter=retrieve_data.file_names_filter)
return success(data=jsonable_encoder(rs), msg="retrieval successful") return success(data=rs, msg="retrieval successful")
case chunk_schema.RetrieveType.SEMANTIC: case chunk_schema.RetrieveType.SEMANTIC:
rs = vector_service.search_by_vector(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.vector_similarity_weight, file_names_filter=retrieve_data.file_names_filter) rs = vector_service.search_by_vector(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.vector_similarity_weight, file_names_filter=retrieve_data.file_names_filter)
return success(data=jsonable_encoder(rs), msg="retrieval successful") return success(data=rs, msg="retrieval successful")
case _: case _:
rs1 = vector_service.search_by_vector(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.vector_similarity_weight, file_names_filter=retrieve_data.file_names_filter) rs1 = vector_service.search_by_vector(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.vector_similarity_weight, file_names_filter=retrieve_data.file_names_filter)
rs2 = vector_service.search_by_full_text(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.similarity_threshold, file_names_filter=retrieve_data.file_names_filter) rs2 = vector_service.search_by_full_text(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.similarity_threshold, file_names_filter=retrieve_data.file_names_filter)
@@ -457,7 +457,7 @@ async def retrieve_chunks(
if doc.metadata["doc_id"] not in seen_ids: if doc.metadata["doc_id"] not in seen_ids:
seen_ids.add(doc.metadata["doc_id"]) seen_ids.add(doc.metadata["doc_id"])
unique_rs.append(doc) unique_rs.append(doc)
rs = vector_service.rerank(query=retrieve_data.query, docs=unique_rs, top_k=retrieve_data.top_k) if unique_rs else [] rs = vector_service.rerank(query=retrieve_data.query, docs=unique_rs, top_k=retrieve_data.top_k)
if retrieve_data.retrieve_type == chunk_schema.RetrieveType.Graph: if retrieve_data.retrieve_type == chunk_schema.RetrieveType.Graph:
kb_ids = [str(kb_id) for kb_id in private_kb_ids] kb_ids = [str(kb_id) for kb_id in private_kb_ids]
workspace_ids = [str(workspace_id) for workspace_id in private_workspace_ids] workspace_ids = [str(workspace_id) for workspace_id in private_workspace_ids]

View File

@@ -19,7 +19,6 @@ from app.models.user_model import User
from app.schemas import file_schema, document_schema from app.schemas import file_schema, document_schema
from app.schemas.response_schema import ApiResponse from app.schemas.response_schema import ApiResponse
from app.services import file_service, document_service from app.services import file_service, document_service
from app.core.quota_stub import check_knowledge_capacity_quota
# Obtain a dedicated API logger # Obtain a dedicated API logger
@@ -132,7 +131,6 @@ async def create_folder(
@router.post("/file", response_model=ApiResponse) @router.post("/file", response_model=ApiResponse)
@check_knowledge_capacity_quota
async def upload_file( async def upload_file(
kb_id: uuid.UUID, kb_id: uuid.UUID,
parent_id: uuid.UUID, parent_id: uuid.UUID,

View File

@@ -27,7 +27,6 @@ from app.schemas import knowledge_schema
from app.schemas.response_schema import ApiResponse from app.schemas.response_schema import ApiResponse
from app.services import knowledge_service, document_service from app.services import knowledge_service, document_service
from app.services.model_service import ModelConfigService from app.services.model_service import ModelConfigService
from app.core.quota_stub import check_knowledge_capacity_quota
# Obtain a dedicated API logger # Obtain a dedicated API logger
api_logger = get_api_logger() api_logger = get_api_logger()
@@ -180,7 +179,6 @@ async def get_knowledges(
@router.post("/knowledge", response_model=ApiResponse) @router.post("/knowledge", response_model=ApiResponse)
@check_knowledge_capacity_quota
async def create_knowledge( async def create_knowledge(
create_data: knowledge_schema.KnowledgeCreate, create_data: knowledge_schema.KnowledgeCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -34,7 +34,6 @@ from app.services.memory_storage_service import (
search_entity, search_entity,
search_statement, search_statement,
) )
from app.core.quota_stub import check_memory_engine_quota
from fastapi import APIRouter, Depends, Header from fastapi import APIRouter, Depends, Header
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -77,7 +76,6 @@ async def get_storage_info(
@router.post("/create_config", response_model=ApiResponse) # 创建配置文件,其他参数默认 @router.post("/create_config", response_model=ApiResponse) # 创建配置文件,其他参数默认
@check_memory_engine_quota
def create_config( def create_config(
payload: ConfigParamsCreate, payload: ConfigParamsCreate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),

View File

@@ -15,7 +15,6 @@ from app.core.response_utils import success
from app.schemas.response_schema import ApiResponse, PageData from app.schemas.response_schema import ApiResponse, PageData
from app.services.model_service import ModelConfigService, ModelApiKeyService, ModelBaseService from app.services.model_service import ModelConfigService, ModelApiKeyService, ModelBaseService
from app.core.logging_config import get_api_logger from app.core.logging_config import get_api_logger
from app.core.quota_stub import check_model_quota, check_model_activation_quota
# 获取API专用日志器 # 获取API专用日志器
api_logger = get_api_logger() api_logger = get_api_logger()
@@ -304,7 +303,6 @@ async def create_model(
@router.post("/composite", response_model=ApiResponse) @router.post("/composite", response_model=ApiResponse)
@check_model_quota
async def create_composite_model( async def create_composite_model(
model_data: model_schema.CompositeModelCreate, model_data: model_schema.CompositeModelCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -331,7 +329,6 @@ async def create_composite_model(
@router.put("/composite/{model_id}", response_model=ApiResponse) @router.put("/composite/{model_id}", response_model=ApiResponse)
@check_model_activation_quota
async def update_composite_model( async def update_composite_model(
model_id: uuid.UUID, model_id: uuid.UUID,
model_data: model_schema.CompositeModelCreate, model_data: model_schema.CompositeModelCreate,
@@ -373,7 +370,6 @@ def delete_composite_model(
@router.put("/{model_id}", response_model=ApiResponse) @router.put("/{model_id}", response_model=ApiResponse)
@check_model_activation_quota
def update_model( def update_model(
model_id: uuid.UUID, model_id: uuid.UUID,
model_data: model_schema.ModelConfigUpdate, model_data: model_schema.ModelConfigUpdate,

View File

@@ -28,8 +28,6 @@ from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Form, H
from fastapi.responses import StreamingResponse, JSONResponse from fastapi.responses import StreamingResponse, JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.quota_stub import check_ontology_project_quota
from app.core.config import settings from app.core.config import settings
from app.core.error_codes import BizCode from app.core.error_codes import BizCode
from app.core.language_utils import get_language_from_header from app.core.language_utils import get_language_from_header
@@ -165,7 +163,7 @@ def _get_ontology_service(
api_key=api_key_config.api_key, api_key=api_key_config.api_key,
base_url=api_key_config.api_base, base_url=api_key_config.api_base,
is_omni=api_key_config.is_omni, is_omni=api_key_config.is_omni,
capability=api_key_config.capability, support_thinking="thinking" in (api_key_config.capability or []),
max_retries=3, max_retries=3,
timeout=60.0 timeout=60.0
) )
@@ -289,7 +287,6 @@ async def extract_ontology(
# ==================== 本体场景管理接口 ==================== # ==================== 本体场景管理接口 ====================
@router.post("/scene", response_model=ApiResponse) @router.post("/scene", response_model=ApiResponse)
@check_ontology_project_quota
async def create_scene( async def create_scene(
request: SceneCreateRequest, request: SceneCreateRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -10,7 +10,6 @@ from sqlalchemy.orm import Session
from app.core.error_codes import BizCode from app.core.error_codes import BizCode
from app.core.exceptions import BusinessException from app.core.exceptions import BusinessException
from app.core.logging_config import get_business_logger from app.core.logging_config import get_business_logger
from app.core.quota_manager import check_end_user_quota
from app.core.response_utils import success, fail from app.core.response_utils import success, fail
from app.db import get_db, get_db_read from app.db import get_db, get_db_read
from app.dependencies import get_share_user_id, ShareTokenData from app.dependencies import get_share_user_id, ShareTokenData
@@ -219,20 +218,9 @@ def list_conversations(
end_user_repo = EndUserRepository(db) end_user_repo = EndUserRepository(db)
app_service = AppService(db) app_service = AppService(db)
app = app_service._get_app_or_404(share.app_id) app = app_service._get_app_or_404(share.app_id)
workspace_id = app.workspace_id
# 仅在新建终端用户时检查配额
existing_end_user = end_user_repo.get_end_user_by_other_id(workspace_id=workspace_id, other_id=other_id)
if existing_end_user is None:
from app.core.quota_manager import _check_quota
from app.models.workspace_model import Workspace
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if ws:
_check_quota(db, ws.tenant_id, "end_user_quota", "end_user", workspace_id=workspace_id)
new_end_user = end_user_repo.get_or_create_end_user( new_end_user = end_user_repo.get_or_create_end_user(
app_id=share.app_id, app_id=share.app_id,
workspace_id=workspace_id, workspace_id=app.workspace_id,
other_id=other_id other_id=other_id
) )
logger.debug(new_end_user.id) logger.debug(new_end_user.id)
@@ -360,18 +348,6 @@ async def chat(
app_service = AppService(db) app_service = AppService(db)
app = app_service._get_app_or_404(share.app_id) app = app_service._get_app_or_404(share.app_id)
workspace_id = app.workspace_id workspace_id = app.workspace_id
# 仅在新建终端用户时检查配额,已有用户复用不受限制
existing_end_user = end_user_repo.get_end_user_by_other_id(workspace_id=workspace_id, other_id=other_id)
logger.info(f"终端用户配额检查: workspace_id={workspace_id}, other_id={other_id}, existing={existing_end_user is not None}")
if existing_end_user is None:
from app.core.quota_manager import _check_quota
from app.models.workspace_model import Workspace
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if ws:
logger.info(f"新终端用户,执行配额检查: tenant_id={ws.tenant_id}")
_check_quota(db, ws.tenant_id, "end_user_quota", "end_user", workspace_id=workspace_id)
new_end_user = end_user_repo.get_or_create_end_user( new_end_user = end_user_repo.get_or_create_end_user(
app_id=share.app_id, app_id=share.app_id,
workspace_id=workspace_id, workspace_id=workspace_id,

View File

@@ -4,17 +4,7 @@
认证方式: API Key 认证方式: API Key
""" """
from fastapi import APIRouter from fastapi import APIRouter
from . import app_api_controller, rag_api_knowledge_controller, rag_api_document_controller, rag_api_file_controller, rag_api_chunk_controller, memory_api_controller, end_user_api_controller
from . import (
app_api_controller,
end_user_api_controller,
memory_api_controller,
memory_config_api_controller,
rag_api_chunk_controller,
rag_api_document_controller,
rag_api_file_controller,
rag_api_knowledge_controller,
)
# 创建 V1 API 路由器 # 创建 V1 API 路由器
service_router = APIRouter() service_router = APIRouter()
@@ -27,6 +17,5 @@ service_router.include_router(rag_api_file_controller.router)
service_router.include_router(rag_api_chunk_controller.router) service_router.include_router(rag_api_chunk_controller.router)
service_router.include_router(memory_api_controller.router) service_router.include_router(memory_api_controller.router)
service_router.include_router(end_user_api_controller.router) service_router.include_router(end_user_api_controller.router)
service_router.include_router(memory_config_api_controller.router)
__all__ = ["service_router"] __all__ = ["service_router"]

View File

@@ -106,16 +106,6 @@ async def chat(
other_id = payload.user_id other_id = payload.user_id
workspace_id = api_key_auth.workspace_id workspace_id = api_key_auth.workspace_id
end_user_repo = EndUserRepository(db) end_user_repo = EndUserRepository(db)
# 仅在新建终端用户时检查配额,已有用户复用不受限制
existing_end_user = end_user_repo.get_end_user_by_other_id(workspace_id=workspace_id, other_id=other_id)
if existing_end_user is None:
from app.core.quota_manager import _check_quota
from app.models.workspace_model import Workspace
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if ws:
_check_quota(db, ws.tenant_id, "end_user_quota", "end_user", workspace_id=workspace_id)
new_end_user = end_user_repo.get_or_create_end_user( new_end_user = end_user_repo.get_or_create_end_user(
app_id=app.id, app_id=app.id,
workspace_id=workspace_id, workspace_id=workspace_id,

View File

@@ -5,49 +5,28 @@ import uuid
from fastapi import APIRouter, Body, Depends, Request from fastapi import APIRouter, Body, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.controllers import user_memory_controllers
from app.core.api_key_auth import require_api_key from app.core.api_key_auth import require_api_key
from app.core.error_codes import BizCode from app.core.error_codes import BizCode
from app.core.exceptions import BusinessException from app.core.exceptions import BusinessException
from app.core.logging_config import get_business_logger from app.core.logging_config import get_business_logger
from app.core.quota_stub import check_end_user_quota
from app.core.response_utils import success from app.core.response_utils import success
from app.db import get_db from app.db import get_db
from app.repositories.end_user_repository import EndUserRepository from app.repositories.end_user_repository import EndUserRepository
from app.schemas.api_key_schema import ApiKeyAuth from app.schemas.api_key_schema import ApiKeyAuth
from app.schemas.end_user_info_schema import EndUserInfoUpdate
from app.schemas.memory_api_schema import CreateEndUserRequest, CreateEndUserResponse from app.schemas.memory_api_schema import CreateEndUserRequest, CreateEndUserResponse
from app.services import api_key_service
from app.services.memory_config_service import MemoryConfigService from app.services.memory_config_service import MemoryConfigService
router = APIRouter(prefix="/end_user", tags=["V1 - End User API"]) router = APIRouter(prefix="/end_user", tags=["V1 - End User API"])
logger = get_business_logger() logger = get_business_logger()
def _get_current_user(api_key_auth: ApiKeyAuth, db: Session):
"""Build a current_user object from API key auth
Args:
api_key_auth: Validated API key auth info
db: Database session
Returns:
User object with current_workspace_id set
"""
api_key = api_key_service.ApiKeyService.get_api_key(db, api_key_auth.api_key_id, api_key_auth.workspace_id)
current_user = api_key.creator
current_user.current_workspace_id = api_key_auth.workspace_id
return current_user
@router.post("/create") @router.post("/create")
@require_api_key(scopes=["memory"]) @require_api_key(scopes=["memory"])
@check_end_user_quota
async def create_end_user( async def create_end_user(
request: Request, request: Request,
api_key_auth: ApiKeyAuth = None, api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
message: str = Body(None, description="Request body"), message: str = Body(..., description="Request body"),
): ):
""" """
Create or retrieve an end user for the workspace. Create or retrieve an end user for the workspace.
@@ -58,7 +37,6 @@ async def create_end_user(
Optionally accepts a memory_config_id to connect the end user to a specific Optionally accepts a memory_config_id to connect the end user to a specific
memory configuration. If not provided, falls back to the workspace default config. memory configuration. If not provided, falls back to the workspace default config.
Optionally accepts an app_id to bind the end user to a specific app.
""" """
body = await request.json() body = await request.json()
payload = CreateEndUserRequest(**body) payload = CreateEndUserRequest(**body)
@@ -93,26 +71,14 @@ async def create_end_user(
else: else:
logger.warning(f"No default memory config found for workspace: {workspace_id}") logger.warning(f"No default memory config found for workspace: {workspace_id}")
# Resolve app_id: explicit from payload, otherwise None
app_id = None
if payload.app_id:
try:
app_id = uuid.UUID(payload.app_id)
except ValueError:
raise BusinessException(
f"Invalid app_id format: {payload.app_id}",
BizCode.INVALID_PARAMETER
)
end_user_repo = EndUserRepository(db) end_user_repo = EndUserRepository(db)
end_user = end_user_repo.get_or_create_end_user_with_config( end_user = end_user_repo.get_or_create_end_user_with_config(
app_id=app_id, app_id=api_key_auth.resource_id,
workspace_id=workspace_id, workspace_id=workspace_id,
other_id=payload.other_id, other_id=payload.other_id,
memory_config_id=memory_config_id, memory_config_id=memory_config_id,
other_name=payload.other_name,
) )
end_user.other_name = payload.other_name
logger.info(f"End user ready: {end_user.id}") logger.info(f"End user ready: {end_user.id}")
result = { result = {
@@ -124,50 +90,3 @@ async def create_end_user(
} }
return success(data=CreateEndUserResponse(**result).model_dump(), msg="End user created successfully") return success(data=CreateEndUserResponse(**result).model_dump(), msg="End user created successfully")
@router.get("/info")
@require_api_key(scopes=["memory"])
async def get_end_user_info(
request: Request,
end_user_id: str,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
):
"""
Get end user info.
Retrieves the info record (aliases, meta_data, etc.) for the specified end user.
Delegates to the manager-side controller for shared logic.
"""
current_user = _get_current_user(api_key_auth, db)
return await user_memory_controllers.get_end_user_info(
end_user_id=end_user_id,
current_user=current_user,
db=db,
)
@router.post("/info/update")
@require_api_key(scopes=["memory"])
async def update_end_user_info(
request: Request,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
message: str = Body(None, description="Request body"),
):
"""
Update end user info.
Updates the info record (other_name, aliases, meta_data) for the specified end user.
Delegates to the manager-side controller for shared logic.
"""
body = await request.json()
payload = EndUserInfoUpdate(**body)
current_user = _get_current_user(api_key_auth, db)
return await user_memory_controllers.update_end_user_info(
info_update=payload,
current_user=current_user,
db=db,
)

View File

@@ -1,83 +1,53 @@
"""Memory 服务接口 - 基于 API Key 认证""" """Memory 服务接口 - 基于 API Key 认证"""
from fastapi import APIRouter, Body, Depends, Query, Request
from sqlalchemy.orm import Session
from app.core.api_key_auth import require_api_key from app.core.api_key_auth import require_api_key
from app.core.logging_config import get_business_logger from app.core.logging_config import get_business_logger
from app.core.quota_stub import check_end_user_quota
from app.core.response_utils import success from app.core.response_utils import success
from app.db import get_db from app.db import get_db
from app.schemas.api_key_schema import ApiKeyAuth from app.schemas.api_key_schema import ApiKeyAuth
from app.schemas.memory_api_schema import ( from app.schemas.memory_api_schema import (
CreateEndUserRequest,
CreateEndUserResponse,
ListConfigsResponse,
MemoryReadRequest, MemoryReadRequest,
MemoryReadResponse, MemoryReadResponse,
MemoryReadSyncResponse,
MemoryWriteRequest, MemoryWriteRequest,
MemoryWriteResponse, MemoryWriteResponse,
MemoryWriteSyncResponse,
) )
from app.services.memory_api_service import MemoryAPIService from app.services.memory_api_service import MemoryAPIService
from fastapi import APIRouter, Body, Depends, Request
from sqlalchemy.orm import Session
router = APIRouter(prefix="/memory", tags=["V1 - Memory API"]) router = APIRouter(prefix="/memory", tags=["V1 - Memory API"])
logger = get_business_logger() logger = get_business_logger()
def _sanitize_task_result(result: dict) -> dict:
"""Make Celery task result JSON-serializable.
Converts UUID and other non-serializable values to strings.
Args:
result: Raw task result dict from task_service
Returns:
JSON-safe dict
"""
import uuid as _uuid
from datetime import datetime
def _convert(obj):
if isinstance(obj, dict):
return {k: _convert(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_convert(i) for i in obj]
if isinstance(obj, _uuid.UUID):
return str(obj)
if isinstance(obj, datetime):
return obj.isoformat()
return obj
return _convert(result)
@router.get("") @router.get("")
async def get_memory_info(): async def get_memory_info():
"""获取记忆服务信息(占位)""" """获取记忆服务信息(占位)"""
return success(data={}, msg="Memory API - Coming Soon") return success(data={}, msg="Memory API - Coming Soon")
@router.post("/write") @router.post("/write_api_service")
@require_api_key(scopes=["memory"]) @require_api_key(scopes=["memory"])
async def write_memory( async def write_memory_api_service(
request: Request, request: Request,
api_key_auth: ApiKeyAuth = None, api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
message: str = Body(..., description="Message content"), message: str = Body(..., description="Message content"),
): ):
""" """
Submit a memory write task. Write memory to storage.
Validates the end user, then dispatches the write to a Celery background task Stores memory content for the specified end user using the Memory API Service.
with per-user fair locking. Returns a task_id for status polling.
""" """
body = await request.json() body = await request.json()
payload = MemoryWriteRequest(**body) payload = MemoryWriteRequest(**body)
logger.info(f"Memory write request - end_user_id: {payload.end_user_id}, workspace_id: {api_key_auth.workspace_id}") logger.info(f"Memory write request - end_user_id: {payload.end_user_id}, workspace_id: {api_key_auth.workspace_id}")
memory_api_service = MemoryAPIService(db) memory_api_service = MemoryAPIService(db)
result = memory_api_service.write_memory( result = await memory_api_service.write_memory(
workspace_id=api_key_auth.workspace_id, workspace_id=api_key_auth.workspace_id,
end_user_id=payload.end_user_id, end_user_id=payload.end_user_id,
message=payload.message, message=payload.message,
@@ -85,53 +55,31 @@ async def write_memory(
storage_type=payload.storage_type, storage_type=payload.storage_type,
user_rag_memory_id=payload.user_rag_memory_id, user_rag_memory_id=payload.user_rag_memory_id,
) )
logger.info(f"Memory write task submitted: task_id={result['task_id']}, end_user_id: {payload.end_user_id}") logger.info(f"Memory write successful for end_user: {payload.end_user_id}")
return success(data=MemoryWriteResponse(**result).model_dump(), msg="Memory write task submitted") return success(data=MemoryWriteResponse(**result).model_dump(), msg="Memory written successfully")
@router.get("/write/status") @router.post("/read_api_service")
@require_api_key(scopes=["memory"]) @require_api_key(scopes=["memory"])
async def get_write_task_status( async def read_memory_api_service(
request: Request,
task_id: str = Query(..., description="Celery task ID"),
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
):
"""
Check the status of a memory write task.
Returns the current status and result (if completed) of a previously submitted write task.
"""
logger.info(f"Write task status check - task_id: {task_id}")
from app.services.task_service import get_task_memory_write_result
result = get_task_memory_write_result(task_id)
return success(data=_sanitize_task_result(result), msg="Task status retrieved")
@router.post("/read")
@require_api_key(scopes=["memory"])
async def read_memory(
request: Request, request: Request,
api_key_auth: ApiKeyAuth = None, api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
message: str = Body(..., description="Query message"), message: str = Body(..., description="Query message"),
): ):
""" """
Submit a memory read task. Read memory from storage.
Validates the end user, then dispatches the read to a Celery background task. Queries and retrieves memories for the specified end user with context-aware responses.
Returns a task_id for status polling.
""" """
body = await request.json() body = await request.json()
payload = MemoryReadRequest(**body) payload = MemoryReadRequest(**body)
logger.info(f"Memory read request - end_user_id: {payload.end_user_id}") logger.info(f"Memory read request - end_user_id: {payload.end_user_id}")
memory_api_service = MemoryAPIService(db) memory_api_service = MemoryAPIService(db)
result = memory_api_service.read_memory( result = await memory_api_service.read_memory(
workspace_id=api_key_auth.workspace_id, workspace_id=api_key_auth.workspace_id,
end_user_id=payload.end_user_id, end_user_id=payload.end_user_id,
message=payload.message, message=payload.message,
@@ -140,95 +88,58 @@ async def read_memory(
storage_type=payload.storage_type, storage_type=payload.storage_type,
user_rag_memory_id=payload.user_rag_memory_id, user_rag_memory_id=payload.user_rag_memory_id,
) )
logger.info(f"Memory read task submitted: task_id={result['task_id']}, end_user_id: {payload.end_user_id}") logger.info(f"Memory read successful for end_user: {payload.end_user_id}")
return success(data=MemoryReadResponse(**result).model_dump(), msg="Memory read task submitted") return success(data=MemoryReadResponse(**result).model_dump(), msg="Memory read successfully")
@router.get("/read/status") @router.get("/configs")
@require_api_key(scopes=["memory"]) @require_api_key(scopes=["memory"])
async def get_read_task_status( async def list_memory_configs(
request: Request, request: Request,
task_id: str = Query(..., description="Celery task ID"),
api_key_auth: ApiKeyAuth = None, api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Check the status of a memory read task. List all memory configs for the workspace.
Returns the current status and result (if completed) of a previously submitted read task. Returns all available memory configurations associated with the authorized workspace.
""" """
logger.info(f"Read task status check - task_id: {task_id}") logger.info(f"List configs request - workspace_id: {api_key_auth.workspace_id}")
from app.services.task_service import get_task_memory_read_result
result = get_task_memory_read_result(task_id)
return success(data=_sanitize_task_result(result), msg="Task status retrieved")
@router.post("/write/sync")
@require_api_key(scopes=["memory"])
@check_end_user_quota
async def write_memory_sync(
request: Request,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
message: str = Body(..., description="Message content"),
):
"""
Write memory synchronously.
Blocks until the write completes and returns the result directly.
For async processing with task polling, use /write instead.
"""
body = await request.json()
payload = MemoryWriteRequest(**body)
logger.info(f"Memory write (sync) request - end_user_id: {payload.end_user_id}")
memory_api_service = MemoryAPIService(db) memory_api_service = MemoryAPIService(db)
result = await memory_api_service.write_memory_sync( result = memory_api_service.list_memory_configs(
workspace_id=api_key_auth.workspace_id, workspace_id=api_key_auth.workspace_id,
end_user_id=payload.end_user_id,
message=payload.message,
config_id=payload.config_id,
storage_type=payload.storage_type,
user_rag_memory_id=payload.user_rag_memory_id,
) )
logger.info(f"Memory write (sync) successful for end_user: {payload.end_user_id}") logger.info(f"Listed {result['total']} configs for workspace: {api_key_auth.workspace_id}")
return success(data=MemoryWriteSyncResponse(**result).model_dump(), msg="Memory written successfully") return success(data=ListConfigsResponse(**result).model_dump(), msg="Configs listed successfully")
@router.post("/read/sync") @router.post("/end_users")
@require_api_key(scopes=["memory"]) @require_api_key(scopes=["memory"])
async def read_memory_sync( async def create_end_user(
request: Request, request: Request,
api_key_auth: ApiKeyAuth = None, api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
message: str = Body(..., description="Query message"),
): ):
""" """
Read memory synchronously. Create an end user.
Blocks until the read completes and returns the answer directly. Creates a new end user for the authorized workspace.
For async processing with task polling, use /read instead. If an end user with the same other_id already exists, returns the existing one.
""" """
body = await request.json() body = await request.json()
payload = MemoryReadRequest(**body) payload = CreateEndUserRequest(**body)
logger.info(f"Memory read (sync) request - end_user_id: {payload.end_user_id}") logger.info(f"Create end user request - other_id: {payload.other_id}, workspace_id: {api_key_auth.workspace_id}")
memory_api_service = MemoryAPIService(db) memory_api_service = MemoryAPIService(db)
result = await memory_api_service.read_memory_sync( result = memory_api_service.create_end_user(
workspace_id=api_key_auth.workspace_id, workspace_id=api_key_auth.workspace_id,
end_user_id=payload.end_user_id, other_id=payload.other_id,
message=payload.message,
search_switch=payload.search_switch,
config_id=payload.config_id,
storage_type=payload.storage_type,
user_rag_memory_id=payload.user_rag_memory_id,
) )
logger.info(f"Memory read (sync) successful for end_user: {payload.end_user_id}") logger.info(f"End user ready: {result['id']}")
return success(data=MemoryReadSyncResponse(**result).model_dump(), msg="Memory read successfully") return success(data=CreateEndUserResponse(**result).model_dump(), msg="End user created successfully")

View File

@@ -1,491 +0,0 @@
"""Memory Config 服务接口 - 基于 API Key 认证"""
from typing import Optional
import uuid
from fastapi import APIRouter, Body, Depends, Header, Query, Request
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from app.controllers import memory_storage_controller
from app.controllers import memory_forget_controller
from app.controllers import ontology_controller
from app.controllers import emotion_config_controller
from app.controllers import memory_reflection_controller
from app.schemas.memory_storage_schema import ForgettingConfigUpdateRequest
from app.controllers.emotion_config_controller import EmotionConfigUpdate
from app.schemas.memory_reflection_schemas import Memory_Reflection
from app.core.api_key_auth import require_api_key
from app.core.error_codes import BizCode
from app.core.exceptions import BusinessException
from app.core.logging_config import get_business_logger
from app.core.response_utils import success
from app.db import get_db
from app.repositories.memory_config_repository import MemoryConfigRepository
from app.schemas.api_key_schema import ApiKeyAuth
from app.schemas.memory_api_schema import (
ConfigUpdateExtractedRequest,
ConfigUpdateRequest,
ListConfigsResponse,
ConfigCreateRequest,
ConfigUpdateForgettingRequest,
EmotionConfigUpdateRequest,
ReflectionConfigUpdateRequest,
)
from app.schemas.memory_storage_schema import (
ConfigUpdate,
ConfigUpdateExtracted,
ConfigParamsCreate,
)
from app.services import api_key_service
from app.services.memory_api_service import MemoryAPIService
from app.utils.config_utils import resolve_config_id
router = APIRouter(prefix="/memory_config", tags=["V1 - Memory Config API"])
logger = get_business_logger()
def _get_current_user(api_key_auth: ApiKeyAuth, db: Session):
"""Build a current_user object from API key auth
Args:
api_key_auth: Validated API key auth info
db: Database session
Returns:
User object with current_workspace_id set
"""
api_key = api_key_service.ApiKeyService.get_api_key(db, api_key_auth.api_key_id, api_key_auth.workspace_id)
current_user = api_key.creator
current_user.current_workspace_id = api_key_auth.workspace_id
return current_user
def _verify_config_ownership(config_id:str, workspace_id:uuid.UUID, db:Session):
"""Verify that the config belongs to the workspace.
Args:
config_id: The ID of the config to verify
workspace_id: The workspace ID tocheck against
db: Database session for querying
Raises:
BusinessException: If the config does not exist or does not belong to the workspace
"""
try:
resolved_id = resolve_config_id(config_id, db)
except ValueError as e:
raise BusinessException(
message=f"Invalid config_id: {e}",
code=BizCode.INVALID_PARAMETER,
)
config = MemoryConfigRepository.get_by_id(db, resolved_id)
if not config or config.workspace_id != workspace_id:
raise BusinessException(
message="Config not found or access denied",
code=BizCode.MEMORY_CONFIG_NOT_FOUND,
)
# @router.get("/configs")
# @require_api_key(scopes=["memory"])
# async def list_memory_configs(
# request: Request,
# api_key_auth: ApiKeyAuth = None,
# db: Session = Depends(get_db),
# ):
# """
# List all memory configs for the workspace.
# Returns all available memory configurations associated with the authorized workspace.
# """
# logger.info(f"List configs request - workspace_id: {api_key_auth.workspace_id}")
# memory_api_service = MemoryAPIService(db)
# result = memory_api_service.list_memory_configs(
# workspace_id=api_key_auth.workspace_id,
# )
# logger.info(f"Listed {result['total']} configs for workspace: {api_key_auth.workspace_id}")
# return success(data=ListConfigsResponse(**result).model_dump(), msg="Configs listed successfully")
@router.get("/read_all_config")
@require_api_key(scopes=["memory"])
async def read_all_config(
request:Request,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
):
"""
List all memory configs with full details (enhanced version).
Returns complete config fields for the authorized workspace.
No config_id ownership check needed — results are filtered by workspace.
"""
logger.info(f"V1 get all configs (full) - workspace: {api_key_auth.workspace_id}")
current_user = _get_current_user(api_key_auth, db)
return memory_storage_controller.read_all_config(
current_user=current_user,
db=db,
)
@router.get("/scenes/simple")
@require_api_key(scopes=["memory"])
async def get_ontology_scenes(
request: Request,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
):
"""
Get available ontology scenes for the workspace.
Returns a simple list of scene_id and scene_name for dropdown selection.
Used before creating a memory config to choose which ontology scene to associate.
"""
logger.info(f"V1 get scenes - workspace: {api_key_auth.workspace_id}")
current_user = _get_current_user(api_key_auth, db)
return await ontology_controller.get_scenes_simple(
db=db,
current_user=current_user,
)
@router.get("/read_config_extracted")
@require_api_key(scopes=["memory"])
async def read_config_extracted(
request: Request,
config_id: str = Query(..., description="config_id"),
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
):
"""
Get extraction engine config details for a specific config.
Only configs belonging to the authorized workspace can be queried.
"""
logger.info(f"V1 read extracted config - config_id: {config_id}, workspace: {api_key_auth.workspace_id}")
_verify_config_ownership(config_id, api_key_auth.workspace_id, db)
current_user = _get_current_user(api_key_auth, db)
return memory_storage_controller.read_config_extracted(
config_id = config_id,
current_user = current_user,
db = db,
)
@router.get("/read_config_forgetting")
@require_api_key(scopes=["memory"])
async def read_config_forgetting(
request: Request,
config_id: str = Query(..., description="config_id"),
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
):
"""
Get forgetting settings for a specific memory config.
Only configs belonging to the authorized workspace can be queried.
"""
logger.info(f"V1 read forgetting config - config_id: {config_id}, workspace: {api_key_auth.workspace_id}")
_verify_config_ownership(config_id, api_key_auth.workspace_id, db)
current_user = _get_current_user(api_key_auth, db)
result = await memory_forget_controller.read_forgetting_config(
config_id = config_id,
current_user = current_user,
db = db,
)
return jsonable_encoder(result)
@router.get("/read_config_emotion")
@require_api_key(scopes=["memory"])
async def read_config_emotion(
request: Request,
config_id: str = Query(..., description="config_id"),
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
):
"""
Get emotion engine config details for a specific config.
Only configs belonging to the authorized workspace can be queried.
"""
logger.info(f"V1 read emotion config - config_id: {config_id}, workspace: {api_key_auth.workspace_id}")
_verify_config_ownership(config_id, api_key_auth.workspace_id, db)
current_user = _get_current_user(api_key_auth, db)
return jsonable_encoder(emotion_config_controller.get_emotion_config(
config_id=config_id,
db=db,
current_user=current_user,
))
@router.get("/read_config_reflection")
@require_api_key(scopes=["memory"])
async def read_config_reflection(
request: Request,
config_id: str = Query(..., description="config_id"),
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
):
"""
Get reflection engine config details for a specific config.
Only configs belonging to the authorized workspace can be queried.
"""
logger.info(f"V1 read reflection config - config_id: {config_id}, workspace: {api_key_auth.workspace_id}")
_verify_config_ownership(config_id, api_key_auth.workspace_id, db)
current_user = _get_current_user(api_key_auth, db)
return jsonable_encoder(await memory_reflection_controller.start_reflection_configs(
config_id=config_id,
current_user=current_user,
db=db,
))
@router.post("/create_config")
@require_api_key(scopes=["memory"])
async def create_memory_config(
request: Request,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
message: str = Body(None, description="Request body"),
x_language_type: Optional[str] = Header(None, alias="X-Language-Type"),
):
"""
Create a new memory config for the workspace.
The config will be associated with the workspace of the API Key.
config_name is required, other fields are optional.
"""
body = await request.json()
payload = ConfigCreateRequest(**body)
logger.info(f"V1 create config - workspace: {api_key_auth.workspace_id}, config_name: {payload.config_name}")
# 构造管理端 Schemaworkspace_id 从 API Key 注入
current_user = _get_current_user(api_key_auth, db)
mgmt_payload = ConfigParamsCreate(
config_name=payload.config_name,
config_desc=payload.config_desc or "",
scene_id=payload.scene_id,
llm_id=payload.llm_id,
embedding_id=payload.embedding_id,
rerank_id=payload.rerank_id,
reflection_model_id=payload.reflection_model_id,
emotion_model_id=payload.emotion_model_id,
)
#将返回数据中UUID序列化处理
result =memory_storage_controller.create_config(
payload=mgmt_payload,
current_user=current_user,
db=db,
x_language_type=x_language_type,
)
return jsonable_encoder(result)
@router.put("/update_config")
@require_api_key(scopes=["memory"])
async def update_memory_config(
request: Request,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
message: str = Body(None, description="Request body"),
):
"""
Update memory config basic info (name, description, scene).
Requires API Key with 'memory' scope
Only configs belonging to the authorized workspace can be updated.
"""
body = await request.json()
payload = ConfigUpdateRequest(**body)
logger.info(f"V1 update config - config_id: {payload.config_id}, workspace: {api_key_auth.workspace_id}")
_verify_config_ownership(payload.config_id, api_key_auth.workspace_id, db)
current_user = _get_current_user(api_key_auth, db)
mgmt_payload = ConfigUpdate(
config_id = payload.config_id,
config_name = payload.config_name,
config_desc = payload.config_desc,
scene_id = payload.scene_id,
)
return memory_storage_controller.update_config(
payload = mgmt_payload,
current_user = current_user,
db = db,
)
@router.put("/update_config_extracted")
@require_api_key(scopes=["memory"])
async def update_memory_config_extracted(
request: Request,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
message: str = Body(None, description="Request body"),
):
"""
update memory config extraction engine config (models, thresholds, chunking, pruning, etc.).
Requires API Key with 'memory' scope.
Only configs belonging to the authorized workspace can be updated.
"""
body = await request.json()
payload = ConfigUpdateExtractedRequest(**body)
logger.info(f"V1 update extracted config - config_id: {payload.config_id}, workspace: {api_key_auth.workspace_id}")
#校验权限
_verify_config_ownership(payload.config_id, api_key_auth.workspace_id, db)
current_user = _get_current_user(api_key_auth, db)
update_fields = payload.model_dump(exclude_unset=True)
mgmt_payload = ConfigUpdateExtracted(**update_fields)
return memory_storage_controller.update_config_extracted(
payload = mgmt_payload,
current_user = current_user,
db = db,
)
@router.put("/update_config_forgetting")
@require_api_key(scopes=["memory"])
async def update_memory_config_forgetting(
request: Request,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
message: str = Body(None, description="Request body"),
):
"""
update memory config forgetting settings (forgetting strategy, parameters, etc.).
Requires API Key with 'memory' scope.
Only configs belonging to the authorized workspace can be updated.
"""
body = await request.json()
payload = ConfigUpdateForgettingRequest(**body)
logger.info(f"V1 update forgetting config - config_id: {payload.config_id}, workspace: {api_key_auth.workspace_id}")
#校验权限
_verify_config_ownership(payload.config_id, api_key_auth.workspace_id, db)
current_user = _get_current_user(api_key_auth, db)
update_fields = payload.model_dump(exclude_unset=True)
mgmt_payload = ForgettingConfigUpdateRequest(**update_fields)
#将返回数据中UUID序列化处理
result = await memory_forget_controller.update_forgetting_config(
payload = mgmt_payload,
current_user = current_user,
db = db,
)
return jsonable_encoder(result)
@router.put("/update_config_emotion")
@require_api_key(scopes=["memory"])
async def update_config_emotion(
request: Request,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
message: str = Body(None, description="Request body"),
):
"""
Update emotion engine config (full update).
All fields except emotion_model_id are required.
Only configs belonging to the authorized workspace can be updated.
"""
body = await request.json()
payload = EmotionConfigUpdateRequest(**body)
logger.info(f"V1 update emotion config - config_id: {payload.config_id}, workspace: {api_key_auth.workspace_id}")
_verify_config_ownership(payload.config_id, api_key_auth.workspace_id, db)
current_user = _get_current_user(api_key_auth, db)
update_fields = payload.model_dump(exclude_unset=True)
mgmt_payload = EmotionConfigUpdate(**update_fields)
return jsonable_encoder(emotion_config_controller.update_emotion_config(
config=mgmt_payload,
db=db,
current_user=current_user,
))
@router.put("/update_config_reflection")
@require_api_key(scopes=["memory"])
async def update_config_reflection(
request: Request,
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
message: str = Body(None, description="Request body"),
):
"""
Update reflection engine config (full update).
All fields are required.
Only configs belonging to the authorized workspace can be updated.
"""
body = await request.json()
payload = ReflectionConfigUpdateRequest(**body)
logger.info(f"V1 update reflection config - config_id: {payload.config_id}, workspace: {api_key_auth.workspace_id}")
_verify_config_ownership(payload.config_id, api_key_auth.workspace_id, db)
current_user = _get_current_user(api_key_auth, db)
update_fields = payload.model_dump(exclude_unset=True)
mgmt_payload = Memory_Reflection(**update_fields)
return jsonable_encoder(await memory_reflection_controller.save_reflection_config(
request=mgmt_payload,
current_user=current_user,
db=db,
))
@router.delete("/delete_config")
@require_api_key(scopes=["memory"])
async def delete_memory_config(
config_id: str,
request: Request,
force: bool = Query(False, description="是否强制删除(即使有终端用户正在使用)"),
api_key_auth: ApiKeyAuth = None,
db: Session = Depends(get_db),
):
"""
Delete a memory config.
- Default configs cannot be deleted.
- If end users are connected and force=False, returns a warning.
- If force=True, clears end user references and deletes the config.
Only configs belonging to the authorized workspace can be deleted.
"""
logger.info(f"V1 delete config - config_id: {config_id}, force: {force}, workspace: {api_key_auth.workspace_id}")
_verify_config_ownership(config_id, api_key_auth.workspace_id, db)
current_user = _get_current_user(api_key_auth, db)
return memory_storage_controller.delete_config(
config_id=config_id,
force=force,
current_user=current_user,
db=db,
)

View File

@@ -11,13 +11,11 @@ from app.schemas import skill_schema
from app.schemas.response_schema import PageData, PageMeta from app.schemas.response_schema import PageData, PageMeta
from app.services.skill_service import SkillService from app.services.skill_service import SkillService
from app.core.response_utils import success from app.core.response_utils import success
from app.core.quota_stub import check_skill_quota
router = APIRouter(prefix="/skills", tags=["Skills"]) router = APIRouter(prefix="/skills", tags=["Skills"])
@router.post("", summary="创建技能") @router.post("", summary="创建技能")
@check_skill_quota
def create_skill( def create_skill(
data: skill_schema.SkillCreate, data: skill_schema.SkillCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -1,173 +0,0 @@
"""
租户套餐查询接口(普通用户可访问)
"""
import datetime
from typing import Callable, Optional
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.core.logging_config import get_api_logger
from app.core.response_utils import success, fail
from app.db import get_db
from app.dependencies import get_current_user
from app.i18n.dependencies import get_translator
from app.models.user_model import User
from app.schemas.response_schema import ApiResponse
logger = get_api_logger()
router = APIRouter(prefix="/tenant", tags=["Tenant"])
public_router = APIRouter(tags=["Tenant"])
@router.get("/subscription", response_model=ApiResponse, summary="获取当前用户所属租户的套餐信息")
async def get_my_tenant_subscription(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
t: Callable = Depends(get_translator),
):
"""
获取当前登录用户所属租户的有效套餐订阅信息。
包含套餐名称、版本、配额、到期时间等。
"""
try:
from premium.platform_admin.package_plan_service import TenantSubscriptionService
if not current_user.tenant:
return JSONResponse(status_code=404, content=fail(code=404, msg="用户未关联租户"))
tenant_id = current_user.tenant.id
svc = TenantSubscriptionService(db)
sub = svc.get_subscription(tenant_id)
if not sub:
# 无订阅记录时,兜底返回免费套餐信息
free_plan = svc.plan_repo.get_free_plan()
if not free_plan:
return success(data=None, msg="暂无有效套餐")
return success(data={
"subscription_id": None,
"tenant_id": str(tenant_id),
"package_plan_id": str(free_plan.id),
"package_version": free_plan.version,
"package_plan": {
"id": str(free_plan.id),
"name": free_plan.name,
"name_en": free_plan.name_en,
"version": free_plan.version,
"category": free_plan.category,
"tier_level": free_plan.tier_level,
"price": float(free_plan.price) if free_plan.price is not None else 0.0,
"billing_cycle": free_plan.billing_cycle,
"core_value": free_plan.core_value,
"core_value_en": free_plan.core_value_en,
"tech_support": free_plan.tech_support,
"tech_support_en": free_plan.tech_support_en,
"sla_compliance": free_plan.sla_compliance,
"sla_compliance_en": free_plan.sla_compliance_en,
"page_customization": free_plan.page_customization,
"page_customization_en": free_plan.page_customization_en,
"theme_color": free_plan.theme_color,
},
"started_at": None,
"expired_at": None,
"status": "active",
"quotas": free_plan.quotas or {},
"created_at": int(datetime.datetime.utcnow().timestamp() * 1000),
"updated_at": int(datetime.datetime.utcnow().timestamp() * 1000),
}, msg="免费套餐")
return success(data=svc.build_response(sub))
except ModuleNotFoundError:
# 社区版无 premium 模块,从配置文件读取免费套餐
if not current_user.tenant:
return JSONResponse(status_code=404, content=fail(code=404, msg="用户未关联租户"))
from app.config.default_free_plan import DEFAULT_FREE_PLAN
plan = DEFAULT_FREE_PLAN
response_data = {
"subscription_id": None,
"tenant_id": str(current_user.tenant.id),
"package_plan_id": None,
"package_version": plan["version"],
"package_plan": {
"id": None,
"name": plan["name"],
"name_en": plan.get("name_en"),
"version": plan["version"],
"category": plan["category"],
"tier_level": plan["tier_level"],
"price": float(plan["price"]),
"billing_cycle": plan["billing_cycle"],
"core_value": plan.get("core_value"),
"core_value_en": plan.get("core_value_en"),
"tech_support": plan.get("tech_support"),
"tech_support_en": plan.get("tech_support_en"),
"sla_compliance": plan.get("sla_compliance"),
"sla_compliance_en": plan.get("sla_compliance_en"),
"page_customization": plan.get("page_customization"),
"page_customization_en": plan.get("page_customization_en"),
"theme_color": plan.get("theme_color"),
},
"started_at": None,
"expired_at": None,
"status": "active",
"quotas": plan["quotas"],
"created_at": int(datetime.datetime.utcnow().timestamp() * 1000),
"updated_at": int(datetime.datetime.utcnow().timestamp() * 1000),
}
return success(data=response_data, msg="社区版免费套餐")
except Exception as e:
logger.error(f"获取租户套餐信息失败: {e}", exc_info=True)
return JSONResponse(status_code=500, content=fail(code=500, msg="获取套餐信息失败"))
@public_router.get("/package-plans", response_model=ApiResponse, summary="获取套餐列表(公开)")
async def list_package_plans_public(
category: Optional[str] = None,
status: Optional[bool] = None,
search: Optional[str] = None,
db: Session = Depends(get_db),
):
"""
公开接口,无需鉴权。
SaaS 版从数据库读取套餐列表;社区版降级返回 default_free_plan.py 中的免费套餐。
"""
try:
from premium.platform_admin.package_plan_service import PackagePlanService
from premium.platform_admin.package_plan_schema import PackagePlanResponse
svc = PackagePlanService(db)
result = svc.get_list(page=1, size=9999, category=category, status=status, search=search)
return success(data=[PackagePlanResponse.model_validate(p).model_dump(mode="json") for p in result["items"]])
except ModuleNotFoundError:
from app.config.default_free_plan import DEFAULT_FREE_PLAN
plan = DEFAULT_FREE_PLAN
return success(data=[{
"id": None,
"name": plan["name"],
"name_en": plan.get("name_en"),
"version": plan["version"],
"category": plan["category"],
"tier_level": plan["tier_level"],
"price": float(plan["price"]),
"billing_cycle": plan["billing_cycle"],
"core_value": plan.get("core_value"),
"core_value_en": plan.get("core_value_en"),
"tech_support": plan.get("tech_support"),
"tech_support_en": plan.get("tech_support_en"),
"sla_compliance": plan.get("sla_compliance"),
"sla_compliance_en": plan.get("sla_compliance_en"),
"page_customization": plan.get("page_customization"),
"page_customization_en": plan.get("page_customization_en"),
"theme_color": plan.get("theme_color"),
"status": plan.get("status", True),
"quotas": plan["quotas"],
}])
except Exception as e:
logger.error(f"获取套餐列表失败: {e}", exc_info=True)
return JSONResponse(status_code=500, content=fail(code=500, msg="获取套餐列表失败"))

View File

@@ -114,14 +114,11 @@ def get_current_user_info(
# 设置权限:如果用户来自 SSO Source则使用该 Source 的 permissions否则返回 "all" 表示拥有所有权限 # 设置权限:如果用户来自 SSO Source则使用该 Source 的 permissions否则返回 "all" 表示拥有所有权限
if current_user.external_source: if current_user.external_source:
try: from premium.sso.models import SSOSource
from premium.sso.models import SSOSource source = db.query(SSOSource).filter(SSOSource.source_code == current_user.external_source).first()
source = db.query(SSOSource).filter(SSOSource.source_code == current_user.external_source).first() if source and source.permissions:
if source and source.permissions: result_schema.permissions = source.permissions
result_schema.permissions = source.permissions else:
else:
result_schema.permissions = []
except ModuleNotFoundError:
result_schema.permissions = [] result_schema.permissions = []
else: else:
result_schema.permissions = ["all"] result_schema.permissions = ["all"]

View File

@@ -35,7 +35,6 @@ from app.schemas.workspace_schema import (
WorkspaceUpdate, WorkspaceUpdate,
) )
from app.services import workspace_service from app.services import workspace_service
from app.core.quota_stub import check_workspace_quota
# 获取API专用日志器 # 获取API专用日志器
api_logger = get_api_logger() api_logger = get_api_logger()
@@ -107,7 +106,6 @@ def get_workspaces(
@router.post("", response_model=ApiResponse) @router.post("", response_model=ApiResponse)
@check_workspace_quota
def create_workspace( def create_workspace(
workspace: WorkspaceCreate, workspace: WorkspaceCreate,
language_type: str = Header(default="zh", alias="X-Language-Type"), language_type: str = Header(default="zh", alias="X-Language-Type"),

View File

@@ -12,7 +12,7 @@ import time
from typing import Any, AsyncGenerator, Dict, List, Optional, Sequence from typing import Any, AsyncGenerator, Dict, List, Optional, Sequence
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langchain_core.tools import BaseTool from langchain_core.tools import BaseTool
from langgraph.errors import GraphRecursionError from langgraph.errors import GraphRecursionError
@@ -41,7 +41,6 @@ class LangChainAgent:
max_tool_consecutive_calls: int = 3, # 单个工具最大连续调用次数 max_tool_consecutive_calls: int = 3, # 单个工具最大连续调用次数
deep_thinking: bool = False, # 是否启用深度思考模式 deep_thinking: bool = False, # 是否启用深度思考模式
thinking_budget_tokens: Optional[int] = None, # 深度思考 token 预算 thinking_budget_tokens: Optional[int] = None, # 深度思考 token 预算
json_output: bool = False, # 是否强制 JSON 输出
capability: Optional[List[str]] = None # 模型能力列表,用于校验是否支持深度思考 capability: Optional[List[str]] = None # 模型能力列表,用于校验是否支持深度思考
): ):
"""初始化 LangChain Agent """初始化 LangChain Agent
@@ -65,6 +64,7 @@ class LangChainAgent:
self.streaming = streaming self.streaming = streaming
self.is_omni = is_omni self.is_omni = is_omni
self.max_tool_consecutive_calls = max_tool_consecutive_calls self.max_tool_consecutive_calls = max_tool_consecutive_calls
self.deep_thinking = deep_thinking and ("thinking" in (capability or []))
# 工具调用计数器:记录每个工具的连续调用次数 # 工具调用计数器:记录每个工具的连续调用次数
self.tool_call_counter: Dict[str, int] = {} self.tool_call_counter: Dict[str, int] = {}
@@ -80,17 +80,6 @@ class LangChainAgent:
self.system_prompt = system_prompt or "你是一个专业的AI助手" self.system_prompt = system_prompt or "你是一个专业的AI助手"
# ChatTongyi 要求 messages 含 'json' 字样才能使用 response_format
# 在 system prompt 中注入 JSON 要求
from app.models.models_model import ModelProvider
if json_output and (
(provider.lower() == ModelProvider.DASHSCOPE and not is_omni)
or provider.lower() == ModelProvider.VOLCANO
# 有工具时 response_format 会被移除,所有 provider 都需要 system prompt 注入保证 JSON 输出
or bool(tools)
):
self.system_prompt += "\n请以JSON格式输出。"
logger.debug( logger.debug(
f"Agent 迭代次数配置: max_iterations={self.max_iterations}, " f"Agent 迭代次数配置: max_iterations={self.max_iterations}, "
f"tool_count={len(self.tools)}, " f"tool_count={len(self.tools)}, "
@@ -98,17 +87,23 @@ class LangChainAgent:
f"auto_calculated={max_iterations is None}" f"auto_calculated={max_iterations is None}"
) )
# 创建 RedBearLLMcapability 校验由 RedBearModelConfig 统一处理 # 根据 capability 校验是否真正支持深度思考
actual_deep_thinking = self.deep_thinking
if deep_thinking and not actual_deep_thinking:
logger.warning(
f"模型 {model_name} 不支持深度思考capability 中无 'thinking'),已自动关闭 deep_thinking"
)
# 创建 RedBearLLM支持多提供商
model_config = RedBearModelConfig( model_config = RedBearModelConfig(
model_name=model_name, model_name=model_name,
provider=provider, provider=provider,
api_key=api_key, api_key=api_key,
base_url=api_base, base_url=api_base,
is_omni=is_omni, is_omni=is_omni,
capability=capability, deep_thinking=actual_deep_thinking,
deep_thinking=deep_thinking, thinking_budget_tokens=thinking_budget_tokens if actual_deep_thinking else None,
thinking_budget_tokens=thinking_budget_tokens, support_thinking="thinking" in (capability or []),
json_output=json_output,
extra_params={ extra_params={
"temperature": temperature, "temperature": temperature,
"max_tokens": max_tokens, "max_tokens": max_tokens,
@@ -117,9 +112,6 @@ class LangChainAgent:
) )
self.llm = RedBearLLM(model_config, type=ModelType.CHAT) self.llm = RedBearLLM(model_config, type=ModelType.CHAT)
# 从经过校验的 config 读取实际生效的能力开关
self.deep_thinking = model_config.deep_thinking
self.json_output = model_config.json_output
# 获取底层模型用于真正的流式调用 # 获取底层模型用于真正的流式调用
self._underlying_llm = self.llm._model if hasattr(self.llm, '_model') else self.llm self._underlying_llm = self.llm._model if hasattr(self.llm, '_model') else self.llm
@@ -245,7 +237,9 @@ class LangChainAgent:
Returns: Returns:
List[BaseMessage]: 消息列表 List[BaseMessage]: 消息列表
""" """
messages: list = [] messages:list = [SystemMessage(content=self.system_prompt)]
# 添加系统提示词
# 添加历史消息 # 添加历史消息
if history: if history:

View File

@@ -97,7 +97,7 @@ def require_api_key(
) )
rate_limiter = RateLimiterService() rate_limiter = RateLimiterService()
is_allowed, error_msg, rate_headers = await rate_limiter.check_all_limits(api_key_obj, db=db) is_allowed, error_msg, rate_headers = await rate_limiter.check_all_limits(api_key_obj)
if not is_allowed: if not is_allowed:
logger.warning("API Key 限流触发", extra={ logger.warning("API Key 限流触发", extra={
"api_key_id": str(api_key_obj.id), "api_key_id": str(api_key_obj.id),
@@ -106,12 +106,10 @@ def require_api_key(
"error_msg": error_msg "error_msg": error_msg
}) })
# 根据错误消息判断限流类型 # 根据错误消息判断限流类型
if "Daily" in error_msg: if "QPS" in error_msg:
code = BizCode.API_KEY_DAILY_LIMIT_EXCEEDED
elif "Tenant" in error_msg:
code = BizCode.API_KEY_QPS_LIMIT_EXCEEDED # 租户套餐速率超限,同属 QPS 类
elif "QPS" in error_msg:
code = BizCode.API_KEY_QPS_LIMIT_EXCEEDED code = BizCode.API_KEY_QPS_LIMIT_EXCEEDED
elif "Daily" in error_msg:
code = BizCode.API_KEY_DAILY_LIMIT_EXCEEDED
else: else:
code = BizCode.API_KEY_QUOTA_EXCEEDED code = BizCode.API_KEY_QUOTA_EXCEEDED

View File

@@ -31,9 +31,6 @@ class BizCode(IntEnum):
API_KEY_QPS_LIMIT_EXCEEDED = 3014 API_KEY_QPS_LIMIT_EXCEEDED = 3014
API_KEY_DAILY_LIMIT_EXCEEDED = 3015 API_KEY_DAILY_LIMIT_EXCEEDED = 3015
API_KEY_QUOTA_EXCEEDED = 3016 API_KEY_QUOTA_EXCEEDED = 3016
API_KEY_RATE_LIMIT_EXCEEDED = 3017
QUOTA_EXCEEDED = 3018
RATE_LIMIT_EXCEEDED = 3019
# 资源4xxx # 资源4xxx
NOT_FOUND = 4000 NOT_FOUND = 4000
USER_NOT_FOUND = 4001 USER_NOT_FOUND = 4001
@@ -158,8 +155,7 @@ HTTP_MAPPING = {
BizCode.API_KEY_QPS_LIMIT_EXCEEDED: 429, BizCode.API_KEY_QPS_LIMIT_EXCEEDED: 429,
BizCode.API_KEY_DAILY_LIMIT_EXCEEDED: 429, BizCode.API_KEY_DAILY_LIMIT_EXCEEDED: 429,
BizCode.API_KEY_QUOTA_EXCEEDED: 429, BizCode.API_KEY_QUOTA_EXCEEDED: 429,
BizCode.QUOTA_EXCEEDED: 402,
BizCode.MODEL_CONFIG_INVALID: 400, BizCode.MODEL_CONFIG_INVALID: 400,
BizCode.API_KEY_MISSING: 400, BizCode.API_KEY_MISSING: 400,
BizCode.PROVIDER_NOT_SUPPORTED: 400, BizCode.PROVIDER_NOT_SUPPORTED: 400,
@@ -188,21 +184,4 @@ HTTP_MAPPING = {
BizCode.DB_ERROR: 500, BizCode.DB_ERROR: 500,
BizCode.SERVICE_UNAVAILABLE: 503, BizCode.SERVICE_UNAVAILABLE: 503,
BizCode.RATE_LIMITED: 429, BizCode.RATE_LIMITED: 429,
BizCode.RATE_LIMIT_EXCEEDED: 429,
}
ERROR_CODE_TO_BIZ_CODE = {
"QUOTA_EXCEEDED": BizCode.QUOTA_EXCEEDED,
"RATE_LIMIT_EXCEEDED": BizCode.RATE_LIMIT_EXCEEDED,
"API_KEY_NOT_FOUND": BizCode.API_KEY_NOT_FOUND,
"API_KEY_INVALID": BizCode.API_KEY_INVALID,
"API_KEY_EXPIRED": BizCode.API_KEY_EXPIRED,
"WORKSPACE_NOT_FOUND": BizCode.WORKSPACE_NOT_FOUND,
"WORKSPACE_NO_ACCESS": BizCode.WORKSPACE_NO_ACCESS,
"PERMISSION_DENIED": BizCode.PERMISSION_DENIED,
"TOKEN_EXPIRED": BizCode.TOKEN_EXPIRED,
"TOKEN_INVALID": BizCode.TOKEN_INVALID,
"VALIDATION_FAILED": BizCode.VALIDATION_FAILED,
"INVALID_PARAMETER": BizCode.INVALID_PARAMETER,
"MISSING_PARAMETER": BizCode.MISSING_PARAMETER,
} }

View File

@@ -61,9 +61,9 @@ from app.core.memory.models.triplet_models import (
# User metadata models # User metadata models
from app.core.memory.models.metadata_models import ( from app.core.memory.models.metadata_models import (
UserMetadata, UserMetadata,
UserMetadataBehavioralHints,
UserMetadataProfile, UserMetadataProfile,
MetadataExtractionResponse, MetadataExtractionResponse,
MetadataFieldChange,
) )
# Ontology scenario models (LLM extracted from scenarios) # Ontology scenario models (LLM extracted from scenarios)
@@ -133,9 +133,9 @@ __all__ = [
"Triplet", "Triplet",
"TripletExtractionResponse", "TripletExtractionResponse",
"UserMetadata", "UserMetadata",
"UserMetadataBehavioralHints",
"UserMetadataProfile", "UserMetadataProfile",
"MetadataExtractionResponse", "MetadataExtractionResponse",
"MetadataFieldChange",
# Ontology models # Ontology models
"OntologyClass", "OntologyClass",
"OntologyExtractionResponse", "OntologyExtractionResponse",

View File

@@ -4,7 +4,7 @@ Independent from triplet_models.py - these models are used by the
standalone metadata extraction pipeline (post-dedup async Celery task). standalone metadata extraction pipeline (post-dedup async Celery task).
""" """
from typing import List, Literal, Optional from typing import List
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -13,8 +13,8 @@ class UserMetadataProfile(BaseModel):
"""用户画像信息""" """用户画像信息"""
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
role: List[str] = Field(default_factory=list, description="用户职业或角色") role: str = Field(default="", description="用户职业或角色")
domain: List[str] = Field(default_factory=list, description="用户所在领域") domain: str = Field(default="", description="用户所在领域")
expertise: List[str] = Field( expertise: List[str] = Field(
default_factory=list, description="用户擅长的技能或工具" default_factory=list, description="用户擅长的技能或工具"
) )
@@ -23,37 +23,31 @@ class UserMetadataProfile(BaseModel):
) )
class UserMetadataBehavioralHints(BaseModel):
"""行为偏好"""
model_config = ConfigDict(extra="ignore")
learning_stage: str = Field(default="", description="学习阶段")
preferred_depth: str = Field(default="", description="偏好深度")
tone_preference: str = Field(default="", description="语气偏好")
class UserMetadata(BaseModel): class UserMetadata(BaseModel):
"""用户元数据顶层结构""" """用户元数据顶层结构"""
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
profile: UserMetadataProfile = Field(default_factory=UserMetadataProfile) profile: UserMetadataProfile = Field(default_factory=UserMetadataProfile)
behavioral_hints: UserMetadataBehavioralHints = Field(
default_factory=UserMetadataBehavioralHints
class MetadataFieldChange(BaseModel):
"""单个元数据字段的变更操作"""
model_config = ConfigDict(extra="ignore")
field_path: str = Field(
description="字段路径,用点号分隔,如 'profile.role''profile.expertise'"
)
action: Literal["set", "remove"] = Field(
description="操作类型:'set' 表示新增或修改,'remove' 表示移除"
)
value: Optional[str] = Field(
default=None,
description="字段的新值action='set' 时必填)。标量字段直接填值,列表字段填单个要新增的元素"
) )
knowledge_tags: List[str] = Field(default_factory=list, description="知识标签")
class MetadataExtractionResponse(BaseModel): class MetadataExtractionResponse(BaseModel):
"""元数据提取 LLM 响应结构(增量模式)""" """元数据提取 LLM 响应结构"""
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
metadata_changes: List[MetadataFieldChange] = Field( user_metadata: UserMetadata = Field(default_factory=UserMetadata)
default_factory=list,
description="元数据的增量变更列表,每项描述一个字段的新增、修改或移除操作",
)
aliases_to_add: List[str] = Field( aliases_to_add: List[str] = Field(
default_factory=list, default_factory=list,
description="本次新发现的用户别名(用户自我介绍或他人对用户的称呼)", description="本次新发现的用户别名(用户自我介绍或他人对用户的称呼)",

View File

@@ -118,7 +118,7 @@ class MetadataExtractor:
existing_aliases: Optional[List[str]] = None, existing_aliases: Optional[List[str]] = None,
) -> Optional[tuple]: ) -> Optional[tuple]:
""" """
对筛选后的 statement 列表调用 LLM 提取元数据增量变更和用户别名。 对筛选后的 statement 列表调用 LLM 提取元数据和用户别名。
Args: Args:
statements: 用户发言的 statement 文本列表 statements: 用户发言的 statement 文本列表
@@ -126,8 +126,7 @@ class MetadataExtractor:
existing_aliases: 数据库已有的用户别名列表(可选) existing_aliases: 数据库已有的用户别名列表(可选)
Returns: Returns:
(List[MetadataFieldChange], List[str], List[str]) tuple: (UserMetadata, List[str], List[str]) tuple: (metadata, aliases_to_add, aliases_to_remove) on success, None on failure
(metadata_changes, aliases_to_add, aliases_to_remove) on success, None on failure
""" """
if not statements: if not statements:
return None return None
@@ -161,12 +160,12 @@ class MetadataExtractor:
) )
if response: if response:
changes = response.metadata_changes if response.metadata_changes else [] metadata = response.user_metadata if response.user_metadata else None
to_add = response.aliases_to_add if response.aliases_to_add else [] to_add = response.aliases_to_add if response.aliases_to_add else []
to_remove = ( to_remove = (
response.aliases_to_remove if response.aliases_to_remove else [] response.aliases_to_remove if response.aliases_to_remove else []
) )
return changes, to_add, to_remove return metadata, to_add, to_remove
logger.warning("LLM 返回的响应为空") logger.warning("LLM 返回的响应为空")
return None return None

View File

@@ -4,6 +4,11 @@
本模块提供统一的搜索服务接口,支持关键词搜索、语义搜索和混合搜索。 本模块提供统一的搜索服务接口,支持关键词搜索、语义搜索和混合搜索。
""" """
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.schemas.memory_config_schema import MemoryConfig
from app.core.memory.storage_services.search.hybrid_search import HybridSearchStrategy from app.core.memory.storage_services.search.hybrid_search import HybridSearchStrategy
from app.core.memory.storage_services.search.keyword_search import KeywordSearchStrategy from app.core.memory.storage_services.search.keyword_search import KeywordSearchStrategy
from app.core.memory.storage_services.search.search_strategy import ( from app.core.memory.storage_services.search.search_strategy import (
@@ -24,87 +29,115 @@ __all__ = [
# ============================================================================ # ============================================================================
# 向后兼容的函数式API (DEPRECATED - 未被使用) # 向后兼容的函数式API
# ============================================================================ # ============================================================================
# 所有调用方均直接使用 app.core.memory.src.search.run_hybrid_search # 为了兼容旧代码,提供与 src/search.py 相同的函数式接口
# 保留注释以备参考
# async def run_hybrid_search(
# query_text: str, async def run_hybrid_search(
# search_type: str = "hybrid", query_text: str,
# end_user_id: str | None = None, search_type: str = "hybrid",
# apply_id: str | None = None, end_user_id: str | None = None,
# user_id: str | None = None, apply_id: str | None = None,
# limit: int = 50, user_id: str | None = None,
# include: list[str] | None = None, limit: int = 50,
# alpha: float = 0.6, include: list[str] | None = None,
# use_forgetting_curve: bool = False, alpha: float = 0.6,
# memory_config: "MemoryConfig" = None, use_forgetting_curve: bool = False,
# **kwargs memory_config: "MemoryConfig" = None,
# ) -> dict: **kwargs
# """运行混合搜索向后兼容的函数式API""" ) -> dict:
# from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient """运行混合搜索向后兼容的函数式API
# from app.core.models.base import RedBearModelConfig
# from app.db import get_db_context 这是一个向后兼容的包装函数将旧的函数式API转换为新的基于类的API。
# from app.repositories.neo4j.neo4j_connector import Neo4jConnector
# from app.services.memory_config_service import MemoryConfigService Args:
# query_text: 查询文本
# if not memory_config: search_type: 搜索类型("hybrid", "keyword", "semantic"
# raise ValueError("memory_config is required for search") end_user_id: 组ID过滤
# apply_id: 应用ID过滤
# connector = Neo4jConnector() user_id: 用户ID过滤
# with get_db_context() as db: limit: 每个类别的最大结果数
# config_service = MemoryConfigService(db) include: 要包含的搜索类别列表
# embedder_config_dict = config_service.get_embedder_config(str(memory_config.embedding_model_id)) alpha: BM25分数权重0.0-1.0
# embedder_config = RedBearModelConfig(**embedder_config_dict) use_forgetting_curve: 是否使用遗忘曲线
# embedder_client = OpenAIEmbedderClient(embedder_config) memory_config: MemoryConfig object containing embedding_model_id
# **kwargs: 其他参数
# try:
# if search_type == "keyword": Returns:
# strategy = KeywordSearchStrategy(connector=connector) dict: 搜索结果字典格式与旧API兼容
# elif search_type == "semantic": """
# strategy = SemanticSearchStrategy( from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient
# connector=connector, from app.core.models.base import RedBearModelConfig
# embedder_client=embedder_client from app.db import get_db_context
# ) from app.repositories.neo4j.neo4j_connector import Neo4jConnector
# else: from app.services.memory_config_service import MemoryConfigService
# strategy = HybridSearchStrategy(
# connector=connector, if not memory_config:
# embedder_client=embedder_client, raise ValueError("memory_config is required for search")
# alpha=alpha,
# use_forgetting_curve=use_forgetting_curve # 初始化客户端
# ) connector = Neo4jConnector()
# with get_db_context() as db:
# result = await strategy.search( config_service = MemoryConfigService(db)
# query_text=query_text, embedder_config_dict = config_service.get_embedder_config(str(memory_config.embedding_model_id))
# end_user_id=end_user_id, embedder_config = RedBearModelConfig(**embedder_config_dict)
# limit=limit, embedder_client = OpenAIEmbedderClient(embedder_config)
# include=include,
# alpha=alpha, try:
# use_forgetting_curve=use_forgetting_curve, # 根据搜索类型选择策略
# **kwargs if search_type == "keyword":
# ) strategy = KeywordSearchStrategy(connector=connector)
# elif search_type == "semantic":
# result_dict = result.to_dict() strategy = SemanticSearchStrategy(
# connector=connector,
# output_path = kwargs.get('output_path', 'search_results.json') embedder_client=embedder_client
# if output_path: )
# import json else: # hybrid
# import os strategy = HybridSearchStrategy(
# from datetime import datetime connector=connector,
# embedder_client=embedder_client,
# try: alpha=alpha,
# out_dir = os.path.dirname(output_path) use_forgetting_curve=use_forgetting_curve
# if out_dir: )
# os.makedirs(out_dir, exist_ok=True)
# with open(output_path, "w", encoding="utf-8") as f: # 执行搜索
# json.dump(result_dict, f, ensure_ascii=False, indent=2, default=str) result = await strategy.search(
# print(f"Search results saved to {output_path}") query_text=query_text,
# except Exception as e: end_user_id=end_user_id,
# print(f"Error saving search results: {e}") limit=limit,
# return result_dict include=include,
# alpha=alpha,
# finally: use_forgetting_curve=use_forgetting_curve,
# await connector.close() **kwargs
# )
# __all__.append("run_hybrid_search")
# 转换为旧格式
result_dict = result.to_dict()
# 保存到文件如果指定了output_path
output_path = kwargs.get('output_path', 'search_results.json')
if output_path:
import json
import os
from datetime import datetime
try:
# 确保目录存在
out_dir = os.path.dirname(output_path)
if out_dir:
os.makedirs(out_dir, exist_ok=True)
# 保存结果
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result_dict, f, ensure_ascii=False, indent=2, default=str)
print(f"Search results saved to {output_path}")
except Exception as e:
print(f"Error saving search results: {e}")
return result_dict
finally:
await connector.close()
__all__.append("run_hybrid_search")

View File

@@ -1,5 +1,5 @@
===Task=== ===Task===
Extract user metadata changes from the following conversation statements spoken by the user. Extract user metadata from the following conversation statements spoken by the user.
{% if language == "zh" %} {% if language == "zh" %}
**"三度原则"判断标准:** **"三度原则"判断标准:**
@@ -10,36 +10,28 @@ Extract user metadata changes from the following conversation statements spoken
**提取规则:** **提取规则:**
- **只提取关于"用户本人"的画像信息**,忽略用户提到的第三方人物(如朋友、同事、家人)的信息 - **只提取关于"用户本人"的画像信息**,忽略用户提到的第三方人物(如朋友、同事、家人)的信息
- 仅提取文本中明确提到的信息,不要推测 - 仅提取文本中明确提到的信息,不要推测
- 如果文本中没有可提取的用户画像信息,返回空的 user_metadata 对象
- **输出语言必须与输入文本的语言一致**(输入中文则输出中文值,输入英文则输出英文值) - **输出语言必须与输入文本的语言一致**(输入中文则输出中文值,输入英文则输出英文值)
**增量模式(重要):**
你只需要输出**本次对话引起的变更操作**,不要输出完整的元数据。每个变更是一个对象,包含:
- `field_path`:字段路径,用点号分隔(如 `profile.role`、`profile.expertise`
- `action`:操作类型
* `set`:新增或修改一个字段的值
* `remove`:移除一个字段的值
- `value`:字段的新值(`action="set"` 时必填,`action="remove"` 时填要移除的元素值)
* 所有字段均为列表类型,每个元素一条变更记录
**判断规则:**
- 用户提到新信息 → `action="set"`,填入新值
- 用户明确否定已有信息(如"我不再做老师了"、"我已经不学Python了")→ `action="remove"``value` 填要移除的元素值
- 如果本次对话没有任何可提取的变更,返回空的 `metadata_changes` 数组 `[]`
- **不要为未被提及的字段生成任何变更操作**
{% if existing_metadata %} {% if existing_metadata %}
**已有元数据(仅供参考,用于判断是否需要变更):** **重要:合并已有元数据**
请对比已有数据和用户最新发言,输出差异部分的变更操作。 下方提供了数据库中已有的用户元数据。请结合用户最新发言,输出**合并后的完整元数据**
- 如果用户说的信息和已有数据一致,不需要输出变更 - 如果用户明确否定了已有信息(如"我不再教高中物理了"),在输出中**移除**该信息
- 如果用户否定了已有数据中的某个值,输出 `remove` 操作 - 如果用户提到了新信息,**添加**到对应字段中
- 如果用户提到了新信息,输出 `set` 操作 - 如果已有信息未被用户否定,**保留**在输出中
- 标量字段(如 role、domain如果用户提到了新值用新值替换否则保留已有值
- 最终输出应该是完整的、合并后的元数据,不是增量
{% endif %} {% endif %}
**字段说明:** **字段说明:**
- profile.role用户的职业或角色(列表),如 教师、医生、后端工程师,一个人可以有多个角色 - profile.role用户的职业或角色如 教师、医生、后端工程师
- profile.domain用户所在领域(列表),如 教育、医疗、软件开发,一个人可以涉及多个领域 - profile.domain用户所在领域如 教育、医疗、软件开发
- profile.expertise用户擅长的技能或工具列表),如 Python、心理咨询、高中物理 - profile.expertise用户擅长的技能或工具通用,不限于编程),如 Python、心理咨询、高中物理
- profile.interests用户主动表达兴趣的话题或领域标签(列表) - profile.interests用户主动表达兴趣的话题或领域标签
- behavioral_hints.learning_stage学习阶段初学者/中级/高级)
- behavioral_hints.preferred_depth偏好深度概览/技术细节/深入探讨)
- behavioral_hints.tone_preference语气偏好轻松随意/专业简洁/学术严谨)
- knowledge_tags用户涉及的知识领域标签
**用户别名变更(增量模式):** **用户别名变更(增量模式):**
- **aliases_to_add**:本次新发现的用户别名,包括: - **aliases_to_add**:本次新发现的用户别名,包括:
@@ -51,6 +43,7 @@ Extract user metadata changes from the following conversation statements spoken
- **aliases_to_remove**:用户明确否认的别名,包括: - **aliases_to_remove**:用户明确否认的别名,包括:
* 用户说"我不叫XX了"、"别叫我XX"、"我改名了不叫XX" → 将 XX 放入此数组 * 用户说"我不叫XX了"、"别叫我XX"、"我改名了不叫XX" → 将 XX 放入此数组
* **严格限制**:只将用户原文中**逐字提到**的被否认名字放入,不要推断关联的其他别名 * **严格限制**:只将用户原文中**逐字提到**的被否认名字放入,不要推断关联的其他别名
* 例如:用户说"我不叫陈小刀了" → 只移除"陈小刀",不要移除"陈哥"、"老陈"等未被提及的别名
* 如果没有要移除的别名,返回空数组 `[]` * 如果没有要移除的别名,返回空数组 `[]`
{% if existing_aliases %} {% if existing_aliases %}
- 已有别名:{{ existing_aliases | tojson }}(仅供参考,不需要在输出中重复) - 已有别名:{{ existing_aliases | tojson }}(仅供参考,不需要在输出中重复)
@@ -64,36 +57,28 @@ Extract user metadata changes from the following conversation statements spoken
**Extraction rules:** **Extraction rules:**
- **Only extract profile information about the user themselves**, ignore information about third parties (friends, colleagues, family) mentioned by the user - **Only extract profile information about the user themselves**, ignore information about third parties (friends, colleagues, family) mentioned by the user
- Only extract information explicitly mentioned in the text, do not speculate - Only extract information explicitly mentioned in the text, do not speculate
- If no user profile information can be extracted, return an empty user_metadata object
- **Output language must match the input text language** - **Output language must match the input text language**
**Incremental mode (important):**
You should only output **the change operations caused by this conversation**, not the complete metadata. Each change is an object containing:
- `field_path`: Field path separated by dots (e.g. `profile.role`, `profile.expertise`)
- `action`: Operation type
* `set`: Add or update a field value
* `remove`: Remove a field value
- `value`: The new value for the field (required when `action="set"`, for `action="remove"` fill in the element value to remove)
* All fields are list types, one change record per element
**Decision rules:**
- User mentions new information → `action="set"`, fill in the new value
- User explicitly negates existing info (e.g. "I'm no longer a teacher", "I stopped learning Python") → `action="remove"`, `value` is the element to remove
- If this conversation has no extractable changes, return an empty `metadata_changes` array `[]`
- **Do NOT generate any change operations for fields not mentioned in the conversation**
{% if existing_metadata %} {% if existing_metadata %}
**Existing metadata (for reference only, to determine if changes are needed):** **Important: Merge with existing metadata**
Compare existing data with the user's latest statements, and only output change operations for the differences. Existing user metadata from the database is provided below. Combine with the user's latest statements to output the **complete merged metadata**:
- If the user's statement matches existing data, no change is needed - If the user explicitly negates existing info (e.g. "I no longer teach high school physics"), **remove** it from output
- If the user negates a value in existing data, output a `remove` operation - If the user mentions new info, **add** it to the corresponding field
- If the user mentions new information, output a `set` operation - If existing info is not negated by the user, **keep** it in the output
- Scalar fields (e.g. role, domain): replace with new value if user mentions one; otherwise keep existing
- The final output should be the complete, merged metadata — not an incremental update
{% endif %} {% endif %}
**Field descriptions:** **Field descriptions:**
- profile.role: User's occupation or role (list), e.g. teacher, doctor, software engineer. A person can have multiple roles - profile.role: User's occupation or role, e.g. teacher, doctor, software engineer
- profile.domain: User's domain (list), e.g. education, healthcare, software development. A person can span multiple domains - profile.domain: User's domain, e.g. education, healthcare, software development
- profile.expertise: User's skills or tools (list), e.g. Python, counseling, physics - profile.expertise: User's skills or tools (general, not limited to programming)
- profile.interests: Topics or domain tags the user actively expressed interest in (list) - profile.interests: Topics or domain tags the user actively expressed interest in
- behavioral_hints.learning_stage: Learning stage (beginner/intermediate/advanced)
- behavioral_hints.preferred_depth: Preferred depth (overview/detailed/deep dive)
- behavioral_hints.tone_preference: Tone preference (casual/professional/academic)
- knowledge_tags: Knowledge domain tags related to the user
**User alias changes (incremental mode):** **User alias changes (incremental mode):**
- **aliases_to_add**: Newly discovered user aliases from this conversation, including: - **aliases_to_add**: Newly discovered user aliases from this conversation, including:
@@ -105,6 +90,7 @@ Compare existing data with the user's latest statements, and only output change
- **aliases_to_remove**: Aliases the user explicitly denies, including: - **aliases_to_remove**: Aliases the user explicitly denies, including:
* User says "Don't call me XX anymore", "I'm not called XX", "I changed my name from XX" → put XX in this array * User says "Don't call me XX anymore", "I'm not called XX", "I changed my name from XX" → put XX in this array
* **Strict rule**: Only include the exact name the user **verbatim mentions** as denied. Do NOT infer or remove related aliases * **Strict rule**: Only include the exact name the user **verbatim mentions** as denied. Do NOT infer or remove related aliases
* Example: User says "I'm not called John anymore" → only remove "John", do NOT remove "Johnny", "J" or other related aliases not mentioned
* If no aliases to remove, return empty array `[]` * If no aliases to remove, return empty array `[]`
{% if existing_aliases %} {% if existing_aliases %}
- Existing aliases: {{ existing_aliases | tojson }} (for reference only, do not repeat in output) - Existing aliases: {{ existing_aliases | tojson }} (for reference only, do not repeat in output)
@@ -127,11 +113,20 @@ Compare existing data with the user's latest statements, and only output change
Return a JSON object with the following structure: Return a JSON object with the following structure:
```json ```json
{ {
"metadata_changes": [ "user_metadata": {
{"field_path": "profile.role", "action": "set", "value": "后端工程师"}, "profile": {
{"field_path": "profile.expertise", "action": "set", "value": "Python"}, "role": "",
{"field_path": "profile.expertise", "action": "remove", "value": "Java"} "domain": "",
], "expertise": [],
"interests": []
},
"behavioral_hints": {
"learning_stage": "",
"preferred_depth": "",
"tone_preference": ""
},
"knowledge_tags": []
},
"aliases_to_add": [], "aliases_to_add": [],
"aliases_to_remove": [] "aliases_to_remove": []
} }

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any, Dict, List, Optional, TypeVar from typing import Any, Dict, Optional, TypeVar
from langchain_aws import ChatBedrock from langchain_aws import ChatBedrock
from langchain_community.chat_models import ChatTongyi from langchain_community.chat_models import ChatTongyi
@@ -9,12 +9,12 @@ from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseLLM from langchain_core.language_models import BaseLLM
from langchain_ollama import OllamaLLM from langchain_ollama import OllamaLLM
from langchain_openai import ChatOpenAI, OpenAI from langchain_openai import ChatOpenAI, OpenAI
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field
from app.core.error_codes import BizCode from app.core.error_codes import BizCode
from app.core.exceptions import BusinessException from app.core.exceptions import BusinessException
from app.models.models_model import ModelProvider, ModelType from app.models.models_model import ModelProvider, ModelType
from app.core.models.compatible_chat import CompatibleChatOpenAI from app.core.models.volcano_chat import VolcanoChatOpenAI
T = TypeVar("T") T = TypeVar("T")
@@ -25,11 +25,10 @@ class RedBearModelConfig(BaseModel):
provider: str provider: str
api_key: str api_key: str
base_url: Optional[str] = None base_url: Optional[str] = None
capability: List[str] = Field(default_factory=list) # 模型能力列表,驱动所有能力开关
is_omni: bool = False # 是否为 Omni 模型 is_omni: bool = False # 是否为 Omni 模型
deep_thinking: bool = False # 是否启用深度思考模式 deep_thinking: bool = False # 是否启用深度思考模式
thinking_budget_tokens: Optional[int] = None # 深度思考 token 预算 thinking_budget_tokens: Optional[int] = None # 深度思考 token 预算
json_output: bool = False # 是否强制 JSON 输出 support_thinking: bool = False # 模型是否支持 enable_thinking 参数capability 含 thinking
# 请求超时时间(秒)- 默认120秒以支持复杂的LLM调用可通过环境变量 LLM_TIMEOUT 配置 # 请求超时时间(秒)- 默认120秒以支持复杂的LLM调用可通过环境变量 LLM_TIMEOUT 配置
timeout: float = Field(default_factory=lambda: float(os.getenv("LLM_TIMEOUT", "120.0"))) timeout: float = Field(default_factory=lambda: float(os.getenv("LLM_TIMEOUT", "120.0")))
# 最大重试次数 - 默认2次以避免过长等待可通过环境变量 LLM_MAX_RETRIES 配置 # 最大重试次数 - 默认2次以避免过长等待可通过环境变量 LLM_MAX_RETRIES 配置
@@ -37,23 +36,6 @@ class RedBearModelConfig(BaseModel):
concurrency: int = 5 # 并发限流 concurrency: int = 5 # 并发限流
extra_params: Dict[str, Any] = {} extra_params: Dict[str, Any] = {}
@model_validator(mode="after")
def _resolve_capabilities(self) -> "RedBearModelConfig":
from app.core.logging_config import get_business_logger
logger = get_business_logger()
if self.deep_thinking and "thinking" not in self.capability:
logger.warning(
f"模型 {self.model_name} 不支持深度思考capability 中无 'thinking'),已自动关闭 deep_thinking"
)
self.deep_thinking = False
self.thinking_budget_tokens = None
if self.json_output and "json_output" not in self.capability:
logger.warning(
f"模型 {self.model_name} 不支持 JSON 输出capability 中无 'json_output'),已自动关闭 json_output"
)
self.json_output = False
return self
class RedBearModelFactory: class RedBearModelFactory:
"""模型工厂类""" """模型工厂类"""
@@ -92,19 +74,18 @@ class RedBearModelFactory:
is_streaming = bool(config.extra_params.get("streaming")) is_streaming = bool(config.extra_params.get("streaming"))
if is_streaming: if is_streaming:
params["stream_usage"] = True params["stream_usage"] = True
# 支持 thinking 的模型始终传 enable_thinking,关闭时显式传 False 避免模型默认开启思考 # 只有支持 thinking 的模型传 enable_thinking
if "thinking" in config.capability: if config.support_thinking:
extra_body = params.setdefault("extra_body", {}) model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {})
if config.deep_thinking: if is_streaming:
extra_body["enable_thinking"] = False model_kwargs["enable_thinking"] = config.deep_thinking
if is_streaming: if config.deep_thinking:
extra_body["enable_thinking"] = True model_kwargs["incremental_output"] = True
if config.thinking_budget_tokens: if config.thinking_budget_tokens:
extra_body["thinking_budget"] = config.thinking_budget_tokens model_kwargs["thinking_budget"] = config.thinking_budget_tokens
# JSON 输出模式 else:
if config.json_output: model_kwargs["enable_thinking"] = False
model_kwargs = params.setdefault("model_kwargs", {}) params["model_kwargs"] = model_kwargs
model_kwargs["response_format"] = {"type": "json_object"}
return params return params
if provider in [ModelProvider.OPENAI, ModelProvider.XINFERENCE, ModelProvider.GPUSTACK, ModelProvider.OLLAMA, ModelProvider.VOLCANO]: if provider in [ModelProvider.OPENAI, ModelProvider.XINFERENCE, ModelProvider.GPUSTACK, ModelProvider.OLLAMA, ModelProvider.VOLCANO]:
@@ -127,31 +108,27 @@ class RedBearModelFactory:
**config.extra_params **config.extra_params
} }
# 流式模式下启用 stream_usage 以获取 token 统计 # 流式模式下启用 stream_usage 以获取 token 统计
is_streaming = bool(config.extra_params.get("streaming")) if config.extra_params.get("streaming"):
if is_streaming:
params["stream_usage"] = True params["stream_usage"] = True
# 支持 thinking 的模型始终传 enable_thinking关闭时显式传 False 避免模型默认开启思考 # 深度思考模式
if "thinking" in config.capability: is_streaming = bool(config.extra_params.get("streaming"))
# VOLCANO 深度思考仅流式支持 if config.support_thinking:
if provider == ModelProvider.VOLCANO: if is_streaming and not config.is_omni:
thinking_config: Dict[str, Any] = {"type": "enabled" if config.deep_thinking else "disabled"} if provider == ModelProvider.VOLCANO:
if config.deep_thinking and config.thinking_budget_tokens: # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数
thinking_config["budget_tokens"] = config.thinking_budget_tokens thinking_config: Dict[str, Any] = {
params["extra_body"] = {"thinking": thinking_config} "type": "enabled" if config.deep_thinking else "disabled"
else: }
extra_body = params.setdefault("extra_body", {}) if config.deep_thinking and config.thinking_budget_tokens:
if config.deep_thinking: thinking_config["budget_tokens"] = config.thinking_budget_tokens
extra_body["enable_thinking"] = False params["extra_body"] = {"thinking": thinking_config}
if is_streaming: else:
extra_body["enable_thinking"] = True # 始终显式传递 enable_thinking不支持该参数的模型如 DeepSeek-R1会直接忽略
if config.thinking_budget_tokens: model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {})
extra_body["thinking_budget"] = config.thinking_budget_tokens model_kwargs["enable_thinking"] = config.deep_thinking
# JSON 输出模式 if config.deep_thinking and config.thinking_budget_tokens:
if config.json_output: model_kwargs["thinking_budget"] = config.thinking_budget_tokens
model_kwargs = params.setdefault("model_kwargs", {}) params["model_kwargs"] = model_kwargs
# VOLCANO 模型不支持 response_formatJSON 输出由 system prompt 注入实现
if provider != ModelProvider.VOLCANO:
model_kwargs["response_format"] = {"type": "json_object"}
return params return params
elif provider == ModelProvider.DASHSCOPE: elif provider == ModelProvider.DASHSCOPE:
params = { params = {
@@ -160,20 +137,19 @@ class RedBearModelFactory:
"max_retries": config.max_retries, "max_retries": config.max_retries,
**config.extra_params **config.extra_params
} }
# 支持 thinking 的模型始终传 enable_thinking,关闭时显式传 False 避免模型默认开启思考 # 只有支持 thinking 的模型传 enable_thinking
if "thinking" in config.capability: if config.support_thinking:
is_streaming = bool(config.extra_params.get("streaming")) is_streaming = bool(config.extra_params.get("streaming"))
model_kwargs = params.setdefault("model_kwargs", {}) model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {})
if config.deep_thinking: if is_streaming:
model_kwargs["enable_thinking"] = False model_kwargs["enable_thinking"] = config.deep_thinking
if is_streaming: if config.deep_thinking:
model_kwargs["enable_thinking"] = True
model_kwargs["incremental_output"] = True model_kwargs["incremental_output"] = True
if config.thinking_budget_tokens: if config.thinking_budget_tokens:
model_kwargs["thinking_budget"] = config.thinking_budget_tokens model_kwargs["thinking_budget"] = config.thinking_budget_tokens
if config.json_output: else:
model_kwargs = params.setdefault("model_kwargs", {}) model_kwargs["enable_thinking"] = False
model_kwargs["response_format"] = {"type": "json_object"} params["model_kwargs"] = model_kwargs
return params return params
elif provider == ModelProvider.BEDROCK: elif provider == ModelProvider.BEDROCK:
# Bedrock 使用 AWS 凭证 # Bedrock 使用 AWS 凭证
@@ -220,10 +196,6 @@ class RedBearModelFactory:
params["additional_model_request_fields"] = { params["additional_model_request_fields"] = {
"thinking": {"type": "enabled", "budget_tokens": budget} "thinking": {"type": "enabled", "budget_tokens": budget}
} }
# JSON 输出模式
if config.json_output:
model_kwargs = params.setdefault("model_kwargs", {})
model_kwargs["response_format"] = {"type": "json_object"}
return params return params
else: else:
raise BusinessException(f"不支持的提供商: {provider}", code=BizCode.PROVIDER_NOT_SUPPORTED) raise BusinessException(f"不支持的提供商: {provider}", code=BizCode.PROVIDER_NOT_SUPPORTED)
@@ -252,19 +224,18 @@ def get_provider_llm_class(config: RedBearModelConfig, type: ModelType = ModelTy
"""根据模型提供商获取对应的模型类""" """根据模型提供商获取对应的模型类"""
provider = config.provider.lower() provider = config.provider.lower()
# dashscopeomni模型 和 volcano模型使用 # dashscopeomni 模型使用 OpenAI 兼容模式
if provider == ModelProvider.DASHSCOPE and config.is_omni: if provider == ModelProvider.DASHSCOPE and config.is_omni:
return CompatibleChatOpenAI return ChatOpenAI
if provider == ModelProvider.VOLCANO: if provider == ModelProvider.VOLCANO:
return CompatibleChatOpenAI return VolcanoChatOpenAI
if provider in [ModelProvider.OPENAI, ModelProvider.XINFERENCE, ModelProvider.GPUSTACK]: if provider in [ModelProvider.OPENAI, ModelProvider.XINFERENCE, ModelProvider.GPUSTACK]:
return CompatibleChatOpenAI if type == ModelType.LLM:
# if type == ModelType.LLM: return OpenAI
# return OpenAI elif type == ModelType.CHAT:
# elif type == ModelType.CHAT: return ChatOpenAI
# return CompatibleChatOpenAI else:
# else: raise BusinessException(f"不支持的模型提供商及类型: {provider}-{type}", code=BizCode.PROVIDER_NOT_SUPPORTED)
# raise BusinessException(f"不支持的模型提供商及类型: {provider}-{type}", code=BizCode.PROVIDER_NOT_SUPPORTED)
elif provider == ModelProvider.DASHSCOPE: elif provider == ModelProvider.DASHSCOPE:
return ChatTongyi return ChatTongyi
elif provider == ModelProvider.OLLAMA: elif provider == ModelProvider.OLLAMA:

View File

@@ -6,8 +6,7 @@ models:
description: AI21 Labs大语言模型completion生成模式256000上下文窗口 description: AI21 Labs大语言模型completion生成模式256000上下文窗口
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -21,7 +20,6 @@ models:
is_official: true is_official: true
capability: capability:
- vision - vision
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -40,7 +38,6 @@ models:
capability: capability:
- vision - vision
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -57,8 +54,7 @@ models:
description: Cohere大语言模型支持智能体思考、工具调用、流式工具调用128000上下文窗口对话模式 description: Cohere大语言模型支持智能体思考、工具调用、流式工具调用128000上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -76,7 +72,6 @@ models:
capability: capability:
- vision - vision
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -92,8 +87,7 @@ models:
description: Meta Llama大语言模型支持智能体思考、工具调用128000上下文窗口对话模式 description: Meta Llama大语言模型支持智能体思考、工具调用128000上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -107,8 +101,7 @@ models:
description: Mistral AI大语言模型支持智能体思考、工具调用32000上下文窗口对话模式 description: Mistral AI大语言模型支持智能体思考、工具调用32000上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -122,8 +115,7 @@ models:
description: OpenAI大语言模型支持智能体思考、工具调用、流式工具调用32768上下文窗口对话模式 description: OpenAI大语言模型支持智能体思考、工具调用、流式工具调用32768上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -138,8 +130,7 @@ models:
description: Qwen大语言模型支持智能体思考、工具调用、流式工具调用32768上下文窗口对话模式 description: Qwen大语言模型支持智能体思考、工具调用、流式工具调用32768上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型

View File

@@ -8,7 +8,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -23,7 +22,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -38,7 +36,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -51,8 +48,7 @@ models:
description: DeepSeek-V3.1大语言模型支持智能体思考131072超大上下文窗口对话模式支持丰富生成参数调节 description: DeepSeek-V3.1大语言模型支持智能体思考131072超大上下文窗口对话模式支持丰富生成参数调节
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -65,8 +61,7 @@ models:
description: DeepSeek-V3.2-exp实验版大语言模型支持智能体思考131072超大上下文窗口对话模式支持丰富生成参数调节 description: DeepSeek-V3.2-exp实验版大语言模型支持智能体思考131072超大上下文窗口对话模式支持丰富生成参数调节
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -79,8 +74,7 @@ models:
description: DeepSeek-V3.2大语言模型支持智能体思考131072超大上下文窗口对话模式支持丰富生成参数调节 description: DeepSeek-V3.2大语言模型支持智能体思考131072超大上下文窗口对话模式支持丰富生成参数调节
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -93,8 +87,7 @@ models:
description: DeepSeek-V3大语言模型支持智能体思考64000上下文窗口对话模式支持文本与JSON格式输出 description: DeepSeek-V3大语言模型支持智能体思考64000上下文窗口对话模式支持文本与JSON格式输出
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -107,8 +100,7 @@ models:
description: farui-plus大语言模型支持多工具调用、智能体思考、流式工具调用12288上下文窗口对话模式 description: farui-plus大语言模型支持多工具调用、智能体思考、流式工具调用12288上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -123,8 +115,7 @@ models:
description: GLM-4.7大语言模型支持多工具调用、智能体思考、流式工具调用202752超大上下文窗口对话模式 description: GLM-4.7大语言模型支持多工具调用、智能体思考、流式工具调用202752超大上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -142,7 +133,6 @@ models:
capability: capability:
- vision - vision
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -160,7 +150,6 @@ models:
capability: capability:
- vision - vision
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -191,7 +180,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -222,7 +210,7 @@ models:
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability:
- json_output - thinking
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -388,7 +376,6 @@ models:
capability: capability:
- vision - vision
- video - video
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -461,7 +448,6 @@ models:
capability: capability:
- vision - vision
- video - video
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -480,7 +466,6 @@ models:
capability: capability:
- vision - vision
- video - video
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -496,8 +481,7 @@ models:
description: qwen2.5-0.5b-instruct大语言模型支持多工具调用、智能体思考、流式工具调用32768上下文窗口对话模式未废弃 description: qwen2.5-0.5b-instruct大语言模型支持多工具调用、智能体思考、流式工具调用32768上下文窗口对话模式未废弃
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -514,7 +498,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -530,7 +513,7 @@ models:
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability:
- json_output - thinking
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -547,7 +530,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -564,7 +546,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -580,7 +561,7 @@ models:
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability:
- json_output - thinking
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -597,7 +578,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -614,7 +594,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -631,7 +610,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -648,7 +626,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -664,7 +641,7 @@ models:
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability:
- json_output - thinking
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -679,7 +656,7 @@ models:
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability:
- json_output - thinking
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -695,7 +672,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -711,7 +687,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -727,7 +702,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -745,7 +719,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -763,7 +736,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -780,7 +752,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -797,7 +768,7 @@ models:
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability:
- json_output - thinking
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -814,7 +785,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -833,8 +803,6 @@ models:
- vision - vision
- video - video
- audio - audio
- thinking
- json_output
is_omni: true is_omni: true
tags: tags:
- 大语言模型 - 大语言模型
@@ -854,7 +822,7 @@ models:
capability: capability:
- vision - vision
- video - video
- json_output - thinking
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -876,7 +844,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -897,7 +864,7 @@ models:
capability: capability:
- vision - vision
- video - video
- json_output - thinking
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -919,7 +886,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -941,7 +907,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -963,7 +928,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -983,7 +947,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -1001,7 +964,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -1017,7 +979,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -1033,7 +994,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型

View File

@@ -10,7 +10,6 @@ models:
- vision - vision
- audio - audio
- video - video
- json_output
is_omni: true is_omni: true
tags: tags:
- 大语言模型 - 大语言模型
@@ -28,8 +27,7 @@ models:
description: gpt-3.5-turbo-0125大语言模型支持多工具调用、智能体思考、流式工具调用16385上下文窗口对话模式 description: gpt-3.5-turbo-0125大语言模型支持多工具调用、智能体思考、流式工具调用16385上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -44,8 +42,7 @@ models:
description: gpt-3.5-turbo-1106大语言模型支持多工具调用、智能体思考、流式工具调用16385上下文窗口对话模式 description: gpt-3.5-turbo-1106大语言模型支持多工具调用、智能体思考、流式工具调用16385上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -60,8 +57,7 @@ models:
description: gpt-3.5-turbo-16k大语言模型支持多工具调用、智能体思考、流式工具调用16385上下文窗口对话模式 description: gpt-3.5-turbo-16k大语言模型支持多工具调用、智能体思考、流式工具调用16385上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -88,8 +84,7 @@ models:
description: gpt-3.5-turbo大语言模型支持多工具调用、智能体思考、流式工具调用16385上下文窗口对话模式 description: gpt-3.5-turbo大语言模型支持多工具调用、智能体思考、流式工具调用16385上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -104,8 +99,7 @@ models:
description: gpt-4-0125-preview大语言模型支持多工具调用、智能体思考、流式工具调用128000上下文窗口对话模式 description: gpt-4-0125-preview大语言模型支持多工具调用、智能体思考、流式工具调用128000上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -120,8 +114,7 @@ models:
description: gpt-4-1106-preview大语言模型支持多工具调用、智能体思考、流式工具调用128000上下文窗口对话模式 description: gpt-4-1106-preview大语言模型支持多工具调用、智能体思考、流式工具调用128000上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -138,7 +131,6 @@ models:
is_official: true is_official: true
capability: capability:
- vision - vision
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -154,8 +146,7 @@ models:
description: gpt-4-turbo-preview大语言模型支持多工具调用、智能体思考、流式工具调用128000上下文窗口对话模式 description: gpt-4-turbo-preview大语言模型支持多工具调用、智能体思考、流式工具调用128000上下文窗口对话模式
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -172,7 +163,6 @@ models:
is_official: true is_official: true
capability: capability:
- vision - vision
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -204,7 +194,6 @@ models:
capability: capability:
- vision - vision
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -224,7 +213,6 @@ models:
capability: capability:
- vision - vision
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -243,7 +231,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -261,7 +248,6 @@ models:
is_official: true is_official: true
capability: capability:
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -280,7 +266,6 @@ models:
capability: capability:
- vision - vision
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -299,7 +284,6 @@ models:
capability: capability:
- vision - vision
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -318,7 +302,6 @@ models:
capability: capability:
- vision - vision
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -338,7 +321,6 @@ models:
capability: capability:
- vision - vision
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -358,7 +340,6 @@ models:
capability: capability:
- vision - vision
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型

View File

@@ -11,7 +11,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -27,7 +26,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -43,7 +41,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -59,7 +56,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -76,7 +72,6 @@ models:
capability: capability:
- vision - vision
- video - video
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -92,7 +87,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -108,7 +102,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -124,7 +117,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -140,7 +132,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -157,7 +148,6 @@ models:
- vision - vision
- video - video
- thinking - thinking
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -185,8 +175,7 @@ models:
description: 全新一代主力模型,性能全面升级,在知识、代码、推理等方面表现卓越。最大支持 128k 上下文窗口,输出长度支持最大 12k tokens。 description: 全新一代主力模型,性能全面升级,在知识、代码、推理等方面表现卓越。最大支持 128k 上下文窗口,输出长度支持最大 12k tokens。
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型
@@ -198,8 +187,7 @@ models:
description: 全新一代轻量版模型,极致响应速度,效果与时延均达到全球一流水平。支持 32k 上下文窗口,输出长度支持最大 12k tokens。 description: 全新一代轻量版模型,极致响应速度,效果与时延均达到全球一流水平。支持 32k 上下文窗口,输出长度支持最大 12k tokens。
is_deprecated: false is_deprecated: false
is_official: true is_official: true
capability: capability: []
- json_output
is_omni: false is_omni: false
tags: tags:
- 大语言模型 - 大语言模型

View File

@@ -8,33 +8,12 @@ from __future__ import annotations
from typing import Any, Optional, Union from typing import Any, Optional, Union
from langchain_core.messages import BaseMessage
from langchain_core.outputs import ChatGenerationChunk, ChatResult from langchain_core.outputs import ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
class CompatibleChatOpenAI(ChatOpenAI): class VolcanoChatOpenAI(ChatOpenAI):
"""火山和千问的omni兼容模型支持深度思考内容reasoning_content的流式和非流式透传。 """火山引擎 Chat 模型支持深度思考内容reasoning_content的流式和非流式透传。"""
同时修复 json_output + tools 同时使用时 langchain_openai 强制走 .parse()/.stream()
导致 strict 校验报错的问题有工具时从 payload 中移除 response_format
让父类走普通 .create()/.astream() 路径JSON 输出由 system prompt 指令保证
"""
def _get_request_payload(
self,
input_: list[BaseMessage],
*,
stop: list[str] | None = None,
**kwargs: Any,
) -> dict:
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
# 有工具时 langchain_openai 检测到 response_format 会切换到 .parse()/.stream()
# 接口OpenAI SDK 要求此时所有工具必须 strict=True动态生成的工具不满足。
# 移除 response_format让父类走普通路径JSON 输出由 system prompt 指令保证。
if payload.get("tools") and "response_format" in payload:
payload.pop("response_format")
return payload
def _create_chat_result(self, response: Union[dict, Any], generation_info: Optional[dict] = None) -> ChatResult: def _create_chat_result(self, response: Union[dict, Any], generation_info: Optional[dict] = None) -> ChatResult:
result = super()._create_chat_result(response, generation_info) result = super()._create_chat_result(response, generation_info)

View File

@@ -1,791 +0,0 @@
"""
统一配额管理器 - 社区版和 SaaS 版共用
配额来源策略:
1. 优先从 premium 模块的 tenant_subscriptions 表读取SaaS 版)
2. 降级到 default_free_plan.py 配置文件(社区版兜底)
"""
import asyncio
from functools import wraps
from typing import Optional, Callable, Dict, Any
from uuid import UUID
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.logging_config import get_auth_logger
from app.i18n.exceptions import QuotaExceededError, InternalServerError
logger = get_auth_logger()
# Redis key 格式常量,与 RateLimiterService.check_qps 保持一致per api_key 独立计数)
API_KEY_QPS_REDIS_KEY = "rate_limit:qps:{api_key_id}"
def _get_user_from_kwargs(kwargs: dict):
"""从 kwargs 中获取 user 对象"""
for key in ["user", "current_user"]:
if key in kwargs:
return kwargs[key]
return None
def _get_workspace_id_from_kwargs(kwargs: dict):
"""从 kwargs 中获取 workspace_id"""
# 优先从 kwargs['workspace_id'] 获取
workspace_id = kwargs.get("workspace_id")
if workspace_id:
return workspace_id
# 从 api_key_auth.workspace_id 获取API Key 认证场景)
api_key_auth = kwargs.get("api_key_auth")
if api_key_auth and hasattr(api_key_auth, 'workspace_id'):
return api_key_auth.workspace_id
# 从 user.current_workspace_id 获取
user = _get_user_from_kwargs(kwargs)
if user:
ws_id = getattr(user, 'current_workspace_id', None)
if ws_id:
return ws_id
logger.warning(f"无法获取 workspace_id, kwargs keys: {list(kwargs.keys())}")
return None
def _get_tenant_id_from_kwargs(db: Session, kwargs: dict):
"""从 kwargs 中获取 tenant_id"""
user = _get_user_from_kwargs(kwargs)
if user and hasattr(user, 'tenant_id'):
return user.tenant_id
workspace_id = kwargs.get("workspace_id")
if workspace_id:
from app.models.workspace_model import Workspace
workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if workspace:
return workspace.tenant_id
api_key_auth = kwargs.get("api_key_auth")
if api_key_auth and hasattr(api_key_auth, 'workspace_id'):
from app.models.workspace_model import Workspace
workspace = db.query(Workspace).filter(Workspace.id == api_key_auth.workspace_id).first()
if workspace:
return workspace.tenant_id
data = kwargs.get("data") or kwargs.get("body") or kwargs.get("payload")
if data and hasattr(data, "workspace_id"):
from app.models.workspace_model import Workspace
workspace = db.query(Workspace).filter(Workspace.id == data.workspace_id).first()
if workspace:
return workspace.tenant_id
share_data = kwargs.get("share_data")
if share_data and hasattr(share_data, 'share_token'):
from app.models.workspace_model import Workspace
from app.models.app_model import App
share_token = share_data.share_token
from app.models.release_share_model import ReleaseShare
share_record = db.query(ReleaseShare).filter(ReleaseShare.share_token == share_token).first()
if share_record:
app = db.query(App).filter(App.id == share_record.app_id, App.is_active.is_(True)).first()
if app:
workspace = db.query(Workspace).filter(Workspace.id == app.workspace_id).first()
if workspace:
return workspace.tenant_id
return None
def _get_quota_config(db: Session, tenant_id: UUID) -> Optional[Dict[str, Any]]:
"""
获取租户的配额配置
优先级:
1. premium 模块的 tenant_subscriptionsSaaS 版)
2. default_free_plan.py 配置文件(社区版兜底)
"""
# 尝试从 premium 模块获取SaaS 版)
try:
from premium.platform_admin.package_plan_service import TenantSubscriptionService
# premium 模块存在,运行时错误不应被静默降级,直接抛出
quota_config = TenantSubscriptionService(db).get_effective_quota(tenant_id)
if quota_config:
logger.debug(f"从 premium 模块获取租户 {tenant_id} 配额配置")
return quota_config
# premium 存在但该租户无订阅记录,降级到免费套餐
logger.debug(f"租户 {tenant_id} 无 premium 订阅,降级到免费套餐")
except (ModuleNotFoundError, ImportError):
# 社区版premium 包不存在,正常降级
logger.debug("premium 模块不存在,使用社区版免费套餐配额")
# 降级到社区版配置文件
try:
from app.config.default_free_plan import DEFAULT_FREE_PLAN
logger.debug(f"使用社区版免费套餐配额: tenant={tenant_id}")
return DEFAULT_FREE_PLAN.get("quotas")
except Exception as e:
logger.error(f"无法从配置文件获取配额: {e}")
return None
def get_api_ops_rate_limit(db: Session, tenant_id: UUID) -> Optional[int]:
"""
获取租户套餐的 API 操作速率限制QPS 上限)
该函数兼容社区版和 SaaS 版:
- SaaS 版:从 premium 模块的套餐配额读取
- 社区版:从 default_free_plan.py 配置文件读取
Returns:
int: api_ops_rate_limit 值,如果未配置则返回 None
"""
quota_config = _get_quota_config(db, tenant_id)
if quota_config:
return quota_config.get("api_ops_rate_limit")
return None
class QuotaUsageRepository:
"""配额使用量数据访问层"""
def __init__(self, db: Session):
self.db = db
def count_workspaces(self, tenant_id: UUID) -> int:
from app.models.workspace_model import Workspace
return self.db.query(Workspace).filter(
Workspace.tenant_id == tenant_id,
Workspace.is_active.is_(True)
).count()
def count_apps(self, tenant_id: UUID, workspace_id: Optional[UUID] = None) -> int:
from app.models.app_model import App
from app.models.workspace_model import Workspace
query = self.db.query(App).join(
Workspace, App.workspace_id == Workspace.id
).filter(
App.is_active.is_(True)
)
if workspace_id:
query = query.filter(App.workspace_id == workspace_id)
else:
query = query.filter(Workspace.tenant_id == tenant_id)
return query.count()
def count_skills(self, tenant_id: UUID) -> int:
from app.models.skill_model import Skill
return self.db.query(Skill).filter(
Skill.tenant_id == tenant_id,
Skill.is_active.is_(True)
).count()
def sum_knowledge_capacity_gb(self, tenant_id: UUID, workspace_id: Optional[UUID] = None) -> float:
from app.models.document_model import Document
from app.models.knowledge_model import Knowledge
from app.models.workspace_model import Workspace
query = self.db.query(func.coalesce(func.sum(Document.file_size), 0)).join(
Knowledge, Document.kb_id == Knowledge.id
).join(
Workspace, Knowledge.workspace_id == Workspace.id
).filter(
Document.status == 1,
)
if workspace_id:
query = query.filter(Knowledge.workspace_id == workspace_id)
else:
query = query.filter(Workspace.tenant_id == tenant_id)
result = query.scalar()
return float(result) / (1024 ** 3) if result else 0.0
def count_memory_engines(self, tenant_id: UUID, workspace_id: Optional[UUID] = None) -> int:
from app.models.memory_config_model import MemoryConfig
from app.models.workspace_model import Workspace
query = self.db.query(MemoryConfig).join(
Workspace, MemoryConfig.workspace_id == Workspace.id
)
if workspace_id:
query = query.filter(MemoryConfig.workspace_id == workspace_id)
else:
query = query.filter(Workspace.tenant_id == tenant_id)
return query.count()
def count_end_users(self, tenant_id: UUID, workspace_id: Optional[UUID] = None) -> int:
from app.models.end_user_model import EndUser
from app.models.workspace_model import Workspace
from app.models.user_model import User
query = self.db.query(EndUser).join(
Workspace, EndUser.workspace_id == Workspace.id
)
if workspace_id:
query = query.filter(EndUser.workspace_id == workspace_id)
else:
query = query.filter(Workspace.tenant_id == tenant_id)
trial_user_ids = [
str(u.id) for u in self.db.query(User.id).filter(User.tenant_id == tenant_id).all()
]
if trial_user_ids:
query = query.filter(~EndUser.other_id.in_(trial_user_ids))
return query.count()
def count_models(self, tenant_id: UUID) -> int:
from app.models.models_model import ModelConfig
return self.db.query(ModelConfig).filter(
ModelConfig.tenant_id == tenant_id,
ModelConfig.is_active == True,
ModelConfig.is_composite == True
).count()
def count_ontology_projects(self, tenant_id: UUID, workspace_id: Optional[UUID] = None) -> int:
from app.models.ontology_scene import OntologyScene
from app.models.workspace_model import Workspace
if workspace_id:
return self.db.query(OntologyScene).filter(
OntologyScene.workspace_id == workspace_id
).count()
return self.db.query(OntologyScene).join(
Workspace, OntologyScene.workspace_id == Workspace.id
).filter(
Workspace.tenant_id == tenant_id
).count()
def get_usage_by_quota_type(self, tenant_id: UUID, quota_type: str, workspace_id: Optional[UUID] = None):
"""按配额类型分发,返回当前使用量"""
dispatch = {
"workspace_quota": self.count_workspaces,
"app_quota": self.count_apps,
"skill_quota": self.count_skills,
"knowledge_capacity_quota": self.sum_knowledge_capacity_gb,
"memory_engine_quota": self.count_memory_engines,
"end_user_quota": self.count_end_users,
"model_quota": self.count_models,
"ontology_project_quota": self.count_ontology_projects,
}
fn = dispatch.get(quota_type)
if workspace_id:
return fn(tenant_id, workspace_id) if fn else 0
return fn(tenant_id) if fn else 0
def _check_quota(
db: Session,
tenant_id: UUID,
quota_type: str,
resource_name: str,
usage_func: Optional[Callable] = None,
workspace_id: Optional[UUID] = None,
) -> None:
"""核心配额检查逻辑:对比使用量和配额限制"""
try:
quota_config = _get_quota_config(db, tenant_id)
if not quota_config:
logger.warning(f"租户 {tenant_id} 无有效配额配置,跳过配额检查")
return
quota_limit = quota_config.get(quota_type)
if quota_limit is None:
logger.warning(f"配额配置未包含 {quota_type},跳过配额检查")
return
if usage_func:
current_usage = usage_func(db, tenant_id, workspace_id) if workspace_id else usage_func(db, tenant_id)
else:
current_usage = QuotaUsageRepository(db).get_usage_by_quota_type(tenant_id, quota_type, workspace_id)
if current_usage >= quota_limit:
logger.warning(
f"配额不足: tenant={tenant_id}, workspace={workspace_id}, type={quota_type}, "
f"usage={current_usage}, limit={quota_limit}"
)
raise QuotaExceededError(
resource=resource_name,
current_usage=current_usage,
quota_limit=quota_limit,
)
logger.debug(
f"配额检查通过: tenant={tenant_id}, workspace={workspace_id}, type={quota_type}, "
f"usage={current_usage}, limit={quota_limit}"
)
except QuotaExceededError:
raise
except Exception as e:
logger.error(
f"配额检查异常: tenant={tenant_id}, workspace={workspace_id}, type={quota_type}, "
f"error_type={type(e).__name__}, error={str(e)}",
exc_info=True,
)
raise
# ─── 具名装饰器 ────────────────────────────────────────────────────────────
def check_workspace_quota(func: Callable) -> Callable:
@wraps(func)
async def async_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "workspace_quota", "workspace")
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "workspace_quota", "workspace")
return func(*args, **kwargs)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
def check_skill_quota(func: Callable) -> Callable:
@wraps(func)
async def async_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "skill_quota", "skill")
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "skill_quota", "skill")
return func(*args, **kwargs)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
def check_app_quota(func: Callable) -> Callable:
@wraps(func)
async def async_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
workspace_id = _get_workspace_id_from_kwargs(kwargs)
if not workspace_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 workspace_id拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "app_quota", "app", workspace_id=workspace_id)
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
workspace_id = _get_workspace_id_from_kwargs(kwargs)
if not workspace_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 workspace_id拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "app_quota", "app", workspace_id=workspace_id)
return func(*args, **kwargs)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
def check_knowledge_capacity_quota(func: Callable) -> Callable:
@wraps(func)
async def async_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
if not db:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 参数,拒绝请求")
raise InternalServerError()
tenant_id = _get_tenant_id_from_kwargs(db, kwargs)
if not tenant_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 tenant_id拒绝请求")
raise InternalServerError()
workspace_id = _get_workspace_id_from_kwargs(kwargs)
if not workspace_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 workspace_id拒绝请求")
raise InternalServerError()
_check_quota(db, tenant_id, "knowledge_capacity_quota", "knowledge_capacity", workspace_id=workspace_id)
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
workspace_id = _get_workspace_id_from_kwargs(kwargs)
if not workspace_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 workspace_id拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "knowledge_capacity_quota", "knowledge_capacity", workspace_id=workspace_id)
return func(*args, **kwargs)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
def check_memory_engine_quota(func: Callable) -> Callable:
@wraps(func)
async def async_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
logger.debug(f"check_memory_engine_quota async_wrapper: db={db is not None}, user={user}, kwargs_keys={list(kwargs.keys())}")
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
workspace_id = _get_workspace_id_from_kwargs(kwargs)
if not workspace_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 workspace_id拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "memory_engine_quota", "memory_engine", workspace_id=workspace_id)
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
logger.debug(f"check_memory_engine_quota sync_wrapper: db={db is not None}, user={user}, kwargs_keys={list(kwargs.keys())}")
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
workspace_id = _get_workspace_id_from_kwargs(kwargs)
if not workspace_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 workspace_id拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "memory_engine_quota", "memory_engine", workspace_id=workspace_id)
return func(*args, **kwargs)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
def check_end_user_quota(func: Callable) -> Callable:
@wraps(func)
async def async_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
if not db:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 参数,拒绝请求")
raise InternalServerError()
tenant_id = _get_tenant_id_from_kwargs(db, kwargs)
if not tenant_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 tenant_id拒绝请求")
raise InternalServerError()
workspace_id = _get_workspace_id_from_kwargs(kwargs)
if not workspace_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 workspace_id拒绝请求")
raise InternalServerError()
_check_quota(db, tenant_id, "end_user_quota", "end_user", workspace_id=workspace_id)
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
if not db:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 参数,拒绝请求")
raise InternalServerError()
tenant_id = _get_tenant_id_from_kwargs(db, kwargs)
if not tenant_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 tenant_id拒绝请求")
raise InternalServerError()
workspace_id = _get_workspace_id_from_kwargs(kwargs)
if not workspace_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 workspace_id拒绝请求")
raise InternalServerError()
_check_quota(db, tenant_id, "end_user_quota", "end_user", workspace_id=workspace_id)
return func(*args, **kwargs)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
def check_ontology_project_quota(func: Callable) -> Callable:
@wraps(func)
async def async_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
workspace_id = _get_workspace_id_from_kwargs(kwargs)
if not workspace_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 workspace_id拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "ontology_project_quota", "ontology_project", workspace_id=workspace_id)
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
workspace_id = _get_workspace_id_from_kwargs(kwargs)
if not workspace_id:
logger.error(f"配额检查失败:{func.__name__} 无法获取 workspace_id拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "ontology_project_quota", "ontology_project", workspace_id=workspace_id)
return func(*args, **kwargs)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
def check_model_quota(func: Callable) -> Callable:
@wraps(func)
async def async_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "model_quota", "model")
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, "model_quota", "model")
return func(*args, **kwargs)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
def check_model_activation_quota(func: Callable) -> Callable:
"""模型激活时的配额检查装饰器"""
@wraps(func)
async def async_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
model_id = kwargs.get("model_id") or (args[1] if len(args) > 1 else None)
model_data = kwargs.get("model_data")
if not model_id or not model_data:
logger.warning("模型激活配额检查失败:缺少 model_id 或 model_data 参数")
return await func(*args, **kwargs)
if model_data.is_active:
try:
from app.services.model_service import ModelConfigService
existing_model = ModelConfigService.get_model_by_id(
db=db,
model_id=model_id,
tenant_id=user.tenant_id
)
if not existing_model.is_active:
logger.info(f"模型激活操作,检查配额: model_id={model_id}, tenant_id={user.tenant_id}")
_check_quota(db, user.tenant_id, "model_quota", "model")
except Exception as e:
logger.error(f"模型激活配额检查异常: model_id={model_id}, error={str(e)}")
raise
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
model_id = kwargs.get("model_id") or (args[1] if len(args) > 1 else None)
model_data = kwargs.get("model_data")
if not model_id or not model_data:
logger.warning("模型激活配额检查失败:缺少 model_id 或 model_data 参数")
return func(*args, **kwargs)
if model_data.is_active:
try:
from app.services.model_service import ModelConfigService
existing_model = ModelConfigService.get_model_by_id(
db=db,
model_id=model_id,
tenant_id=user.tenant_id
)
if not existing_model.is_active:
logger.info(f"模型激活操作,检查配额: model_id={model_id}, tenant_id={user.tenant_id}")
_check_quota(db, user.tenant_id, "model_quota", "model")
except Exception as e:
logger.error(f"模型激活配额检查异常: model_id={model_id}, error={str(e)}")
raise
return func(*args, **kwargs)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
def check_quota(quota_type: str, resource_name: str, usage_func: Optional[Callable] = None):
"""通用配额检查装饰器,支持自定义使用量获取函数"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def async_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, quota_type, resource_name, usage_func)
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
db: Session = kwargs.get("db")
user = _get_user_from_kwargs(kwargs)
if not db or not user:
logger.error(f"配额检查失败:{func.__name__} 缺少 db 或 user 参数,拒绝请求")
raise InternalServerError()
_check_quota(db, user.tenant_id, quota_type, resource_name, usage_func)
return func(*args, **kwargs)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
return decorator
# ─── 配额使用统计 ────────────────────────────────────────────────────────────
async def get_quota_usage(db: Session, tenant_id: UUID) -> dict:
"""获取租户所有配额的使用情况
对于 workspace 级别的配额app/knowledge_capacity/memory_engine/end_user
- used: 租户汇总(所有空间加总)
- limit: quota × 活跃工作区数(有效总限额,使汇总数据自洽)
- per_workspace: 各空间明细,包含 workspace_id、workspace_name、used、limit、percentage
- 配额检查逻辑不变:仍按单个空间独立检查
"""
quota_config = _get_quota_config(db, tenant_id)
if not quota_config:
return {}
repo = QuotaUsageRepository(db)
def pct(used, limit):
return round(used / limit * 100, 1) if limit else None
workspace_count = repo.count_workspaces(tenant_id)
skill_count = repo.count_skills(tenant_id)
app_count = repo.count_apps(tenant_id)
knowledge_gb = repo.sum_knowledge_capacity_gb(tenant_id)
memory_count = repo.count_memory_engines(tenant_id)
end_user_count = repo.count_end_users(tenant_id)
model_count = repo.count_models(tenant_id)
ontology_count = repo.count_ontology_projects(tenant_id)
# 获取租户下所有活跃工作区,用于按空间拆分明细
from app.models.workspace_model import Workspace
active_workspaces = db.query(Workspace).filter(
Workspace.tenant_id == tenant_id,
Workspace.is_active.is_(True)
).all()
# 构建各空间的 workspace 级配额明细
def _build_per_workspace_detail(count_func, per_unit_limit):
"""为 workspace 级配额构建 per_workspace 明细列表"""
if not per_unit_limit or not active_workspaces:
return []
details = []
for ws in active_workspaces:
ws_used = count_func(tenant_id, ws.id)
details.append({
"workspace_id": str(ws.id),
"workspace_name": ws.name,
"used": ws_used,
"limit": per_unit_limit,
"percentage": pct(ws_used, per_unit_limit),
})
return details
# workspace 级配额的每空间限额
app_quota_per_ws = quota_config.get("app_quota")
knowledge_quota_per_ws = quota_config.get("knowledge_capacity_quota")
memory_quota_per_ws = quota_config.get("memory_engine_quota")
end_user_quota_per_ws = quota_config.get("end_user_quota")
ontology_quota_per_ws = quota_config.get("ontology_project_quota")
# workspace 级配额的有效总限额 = 每空间限额 × 活跃工作区数
app_effective_limit = app_quota_per_ws * workspace_count if app_quota_per_ws is not None and workspace_count > 0 else app_quota_per_ws
knowledge_effective_limit = knowledge_quota_per_ws * workspace_count if knowledge_quota_per_ws is not None and workspace_count > 0 else knowledge_quota_per_ws
memory_effective_limit = memory_quota_per_ws * workspace_count if memory_quota_per_ws is not None and workspace_count > 0 else memory_quota_per_ws
end_user_effective_limit = end_user_quota_per_ws * workspace_count if end_user_quota_per_ws is not None and workspace_count > 0 else end_user_quota_per_ws
ontology_effective_limit = ontology_quota_per_ws * workspace_count if ontology_quota_per_ws is not None and workspace_count > 0 else ontology_quota_per_ws
api_ops_current = 0
try:
from app.aioRedis import aio_redis as _aio_redis
from app.models.api_key_model import ApiKey
# api_ops_rate_limit 限的是每个 api_key 每秒最高限额
# 展示当前最接近触发限流的 key 的 QPS取最大值
api_key_ids = db.query(ApiKey.id).join(
Workspace, ApiKey.workspace_id == Workspace.id
).filter(
Workspace.tenant_id == tenant_id,
ApiKey.is_active.is_(True)
).all()
for (key_id,) in api_key_ids:
_rk = API_KEY_QPS_REDIS_KEY.format(api_key_id=key_id)
val = await _aio_redis.get(_rk)
count = int(val) if val else 0
if count > api_ops_current:
api_ops_current = count
except Exception as e:
logger.warning(f"获取 api_ops_current 失败,返回 0: {type(e).__name__}: {e}")
return {
"workspace": {"used": workspace_count, "limit": quota_config.get("workspace_quota"), "percentage": pct(workspace_count, quota_config.get("workspace_quota"))},
"skill": {"used": skill_count, "limit": quota_config.get("skill_quota"), "percentage": pct(skill_count, quota_config.get("skill_quota"))},
"app": {
"used": app_count,
"limit": app_effective_limit,
"percentage": pct(app_count, app_effective_limit),
"per_workspace": _build_per_workspace_detail(repo.count_apps, app_quota_per_ws),
},
"knowledge_capacity": {
"used": round(knowledge_gb, 2),
"limit": knowledge_effective_limit,
"percentage": pct(knowledge_gb, knowledge_effective_limit),
"unit": "GB",
"per_workspace": _build_per_workspace_detail(repo.sum_knowledge_capacity_gb, knowledge_quota_per_ws),
},
"memory_engine": {
"used": memory_count,
"limit": memory_effective_limit,
"percentage": pct(memory_count, memory_effective_limit),
"per_workspace": _build_per_workspace_detail(repo.count_memory_engines, memory_quota_per_ws),
},
"end_user": {
"used": end_user_count,
"limit": end_user_effective_limit,
"percentage": pct(end_user_count, end_user_effective_limit),
"per_workspace": _build_per_workspace_detail(repo.count_end_users, end_user_quota_per_ws),
},
"ontology_project": {
"used": ontology_count,
"limit": ontology_effective_limit,
"percentage": pct(ontology_count, ontology_effective_limit),
"per_workspace": _build_per_workspace_detail(repo.count_ontology_projects, ontology_quota_per_ws),
},
"model": {"used": model_count, "limit": quota_config.get("model_quota"), "percentage": pct(model_count, quota_config.get("model_quota"))},
"api_ops_rate_limit": {"current": api_ops_current, "limit": quota_config.get("api_ops_rate_limit"), "percentage": None, "unit": "次/秒"},
}

View File

@@ -1,38 +0,0 @@
"""
配额检查 stub - 社区版和 SaaS 版统一使用 core.quota_manager 实现
所有配额检查逻辑统一在 core 层实现,两个版本共用:
- 社区版:从 default_free_plan.py 读取配额限制
- SaaS 版:优先从 tenant_subscriptions 表读取,降级到配置文件
"""
from app.core.quota_manager import (
check_workspace_quota,
check_skill_quota,
check_app_quota,
check_knowledge_capacity_quota,
check_memory_engine_quota,
check_end_user_quota,
check_ontology_project_quota,
check_model_quota,
check_model_activation_quota,
get_quota_usage,
_check_quota,
QuotaUsageRepository,
API_KEY_QPS_REDIS_KEY,
)
__all__ = [
"check_workspace_quota",
"check_skill_quota",
"check_app_quota",
"check_knowledge_capacity_quota",
"check_memory_engine_quota",
"check_end_user_quota",
"check_ontology_project_quota",
"check_model_quota",
"check_model_activation_quota",
"get_quota_usage",
"_check_quota",
"QuotaUsageRepository",
"API_KEY_QPS_REDIS_KEY",
]

View File

@@ -33,16 +33,18 @@ def timeout(seconds: float | int | str = None, attempts: int = 2, *, exception:
thread.daemon = True thread.daemon = True
thread.start() thread.start()
effective_timeout = seconds if seconds else 120 # 默认 120 秒超时
for a in range(attempts): for a in range(attempts):
try: try:
result = result_queue.get(timeout=effective_timeout) if os.environ.get("ENABLE_TIMEOUT_ASSERTION"):
result = result_queue.get(timeout=seconds)
else:
result = result_queue.get()
if isinstance(result, Exception): if isinstance(result, Exception):
raise result raise result
return result return result
except queue.Empty: except queue.Empty:
pass pass
raise TimeoutError(f"Function '{func.__name__}' timed out after {effective_timeout} seconds and {attempts} attempts.") raise TimeoutError(f"Function '{func.__name__}' timed out after {seconds} seconds and {attempts} attempts.")
@wraps(func) @wraps(func)
async def async_wrapper(*args, **kwargs) -> Any: async def async_wrapper(*args, **kwargs) -> Any:

View File

@@ -113,7 +113,7 @@ def knowledge_retrieval(
continue continue
# Use the specified reranker for re-ranking # Use the specified reranker for re-ranking
if reranker_id and all_results: if reranker_id:
try: try:
all_results = rerank(db=db, reranker_id=reranker_id, query=query, docs=all_results, top_k=reranker_top_k) all_results = rerank(db=db, reranker_id=reranker_id, query=query, docs=all_results, top_k=reranker_top_k)
except Exception as rerank_error: except Exception as rerank_error:

View File

@@ -68,9 +68,9 @@ class ESConnection(DocStoreConnection):
client_config = { client_config = {
"hosts": [hosts], "hosts": [hosts],
"basic_auth": (os.getenv("ELASTICSEARCH_USERNAME", "elastic"), os.getenv("ELASTICSEARCH_PASSWORD", "elastic")), "basic_auth": (os.getenv("ELASTICSEARCH_USERNAME", "elastic"), os.getenv("ELASTICSEARCH_PASSWORD", "elastic")),
"request_timeout": int(os.getenv("ELASTICSEARCH_REQUEST_TIMEOUT", 30)), "request_timeout": int(os.getenv("ELASTICSEARCH_REQUEST_TIMEOUT", 100000)),
"retry_on_timeout": os.getenv("ELASTICSEARCH_RETRY_ON_TIMEOUT", True) == "true", "retry_on_timeout": os.getenv("ELASTICSEARCH_RETRY_ON_TIMEOUT", True) == "true",
"max_retries": int(os.getenv("ELASTICSEARCH_MAX_RETRIES", 3)), "max_retries": int(os.getenv("ELASTICSEARCH_MAX_RETRIES", 10000)),
} }
# Only add SSL settings if using HTTPS # Only add SSL settings if using HTTPS

View File

@@ -1,22 +1,25 @@
import os import os
import logging import logging
import threading from typing import Any, cast
from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import uuid
import requests import requests
from elasticsearch import Elasticsearch, helpers from elasticsearch import Elasticsearch, helpers
from elasticsearch.helpers import BulkIndexError from elasticsearch.helpers import BulkIndexError
from packaging.version import parse as parse_version from packaging.version import parse as parse_version
from pydantic import BaseModel, model_validator
from abc import ABC
# langchain-community # langchain-community
# langchain-xinference # langchain-xinference
# from langchain_community.embeddings import XinferenceEmbeddings # from langchain_community.embeddings import XinferenceEmbeddings
# from langchain_xinference import XinferenceRerank # from langchain_xinference import XinferenceRerank
from langchain_core.documents import Document from langchain_core.documents import Document
from app.core.models.base import RedBearModelConfig from app.core.models.base import RedBearModelConfig
from app.core.models import RedBearRerank from app.core.models import RedBearLLM, RedBearRerank
from app.core.models.embedding import RedBearEmbeddings from app.core.models.embedding import RedBearEmbeddings
from app.models.models_model import ModelApiKey from app.models.models_model import ModelConfig, ModelApiKey
from app.services.model_service import ModelConfigService
from app.models.knowledge_model import Knowledge from app.models.knowledge_model import Knowledge
from app.core.rag.vdb.field import Field from app.core.rag.vdb.field import Field
@@ -26,9 +29,37 @@ from app.core.rag.models.chunk import DocumentChunk
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ElasticSearchConfig(BaseModel):
# Regular Elasticsearch config
host: str | None = None
port: int | None = None
username: str | None = None
password: str | None = None
# Common config
ca_certs: str | None = None
verify_certs: bool = False
request_timeout: int = 100000
retry_on_timeout: bool = True
max_retries: int = 10000
@model_validator(mode="before")
@classmethod
def validate_config(cls, values: dict):
# Regular Elasticsearch validation
if not values.get("host"):
raise ValueError("config HOST is required for regular Elasticsearch")
if not values.get("port"):
raise ValueError("config PORT is required for regular Elasticsearch")
if not values.get("username"):
raise ValueError("config USERNAME is required for regular Elasticsearch")
if not values.get("password"):
raise ValueError("config PASSWORD is required for regular Elasticsearch")
return values
class ElasticSearchVector(BaseVector): class ElasticSearchVector(BaseVector):
def __init__(self, index_name: str, client: Elasticsearch, def __init__(self, index_name: str, config: ElasticSearchConfig, embedding_config: ModelApiKey, reranker_config: ModelApiKey):
embedding_config: ModelApiKey, reranker_config: ModelApiKey):
super().__init__(index_name.lower()) super().__init__(index_name.lower())
# 初始化 Embedding 模型(自动支持火山引擎多模态) # 初始化 Embedding 模型(自动支持火山引擎多模态)
@@ -46,8 +77,58 @@ class ElasticSearchVector(BaseVector):
api_key=reranker_config.api_key, api_key=reranker_config.api_key,
base_url=reranker_config.api_base base_url=reranker_config.api_base
)) ))
# 使用外部传入的共享客户端 self._client = self._init_client(config)
self._client = client self._version = self._get_version()
self._check_version()
def _init_client(self, config: ElasticSearchConfig) -> Elasticsearch:
"""
Initialize Elasticsearch client for regular Elasticsearch.
"""
try:
# Regular Elasticsearch configuration
parsed_url = urlparse(config.host or "")
if parsed_url.scheme in {"http", "https"}:
hosts = f"{config.host}:{config.port}"
use_https = parsed_url.scheme == "https"
else:
hosts = f"https://{config.host}:{config.port}"
use_https = False
client_config = {
"hosts": [hosts],
"basic_auth": (config.username, config.password),
"request_timeout": config.request_timeout,
"retry_on_timeout": config.retry_on_timeout,
"max_retries": config.max_retries,
}
# Only add SSL settings if using HTTPS
if use_https:
client_config["verify_certs"] = config.verify_certs
if config.ca_certs:
client_config["ca_certs"] = config.ca_certs
client = Elasticsearch(**client_config)
# Test connection
if not client.ping():
raise ConnectionError("Failed to connect to Elasticsearch")
except requests.ConnectionError as e:
raise ConnectionError(f"Vector database connection error: {str(e)}")
except Exception as e:
raise ConnectionError(f"Elasticsearch client initialization failed: {str(e)}")
return client
def _get_version(self) -> str:
info = self._client.info()
return cast(str, info["version"]["number"])
def _check_version(self):
if parse_version(self._version) < parse_version("8.0.0"):
raise ValueError("Elasticsearch vector database version must be greater than 8.0.0")
def get_type(self) -> str: def get_type(self) -> str:
return "elasticsearch" return "elasticsearch"
@@ -664,79 +745,29 @@ class ElasticSearchVector(BaseVector):
class ElasticSearchVectorFactory: class ElasticSearchVectorFactory:
"""ES 向量服务工厂 - 单例共享连接""" @staticmethod
def init_vector(knowledge: Knowledge) -> ElasticSearchVector:
_client: Elasticsearch | None = None
_lock = threading.Lock()
_version_checked = False
@classmethod
def _get_shared_client(cls) -> Elasticsearch:
"""获取共享的 ES 客户端(线程安全的懒加载单例)"""
if cls._client is not None:
return cls._client
with cls._lock:
# 双重检查,防止并发时重复创建
if cls._client is not None:
return cls._client
try:
parsed_url = urlparse(os.getenv("ELASTICSEARCH_HOST", "127.0.0.1") or "")
if parsed_url.scheme in {"http", "https"}:
hosts = f'{os.getenv("ELASTICSEARCH_HOST")}:{os.getenv("ELASTICSEARCH_PORT", 9200)}'
use_https = parsed_url.scheme == "https"
else:
hosts = f'https://{os.getenv("ELASTICSEARCH_HOST", "127.0.0.1")}:{os.getenv("ELASTICSEARCH_PORT", 9200)}'
use_https = False
client_config = {
"hosts": [hosts],
"basic_auth": (
os.getenv("ELASTICSEARCH_USERNAME", "elastic"),
os.getenv("ELASTICSEARCH_PASSWORD", "elastic"),
),
"request_timeout": int(os.getenv("ELASTICSEARCH_REQUEST_TIMEOUT", 30)),
"retry_on_timeout": True,
"max_retries": int(os.getenv("ELASTICSEARCH_MAX_RETRIES", 3)),
"connections_per_node": int(os.getenv("ELASTICSEARCH_CONNECTIONS_PER_NODE", 10)),
}
if use_https:
client_config["verify_certs"] = os.getenv("ELASTICSEARCH_VERIFY_CERTS", "false") == "true"
ca_certs = os.getenv("ELASTICSEARCH_CA_CERTS")
if ca_certs:
client_config["ca_certs"] = str(ca_certs)
client = Elasticsearch(**client_config)
if not client.ping():
raise ConnectionError("Failed to connect to Elasticsearch")
# 版本检查只做一次
if not cls._version_checked:
info = client.info()
version = info["version"]["number"]
if parse_version(version) < parse_version("8.0.0"):
raise ValueError(f"Elasticsearch version must be >= 8.0.0, got {version}")
cls._version_checked = True
logger.info(f"Elasticsearch shared client initialized, version: {version}")
cls._client = client
except requests.ConnectionError as e:
raise ConnectionError(f"Vector database connection error: {str(e)}")
except Exception as e:
raise ConnectionError(f"Elasticsearch client initialization failed: {str(e)}")
return cls._client
@classmethod
def init_vector(cls, knowledge: Knowledge) -> ElasticSearchVector:
"""创建向量服务实例(共享 ES 连接)"""
client = cls._get_shared_client()
collection_name = f"Vector_index_{knowledge.id}_Node" collection_name = f"Vector_index_{knowledge.id}_Node"
# Use regular Elasticsearch with config values
config_dict = {
"host": os.getenv("ELASTICSEARCH_HOST", "127.0.0.1"),
"port": os.getenv("ELASTICSEARCH_PORT", 9200),
"username": os.getenv("ELASTICSEARCH_USERNAME", "elastic"),
"password": os.getenv("ELASTICSEARCH_PASSWORD", "elastic"),
}
# Common configuration
config_dict.update(
{
"ca_certs": str(os.getenv("ELASTICSEARCH_CA_CERTS")) if os.getenv("ELASTICSEARCH_CA_CERTS") else None,
"verify_certs": os.getenv("ELASTICSEARCH_VERIFY_CERTS", False) == "true",
"request_timeout": int(os.getenv("ELASTICSEARCH_REQUEST_TIMEOUT", 100000)),
"retry_on_timeout": os.getenv("ELASTICSEARCH_RETRY_ON_TIMEOUT", True) == "true",
"max_retries": int(os.getenv("ELASTICSEARCH_MAX_RETRIES", 10000)),
}
)
if knowledge.embedding is None: if knowledge.embedding is None:
raise ValueError(f"embedding_id config error: {str(knowledge.embedding_id)}") raise ValueError(f"embedding_id config error: {str(knowledge.embedding_id)}")
if knowledge.reranker is None: if knowledge.reranker is None:
@@ -744,9 +775,9 @@ class ElasticSearchVectorFactory:
return ElasticSearchVector( return ElasticSearchVector(
index_name=collection_name, index_name=collection_name,
client=client, config=ElasticSearchConfig(**config_dict),
embedding_config=knowledge.embedding.api_keys[0], embedding_config=knowledge.embedding.api_keys[0],
reranker_config=knowledge.reranker.api_keys[0], reranker_config=knowledge.reranker.api_keys[0]
) )

View File

@@ -253,9 +253,9 @@ class DateTimeTool(BuiltinTool):
return { return {
"datetime": input_value, "datetime": input_value,
"timezone": timezone_str, "timezone": timezone_str,
"timestamp": int(dt.timestamp() * 1000), "timestamp": int(dt.timestamp()) * 1000,
"iso_format": dt.isoformat(), "iso_format": dt.isoformat(),
"result_data": int(dt.timestamp() * 1000) "result_data": int(dt.timestamp()) * 1000
} }
def _calculate_datetime(self, kwargs) -> dict: def _calculate_datetime(self, kwargs) -> dict:

View File

@@ -201,15 +201,12 @@ class VariablePool:
@staticmethod @staticmethod
def _extract_field(struct: "VariableStruct", field: str | None) -> Any: def _extract_field(struct: "VariableStruct", field: str | None) -> Any:
"""If field is given, drill into a dict/object/array[file] variable's value.""" """If field is given, drill into a dict/object variable's value."""
if field is None: if field is None:
return struct.instance.get_value() return struct.instance.get_value()
value = struct.instance.get_value() value = struct.instance.get_value()
# array[file]: extract the field from every element, return a list
if isinstance(value, list):
return [item.get(field) if isinstance(item, dict) else getattr(item, field, None) for item in value]
if not isinstance(value, dict): if not isinstance(value, dict):
raise KeyError(f"Variable is not an object or array, cannot access field '{field}'") raise KeyError(f"Variable is not an object, cannot access field '{field}'")
return value.get(field) return value.get(field)
def get_instance( def get_instance(

View File

@@ -28,135 +28,86 @@ class IterationRuntime:
def __init__( def __init__(
self, self,
start_id: str,
stream: bool, stream: bool,
graph: CompiledStateGraph,
node_id: str, node_id: str,
config: dict[str, Any], config: dict[str, Any],
state: WorkflowState, state: WorkflowState,
variable_pool: VariablePool, variable_pool: VariablePool,
cycle_nodes: list, child_variable_pool: VariablePool,
cycle_edges: list,
): ):
""" """
Initialize the iteration runtime. Initialize the iteration runtime.
Args: Args:
stream: Whether to run in streaming mode. When True, each iteration graph: Compiled workflow graph capable of async invocation.
uses graph.astream and emits cycle_item events in real time. node_id: Unique identifier of the loop node.
When False, graph.ainvoke is used instead. config: Dictionary containing iteration node configuration.
node_id: The unique identifier of the iteration node in the workflow. state: Current workflow state at the point of iteration.
Also used as the variable namespace for item/index inside
the subgraph (e.g. {{ node_id.item }}).
config: Raw configuration dict for the iteration node, parsed into
IterationNodeConfig. Controls input/output variable selectors,
parallel execution settings, and output flattening.
state: The parent workflow state at the point the iteration node is
entered. Each task receives a copy of this state as its
starting point.
variable_pool: The parent VariablePool containing all variables available
at the time the iteration node executes, including sys.*,
conv.*, and outputs from upstream nodes. Used as the source
for deep-copying into each task's independent child pool.
cycle_nodes: List of node config dicts belonging to this iteration's
subgraph (i.e. nodes whose cycle field equals node_id).
Passed to GraphBuilder when constructing each task's subgraph.
cycle_edges: List of edge config dicts connecting nodes within the subgraph.
Passed to GraphBuilder alongside cycle_nodes.
""" """
self.start_id = start_id
self.stream = stream self.stream = stream
self.graph = graph
self.state = state self.state = state
self.node_id = node_id self.node_id = node_id
self.typed_config = IterationNodeConfig(**config) self.typed_config = IterationNodeConfig(**config)
self.looping = True self.looping = True
self.variable_pool = variable_pool self.variable_pool = variable_pool
self.cycle_nodes = cycle_nodes self.child_variable_pool = child_variable_pool
self.cycle_edges = cycle_edges
self.event_write = get_stream_writer() self.event_write = get_stream_writer()
self.checkpoint = RunnableConfig(
configurable={
"thread_id": uuid.uuid4()
}
)
self.output_value = None self.output_value = None
self.result: list = [] self.result: list = []
def _build_child_graph(self) -> tuple[CompiledStateGraph, VariablePool, str]: async def _init_iteration_state(self, item, idx):
""" """
Build an independent compiled subgraph for a single iteration task. Initialize a per-iteration copy of the workflow state.
Each call creates a brand-new VariablePool by deep-copying the parent pool,
then passes it to GraphBuilder. GraphBuilder binds this pool to every node's
execution closure at build time, so the pool and the subgraph always reference
the same object. This is the key design invariant: item/index written into the
pool after build will be visible to all nodes inside the subgraph.
Returns:
graph: The compiled LangGraph subgraph ready for invocation.
child_pool: The VariablePool bound to this subgraph's node closures.
Callers must write item/index into this pool before invoking
the graph, and read output from it after invocation.
start_node_id: The ID of the CYCLE_START node inside the subgraph,
used to set the initial activation signal in workflow state.
"""
from app.core.workflow.engine.graph_builder import GraphBuilder
child_pool = VariablePool()
child_pool.copy(self.variable_pool)
builder = GraphBuilder(
{"nodes": self.cycle_nodes, "edges": self.cycle_edges},
stream=self.stream,
variable_pool=child_pool,
cycle=self.node_id,
)
graph = builder.build()
return graph, builder.variable_pool, builder.start_node_id
async def _init_iteration_state(self, item, idx, child_pool: VariablePool, start_id: str):
"""
Initialize the workflow state for a single iteration.
Writes the current item and its index into child_pool under the iteration
node's namespace (e.g. iteration_xxx.item, iteration_xxx.index), making them
accessible to downstream nodes inside the subgraph via variable selectors.
Also prepares a copy of the parent workflow state with:
- node_outputs[node_id] set to {item, index} so the state snapshot is consistent
with the pool values.
- looping flag set to 1 (active) to signal the subgraph is inside a cycle.
- activate[start_id] set to True to trigger the CYCLE_START node.
Args: Args:
item: The current element from the input array. item: Current element from the input array for this iteration.
idx: The zero-based index of this element in the input array. idx: Index of the element in the input array.
child_pool: The VariablePool bound to this iteration's subgraph.
Must be the same object returned by _build_child_graph.
start_id: The ID of the CYCLE_START node inside the subgraph.
Returns: Returns:
A WorkflowState instance ready to be passed to graph.ainvoke or graph.astream. A copy of the workflow state with iteration-specific variables set.
""" """
loopstate = WorkflowState(**self.state) loopstate = WorkflowState(
await child_pool.new(self.node_id, "item", item, VariableType.type_map(item), mut=True) **self.state
await child_pool.new(self.node_id, "index", idx, VariableType.type_map(idx), mut=True) )
loopstate["node_outputs"][self.node_id] = {"item": item, "index": idx} self.child_variable_pool.copy(self.variable_pool)
await self.child_variable_pool.new(self.node_id, "item", item, VariableType.type_map(item), mut=True)
await self.child_variable_pool.new(self.node_id, "index", item, VariableType.type_map(item), mut=True)
loopstate["node_outputs"][self.node_id] = {
"item": item,
"index": idx,
}
loopstate["looping"] = 1 loopstate["looping"] = 1
loopstate["activate"][start_id] = True loopstate["activate"][self.start_id] = True
return loopstate return loopstate
def _merge_conv_vars(self, child_pool: VariablePool): def merge_conv_vars(self):
self.variable_pool.variables["conv"].update(child_pool.variables["conv"]) self.variable_pool.variables["conv"].update(
self.child_variable_pool.variables["conv"]
)
async def run_task(self, item, idx): async def run_task(self, item, idx):
""" """
Execute a single iteration asynchronously. Execute a single iteration asynchronously.
Each task builds its own subgraph so the variable pool closure is independent.
Returns: Args:
Tuple of (idx, output, result, child_pool, stopped) item: The input element for this iteration.
idx: The index of this iteration.
""" """
graph, child_pool, start_id = self._build_child_graph()
checkpoint = RunnableConfig(configurable={"thread_id": uuid.uuid4()})
init_state = await self._init_iteration_state(item, idx, child_pool, start_id)
if self.stream: if self.stream:
async for event in graph.astream( async for event in self.graph.astream(
init_state, await self._init_iteration_state(item, idx),
stream_mode=["debug"], stream_mode=["debug"],
config=checkpoint config=self.checkpoint
): ):
if isinstance(event, tuple) and len(event) == 2: if isinstance(event, tuple) and len(event) == 2:
mode, data = event mode, data = event
@@ -166,6 +117,7 @@ class IterationRuntime:
event_type = data.get("type") event_type = data.get("type")
payload = data.get("payload", {}) payload = data.get("payload", {})
node_name = payload.get("name") node_name = payload.get("name")
if node_name and node_name.startswith("nop"): if node_name and node_name.startswith("nop"):
continue continue
if event_type == "task_result": if event_type == "task_result":
@@ -188,13 +140,17 @@ class IterationRuntime:
"token_usage": result.get("node_outputs", {}).get(node_name, {}).get("token_usage") "token_usage": result.get("node_outputs", {}).get(node_name, {}).get("token_usage")
} }
}) })
result = graph.get_state(config=checkpoint).values result = self.graph.get_state(config=self.checkpoint).values
else: else:
result = await graph.ainvoke(init_state) result = await self.graph.ainvoke(await self._init_iteration_state(item, idx))
output = self.child_variable_pool.get_value(self.output_value)
output = child_pool.get_value(self.output_value) if isinstance(output, list) and self.typed_config.flatten:
stopped = result["looping"] == 2 self.result.extend(output)
return idx, output, result, child_pool, stopped else:
self.result.append(output)
if result["looping"] == 2:
self.looping = False
return result
def _create_iteration_tasks(self, array_obj, idx): def _create_iteration_tasks(self, array_obj, idx):
""" """
@@ -240,32 +196,16 @@ class IterationRuntime:
tasks = self._create_iteration_tasks(array_obj, idx) tasks = self._create_iteration_tasks(array_obj, idx)
logger.info(f"Iteration node {self.node_id}: running, concurrency {len(tasks)}") logger.info(f"Iteration node {self.node_id}: running, concurrency {len(tasks)}")
idx += self.typed_config.parallel_count idx += self.typed_config.parallel_count
batch = await asyncio.gather(*tasks) child_state.extend(await asyncio.gather(*tasks))
# Sort by idx to preserve order, then collect results self.merge_conv_vars()
batch_sorted = sorted(batch, key=lambda x: x[0])
for _, output, result, child_pool, stopped in batch_sorted:
if isinstance(output, list) and self.typed_config.flatten:
self.result.extend(output)
else:
self.result.append(output)
child_state.append(result)
self._merge_conv_vars(child_pool)
if stopped:
self.looping = False
else: else:
# Execute iterations sequentially # Execute iterations sequentially
while idx < len(array_obj) and self.looping: while idx < len(array_obj) and self.looping:
logger.info(f"Iteration node {self.node_id}: running") logger.info(f"Iteration node {self.node_id}: running")
item = array_obj[idx] item = array_obj[idx]
_, output, result, child_pool, stopped = await self.run_task(item, idx) result = await self.run_task(item, idx)
if isinstance(output, list) and self.typed_config.flatten: self.merge_conv_vars()
self.result.extend(output)
else:
self.result.append(output)
self._merge_conv_vars(child_pool)
child_state.append(result) child_state.append(result)
if stopped:
self.looping = False
idx += 1 idx += 1
logger.info(f"Iteration node {self.node_id}: execution completed") logger.info(f"Iteration node {self.node_id}: execution completed")
return { return {

View File

@@ -123,7 +123,7 @@ class CycleGraphNode(BaseNode):
return cycle_nodes, cycle_edges return cycle_nodes, cycle_edges
def build_graph(self, variable_pool: VariablePool): def build_graph(self):
""" """
Build and compile the internal subgraph for this cycle node. Build and compile the internal subgraph for this cycle node.
@@ -135,7 +135,6 @@ class CycleGraphNode(BaseNode):
from app.core.workflow.engine.graph_builder import GraphBuilder from app.core.workflow.engine.graph_builder import GraphBuilder
self.child_variable_pool = VariablePool() self.child_variable_pool = VariablePool()
self.child_variable_pool.copy(variable_pool)
builder = GraphBuilder( builder = GraphBuilder(
{ {
"nodes": self.cycle_nodes, "nodes": self.cycle_nodes,
@@ -166,8 +165,8 @@ class CycleGraphNode(BaseNode):
Raises: Raises:
RuntimeError: If the node type is unsupported. RuntimeError: If the node type is unsupported.
""" """
self.build_graph()
if self.node_type == NodeType.LOOP: if self.node_type == NodeType.LOOP:
self.build_graph(variable_pool)
return await LoopRuntime( return await LoopRuntime(
start_id=self.start_node_id, start_id=self.start_node_id,
stream=False, stream=False,
@@ -180,19 +179,20 @@ class CycleGraphNode(BaseNode):
).run() ).run()
if self.node_type == NodeType.ITERATION: if self.node_type == NodeType.ITERATION:
return await IterationRuntime( return await IterationRuntime(
start_id=self.start_node_id,
stream=False, stream=False,
graph=self.graph,
node_id=self.node_id, node_id=self.node_id,
config=self.config, config=self.config,
state=state, state=state,
variable_pool=variable_pool, variable_pool=variable_pool,
cycle_nodes=self.cycle_nodes, child_variable_pool=self.child_variable_pool
cycle_edges=self.cycle_edges,
).run() ).run()
raise RuntimeError("Unknown cycle node type") raise RuntimeError("Unknown cycle node type")
async def execute_stream(self, state: WorkflowState, variable_pool: VariablePool): async def execute_stream(self, state: WorkflowState, variable_pool: VariablePool):
self.build_graph()
if self.node_type == NodeType.LOOP: if self.node_type == NodeType.LOOP:
self.build_graph(variable_pool)
yield { yield {
"__final__": True, "__final__": True,
"result": await LoopRuntime( "result": await LoopRuntime(
@@ -211,13 +211,14 @@ class CycleGraphNode(BaseNode):
yield { yield {
"__final__": True, "__final__": True,
"result": await IterationRuntime( "result": await IterationRuntime(
start_id=self.start_node_id,
stream=True, stream=True,
graph=self.graph,
node_id=self.node_id, node_id=self.node_id,
config=self.config, config=self.config,
state=state, state=state,
variable_pool=variable_pool, variable_pool=variable_pool,
cycle_nodes=self.cycle_nodes, child_variable_pool=self.child_variable_pool
cycle_edges=self.cycle_edges,
).run() ).run()
} }
return return

View File

@@ -6,30 +6,6 @@ from app.core.workflow.nodes.base_config import BaseNodeConfig
from app.core.workflow.nodes.enums import ComparisonOperator, LogicOperator, ValueInputType from app.core.workflow.nodes.enums import ComparisonOperator, LogicOperator, ValueInputType
class SubVariableConditionItem(BaseModel):
"""A single condition on a file object's field, used inside sub_variable_condition."""
key: str = Field(..., description="Field name of the file object, e.g. type, size, name")
operator: ComparisonOperator = Field(..., description="Comparison operator")
value: Any = Field(default=None, description="Value to compare with, or variable selector when input_type=variable")
input_type: ValueInputType = Field(default=ValueInputType.CONSTANT, description="constant or variable")
@field_validator("input_type", mode="before")
@classmethod
def lower_input_type(cls, v):
if isinstance(v, str):
try:
return ValueInputType(v.lower())
except ValueError:
raise ValueError(f"Invalid input_type: {v}")
return v
class SubVariableCondition(BaseModel):
"""Sub-conditions applied to each file element in an array[file] variable."""
logical_operator: LogicOperator = Field(default=LogicOperator.AND)
conditions: list[SubVariableConditionItem] = Field(default_factory=list)
class ConditionDetail(BaseModel): class ConditionDetail(BaseModel):
operator: ComparisonOperator = Field( operator: ComparisonOperator = Field(
..., ...,
@@ -38,12 +14,12 @@ class ConditionDetail(BaseModel):
left: str = Field( left: str = Field(
..., ...,
description="Variable selector, e.g. {{sys.files}}" description="Value to compare against"
) )
right: Any = Field( right: Any = Field(
default=None, default=None,
description="Value to compare with (unused when sub_variable_condition is set)" description="Value to compare with"
) )
input_type: ValueInputType = Field( input_type: ValueInputType = Field(
@@ -51,11 +27,6 @@ class ConditionDetail(BaseModel):
description="Value input type for comparison" description="Value input type for comparison"
) )
sub_variable_condition: SubVariableCondition | None = Field(
default=None,
description="Sub-conditions for array[file] fields. When set, operator must be contains/not_contains."
)
@field_validator("input_type", mode="before") @field_validator("input_type", mode="before")
@classmethod @classmethod
def lower_input_type(cls, v): def lower_input_type(cls, v):
@@ -68,19 +39,16 @@ class ConditionDetail(BaseModel):
class ConditionBranchConfig(BaseModel): class ConditionBranchConfig(BaseModel):
"""Configuration for a conditional branch. """Configuration for a conditional branch"""
logical_operator controls how all expressions are combined (AND/OR).
"""
logical_operator: LogicOperator = Field( logical_operator: LogicOperator = Field(
default=LogicOperator.AND, default=LogicOperator.AND,
description="Logical operator used to combine all conditions" description="Logical operator used to combine multiple condition expressions"
) )
expressions: list[ConditionDetail] = Field( expressions: list[ConditionDetail] = Field(
default_factory=list, ...,
description="List of conditions within this branch" description="List of condition expressions within this branch"
) )

View File

@@ -7,7 +7,7 @@ from app.core.workflow.engine.variable_pool import VariablePool
from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.base_node import BaseNode
from app.core.workflow.nodes.enums import ComparisonOperator, LogicOperator, ValueInputType from app.core.workflow.nodes.enums import ComparisonOperator, LogicOperator, ValueInputType
from app.core.workflow.nodes.if_else import IfElseNodeConfig from app.core.workflow.nodes.if_else import IfElseNodeConfig
from app.core.workflow.nodes.operators import ConditionExpressionResolver, CompareOperatorInstance, ArrayFileContainsOperator from app.core.workflow.nodes.operators import ConditionExpressionResolver, CompareOperatorInstance
from app.core.workflow.variable.base_variable import VariableType from app.core.workflow.variable.base_variable import VariableType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -90,9 +90,11 @@ class IfElseNode(BaseNode):
list[str]: A list of Python boolean expression strings, list[str]: A list of Python boolean expression strings,
ordered by branch priority. ordered by branch priority.
""" """
branch_index = 0
conditions = [] conditions = []
for case_branch in self.typed_config.cases: for case_branch in self.typed_config.cases:
branch_index += 1
branch_result = [] branch_result = []
for expression in case_branch.expressions: for expression in case_branch.expressions:
pattern = r"\{\{\s*(.*?)\s*\}\}" pattern = r"\{\{\s*(.*?)\s*\}\}"
@@ -101,18 +103,13 @@ class IfElseNode(BaseNode):
left_value = self.get_variable(left_string, variable_pool) left_value = self.get_variable(left_string, variable_pool)
except KeyError: except KeyError:
left_value = None left_value = None
evaluator = ConditionExpressionResolver.resolve_by_value(left_value)(
if expression.sub_variable_condition is not None and isinstance(left_value, list): variable_pool,
evaluator = ArrayFileContainsOperator(left_value, expression.sub_variable_condition, variable_pool) expression.left,
else: expression.right,
evaluator = ConditionExpressionResolver.resolve_by_value(left_value)( expression.input_type
variable_pool, )
expression.left,
expression.right,
expression.input_type
)
branch_result.append(self._evaluate(expression.operator, evaluator)) branch_result.append(self._evaluate(expression.operator, evaluator))
if case_branch.logical_operator == LogicOperator.AND: if case_branch.logical_operator == LogicOperator.AND:
conditions.append(all(branch_result)) conditions.append(all(branch_result))
else: else:

View File

@@ -116,11 +116,6 @@ class LLMNodeConfig(BaseNodeConfig):
description="Top-p 采样参数" description="Top-p 采样参数"
) )
json_output: bool = Field(
default=False,
description="是否以 JSON 格式输出"
)
frequency_penalty: float | None = Field( frequency_penalty: float | None = Field(
default=None, default=None,
ge=-2.0, ge=-2.0,

View File

@@ -5,6 +5,7 @@ LLM 节点实现
""" """
import logging import logging
import re
from typing import Any from typing import Any
from langchain_core.messages import AIMessage from langchain_core.messages import AIMessage
@@ -21,7 +22,6 @@ from app.db import get_db_context
from app.models import ModelType from app.models import ModelType
from app.schemas.model_schema import ModelInfo from app.schemas.model_schema import ModelInfo
from app.services.model_service import ModelConfigService from app.services.model_service import ModelConfigService
from app.models.models_model import ModelProvider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -80,7 +80,7 @@ class LLMNode(BaseNode):
def _render_context(self, message: str, variable_pool: VariablePool): def _render_context(self, message: str, variable_pool: VariablePool):
context = f"<context>{self._render_template(self.typed_config.context, variable_pool)}</context>" context = f"<context>{self._render_template(self.typed_config.context, variable_pool)}</context>"
return message.replace("{{context}}", context) return re.sub(r"{{context}}", context, message)
async def _prepare_llm( async def _prepare_llm(
self, self,
@@ -126,11 +126,7 @@ class LLMNode(BaseNode):
# 4. 创建 LLM 实例(使用已提取的数据) # 4. 创建 LLM 实例(使用已提取的数据)
# 注意:对于流式输出,需要在模型初始化时设置 streaming=True # 注意:对于流式输出,需要在模型初始化时设置 streaming=True
extra_params: dict[str, Any] = {"streaming": stream} if stream else {} extra_params = {"streaming": stream} if stream else {}
if self.typed_config.temperature is not None:
extra_params["temperature"] = self.typed_config.temperature
if self.typed_config.max_tokens is not None:
extra_params["max_tokens"] = self.typed_config.max_tokens
llm = RedBearLLM( llm = RedBearLLM(
RedBearModelConfig( RedBearModelConfig(
@@ -139,9 +135,7 @@ class LLMNode(BaseNode):
api_key=model_info.api_key, api_key=model_info.api_key,
base_url=model_info.api_base, base_url=model_info.api_base,
extra_params=extra_params, extra_params=extra_params,
is_omni=model_info.is_omni, is_omni=model_info.is_omni
capability=model_info.capability,
json_output=self.typed_config.json_output,
), ),
type=model_info.model_type type=model_info.model_type
) )
@@ -224,19 +218,6 @@ class LLMNode(BaseNode):
rendered = self._render_template(prompt_template, variable_pool) rendered = self._render_template(prompt_template, variable_pool)
self.messages = [{"role": "user", "content": rendered}] self.messages = [{"role": "user", "content": rendered}]
# ChatTongyi 要求 messages 含 'json' 字样才能使用 response_format在 system prompt 中注入
# VOLCANO 模型不支持 response_format同样需要 system prompt 注入
need_json_prompt = self.typed_config.json_output and (
(model_info.provider.lower() == ModelProvider.DASHSCOPE and not model_info.is_omni)
or model_info.provider.lower() == ModelProvider.VOLCANO
)
if need_json_prompt:
system_msg = next((m for m in self.messages if m["role"] == "system"), None)
if system_msg:
system_msg["content"] += "\n请以JSON格式输出。"
else:
self.messages.insert(0, {"role": "system", "content": "请以JSON格式输出。"})
return llm return llm
async def execute(self, state: WorkflowState, variable_pool: VariablePool) -> AIMessage: async def execute(self, state: WorkflowState, variable_pool: VariablePool) -> AIMessage:

View File

@@ -395,73 +395,11 @@ class NoneObjectComparisonOperator:
return lambda *args, **kwargs: False return lambda *args, **kwargs: False
class ArrayFileContainsOperator:
"""Handles contains/not_contains on array[file] with sub_variable_condition."""
def __init__(self, left_value: list[dict], sub_variable_condition: Any, pool: VariablePool | None = None):
self.left_value = left_value
self.sub_variable_condition = sub_variable_condition
self.pool = pool
def _resolve_value(self, cond: Any) -> Any:
if cond.input_type == ValueInputType.VARIABLE and self.pool is not None:
pattern = r"\{\{\s*(.*?)\s*\}\}"
selector = re.sub(pattern, r"\1", str(cond.value)).strip()
return self.pool.get_value(selector, default=None, strict=False)
return cond.value
def _match_item(self, file_item: dict) -> bool:
results = []
for cond in self.sub_variable_condition.conditions:
field_val = file_item.get(cond.key)
expected = self._resolve_value(cond)
result = self._eval_sub(field_val, cond.operator.value, expected)
results.append(result)
if self.sub_variable_condition.logical_operator.value == "and":
return all(results)
return any(results)
@staticmethod
def _eval_sub(field_val: Any, op: str, expected: Any) -> bool:
if field_val is None:
return op == "empty"
match op:
case "eq": return str(field_val) == str(expected)
case "ne": return str(field_val) != str(expected)
case "contains": return isinstance(field_val, str) and str(expected) in field_val
case "not_contains": return isinstance(field_val, str) and str(expected) not in field_val
case "in": return field_val in (expected if isinstance(expected, list) else [expected])
case "not_in": return field_val not in (expected if isinstance(expected, list) else [expected])
case "gt": return isinstance(field_val, (int, float)) and field_val > float(expected)
case "ge": return isinstance(field_val, (int, float)) and field_val >= float(expected)
case "lt": return isinstance(field_val, (int, float)) and field_val < float(expected)
case "le": return isinstance(field_val, (int, float)) and field_val <= float(expected)
case "empty": return field_val in (None, "", 0)
case "not_empty": return field_val not in (None, "", 0)
case _: return False
def contains(self) -> bool:
return any(self._match_item(f) for f in self.left_value if isinstance(f, dict))
def not_contains(self) -> bool:
return not self.contains()
def empty(self) -> bool:
return not self.left_value
def not_empty(self) -> bool:
return bool(self.left_value)
def __getattr__(self, name):
return lambda *args, **kwargs: False
CompareOperatorInstance = Union[ CompareOperatorInstance = Union[
StringComparisonOperator, StringComparisonOperator,
NumberComparisonOperator, NumberComparisonOperator,
BooleanComparisonOperator, BooleanComparisonOperator,
ArrayComparisonOperator, ArrayComparisonOperator,
ArrayFileContainsOperator,
ObjectComparisonOperator ObjectComparisonOperator
] ]
CompareOperatorType = Type[CompareOperatorInstance] CompareOperatorType = Type[CompareOperatorInstance]

View File

@@ -11,12 +11,10 @@ from app.core.workflow.nodes.tool.config import ToolNodeConfig
from app.core.workflow.variable.base_variable import VariableType from app.core.workflow.variable.base_variable import VariableType
from app.db import get_db_read from app.db import get_db_read
from app.services.tool_service import ToolService from app.services.tool_service import ToolService
from app.models.tool_model import ToolType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TEMPLATE_PATTERN = re.compile(r"\{\{.*?}}") TEMPLATE_PATTERN = re.compile(r"\{\{.*?}}")
PURE_VARIABLE_PATTERN = re.compile(r"^\{\{\s*([\w.]+)\s*}}$")
class ToolNode(BaseNode): class ToolNode(BaseNode):
@@ -54,21 +52,13 @@ class ToolNode(BaseNode):
# 渲染工具参数 # 渲染工具参数
rendered_parameters = {} rendered_parameters = {}
for param_name, param_template in self.typed_config.tool_parameters.items(): for param_name, param_template in self.typed_config.tool_parameters.items():
if isinstance(param_template, str): if isinstance(param_template, str) and TEMPLATE_PATTERN.search(param_template):
pure_match = PURE_VARIABLE_PATTERN.match(param_template) try:
if pure_match: rendered_value = self._render_template(param_template, variable_pool)
# 纯单变量引用直接取原始值,保留 int/bool/float 等类型 except Exception as e:
rendered_value = self.get_variable(pure_match.group(1), variable_pool, strict=False) raise ValueError(f"模板渲染失败:参数 {param_name} 的模板 {param_template} 解析错误") from e
if rendered_value is None:
rendered_value = self._render_template(param_template, variable_pool)
elif TEMPLATE_PATTERN.search(param_template):
try:
rendered_value = self._render_template(param_template, variable_pool)
except Exception as e:
raise ValueError(f"模板渲染失败:参数 {param_name} 的模板 {param_template} 解析错误") from e
else:
rendered_value = param_template
else: else:
# 非模板参数(数字/布尔/普通字符串)直接保留原值
rendered_value = param_template rendered_value = param_template
rendered_parameters[param_name] = rendered_value rendered_parameters[param_name] = rendered_value
@@ -77,18 +67,6 @@ class ToolNode(BaseNode):
# 执行工具 # 执行工具
with get_db_read() as db: with get_db_read() as db:
tool_service = ToolService(db) tool_service = ToolService(db)
# MCP 工具:将 operation 映射为 tool_name其余参数包装进 arguments
tool_instance = tool_service.get_tool_instance(self.typed_config.tool_id, tenant_id)
if tool_instance and tool_instance.tool_type == ToolType.MCP:
operation = rendered_parameters.pop("operation", None)
if operation:
old_params = rendered_parameters
rendered_parameters = {
"tool_name": operation,
"arguments": old_params
}
result = await tool_service.execute_tool( result = await tool_service.execute_tool(
tool_id=self.typed_config.tool_id, tool_id=self.typed_config.tool_id,
parameters=rendered_parameters, parameters=rendered_parameters,

View File

@@ -6,14 +6,12 @@ error messages based on the current request's language.
""" """
import logging import logging
import time
from contextvars import ContextVar from contextvars import ContextVar
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from app.i18n.service import get_translation_service from app.i18n.service import get_translation_service
from app.core.error_codes import ERROR_CODE_TO_BIZ_CODE, BizCode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -120,25 +118,16 @@ class I18nException(HTTPException):
**params **params
) )
# Convert error_code string to BizCode value # Build error detail
biz_code = ERROR_CODE_TO_BIZ_CODE.get(
self.error_code,
BizCode.BAD_REQUEST
)
# Build error detail in standard format for compatibility
# main.py handler expects "message" and "error_code" fields for filtering
# but we also include standard format fields
detail = { detail = {
"code": biz_code.value,
"msg": message,
"message": message,
"error_code": self.error_code, "error_code": self.error_code,
"data": params if params else {}, "message": message,
"error": message,
"time": int(time.time() * 1000),
} }
# Add parameters to detail if provided
if params:
detail["params"] = params
# Initialize HTTPException # Initialize HTTPException
super().__init__( super().__init__(
status_code=status_code, status_code=status_code,
@@ -493,39 +482,14 @@ class RateLimitExceededError(I18nException):
) )
class QuotaExceededError(I18nException): class QuotaExceededError(ForbiddenError):
"""Quota exceeded error (402).""" """Quota exceeded error."""
# resource key -> i18n display key
_RESOURCE_KEY_MAP = {
"workspace": "errors.quota_resources.workspace",
"app": "errors.quota_resources.app",
"skill": "errors.quota_resources.skill",
"knowledge_capacity": "errors.quota_resources.knowledge_capacity",
"memory_engine": "errors.quota_resources.memory_engine",
"end_user": "errors.quota_resources.end_user",
"model": "errors.quota_resources.model",
"ontology_project": "errors.quota_resources.ontology_project",
"api_ops_rate_limit": "errors.quota_resources.api_ops_rate_limit",
}
def __init__(self, resource: Optional[str] = None, **params): def __init__(self, resource: Optional[str] = None, **params):
# Translate resource key to a localized display name before calling super()
if resource: if resource:
resource_i18n_key = self._RESOURCE_KEY_MAP.get(resource) params["resource"] = resource
if resource_i18n_key:
try:
from app.i18n.service import get_translation_service
from app.core.config import settings
_locale = _current_locale.get() or settings.I18N_DEFAULT_LANGUAGE
params["resource"] = get_translation_service().translate(resource_i18n_key, _locale)
except Exception:
params["resource"] = resource
else:
params["resource"] = resource
super().__init__( super().__init__(
error_key="errors.api.quota_exceeded", error_key="errors.api.quota_exceeded",
status_code=402,
error_code="QUOTA_EXCEEDED", error_code="QUOTA_EXCEEDED",
**params **params
) )

View File

@@ -106,7 +106,7 @@
}, },
"api": { "api": {
"rate_limit_exceeded": "API rate limit exceeded", "rate_limit_exceeded": "API rate limit exceeded",
"quota_exceeded": "{resource} quota exceeded", "quota_exceeded": "API quota exceeded",
"invalid_api_key": "Invalid API key", "invalid_api_key": "Invalid API key",
"api_key_expired": "API key has expired", "api_key_expired": "API key has expired",
"api_key_revoked": "API key has been revoked", "api_key_revoked": "API key has been revoked",
@@ -114,8 +114,7 @@
"method_not_allowed": "Method not allowed", "method_not_allowed": "Method not allowed",
"invalid_request": "Invalid request", "invalid_request": "Invalid request",
"missing_parameter": "Missing required parameter: {param}", "missing_parameter": "Missing required parameter: {param}",
"invalid_parameter": "Invalid parameter: {param}", "invalid_parameter": "Invalid parameter: {param}"
"api_key_rate_limit_exceeded": "API Key rate limit ({rate_limit}) exceeds tenant plan limit ({limit})"
}, },
"database": { "database": {
"connection_failed": "Database connection failed", "connection_failed": "Database connection failed",
@@ -135,16 +134,5 @@
"invalid_format": "Invalid format: {field}", "invalid_format": "Invalid format: {field}",
"invalid_value": "Invalid value: {field}", "invalid_value": "Invalid value: {field}",
"out_of_range": "Value out of range: {field}" "out_of_range": "Value out of range: {field}"
},
"quota_resources": {
"workspace": "Workspace",
"app": "App",
"skill": "Skill",
"knowledge_capacity": "Knowledge capacity",
"memory_engine": "Memory engine",
"end_user": "End user",
"model": "Model",
"ontology_project": "Ontology project",
"api_ops_rate_limit": "API ops rate limit"
} }
} }

View File

@@ -106,7 +106,7 @@
}, },
"api": { "api": {
"rate_limit_exceeded": "API调用频率超限", "rate_limit_exceeded": "API调用频率超限",
"quota_exceeded": "{resource} 配额已超限", "quota_exceeded": "API调用配额已用完",
"invalid_api_key": "无效的API密钥", "invalid_api_key": "无效的API密钥",
"api_key_expired": "API密钥已过期", "api_key_expired": "API密钥已过期",
"api_key_revoked": "API密钥已被撤销", "api_key_revoked": "API密钥已被撤销",
@@ -114,8 +114,7 @@
"method_not_allowed": "不支持的请求方法", "method_not_allowed": "不支持的请求方法",
"invalid_request": "无效的请求", "invalid_request": "无效的请求",
"missing_parameter": "缺少必需参数:{param}", "missing_parameter": "缺少必需参数:{param}",
"invalid_parameter": "参数无效:{param}", "invalid_parameter": "参数无效:{param}"
"api_key_rate_limit_exceeded": "API Key 的 QPS 限制({rate_limit})超过租户套餐上限({limit}"
}, },
"database": { "database": {
"connection_failed": "数据库连接失败", "connection_failed": "数据库连接失败",
@@ -135,16 +134,5 @@
"invalid_format": "格式不正确:{field}", "invalid_format": "格式不正确:{field}",
"invalid_value": "值无效:{field}", "invalid_value": "值无效:{field}",
"out_of_range": "值超出范围:{field}" "out_of_range": "值超出范围:{field}"
},
"quota_resources": {
"workspace": "工作空间",
"app": "应用",
"skill": "技能",
"knowledge_capacity": "知识库容量",
"memory_engine": "记忆引擎",
"end_user": "终端用户",
"model": "模型",
"ontology_project": "本体工程",
"api_ops_rate_limit": "API 操作速率"
} }
} }

View File

@@ -29,8 +29,11 @@ class Tenants(Base):
contact_email = Column(String(255), nullable=True) # 联系人邮箱 contact_email = Column(String(255), nullable=True) # 联系人邮箱
contact_phone = Column(String(50), nullable=True) # 联系人电话 contact_phone = Column(String(50), nullable=True) # 联系人电话
# 租户套餐信息(只读,从 tenant_subscriptions 动态获取) # 租户套餐信息
status = Column(String(50), nullable=True, default='active', server_default='active') # 租户状态 plan = Column(String(50), nullable=True) # 套餐类型
plan_expired_at = Column(DateTime, nullable=True) # 套餐到期时间
api_ops_rate_limit = Column(String(100), nullable=True) # API 调用频率限制
status = Column(String(50), nullable=True, default='active') # 租户状态
# Relationship to users - one tenant has many users # Relationship to users - one tenant has many users
users = relationship("User", back_populates="tenant") users = relationship("User", back_populates="tenant")

View File

@@ -66,17 +66,6 @@ class EndUserRepository:
db_logger.error(f"查询宿主 {end_user_id} 时出错: {str(e)}") db_logger.error(f"查询宿主 {end_user_id} 时出错: {str(e)}")
raise raise
def get_end_user_by_other_id(self, workspace_id: uuid.UUID, other_id: str) -> Optional["EndUser"]:
"""按 workspace_id + other_id 查找终端用户,不存在返回 None"""
return (
self.db.query(EndUser)
.filter(
EndUser.workspace_id == workspace_id,
EndUser.other_id == other_id
)
.first()
)
def get_or_create_end_user( def get_or_create_end_user(
self, self,
app_id: uuid.UUID, app_id: uuid.UUID,

View File

@@ -328,7 +328,7 @@ class MemoryConfigRepository:
if not db_config: if not db_config:
db_logger.warning(f"记忆配置不存在: config_id={update.config_id}") db_logger.warning(f"记忆配置不存在: config_id={update.config_id}")
return None return None
#TODO部分更新没有用patch请求是在Repository层中用先查再部分更新的方式实现的后续可以考虑改成patch请求更符合RESTful设计原则
update_data = update.model_dump(exclude_unset=True) update_data = update.model_dump(exclude_unset=True)
update_data.pop("config_id", None) update_data.pop("config_id", None)

View File

@@ -263,15 +263,16 @@ class ModelConfigRepository:
raise raise
@staticmethod @staticmethod
def get_by_type(db: Session, model_types: List[ModelType], tenant_id: uuid.UUID | None = None, is_active: bool = True) -> List[ModelConfig]: def get_by_type(db: Session, model_type: ModelType, tenant_id: uuid.UUID | None = None, is_active: bool = True) -> List[ModelConfig]:
"""根据类型获取模型配置,支持多类型查询""" """根据类型获取模型配置"""
db_logger.debug(f"根据类型查询模型配置: types={[t.value for t in model_types]}, tenant_id={tenant_id}, is_active={is_active}") db_logger.debug(f"根据类型查询模型配置: type={model_type}, tenant_id={tenant_id}, is_active={is_active}")
try: try:
query = db.query(ModelConfig).options( query = db.query(ModelConfig).options(
joinedload(ModelConfig.api_keys) joinedload(ModelConfig.api_keys)
).filter(ModelConfig.type.in_([t.value for t in model_types])) ).filter(ModelConfig.type == model_type)
# 添加租户过滤
if tenant_id: if tenant_id:
query = query.filter( query = query.filter(
or_( or_(
@@ -279,18 +280,16 @@ class ModelConfigRepository:
ModelConfig.is_public ModelConfig.is_public
) )
) )
if is_active: if is_active:
query = query.filter(ModelConfig.is_active) query = query.filter(ModelConfig.is_active)
query = query.filter(ModelConfig.is_composite == False) models = query.order_by(ModelConfig.name).all()
models = query.order_by(ModelConfig.created_at.desc()).all()
db_logger.debug(f"根据类型查询模型配置成功: 数量={len(models)}") db_logger.debug(f"根据类型查询模型配置成功: 数量={len(models)}")
return models return models
except Exception as e: except Exception as e:
db_logger.error(f"根据类型查询模型配置失败: types={model_types} - {str(e)}") db_logger.error(f"根据类型查询模型配置失败: type={model_type} - {str(e)}")
raise raise
@staticmethod @staticmethod

View File

@@ -15,8 +15,8 @@ class ApiKeyCreate(BaseModel):
type: ApiKeyType = Field(..., description="API Key 类型") type: ApiKeyType = Field(..., description="API Key 类型")
scopes: List[str] = Field(default_factory=list, description="权限范围列表") scopes: List[str] = Field(default_factory=list, description="权限范围列表")
resource_id: Optional[uuid.UUID] = Field(None, description="关联资源ID") resource_id: Optional[uuid.UUID] = Field(None, description="关联资源ID")
rate_limit: Optional[int] = Field(50, ge=1, le=1000, description="QPS限制请求/秒)") rate_limit: Optional[int] = Field(100, ge=1, le=1000, description="QPS限制请求/秒)")
daily_request_limit: Optional[int] = Field(100000, description="日请求限制", ge=1) daily_request_limit: Optional[int] = Field(10000, description="日请求限制", ge=1)
quota_limit: Optional[int] = Field(None, description="配额限制(总请求数)", ge=1) quota_limit: Optional[int] = Field(None, description="配额限制(总请求数)", ge=1)
expires_at: Optional[datetime.datetime] = Field(None, description="过期时间") expires_at: Optional[datetime.datetime] = Field(None, description="过期时间")
@@ -55,7 +55,7 @@ class ApiKeyUpdate(BaseModel):
description: Optional[str] = Field(None, description="描述") description: Optional[str] = Field(None, description="描述")
scopes: Optional[List[str]] = Field(None, description="权限范围列表") scopes: Optional[List[str]] = Field(None, description="权限范围列表")
rate_limit: Optional[int] = Field(None, description="速率限制(请求/分钟)", ge=1) rate_limit: Optional[int] = Field(None, description="速率限制(请求/分钟)", ge=1)
daily_request_limit: Optional[int] = Field(100000, description="每日请求数限制", ge=1) daily_request_limit: Optional[int] = Field(10000, description="每日请求数限制", ge=1)
quota_limit: Optional[int] = Field(None, description="配额限制(总请求数)", ge=1) quota_limit: Optional[int] = Field(None, description="配额限制(总请求数)", ge=1)
is_active: Optional[bool] = Field(None, description="是否激活") is_active: Optional[bool] = Field(None, description="是否激活")
expires_at: Optional[datetime.datetime] = Field(None, description="过期时间") expires_at: Optional[datetime.datetime] = Field(None, description="过期时间")

View File

@@ -44,8 +44,6 @@ class FileInput(BaseModel):
upload_file_id: Optional[uuid.UUID] = Field(None, description="已上传文件IDlocal_file时必填") upload_file_id: Optional[uuid.UUID] = Field(None, description="已上传文件IDlocal_file时必填")
url: Optional[str] = Field(None, description="远程URLremote_url时必填") url: Optional[str] = Field(None, description="远程URLremote_url时必填")
file_type: Optional[str] = Field(None, description="具体文件格式如image/jpg、audio/wav、document/docx、video/mp4") file_type: Optional[str] = Field(None, description="具体文件格式如image/jpg、audio/wav、document/docx、video/mp4")
name: Optional[str] = Field(None, description="文件名")
size: Optional[int] = Field(None, description="文件大小(字节)")
_content = None _content = None
@@ -245,7 +243,6 @@ class ModelParameters(BaseModel):
stop: Optional[List[str]] = Field(default=None, description="停止序列") stop: Optional[List[str]] = Field(default=None, description="停止序列")
deep_thinking: bool = Field(default=False, description="是否启用深度思考模式(需模型支持,如 DeepSeek-R1、QwQ 等)") deep_thinking: bool = Field(default=False, description="是否启用深度思考模式(需模型支持,如 DeepSeek-R1、QwQ 等)")
thinking_budget_tokens: Optional[int] = Field(default=None, ge=1024, le=131072, description="深度思考 token 预算(仅部分模型支持)") thinking_budget_tokens: Optional[int] = Field(default=None, ge=1024, le=131072, description="深度思考 token 预算(仅部分模型支持)")
json_output: bool = Field(default=False, description="是否强制 JSON 格式输出(需模型支持 json_output 能力)")
class VariableDefinition(BaseModel): class VariableDefinition(BaseModel):

View File

@@ -4,10 +4,9 @@ This module defines Pydantic schemas for the Memory API Service endpoints,
including request validation and response structures for read and write operations. including request validation and response structures for read and write operations.
""" """
from typing import Any, Dict, List, Literal, Optional from typing import Any, Dict, List, Optional
import uuid
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, Field, field_validator
class MemoryWriteRequest(BaseModel): class MemoryWriteRequest(BaseModel):
@@ -111,30 +110,6 @@ class MemoryReadRequest(BaseModel):
class MemoryWriteResponse(BaseModel): class MemoryWriteResponse(BaseModel):
"""Response schema for memory write operation. """Response schema for memory write operation.
Attributes:
task_id: Celery task ID for status polling
status: Initial task status (PENDING)
end_user_id: End user ID the write was submitted for
"""
task_id: str = Field(..., description="Celery task ID for polling")
status: str = Field(..., description="Task status: PENDING")
end_user_id: str = Field(..., description="End user ID")
class TaskStatusResponse(BaseModel):
"""Response schema for task status check.
Attributes:
status: Task status (PENDING, STARTED, SUCCESS, FAILURE, SKIPPED)
result: Task result data (available when status is SUCCESS or FAILURE)
"""
status: str = Field(..., description="Task status")
result: Optional[Dict[str, Any]] = Field(None, description="Task result when completed")
class MemoryWriteSyncResponse(BaseModel):
"""Response schema for synchronous memory write.
Attributes: Attributes:
status: Operation status (success or failed) status: Operation status (success or failed)
end_user_id: End user ID that was written to end_user_id: End user ID that was written to
@@ -143,8 +118,8 @@ class MemoryWriteSyncResponse(BaseModel):
end_user_id: str = Field(..., description="End user ID") end_user_id: str = Field(..., description="End user ID")
class MemoryReadSyncResponse(BaseModel): class MemoryReadResponse(BaseModel):
"""Response schema for synchronous memory read. """Response schema for memory read operation.
Attributes: Attributes:
answer: Generated answer from memory retrieval answer: Generated answer from memory retrieval
@@ -153,25 +128,12 @@ class MemoryReadSyncResponse(BaseModel):
""" """
answer: str = Field(..., description="Generated answer") answer: str = Field(..., description="Generated answer")
intermediate_outputs: List[Dict[str, Any]] = Field( intermediate_outputs: List[Dict[str, Any]] = Field(
default_factory=list, default_factory=list,
description="Intermediate retrieval outputs" description="Intermediate retrieval outputs"
) )
end_user_id: str = Field(..., description="End user ID") end_user_id: str = Field(..., description="End user ID")
class MemoryReadResponse(BaseModel):
"""Response schema for memory read operation.
Attributes:
task_id: Celery task ID for status polling
status: Initial task status (PENDING)
end_user_id: End user ID the read was submitted for
"""
task_id: str = Field(..., description="Celery task ID for polling")
status: str = Field(..., description="Task status: PENDING")
end_user_id: str = Field(..., description="End user ID")
class CreateEndUserRequest(BaseModel): class CreateEndUserRequest(BaseModel):
"""Request schema for creating an end user. """Request schema for creating an end user.
@@ -179,12 +141,10 @@ class CreateEndUserRequest(BaseModel):
other_id: External user identifier (required) other_id: External user identifier (required)
other_name: Display name for the end user other_name: Display name for the end user
memory_config_id: Optional memory config ID. If not provided, uses workspace default. memory_config_id: Optional memory config ID. If not provided, uses workspace default.
app_id: Optional app ID to bind the end user to.
""" """
other_id: str = Field(..., description="External user identifier (required)") other_id: str = Field(..., description="External user identifier (required)")
other_name: Optional[str] = Field("", description="Display name") other_name: Optional[str] = Field("", description="Display name")
memory_config_id: Optional[str] = Field(None, description="Memory config ID. Falls back to workspace default if not provided.") memory_config_id: Optional[str] = Field(None, description="Memory config ID. Falls back to workspace default if not provided.")
app_id: Optional[str] = Field(None, description="App ID to bind the end user to")
@field_validator("other_id") @field_validator("other_id")
@classmethod @classmethod
@@ -232,7 +192,6 @@ class MemoryConfigItem(BaseModel):
created_at: Optional[str] = Field(None, description="Creation timestamp") created_at: Optional[str] = Field(None, description="Creation timestamp")
updated_at: Optional[str] = Field(None, description="Last update timestamp") updated_at: Optional[str] = Field(None, description="Last update timestamp")
# ========== V1 记忆配置管理接口 Schema ==========
class ListConfigsResponse(BaseModel): class ListConfigsResponse(BaseModel):
"""Response schema for listing memory configs. """Response schema for listing memory configs.
@@ -243,203 +202,3 @@ class ListConfigsResponse(BaseModel):
""" """
configs: List[MemoryConfigItem] = Field(default_factory=list, description="List of configs") configs: List[MemoryConfigItem] = Field(default_factory=list, description="List of configs")
total: int = Field(0, description="Total number of configs") total: int = Field(0, description="Total number of configs")
class ConfigCreateRequest(BaseModel):
"""Request schema for creating a new memory config."""
config_name: str = Field(..., description="Configuration name")
config_desc: Optional[str] = Field("", description="Configuration description")
scene_id: uuid.UUID = Field(..., description="Associated ontology scene ID (UUID, required)")
llm_id: Optional[str] = Field(None, description="LLM model configuration ID")
embedding_id: Optional[str] = Field(None, description="Embedding model configuration ID")
rerank_id: Optional[str] = Field(None, description="Reranking model configuration ID")
reflection_model_id: Optional[str] = Field(None, description="Reflection model ID")
emotion_model_id: Optional[str] = Field(None, description="Emotion analysis model ID")
@field_validator("config_name")
@classmethod
def validate_config_name(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("config_name is required and cannot be empty")
return v.strip()
class ConfigUpdateRequest(BaseModel):
"""Request schema for updating memory config basic info.
Attributes:
config_id: Configuration UUID to update (required)
config_name: New configuration name
config_desc: New configuration description
scene_id: New associated ontology scene ID
"""
config_id: str = Field(..., description="Configuration ID to update")
config_name: Optional[str] = Field(None, description="Configuration name")
config_desc: Optional[str] = Field(None, description="Configuration description")
scene_id: Optional[uuid.UUID] = Field(None, description="Associated ontology scene ID")
@field_validator("config_id")
@classmethod
def validate_config_id(cls, v: str) -> str:
"""Validate that config_id is not empty."""
if not v or not v.strip():
raise ValueError("config_id is required and cannot be empty")
return v.strip()
class ConfigUpdateExtractedRequest(BaseModel):
"""Request schema for updating memory config extracted parameters.
Attributes:
config_id: Configuration UUID to update (required)
llm_id: Optional LLM model configuration ID
audio_id: Optional audio model configuration ID
vision_id: Optional vision model configuration ID
video_id: Optional video model configuration ID
embedding_id: Optional embedding model configuration ID
rerank_id: Optional reranking model configuration ID
enable_llm_dedup_blockwise: Optional toggle for LLM decision deduplication
enable_llm_disambiguation: Optional toggle for LLM decision disambiguation
deep_retrieval: Optional toggle for deep retrieval
t_type_strict: Optional float (0-1) for type strictness threshold
t_name_strict: Optional float (0-1) for name strictness threshold
t_overall: Optional float (0-1) for overall strictness threshold
state: Optional boolean for config active state
chunker_strategy: Optional string for memory chunking strategy
statement_granularity: Optional int (1-3) for statement extraction granularity
include_dialogue_context: Optional boolean for including dialogue context in retrieval
max_context: Optional int for maximum dialogue context length in characters
pruning_enabled: Optional boolean to enable intelligent semantic pruning
pruning_scene: Optional string for semantic pruning scene
pruning_threshold: Optional float (0-0.9) for semantic pruning threshold
enable_self_reflexion: Optional boolean to enable self-reflexion
iteration_period: Optional string for reflexion iteration period in hours (1, 3, 6, 12, 24)
reflexion_range: Optional string for reflexion range (partial or all)
baseline: Optional string for baseline (TIME/FACT/TIME-FACT)
"""
config_id: str = Field(..., description="Configuration ID (UUID)")
llm_id: Optional[str] = Field(None, description="LLM model configuration ID")
audio_id: Optional[str] = Field(None, description="Audio model ID")
vision_id: Optional[str] = Field(None, description="Vision model ID")
video_id: Optional[str] = Field(None, description="Video model ID")
embedding_id: Optional[str] = Field(None, description="Embedding model configuration ID")
rerank_id: Optional[str] = Field(None, description="Reranking model configuration ID")
enable_llm_dedup_blockwise: Optional[bool] = Field(None, description="Enable LLM decision deduplication")
enable_llm_disambiguation: Optional[bool] = Field(None, description="Enable LLM decision disambiguation")
deep_retrieval: Optional[bool] = Field(None, description="Deep retrieval toggle")
t_type_strict: Optional[float] = Field(None, ge=0.0, le=1.0, description="type strictness threshold")
t_name_strict: Optional[float] = Field(None, ge=0.0, le=1.0, description="name strictness threshold")
t_overall: Optional[float] = Field(None, ge=0.0, le=1.0, description="overall strictness threshold")
state: Optional[bool] = Field(None, description="config active state")
# 句子提取
chunker_strategy: Optional[str] = Field(None, description="memory chunking strategy")
statement_granularity: Optional[int] = Field(None, ge=1, le=3, description="statement extraction granularity")
include_dialogue_context: Optional[bool] = Field(None, description="whether to include dialogue context in retrieval")
max_context: Optional[int] = Field(None, gt=100, description="maximum dialogue context length in characters")
# 剪枝配置:与 runtime.json 中 pruning 段对应
pruning_enabled: Optional[bool] = Field(None, description="whether to enable intelligent semantic pruning")
pruning_scene: Optional[str] = Field(None, description="semantic pruning scene")
pruning_threshold: Optional[float] = Field(None, ge=0.0, le=0.9, description="semantic pruning threshold (0-0.9)")
enable_self_reflexion: Optional[bool] = Field(None, description="whether to enable self-reflexion")
iteration_period: Optional[Literal["1", "3", "6", "12", "24"]] = Field(None, description="reflexion iteration period in hours (1, 3, 6, 12, 24)")
reflexion_range: Optional[Literal["partial", "all"]] = Field(None, description="reflexion range: partial/all")
baseline: Optional[Literal["TIME", "FACT", "TIME-FACT"]] = Field(None, description="baseline: TIME/FACT/TIME-FACT")
@field_validator("config_id")
@classmethod
def validate_config_id(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("config_id is required and cannot be empty")
return v.strip()
class ConfigUpdateForgettingRequest(BaseModel):
"""Request schema for updating memory config forgetting parameters.
Attributes:
config_id: Configuration UUID to update (required)
decay_constant: Decay constant for forgetting
lambda_time: Time decay parameter
lambda_mem: Memory decay parameter
offset: Offset for forgetting curve
max_history_length: Maximum history length to consider for forgetting
forgetting_threshold: Threshold for forgetting
min_days_since_access: Minimum days since last access to trigger forgetting
enable_llm_summary: Whether to use LLM-generated summaries for forgetting
max_merge_batch_size: Maximum batch size for merging nodes during forgetting
forgetting_interval_hours: Interval in hours for periodic forgetting
"""
model_config = ConfigDict(populate_by_name=True, extra="forbid")
config_id: str = Field(..., description="Configuration ID (UUID)")
decay_constant: Optional[float] = Field(None, ge=0.0, le=1.0, description="Decay constant for forgetting")
lambda_time: Optional[float] = Field(None, ge=0.0, le=1.0, description="Time decay parameter")
lambda_mem: Optional[float] = Field(None, ge=0.0, le=1.0, description="Memory decay parameter")
offset: Optional[float] = Field(None, ge=0.0, le=1.0, description="Offset for forgetting curve")
max_history_length: Optional[int] = Field(None, ge=10, le=1000, description="Maximum history length to consider for forgetting")
forgetting_threshold: Optional[float] = Field(None, ge=0.0, le=1.0, description="Forgetting threshold")
min_days_since_access: Optional[int] = Field(None, ge=1, le=365, description="Minimum days since last access to trigger forgetting")
enable_llm_summary: Optional[bool] = Field(None, description="Whether to use LLM-generated summaries for forgetting")
max_merge_batch_size: Optional[int] = Field(None, ge=1, le=1000, description="Maximum batch size for merging nodes during forgetting")
forgetting_interval_hours: Optional[int] = Field(None, ge=1, le=168, description="Interval in hours for periodic forgetting")
@field_validator("config_id")
@classmethod
def validate_config_id(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("config_id is required and cannot be empty")
return v.strip()
class EmotionConfigUpdateRequest(BaseModel):
"""Request schema for updating memory config emotion parameters.
Attributes:
config_id: Configuration UUID to update (required)
emotion_enabled: Whether to enable emotion extraction
emotion_model_id: Emotion analysis model ID
emotion_extract_keywords: Whether to extract emotion keywords
emotion_min_intensity: Minimum emotion intensity threshold (0.0-1.0)
emotion_enable_subject: Whether to enable subject classification for emotions
"""
config_id: str = Field(..., description="Configuration ID (UUID)")
emotion_enabled: bool = Field(..., description="Whether to enable emotion extraction")
emotion_model_id: Optional[str] = Field(None, description="Emotion analysis model ID")
emotion_extract_keywords: bool = Field(..., description="Whether to extract emotion keywords")
emotion_min_intensity: float = Field(..., ge=0.0, le=1.0, description="Minimum emotion intensity threshold")
emotion_enable_subject: bool = Field(..., description="Whether to enable subject classification for emotions")
@field_validator("config_id")
@classmethod
def validate_config_id(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("config_id is required and cannot be empty")
return v.strip()
class ReflectionConfigUpdateRequest(BaseModel):
"""Request schema for updating memory config reflection parameters.
Attributes:
config_id: Configuration UUID to update (required)
reflection_enabled: Whether to enable self-reflection
reflection_period_in_hours: Reflection iteration period in hours
reflexion_range: Reflection range (partial or all)
baseline: Baseline for reflection (TIME/FACT/TIME-FACT)
reflection_model_id: Reflection model ID
memory_verify: Whether to enable memory verification
quality_assessment: Whether to enable quality assessment
"""
config_id: str = Field(..., description="Configuration ID (UUID)")
reflection_enabled: bool = Field(..., description="Whether to enable self-reflection")
reflection_period_in_hours: str = Field(..., description="Reflection iteration period in hours")
reflexion_range: Literal["partial", "all"] = Field(..., description="Reflection range: partial/all")
baseline: Literal["TIME", "FACT", "TIME-FACT"] = Field(..., description="Baseline: TIME/FACT/TIME-FACT")
reflection_model_id: str = Field(..., description="Reflection model ID")
memory_verify: bool = Field(..., description="Whether to enable memory verification")
quality_assessment: bool = Field(..., description="Whether to enable quality assessment")
@field_validator("config_id")
@classmethod
def validate_config_id(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("config_id is required and cannot be empty")
return v.strip()

View File

@@ -291,7 +291,7 @@ class ConfigUpdateExtracted(BaseModel): # 更新记忆萃取引擎配置参数
pruning_threshold: Optional[float] = Field( pruning_threshold: Optional[float] = Field(
None, ge=0.0, le=0.9, description="智能语义剪枝阈值0-0.9" None, ge=0.0, le=0.9, description="智能语义剪枝阈值0-0.9"
) )
#TODO:萃取引擎的更新的更新会带有反思引擎的参数,需判断业务是否需要,不需要可以重构
# 反思配置 # 反思配置
enable_self_reflexion: Optional[bool] = Field(None, description="是否启用自我反思") enable_self_reflexion: Optional[bool] = Field(None, description="是否启用自我反思")
iteration_period: Optional[Literal["1", "3", "6", "12", "24"]] = Field( iteration_period: Optional[Literal["1", "3", "6", "12", "24"]] = Field(

View File

@@ -51,19 +51,6 @@ class ApiKeyService:
if existing: if existing:
raise BusinessException(f"API Key 名称 {data.name} 已存在", BizCode.API_KEY_DUPLICATE_NAME) raise BusinessException(f"API Key 名称 {data.name} 已存在", BizCode.API_KEY_DUPLICATE_NAME)
# 若 rate_limit 超过租户套餐的 api_ops_rate_limit直接报错
from app.models.workspace_model import Workspace
from app.core.quota_manager import get_api_ops_rate_limit
workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if workspace:
tenant_api_ops_limit = get_api_ops_rate_limit(db, workspace.tenant_id)
if tenant_api_ops_limit and data.rate_limit > tenant_api_ops_limit:
raise BusinessException(
f"API Key QPS 不能超过套餐上限 {tenant_api_ops_limit}",
BizCode.BAD_REQUEST
)
# 生成 API Key # 生成 API Key
api_key = generate_api_key(data.type) api_key = generate_api_key(data.type)
@@ -165,20 +152,6 @@ class ApiKeyService:
if existing: if existing:
raise BusinessException(f"API Key 名称 {data.name} 已存在", BizCode.API_KEY_DUPLICATE_NAME) raise BusinessException(f"API Key 名称 {data.name} 已存在", BizCode.API_KEY_DUPLICATE_NAME)
# 若 rate_limit 超过租户套餐的 api_ops_rate_limit直接报错
if data.rate_limit is not None:
from app.models.workspace_model import Workspace
from app.core.quota_manager import get_api_ops_rate_limit
workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if workspace:
tenant_api_ops_limit = get_api_ops_rate_limit(db, workspace.tenant_id)
if tenant_api_ops_limit and data.rate_limit > tenant_api_ops_limit:
raise BusinessException(
f"API Key QPS 不能超过套餐上限 {tenant_api_ops_limit}",
BizCode.BAD_REQUEST
)
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
ApiKeyRepository.update(db, api_key_id, update_data) ApiKeyRepository.update(db, api_key_id, update_data)
db.commit() db.commit()
@@ -276,13 +249,12 @@ class RateLimiterService:
self.redis = aio_redis self.redis = aio_redis
async def check_qps(self, api_key_id: uuid.UUID, limit: int) -> Tuple[bool, dict]: async def check_qps(self, api_key_id: uuid.UUID, limit: int) -> Tuple[bool, dict]:
"""检查QPS限制 """
检查QPS限制
Returns: Returns:
(is_allowed, rate_limit_info) (is_allowed, rate_limit_info)
""" """
key = f"rate_limit:qps:{api_key_id}" key = f"rate_limit:qps:{api_key_id}"
async with self.redis.pipeline() as pipe: async with self.redis.pipeline() as pipe:
pipe.incr(key) pipe.incr(key)
pipe.expire(key, 1, nx=True) # 1 秒过期 pipe.expire(key, 1, nx=True) # 1 秒过期
@@ -294,9 +266,8 @@ class RateLimiterService:
return current <= limit, { return current <= limit, {
"limit": limit, "limit": limit,
"current": current,
"remaining": remaining, "remaining": remaining,
"reset": reset_time, "reset": reset_time
} }
async def check_daily_requests( async def check_daily_requests(
@@ -304,9 +275,7 @@ class RateLimiterService:
api_key_id: uuid.UUID, api_key_id: uuid.UUID,
limit: int limit: int
) -> Tuple[bool, dict]: ) -> Tuple[bool, dict]:
"""检查日调用量限制 """检查日调用量限制"""
使用原子 INCR先写后判断极低概率下允许轻微超限并发场景下可接受
"""
today = datetime.now().strftime("%Y%m%d") today = datetime.now().strftime("%Y%m%d")
key = f"rate_limit:daily:{api_key_id}:{today}" key = f"rate_limit:daily:{api_key_id}:{today}"
@@ -315,7 +284,6 @@ class RateLimiterService:
hour=0, minute=0, second=0, microsecond=0 hour=0, minute=0, second=0, microsecond=0
) )
expire_seconds = int((tomorrow_0 - now).total_seconds()) expire_seconds = int((tomorrow_0 - now).total_seconds())
reset_time = int(tomorrow_0.timestamp())
async with self.redis.pipeline() as pipe: async with self.redis.pipeline() as pipe:
pipe.incr(key) pipe.incr(key)
@@ -323,74 +291,36 @@ class RateLimiterService:
results = await pipe.execute() results = await pipe.execute()
current = results[0] current = results[0]
remaining = max(0, limit - current)
reset_time = int(tomorrow_0.timestamp())
if current > limit: return current <= limit, {
return False, {
"limit": limit,
"remaining": 0,
"reset": reset_time,
}
return True, {
"limit": limit, "limit": limit,
"remaining": max(0, limit - current), "remaining": remaining,
"reset": reset_time, "reset": reset_time
} }
async def check_all_limits( async def check_all_limits(
self, self,
api_key: ApiKey, api_key: ApiKey
db: Optional[Session] = None,
) -> Tuple[bool, str, dict]: ) -> Tuple[bool, str, dict]:
""" """
检查所有限制,按以下顺序: 检查所有限制
1. API Key QPS取 api_key.rate_limit 与套餐 api_ops_rate_limit 的最小值作为限额 Returns:
2. API Key 日调用量 (is_allowed, error_message, rate_limit_headers)
""" """
# 1. 取套餐限额与 api_key 自身限额的最小值 # Check QPS
effective_limit = api_key.rate_limit qps_ok, qps_info = await self.check_qps(
if db is not None: api_key.id,
try: api_key.rate_limit
from app.models.workspace_model import Workspace )
from app.core.quota_manager import get_api_ops_rate_limit
cache_key = f"tenant_api_ops_limit:{api_key.workspace_id}"
cached = await self.redis.get(cache_key)
if cached is not None:
try:
tenant_limit = int(cached) if cached != "0" else None
except (ValueError, TypeError):
cached = None
tenant_limit = None
if cached is None:
workspace = db.query(Workspace).filter(Workspace.id == api_key.workspace_id).first()
if workspace:
tenant_limit = get_api_ops_rate_limit(db, workspace.tenant_id)
await self.redis.set(cache_key, str(tenant_limit) if tenant_limit else "0", ex=60)
else:
tenant_limit = None
if tenant_limit:
effective_limit = min(api_key.rate_limit, tenant_limit)
except Exception as e:
logger.warning(f"获取套餐限额失败,使用 api_key 自身限额: {e}")
# 用最终有效限额做 QPS 检查
qps_ok, qps_info = await self.check_qps(api_key.id, effective_limit)
if not qps_ok: if not qps_ok:
# 判断是套餐限额触发还是 api_key 自身限额触发 return False, "QPS limit exceeded", {
if tenant_limit and effective_limit == tenant_limit and api_key.rate_limit > tenant_limit:
error_msg = "Tenant limit exceeded"
else:
error_msg = "QPS limit exceeded"
return False, error_msg, {
"X-RateLimit-Limit-QPS": str(qps_info["limit"]), "X-RateLimit-Limit-QPS": str(qps_info["limit"]),
"X-RateLimit-Remaining-QPS": str(qps_info["remaining"]), "X-RateLimit-Remaining-QPS": str(qps_info["remaining"]),
"X-RateLimit-Reset": str(qps_info["reset"]) "X-RateLimit-Reset": str(qps_info["reset"])
} }
# 2. 检查日调用量
daily_ok, daily_info = await self.check_daily_requests( daily_ok, daily_info = await self.check_daily_requests(
api_key.id, api_key.id,
api_key.daily_request_limit api_key.daily_request_limit
@@ -402,13 +332,14 @@ class RateLimiterService:
"X-RateLimit-Reset": str(daily_info["reset"]) "X-RateLimit-Reset": str(daily_info["reset"])
} }
return True, "", { headers = {
"X-RateLimit-Limit-QPS": str(qps_info["limit"]), "X-RateLimit-Limit-QPS": str(qps_info["limit"]),
"X-RateLimit-Remaining-QPS": str(qps_info["remaining"]), "X-RateLimit-Remaining-QPS": str(qps_info["remaining"]),
"X-RateLimit-Limit-Day": str(daily_info["limit"]), "X-RateLimit-Limit-Day": str(daily_info["limit"]),
"X-RateLimit-Remaining-Day": str(daily_info["remaining"]), "X-RateLimit-Remaining-Day": str(daily_info["remaining"]),
"X-RateLimit-Reset": str(daily_info["reset"]), "X-RateLimit-Reset": str(daily_info["reset"])
} }
return True, "", headers
class ApiKeyAuthService: class ApiKeyAuthService:

View File

@@ -26,7 +26,6 @@ from app.services.model_service import ModelApiKeyService
from app.services.multi_agent_orchestrator import MultiAgentOrchestrator from app.services.multi_agent_orchestrator import MultiAgentOrchestrator
from app.services.multimodal_service import MultimodalService from app.services.multimodal_service import MultimodalService
from app.services.workflow_service import WorkflowService from app.services.workflow_service import WorkflowService
from app.models.file_metadata_model import FileMetadata
logger = get_business_logger() logger = get_business_logger()
@@ -120,7 +119,6 @@ class AppChatService:
tools=tools, tools=tools,
deep_thinking=model_parameters.get("deep_thinking", False), deep_thinking=model_parameters.get("deep_thinking", False),
thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"),
json_output=model_parameters.get("json_output", False),
capability=api_key_obj.capability or [], capability=api_key_obj.capability or [],
) )
@@ -220,29 +218,11 @@ class AppChatService:
"reasoning_content": result.get("reasoning_content") "reasoning_content": result.get("reasoning_content")
} }
if files: if files:
local_ids = [f.upload_file_id for f in files
if f.transfer_method.value == "local_file" and f.upload_file_id
and (not f.name or not f.size)]
meta_map = {}
if local_ids:
rows = self.db.query(FileMetadata).filter(
FileMetadata.id.in_(local_ids),
FileMetadata.status == "completed"
).all()
meta_map = {str(r.id): r for r in rows}
for f in files: for f in files:
name, size = f.name, f.size # url = await MultimodalService(self.db).get_file_url(f)
if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size):
meta = meta_map.get(str(f.upload_file_id))
if meta:
name = name or meta.file_name
size = size or meta.file_size
human_meta["files"].append({ human_meta["files"].append({
"type": f.type, "type": f.type,
"url": f.url, "url": f.url
"name": name,
"size": size,
"file_type": f.file_type,
}) })
if processed_files: if processed_files:
@@ -393,7 +373,6 @@ class AppChatService:
streaming=True, streaming=True,
deep_thinking=model_parameters.get("deep_thinking", False), deep_thinking=model_parameters.get("deep_thinking", False),
thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"),
json_output=model_parameters.get("json_output", False),
capability=api_key_obj.capability or [], capability=api_key_obj.capability or [],
) )
@@ -530,29 +509,10 @@ class AppChatService:
} }
if files: if files:
local_ids = [f.upload_file_id for f in files
if f.transfer_method.value == "local_file" and f.upload_file_id
and (not f.name or not f.size)]
meta_map = {}
if local_ids:
rows = self.db.query(FileMetadata).filter(
FileMetadata.id.in_(local_ids),
FileMetadata.status == "completed"
).all()
meta_map = {str(r.id): r for r in rows}
for f in files: for f in files:
name, size = f.name, f.size
if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size):
meta = meta_map.get(str(f.upload_file_id))
if meta:
name = name or meta.file_name
size = size or meta.file_size
human_meta["files"].append({ human_meta["files"].append({
"type": f.type, "type": f.type,
"url": f.url, "url": f.url
"name": name,
"size": size,
"file_type": f.file_type,
}) })
if processed_files: if processed_files:
human_meta["history_files"] = { human_meta["history_files"] = {

View File

@@ -14,14 +14,12 @@ from app.models.app_model import App, AppType
from app.models.appshare_model import AppShare from app.models.appshare_model import AppShare
from app.models.app_release_model import AppRelease from app.models.app_release_model import AppRelease
from app.models.knowledge_model import Knowledge from app.models.knowledge_model import Knowledge
from app.models.knowledgeshare_model import KnowledgeShare
from app.models.models_model import ModelConfig from app.models.models_model import ModelConfig
from app.models.tool_model import ToolConfig as ToolConfigModel from app.models.tool_model import ToolConfig as ToolConfigModel
from app.models.skill_model import Skill from app.models.skill_model import Skill
from app.models.workflow_model import WorkflowConfig from app.models.workflow_model import WorkflowConfig
from app.services.workflow_service import WorkflowService from app.services.workflow_service import WorkflowService
from app.core.workflow.adapters.memory_bear.memory_bear_adapter import MemoryBearAdapter from app.core.workflow.adapters.memory_bear.memory_bear_adapter import MemoryBearAdapter
from app.core.workflow.nodes.enums import NodeType
from app.models.memory_config_model import MemoryConfig as MemoryConfigModel from app.models.memory_config_model import MemoryConfig as MemoryConfigModel
@@ -229,11 +227,8 @@ class AppDslService:
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
user_id: uuid.UUID, user_id: uuid.UUID,
app_id: Optional[uuid.UUID] = None,
) -> tuple[App, list[str]]: ) -> tuple[App, list[str]]:
"""解析 DSL创建或覆盖应用配置,返回 (app, warnings) """解析 DSL创建应用配置,返回 (new_app, warnings)"""
app_id 不为空时:校验类型一致后覆盖配置;为空时创建新应用。
"""
app_meta = dsl.get("app", {}) app_meta = dsl.get("app", {})
app_type = app_meta.get("type") app_type = app_meta.get("type")
if app_type not in (AppType.AGENT, AppType.MULTI_AGENT, AppType.WORKFLOW): if app_type not in (AppType.AGENT, AppType.MULTI_AGENT, AppType.WORKFLOW):
@@ -242,9 +237,6 @@ class AppDslService:
warnings: list[str] = [] warnings: list[str] = []
now = datetime.datetime.now() now = datetime.datetime.now()
if app_id is not None:
return self._overwrite_dsl(dsl, app_id, app_type, workspace_id, tenant_id, warnings, now)
new_app = App( new_app = App(
id=uuid.uuid4(), id=uuid.uuid4(),
workspace_id=workspace_id, workspace_id=workspace_id,
@@ -264,57 +256,11 @@ class AppDslService:
self.db.add(new_app) self.db.add(new_app)
self.db.flush() self.db.flush()
self._write_config(new_app.id, app_type, dsl, workspace_id, tenant_id, warnings, now, create=True)
self.db.commit()
self.db.refresh(new_app)
return new_app, warnings
def _overwrite_dsl(
self,
dsl: dict,
app_id: uuid.UUID,
app_type: str,
workspace_id: uuid.UUID,
tenant_id: uuid.UUID,
warnings: list,
now: datetime.datetime,
) -> tuple[App, list[str]]:
"""覆盖已有应用的配置,类型不一致时抛出异常"""
app = self.db.query(App).filter(
App.id == app_id,
App.workspace_id == workspace_id,
App.is_active.is_(True)
).first()
if not app:
raise ResourceNotFoundException("应用", str(app_id))
if app.type != app_type:
raise BusinessException(
f"YAML 类型 '{app_type}' 与应用类型 '{app.type}' 不一致,无法导入",
BizCode.BAD_REQUEST
)
self._write_config(app_id, app_type, dsl, workspace_id, tenant_id, warnings, now, create=False)
self.db.commit()
self.db.refresh(app)
return app, warnings
def _write_config(
self,
app_id: uuid.UUID,
app_type: str,
dsl: dict,
workspace_id: uuid.UUID,
tenant_id: uuid.UUID,
warnings: list,
now: datetime.datetime,
create: bool,
) -> None:
"""写入(新建或覆盖)应用配置"""
if app_type == AppType.AGENT: if app_type == AppType.AGENT:
cfg = dsl.get("agent_config") or {} cfg = dsl.get("agent_config") or {}
fields = dict( self.db.add(AgentConfig(
id=uuid.uuid4(),
app_id=new_app.id,
system_prompt=cfg.get("system_prompt"), system_prompt=cfg.get("system_prompt"),
model_parameters=cfg.get("model_parameters"), model_parameters=cfg.get("model_parameters"),
default_model_config_id=self._resolve_model(cfg.get("default_model_config_ref"), tenant_id, warnings), default_model_config_id=self._resolve_model(cfg.get("default_model_config_ref"), tenant_id, warnings),
@@ -324,21 +270,16 @@ class AppDslService:
tools=self._resolve_tools(cfg.get("tools", []), tenant_id, warnings), tools=self._resolve_tools(cfg.get("tools", []), tenant_id, warnings),
skills=self._resolve_skills(cfg.get("skills", {}), tenant_id, warnings), skills=self._resolve_skills(cfg.get("skills", {}), tenant_id, warnings),
features=cfg.get("features", {}), features=cfg.get("features", {}),
is_active=True,
created_at=now,
updated_at=now, updated_at=now,
) ))
if create:
self.db.add(AgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields))
else:
existing = self.db.query(AgentConfig).filter(AgentConfig.app_id == app_id).first()
if existing:
for k, v in fields.items():
setattr(existing, k, v)
else:
self.db.add(AgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields))
elif app_type == AppType.MULTI_AGENT: elif app_type == AppType.MULTI_AGENT:
cfg = dsl.get("multi_agent_config") or {} cfg = dsl.get("multi_agent_config") or {}
fields = dict( self.db.add(MultiAgentConfig(
id=uuid.uuid4(),
app_id=new_app.id,
orchestration_mode=cfg.get("orchestration_mode", "collaboration"), orchestration_mode=cfg.get("orchestration_mode", "collaboration"),
master_agent_name=cfg.get("master_agent_name"), master_agent_name=cfg.get("master_agent_name"),
model_parameters=cfg.get("model_parameters"), model_parameters=cfg.get("model_parameters"),
@@ -348,24 +289,13 @@ class AppDslService:
routing_rules=self._resolve_routing_rules(cfg.get("routing_rules"), warnings), routing_rules=self._resolve_routing_rules(cfg.get("routing_rules"), warnings),
execution_config=cfg.get("execution_config", {}), execution_config=cfg.get("execution_config", {}),
aggregation_strategy=cfg.get("aggregation_strategy", "merge"), aggregation_strategy=cfg.get("aggregation_strategy", "merge"),
is_active=True,
created_at=now,
updated_at=now, updated_at=now,
) ))
if create:
self.db.add(MultiAgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields))
else:
existing = self.db.query(MultiAgentConfig).filter(MultiAgentConfig.app_id == app_id).first()
if existing:
for k, v in fields.items():
setattr(existing, k, v)
else:
self.db.add(MultiAgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields))
elif app_type == AppType.WORKFLOW: elif app_type == AppType.WORKFLOW:
raw_wf = dsl.get("workflow") or {} adapter = MemoryBearAdapter(dsl)
raw_nodes = raw_wf.get("nodes") or []
resolved_nodes = self._resolve_workflow_nodes(raw_nodes, tenant_id, workspace_id, warnings)
resolved_dsl = {**dsl, "workflow": {**raw_wf, "nodes": resolved_nodes}}
adapter = MemoryBearAdapter(resolved_dsl)
if not adapter.validate_config(): if not adapter.validate_config():
raise BusinessException("工作流配置格式无效", BizCode.BAD_REQUEST) raise BusinessException("工作流配置格式无效", BizCode.BAD_REQUEST)
result = adapter.parse_workflow() result = adapter.parse_workflow()
@@ -373,39 +303,21 @@ class AppDslService:
warnings.append(f"[节点错误] {e.node_name or e.node_id}: {e.detail}") warnings.append(f"[节点错误] {e.node_name or e.node_id}: {e.detail}")
for w in result.warnings: for w in result.warnings:
warnings.append(f"[节点警告] {w.node_name or w.node_id}: {w.detail}") warnings.append(f"[节点警告] {w.node_name or w.node_id}: {w.detail}")
wf_service = WorkflowService(self.db) wf = dsl.get("workflow") or {}
if create: WorkflowService(self.db).create_workflow_config(
wf_service.create_workflow_config( app_id=new_app.id,
app_id=app_id, nodes=[n.model_dump() for n in result.nodes],
nodes=[n.model_dump() for n in result.nodes], edges=[e.model_dump() for e in result.edges],
edges=[e.model_dump() for e in result.edges], variables=[v.model_dump() for v in result.variables],
variables=[v.model_dump() for v in result.variables], execution_config=wf.get("execution_config", {}),
execution_config=raw_wf.get("execution_config", {}), features=wf.get("features", {}),
features=raw_wf.get("features", {}), triggers=wf.get("triggers", []),
triggers=raw_wf.get("triggers", []), validate=False,
validate=False, )
)
else: self.db.commit()
existing = self.db.query(WorkflowConfig).filter(WorkflowConfig.app_id == app_id).first() self.db.refresh(new_app)
if existing: return new_app, warnings
existing.nodes = [n.model_dump() for n in result.nodes]
existing.edges = [e.model_dump() for e in result.edges]
existing.variables = [v.model_dump() for v in result.variables]
existing.execution_config = raw_wf.get("execution_config", {})
existing.features = raw_wf.get("features", {})
existing.triggers = raw_wf.get("triggers", [])
existing.updated_at = now
else:
wf_service.create_workflow_config(
app_id=app_id,
nodes=[n.model_dump() for n in result.nodes],
edges=[e.model_dump() for e in result.edges],
variables=[v.model_dump() for v in result.variables],
execution_config=raw_wf.get("execution_config", {}),
features=raw_wf.get("features", {}),
triggers=raw_wf.get("triggers", []),
validate=False,
)
def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str: def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str:
"""生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用""" """生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用"""
@@ -434,98 +346,44 @@ class AppDslService:
def _resolve_model(self, ref: Optional[dict], tenant_id: uuid.UUID, warnings: list) -> Optional[uuid.UUID]: def _resolve_model(self, ref: Optional[dict], tenant_id: uuid.UUID, warnings: list) -> Optional[uuid.UUID]:
if not ref: if not ref:
return None return None
model_id = ref.get("id") q = self.db.query(ModelConfig).filter(
if model_id: ModelConfig.tenant_id == tenant_id,
try: ModelConfig.name == ref.get("name"),
model_uuid = uuid.UUID(str(model_id)) ModelConfig.is_active.is_(True)
m = self.db.query(ModelConfig).filter( )
ModelConfig.id == model_uuid, if ref.get("provider"):
ModelConfig.tenant_id == tenant_id, q = q.filter(ModelConfig.provider == ref["provider"])
ModelConfig.is_active.is_(True) if ref.get("type"):
).first() q = q.filter(ModelConfig.type == ref["type"])
if m: m = q.first()
return str(m.id) if not m:
except (ValueError, AttributeError): warnings.append(f"模型 '{ref.get('name')}' 未匹配,已置空,请导入后手动配置")
pass return m.id if m else None
model_name = ref.get("name")
if model_name:
q = self.db.query(ModelConfig).filter(
ModelConfig.tenant_id == tenant_id,
ModelConfig.name == model_name,
ModelConfig.is_active.is_(True)
)
if ref.get("provider"):
q = q.filter(ModelConfig.provider == ref["provider"])
if ref.get("type"):
q = q.filter(ModelConfig.type == ref["type"])
m = q.first()
if m:
return str(m.id)
warnings.append(f"模型 '{model_name}' 未匹配,已置空,请导入后手动配置")
else:
warnings.append(f"模型 ID '{model_id}' 未匹配,已置空,请导入后手动配置")
return None
def _resolve_kb(self, ref: Optional[dict], workspace_id: uuid.UUID, warnings: list) -> Optional[str]: def _resolve_kb(self, ref: Optional[dict], workspace_id: uuid.UUID, warnings: list) -> Optional[str]:
if not ref: if not ref:
return None return None
kb_id = ref.get("id") kb = self.db.query(Knowledge).filter(
if kb_id: Knowledge.workspace_id == workspace_id,
try: Knowledge.name == ref.get("name")
kb_uuid = uuid.UUID(str(kb_id)) ).first()
kb_share = self.db.query(KnowledgeShare).filter( if not kb:
KnowledgeShare.target_workspace_id == workspace_id, warnings.append(f"知识库 '{ref.get('name')}' 未匹配,已置空,请导入后手动配置")
KnowledgeShare.source_kb_id == kb_uuid return str(kb.id) if kb else None
).first()
if kb_share:
kb = self.db.query(Knowledge).filter(
Knowledge.id == kb_share.target_kb_id
).first()
if kb and kb.status == 1:
return str(kb_share.target_kb_id)
kb = self.db.query(Knowledge).filter(
Knowledge.workspace_id == workspace_id,
Knowledge.id == kb_uuid,
Knowledge.status == 1
).first()
if kb:
return str(kb.id)
except (ValueError, AttributeError):
pass
warnings.append(f"知识库 '{kb_id}' 未匹配,已置空,请导入后手动配置")
return None
def _resolve_tool(self, ref: Optional[dict], tenant_id: uuid.UUID, warnings: list) -> Optional[str]: def _resolve_tool(self, ref: Optional[dict], tenant_id: uuid.UUID, warnings: list) -> Optional[str]:
if not ref: if not ref:
return None return None
tool_id = ref.get("id") q = self.db.query(ToolConfigModel).filter(
tool_name = ref.get("name") ToolConfigModel.tenant_id == tenant_id,
if tool_id: ToolConfigModel.name == ref.get("name")
try: )
tool_uuid = uuid.UUID(str(tool_id)) if ref.get("tool_type"):
t = self.db.query(ToolConfigModel).filter( q = q.filter(ToolConfigModel.tool_type == ref["tool_type"])
ToolConfigModel.id == tool_uuid, t = q.first()
ToolConfigModel.tenant_id == tenant_id, if not t:
ToolConfigModel.is_active.is_(True) warnings.append(f"工具 '{ref.get('name')}' 未匹配,已置空,请导入后手动配置")
).first() return str(t.id) if t else None
if t:
return str(t.id)
except (ValueError, AttributeError):
pass
if tool_name:
q = self.db.query(ToolConfigModel).filter(
ToolConfigModel.tenant_id == tenant_id,
ToolConfigModel.name == tool_name
)
if ref.get("tool_type"):
q = q.filter(ToolConfigModel.tool_type == ref["tool_type"])
t = q.first()
if t:
return str(t.id)
warnings.append(f"工具 '{tool_name}' 未匹配,已置空,请导入后手动配置")
else:
warnings.append(f"工具 '{tool_id}' 未匹配,已置空,请导入后手动配置")
return None
def _resolve_release(self, ref: Optional[dict], warnings: list) -> Optional[uuid.UUID]: def _resolve_release(self, ref: Optional[dict], warnings: list) -> Optional[uuid.UUID]:
if not ref: if not ref:
@@ -567,88 +425,6 @@ class AppDslService:
result.append(entry) result.append(entry)
return result return result
def _resolve_workflow_nodes(self, nodes: list, tenant_id: uuid.UUID, workspace_id: uuid.UUID, warnings: list) -> list:
"""解析工作流节点中的工具ID和知识库ID匹配不到则清空配置"""
resolved_nodes = []
for node in nodes:
node_type = node.get("type")
config = dict(node.get("config") or {})
node_label = node.get("name") or node.get("id")
if node_type == NodeType.TOOL.value:
tool_id = config.get("tool_id")
if not tool_id:
# tool_id 本身就是空,直接置空不重复 warning
config["tool_id"] = None
config["tool_parameters"] = {}
else:
tool_ref = {}
if isinstance(tool_id, str) and len(tool_id) >= 36:
try:
uuid.UUID(tool_id)
tool_ref["id"] = tool_id
except ValueError:
tool_ref["name"] = tool_id
else:
tool_ref["name"] = tool_id
resolved_tool_id = self._resolve_tool(tool_ref, tenant_id, [])
if resolved_tool_id:
config["tool_id"] = resolved_tool_id
else:
warnings.append(f"[{node_label}] 工具 '{tool_id}' 未匹配,已置空,请导入后手动配置")
config["tool_id"] = None
config["tool_parameters"] = {}
elif node_type == NodeType.KNOWLEDGE_RETRIEVAL.value:
knowledge_bases = config.get("knowledge_bases") or []
resolved_kbs = []
for kb in knowledge_bases:
kb_id = kb.get("kb_id")
if not kb_id:
continue
kb_ref = {}
if isinstance(kb_id, str):
try:
uuid.UUID(kb_id)
kb_ref["id"] = kb_id
except ValueError:
kb_ref["name"] = kb_id
else:
kb_ref["name"] = kb_id
resolved_id = self._resolve_kb(kb_ref, workspace_id, [])
if resolved_id:
resolved_kbs.append({**kb, "kb_id": resolved_id})
else:
warnings.append(f"[{node_label}] 知识库 '{kb_id}' 未匹配,已移除,请导入后手动配置")
config["knowledge_bases"] = resolved_kbs
elif node_type in (NodeType.LLM.value, NodeType.QUESTION_CLASSIFIER.value, NodeType.PARAMETER_EXTRACTOR.value):
model_ref = config.get("model_id")
if model_ref:
ref_dict = None
if isinstance(model_ref, dict):
ref_id = model_ref.get("id")
ref_name = model_ref.get("name")
if ref_id:
ref_dict = {"id": ref_id}
elif ref_name is not None:
ref_dict = {"name": ref_name, "provider": model_ref.get("provider"), "type": model_ref.get("type")}
elif isinstance(model_ref, str):
try:
uuid.UUID(model_ref)
ref_dict = {"id": model_ref}
except ValueError:
ref_dict = {"name": model_ref}
if ref_dict:
resolved_model_id = self._resolve_model(ref_dict, tenant_id, warnings)
if resolved_model_id:
config["model_id"] = resolved_model_id
else:
warnings.append(f"[{node_label}] 模型未匹配,已置空,请导入后手动配置")
config["model_id"] = None
else:
warnings.append(f"[{node_label}] 模型未匹配,已置空,请导入后手动配置")
config["model_id"] = None
resolved_nodes.append({**node, "config": config})
return resolved_nodes
def _resolve_knowledge_retrieval(self, kr: Optional[dict], workspace_id: uuid.UUID, warnings: list) -> Optional[dict]: def _resolve_knowledge_retrieval(self, kr: Optional[dict], workspace_id: uuid.UUID, warnings: list) -> Optional[dict]:
if not kr: if not kr:
return kr return kr

View File

@@ -1452,32 +1452,6 @@ class AppService:
logger.debug("配置不存在,返回默认模板", extra={"app_id": str(app_id)}) logger.debug("配置不存在,返回默认模板", extra={"app_id": str(app_id)})
return self._create_default_agent_config(app_id) return self._create_default_agent_config(app_id)
def get_default_model_parameters(
self,
*,
app_id: uuid.UUID,
) -> "ModelParameters":
"""获取 Agent 默认模型参数(不修改数据库)
Args:
app_id: 应用ID
Returns:
ModelParameters: 默认模型参数
"""
logger.info("获取 Agent 默认模型参数", extra={"app_id": str(app_id)})
app = self._get_app_or_404(app_id)
if app.type != "agent":
raise BusinessException("只有 Agent 类型应用支持 Agent 配置", BizCode.APP_TYPE_NOT_SUPPORTED)
from app.schemas.app_schema import ModelParameters
default_model_parameters = ModelParameters()
logger.info("获取 Agent 默认模型参数成功", extra={"app_id": str(app_id)})
return default_model_parameters
def _create_default_agent_config(self, app_id: uuid.UUID) -> AgentConfig: def _create_default_agent_config(self, app_id: uuid.UUID) -> AgentConfig:
"""创建默认的 Agent 配置模板(不保存到数据库) """创建默认的 Agent 配置模板(不保存到数据库)

View File

@@ -544,7 +544,7 @@ class ConversationService:
api_key=api_key, api_key=api_key,
base_url=api_base, base_url=api_base,
is_omni=is_omni, is_omni=is_omni,
capability=capability, support_thinking="thinking" in (capability or []),
), ),
type=ModelType(model_type) type=ModelType(model_type)
) )

View File

@@ -597,7 +597,6 @@ class AgentRunService:
tools=tools, tools=tools,
deep_thinking=effective_params.get("deep_thinking", False), deep_thinking=effective_params.get("deep_thinking", False),
thinking_budget_tokens=effective_params.get("thinking_budget_tokens"), thinking_budget_tokens=effective_params.get("thinking_budget_tokens"),
json_output=effective_params.get("json_output", False),
capability=api_key_config.get("capability", []), capability=api_key_config.get("capability", []),
) )
@@ -854,7 +853,6 @@ class AgentRunService:
streaming=True, streaming=True,
deep_thinking=effective_params.get("deep_thinking", False), deep_thinking=effective_params.get("deep_thinking", False),
thinking_budget_tokens=effective_params.get("thinking_budget_tokens"), thinking_budget_tokens=effective_params.get("thinking_budget_tokens"),
json_output=effective_params.get("json_output", False),
capability=api_key_config.get("capability", []), capability=api_key_config.get("capability", []),
) )
@@ -1301,30 +1299,10 @@ class AgentRunService:
"history_files": {} "history_files": {}
} }
if files: if files:
from app.models.file_metadata_model import FileMetadata
local_ids = [f.upload_file_id for f in files
if f.transfer_method.value == "local_file" and f.upload_file_id
and (not f.name or not f.size)]
meta_map = {}
if local_ids:
rows = self.db.query(FileMetadata).filter(
FileMetadata.id.in_(local_ids),
FileMetadata.status == "completed"
).all()
meta_map = {str(r.id): r for r in rows}
for f in files: for f in files:
name, size = f.name, f.size
if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size):
meta = meta_map.get(str(f.upload_file_id))
if meta:
name = name or meta.file_name
size = size or meta.file_size
human_meta["files"].append({ human_meta["files"].append({
"type": f.type, "type": f.type,
"url": f.url, "url": f.url
"file_type": f.file_type,
"name": name,
"size": size
}) })
# 保存 history_files包含 provider 和 is_omni 信息 # 保存 history_files包含 provider 和 is_omni 信息

View File

@@ -2,13 +2,11 @@ import uuid
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.user_model import User from app.models.user_model import User
from app.models.knowledge_model import Knowledge from app.models.knowledge_model import Knowledge
from app.models.workspace_model import Workspace
from app.models.models_model import ModelConfig
from app.schemas.knowledge_schema import KnowledgeCreate, KnowledgeUpdate from app.schemas.knowledge_schema import KnowledgeCreate, KnowledgeUpdate
from app.repositories import knowledge_repository from app.repositories import knowledge_repository
from app.core.logging_config import get_business_logger from app.core.logging_config import get_business_logger
from app.models.models_model import ModelType
# Obtain a dedicated logger for business logic
business_logger = get_business_logger() business_logger = get_business_logger()
@@ -62,47 +60,13 @@ def create_knowledge(
db: Session, knowledge: KnowledgeCreate, current_user: User db: Session, knowledge: KnowledgeCreate, current_user: User
) -> Knowledge: ) -> Knowledge:
business_logger.info(f"Create a knowledge base: {knowledge.name}, creator: {current_user.username}") business_logger.info(f"Create a knowledge base: {knowledge.name}, creator: {current_user.username}")
try: try:
knowledge.created_by = current_user.id knowledge.created_by = current_user.id
if knowledge.workspace_id is None: if knowledge.workspace_id is None:
knowledge.workspace_id = current_user.current_workspace_id knowledge.workspace_id = current_user.current_workspace_id
if knowledge.parent_id is None: if knowledge.parent_id is None:
knowledge.parent_id = knowledge.workspace_id knowledge.parent_id = knowledge.workspace_id
workspace = db.query(Workspace).filter(Workspace.id == knowledge.workspace_id).first()
if not workspace:
raise Exception(f"Workspace {knowledge.workspace_id} not found")
tenant_id = workspace.tenant_id
if not knowledge.embedding_id:
if not workspace.embedding:
raise Exception("工作空间未配置 Embedding 模型,请先完善工作空间配置后重试")
knowledge.embedding_id = workspace.embedding
if not knowledge.reranker_id:
if not workspace.rerank:
raise Exception("工作空间未配置 Rerank 模型,请先完善工作空间配置后重试")
knowledge.reranker_id = workspace.rerank
if not knowledge.llm_id:
if not workspace.llm:
raise Exception("工作空间未配置 LLM 模型,请先完善工作空间配置后重试")
knowledge.llm_id = workspace.llm
if not knowledge.image2text_id:
model = db.query(ModelConfig).filter(
ModelConfig.tenant_id == tenant_id,
ModelConfig.type.in_([ModelType.CHAT.value, ModelType.LLM.value]),
ModelConfig.capability.contains(["vision"]),
ModelConfig.is_active == True,
).order_by(ModelConfig.created_at.desc()).first()
if not model:
raise Exception("租户下没有可用的视觉模型,创建知识库失败")
knowledge.image2text_id = model.id
business_logger.debug(f"Auto-bind image2text model: {model.id}")
business_logger.debug(f"Start creating the knowledge base: {knowledge.name}") business_logger.debug(f"Start creating the knowledge base: {knowledge.name}")
db_knowledge = knowledge_repository.create_knowledge( db_knowledge = knowledge_repository.create_knowledge(
db=db, knowledge=knowledge db=db, knowledge=knowledge

View File

@@ -415,11 +415,9 @@ class LLMRouter:
api_key=api_key_config.api_key, api_key=api_key_config.api_key,
base_url=api_key_config.api_base, base_url=api_key_config.api_base,
is_omni=api_key_config.is_omni, is_omni=api_key_config.is_omni,
capability=api_key_config.capability, support_thinking="thinking" in (api_key_config.capability or []),
extra_params={ temperature=0.3,
"temperature": 0.3, max_tokens=500
"max_tokens": 500
}
) )
logger.debug(f"创建 LLM 实例 - Provider: {api_key_config.provider}, Model: {api_key_config.model_name}") logger.debug(f"创建 LLM 实例 - Provider: {api_key_config.provider}, Model: {api_key_config.model_name}")

View File

@@ -393,7 +393,7 @@ class MasterAgentRouter:
api_key=api_key_config.api_key, api_key=api_key_config.api_key,
base_url=api_key_config.api_base, base_url=api_key_config.api_base,
is_omni=api_key_config.is_omni, is_omni=api_key_config.is_omni,
capability=api_key_config.capability, support_thinking="thinking" in (api_key_config.capability or []),
extra_params = extra_params extra_params = extra_params
) )

View File

@@ -1280,7 +1280,7 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An
} }
logger.info( logger.info(
f"Successfully retrieved connected config: memory_config_id={memory_config_id}, workspace_id={end_user.workspace_id}") f"Successfully retrieved connected config: memory_config_id={memory_config_id}, workspace_id={app.workspace_id}")
return result return result

View File

@@ -8,8 +8,6 @@ This service validates inputs and delegates to MemoryAgentService for core memor
import uuid import uuid
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from app.core.error_codes import BizCode from app.core.error_codes import BizCode
from app.core.exceptions import BusinessException, ResourceNotFoundException from app.core.exceptions import BusinessException, ResourceNotFoundException
from app.core.logging_config import get_logger from app.core.logging_config import get_logger
@@ -17,6 +15,7 @@ from app.models.app_model import App
from app.models.end_user_model import EndUser from app.models.end_user_model import EndUser
from app.schemas.memory_config_schema import ConfigurationError from app.schemas.memory_config_schema import ConfigurationError
from app.services.memory_agent_service import MemoryAgentService from app.services.memory_agent_service import MemoryAgentService
from sqlalchemy.orm import Session
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -125,7 +124,7 @@ class MemoryAPIService:
except Exception as e: except Exception as e:
logger.warning(f"Failed to update memory_config_id for end_user {end_user_id}: {e}") logger.warning(f"Failed to update memory_config_id for end_user {end_user_id}: {e}")
def write_memory( async def write_memory(
self, self,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
end_user_id: str, end_user_id: str,
@@ -134,149 +133,37 @@ class MemoryAPIService:
storage_type: str = "neo4j", storage_type: str = "neo4j",
user_rag_memory_id: Optional[str] = None, user_rag_memory_id: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Submit a memory write task via Celery. """Write memory with validation.
Validates end_user exists and belongs to workspace, updates the end user's Validates end_user exists and belongs to workspace, updates the end user's
memory_config_id, then dispatches write_message_task to Celery for async memory_config_id, then delegates to MemoryAgentService.write_memory.
processing with per-user fair locking.
Args: Args:
workspace_id: Workspace ID for resource validation workspace_id: Workspace ID for resource validation
end_user_id: End user identifier end_user_id: End user identifier (used as end_user_id)
message: Message content to store message: Message content to store
config_id: Memory configuration ID (required) config_id: Memory configuration ID (required)
storage_type: Storage backend (neo4j or rag) storage_type: Storage backend (neo4j or rag)
user_rag_memory_id: Optional RAG memory ID user_rag_memory_id: Optional RAG memory ID
Returns:
Dict with task_id, status, and end_user_id
Raises:
ResourceNotFoundException: If end_user not found
BusinessException: If validation fails
"""
logger.info(f"Submitting memory write for end_user: {end_user_id}, workspace: {workspace_id}")
# Validate end_user exists and belongs to workspace
self.validate_end_user(end_user_id, workspace_id)
# Update end user's memory_config_id
self._update_end_user_config(end_user_id, config_id)
# Convert to message list format expected by write_message_task
messages = message if isinstance(message, list) else [{"role": "user", "content": message}]
from app.tasks import write_message_task
task = write_message_task.delay(
end_user_id,
messages,
config_id,
storage_type,
user_rag_memory_id or "",
)
logger.info(f"Memory write task submitted: task_id={task.id}, end_user_id={end_user_id}")
return {
"task_id": task.id,
"status": "PENDING",
"end_user_id": end_user_id,
}
def read_memory(
self,
workspace_id: uuid.UUID,
end_user_id: str,
message: str,
search_switch: str = "0",
config_id: str = "",
storage_type: str = "neo4j",
user_rag_memory_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Submit a memory read task via Celery.
Validates end_user exists and belongs to workspace, updates the end user's
memory_config_id, then dispatches read_message_task to Celery for async processing.
Args:
workspace_id: Workspace ID for resource validation
end_user_id: End user identifier
message: Query message
search_switch: Search mode (0=deep search with verification, 1=deep search, 2=fast search)
config_id: Memory configuration ID (required)
storage_type: Storage backend (neo4j or rag)
user_rag_memory_id: Optional RAG memory ID
Returns:
Dict with task_id, status, and end_user_id
Raises:
ResourceNotFoundException: If end_user not found
BusinessException: If validation fails
"""
logger.info(f"Submitting memory read for end_user: {end_user_id}, workspace: {workspace_id}")
# Validate end_user exists and belongs to workspace
self.validate_end_user(end_user_id, workspace_id)
# Update end user's memory_config_id
self._update_end_user_config(end_user_id, config_id)
from app.tasks import read_message_task
task = read_message_task.delay(
end_user_id,
message,
[], # history
search_switch,
config_id,
storage_type,
user_rag_memory_id or "",
)
logger.info(f"Memory read task submitted: task_id={task.id}, end_user_id={end_user_id}")
return {
"task_id": task.id,
"status": "PENDING",
"end_user_id": end_user_id,
}
async def write_memory_sync(
self,
workspace_id: uuid.UUID,
end_user_id: str,
message: str,
config_id: str,
storage_type: str = "neo4j",
user_rag_memory_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Write memory synchronously (inline, no Celery).
Validates end_user, then calls MemoryAgentService.write_memory directly.
Blocks until the write completes. Use for cases where the caller needs
immediate confirmation.
Args:
workspace_id: Workspace ID for resource validation
end_user_id: End user identifier
message: Message content to store
config_id: Memory configuration ID (required)
storage_type: Storage backend (neo4j or rag)
user_rag_memory_id: Optional RAG memory ID
Returns: Returns:
Dict with status and end_user_id Dict with status and end_user_id
Raises: Raises:
ResourceNotFoundException: If end_user not found ResourceNotFoundException: If end_user not found
BusinessException: If write fails BusinessException: If end_user not in authorized workspace or write fails
""" """
logger.info(f"Writing memory (sync) for end_user: {end_user_id}, workspace: {workspace_id}") logger.info(f"Writing memory for end_user: {end_user_id}, workspace: {workspace_id}")
# Validate end_user exists and belongs to workspace
self.validate_end_user(end_user_id, workspace_id) self.validate_end_user(end_user_id, workspace_id)
# Update end user's memory_config_id
self._update_end_user_config(end_user_id, config_id) self._update_end_user_config(end_user_id, config_id)
try: try:
# Delegate to MemoryAgentService
# Convert string message to list[dict] format expected by MemoryAgentService
messages = message if isinstance(message, list) else [{"role": "user", "content": message}] messages = message if isinstance(message, list) else [{"role": "user", "content": message}]
result = await MemoryAgentService().write_memory( result = await MemoryAgentService().write_memory(
end_user_id=end_user_id, end_user_id=end_user_id,
@@ -287,8 +174,11 @@ class MemoryAPIService:
user_rag_memory_id=user_rag_memory_id or "", user_rag_memory_id=user_rag_memory_id or "",
) )
logger.info(f"Memory write (sync) successful for end_user: {end_user_id}") logger.info(f"Memory write successful for end_user: {end_user_id}")
# result may be a string "success" or a dict with a "status" key
# Preserve the full dict so callers don't silently lose extra fields
# (e.g. error codes, metadata) returned by MemoryAgentService.
if isinstance(result, dict): if isinstance(result, dict):
return { return {
**result, **result,
@@ -302,17 +192,20 @@ class MemoryAPIService:
except ConfigurationError as e: except ConfigurationError as e:
logger.error(f"Memory configuration error for end_user {end_user_id}: {e}") logger.error(f"Memory configuration error for end_user {end_user_id}: {e}")
raise BusinessException(message=str(e), code=BizCode.MEMORY_CONFIG_NOT_FOUND) raise BusinessException(
message=str(e),
code=BizCode.MEMORY_CONFIG_NOT_FOUND
)
except BusinessException: except BusinessException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Memory write (sync) failed for end_user {end_user_id}: {e}") logger.error(f"Memory write failed for end_user {end_user_id}: {e}")
raise BusinessException( raise BusinessException(
message=f"Memory write failed: {str(e)}", message=f"Memory write failed: {str(e)}",
code=BizCode.MEMORY_WRITE_FAILED code=BizCode.MEMORY_WRITE_FAILED
) )
async def read_memory_sync( async def read_memory(
self, self,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
end_user_id: str, end_user_id: str,
@@ -322,34 +215,37 @@ class MemoryAPIService:
storage_type: str = "neo4j", storage_type: str = "neo4j",
user_rag_memory_id: Optional[str] = None, user_rag_memory_id: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Read memory synchronously (inline, no Celery). """Read memory with validation.
Validates end_user, then calls MemoryAgentService.read_memory directly. Validates end_user exists and belongs to workspace, updates the end user's
Blocks until the read completes. Use for cases where the caller needs memory_config_id, then delegates to MemoryAgentService.read_memory.
the answer immediately.
Args: Args:
workspace_id: Workspace ID for resource validation workspace_id: Workspace ID for resource validation
end_user_id: End user identifier end_user_id: End user identifier (used as end_user_id)
message: Query message message: Query message
search_switch: Search mode (0=deep search with verification, 1=deep search, 2=fast search) search_switch: Search mode (0=deep search with verification, 1=deep search, 2=fast search)
config_id: Memory configuration ID (required) config_id: Memory configuration ID (required)
storage_type: Storage backend (neo4j or rag) storage_type: Storage backend (neo4j or rag)
user_rag_memory_id: Optional RAG memory ID user_rag_memory_id: Optional RAG memory ID
Returns: Returns:
Dict with answer, intermediate_outputs, and end_user_id Dict with answer, intermediate_outputs, and end_user_id
Raises: Raises:
ResourceNotFoundException: If end_user not found ResourceNotFoundException: If end_user not found
BusinessException: If read fails BusinessException: If end_user not in authorized workspace or read fails
""" """
logger.info(f"Reading memory (sync) for end_user: {end_user_id}, workspace: {workspace_id}") logger.info(f"Reading memory for end_user: {end_user_id}, workspace: {workspace_id}")
# Validate end_user exists and belongs to workspace
self.validate_end_user(end_user_id, workspace_id) self.validate_end_user(end_user_id, workspace_id)
# Update end user's memory_config_id
self._update_end_user_config(end_user_id, config_id) self._update_end_user_config(end_user_id, config_id)
try: try:
# Delegate to MemoryAgentService
result = await MemoryAgentService().read_memory( result = await MemoryAgentService().read_memory(
end_user_id=end_user_id, end_user_id=end_user_id,
message=message, message=message,
@@ -361,7 +257,7 @@ class MemoryAPIService:
user_rag_memory_id=user_rag_memory_id or "" user_rag_memory_id=user_rag_memory_id or ""
) )
logger.info(f"Memory read (sync) successful for end_user: {end_user_id}") logger.info(f"Memory read successful for end_user: {end_user_id}")
return { return {
"answer": result.get("answer", ""), "answer": result.get("answer", ""),
@@ -371,11 +267,14 @@ class MemoryAPIService:
except ConfigurationError as e: except ConfigurationError as e:
logger.error(f"Memory configuration error for end_user {end_user_id}: {e}") logger.error(f"Memory configuration error for end_user {end_user_id}: {e}")
raise BusinessException(message=str(e), code=BizCode.MEMORY_CONFIG_NOT_FOUND) raise BusinessException(
message=str(e),
code=BizCode.MEMORY_CONFIG_NOT_FOUND
)
except BusinessException: except BusinessException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Memory read (sync) failed for end_user {end_user_id}: {e}") logger.error(f"Memory read failed for end_user {end_user_id}: {e}")
raise BusinessException( raise BusinessException(
message=f"Memory read failed: {str(e)}", message=f"Memory read failed: {str(e)}",
code=BizCode.MEMORY_READ_FAILED code=BizCode.MEMORY_READ_FAILED

View File

@@ -233,7 +233,7 @@ class MemoryPerceptualService:
api_key=model_config.api_key, api_key=model_config.api_key,
base_url=model_config.api_base, base_url=model_config.api_base,
is_omni=model_config.is_omni, is_omni=model_config.is_omni,
capability=model_config.capability, support_thinking="thinking" in (model_config.capability or []),
) )
) )
return llm, model_config return llm, model_config

View File

@@ -47,8 +47,7 @@ class ModelParameterMerger:
"n": 1, "n": 1,
"stop": None, "stop": None,
"deep_thinking": False, "deep_thinking": False,
"thinking_budget_tokens": None, "thinking_budget_tokens": None
"json_output": False
} }
# 合并参数:默认值 -> 模型配置 -> Agent 配置 # 合并参数:默认值 -> 模型配置 -> Agent 配置

View File

@@ -125,7 +125,9 @@ class ModelConfigService:
api_key=api_key, api_key=api_key,
base_url=api_base, base_url=api_base,
is_omni=is_omni, is_omni=is_omni,
capability=capability support_thinking="thinking" in (capability or []),
temperature=0.7,
max_tokens=100
) )
# 根据模型类型选择不同的验证方式 # 根据模型类型选择不同的验证方式
@@ -369,15 +371,6 @@ class ModelConfigService:
raise BusinessException("模型名称已存在", BizCode.DUPLICATE_NAME) raise BusinessException("模型名称已存在", BizCode.DUPLICATE_NAME)
model = ModelConfigRepository.update(db, model_id, model_data, tenant_id=tenant_id) model = ModelConfigRepository.update(db, model_id, model_data, tenant_id=tenant_id)
# 同步更新关联 api_keys 的 capability 和 is_omni
if model_data.capability is not None or model_data.is_omni is not None:
for api_key in model.api_keys:
if model_data.capability is not None:
api_key.capability = model_data.capability
if model_data.is_omni is not None:
api_key.is_omni = model_data.is_omni
db.commit() db.commit()
db.refresh(model) db.refresh(model)
return model return model
@@ -736,21 +729,10 @@ class ModelApiKeyService:
@staticmethod @staticmethod
def delete_api_key(db: Session, api_key_id: uuid.UUID) -> bool: def delete_api_key(db: Session, api_key_id: uuid.UUID) -> bool:
"""删除API Key""" """删除API Key"""
api_key = ModelApiKeyRepository.get_by_id(db, api_key_id) if not ModelApiKeyRepository.get_by_id(db, api_key_id):
if not api_key:
raise BusinessException("API Key不存在", BizCode.NOT_FOUND) raise BusinessException("API Key不存在", BizCode.NOT_FOUND)
model_config_ids = [mc.id for mc in api_key.model_configs]
success = ModelApiKeyRepository.delete(db, api_key_id) success = ModelApiKeyRepository.delete(db, api_key_id)
for model_config_id in model_config_ids:
model_config = ModelConfigRepository.get_by_id(db, model_config_id)
if model_config:
has_active_key = any(key.is_active for key in model_config.api_keys)
if not has_active_key and model_config.is_active:
model_config.is_active = False
db.commit() db.commit()
return success return success

View File

@@ -2616,11 +2616,9 @@ class MultiAgentOrchestrator:
api_key=api_key_config.api_key, api_key=api_key_config.api_key,
base_url=api_key_config.api_base, base_url=api_key_config.api_base,
is_omni=api_key_config.is_omni, is_omni=api_key_config.is_omni,
capability=api_key_config.capability, support_thinking="thinking" in (api_key_config.capability or []),
extra_params={ temperature=0.7, # 整合任务使用中等温度
"temperature": 0.7, # 整合任务使用中等温度 max_tokens=2000
"max_tokens": 2000
}
) )
# 创建 LLM 实例 # 创建 LLM 实例
@@ -2797,12 +2795,10 @@ class MultiAgentOrchestrator:
api_key=api_key_config.api_key, api_key=api_key_config.api_key,
base_url=api_key_config.api_base, base_url=api_key_config.api_base,
is_omni=api_key_config.is_omni, is_omni=api_key_config.is_omni,
capability=api_key_config.capability, support_thinking="thinking" in (api_key_config.capability or []),
extra_params={ temperature=0.7,
"temperature": 0.7, max_tokens=2000,
"max_tokens": 2000, extra_params={"streaming": True} # 启用流式输出
"streaming": True # 启用流式输出
}
) )
# 创建 LLM 实例 # 创建 LLM 实例

View File

@@ -186,7 +186,7 @@ class PromptOptimizerService:
api_key=api_config.api_key, api_key=api_config.api_key,
base_url=api_config.api_base, base_url=api_config.api_base,
is_omni=api_config.is_omni, is_omni=api_config.is_omni,
capability=api_config.capability, support_thinking="thinking" in (api_config.capability or []),
), type=ModelType(model_config.type)) ), type=ModelType(model_config.type))
try: try:
prompt_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'prompt') prompt_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'prompt')

View File

@@ -250,8 +250,7 @@ class SharedChatService:
tools=tools, tools=tools,
deep_thinking=model_parameters.get("deep_thinking", False), deep_thinking=model_parameters.get("deep_thinking", False),
thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"),
json_output=model_parameters.get("json_output", False), capability=api_key_obj.capability or [],
capability=api_key_obj.capability,
) )
# 加载历史消息 # 加载历史消息
@@ -456,7 +455,6 @@ class SharedChatService:
streaming=True, streaming=True,
deep_thinking=model_parameters.get("deep_thinking", False), deep_thinking=model_parameters.get("deep_thinking", False),
thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"),
json_output=model_parameters.get("json_output", False),
capability=api_key_obj.capability or [], capability=api_key_obj.capability or [],
) )

View File

@@ -399,25 +399,12 @@ class UserMemoryService:
} }
# 构建响应数据(转换时间为毫秒时间戳) # 构建响应数据(转换时间为毫秒时间戳)
# 将 meta_data 中的 profile、knowledge_tags、behavioral_hints 平铺到顶层
meta = end_user_info_record.meta_data or {}
# profile 列表字段截断:只返回前 MAX_PROFILE_LIST_SIZE 条(按时间从新到旧)
MAX_PROFILE_LIST_SIZE = 5
profile = meta.get("profile")
if isinstance(profile, dict):
for key in ("role", "domain", "expertise", "interests"):
if isinstance(profile.get(key), list):
profile[key] = profile[key][:MAX_PROFILE_LIST_SIZE]
response_data = { response_data = {
"end_user_info_id": str(end_user_info_record.id), "end_user_info_id": str(end_user_info_record.id),
"end_user_id": str(end_user_info_record.end_user_id), "end_user_id": str(end_user_info_record.end_user_id),
"other_name": end_user_info_record.other_name, "other_name": end_user_info_record.other_name,
"aliases": end_user_info_record.aliases, "aliases": end_user_info_record.aliases,
"profile": profile, "meta_data": end_user_info_record.meta_data,
"knowledge_tags": meta.get("knowledge_tags"),
"behavioral_hints": meta.get("behavioral_hints"),
"created_at": datetime_to_timestamp(end_user_info_record.created_at), "created_at": datetime_to_timestamp(end_user_info_record.created_at),
"updated_at": datetime_to_timestamp(end_user_info_record.updated_at) "updated_at": datetime_to_timestamp(end_user_info_record.updated_at)
} }

View File

@@ -957,10 +957,7 @@ class WorkflowService:
for file in message["content"]: for file in message["content"]:
human_meta["files"].append({ human_meta["files"].append({
"type": file.get("type"), "type": file.get("type"),
"url": file.get("url"), "url": file.get("url")
"file_type": file.get("origin_file_type"),
"name": file.get("name"),
"size": file.get("size")
}) })
if message["role"] == "assistant": if message["role"] == "assistant":
assistant_message = message["content"] assistant_message = message["content"]

View File

@@ -45,23 +45,6 @@ from app.utils.redis_lock import RedisFairLock
logger = get_logger(__name__) logger = get_logger(__name__)
# ── 预编译文件类型正则 & 常量 ──────────────────────────────────
AUDIO_PATTERN = re.compile(
r"\.(da|wave|wav|mp3|aac|flac|ogg|aiff|au|midi|wma|realaudio|vqf|oggvorbis|ape?)$",
re.IGNORECASE,
)
VIDEO_IMAGE_PATTERN = re.compile(
r"\.(png|jpeg|jpg|gif|bmp|svg|mp4|mov|avi|flv|mpeg|mpg|webm|wmv|3gp|3gpp|mkv?)$",
re.IGNORECASE,
)
DEFAULT_PARSE_LANGUAGE = "Chinese"
DEFAULT_PARSE_TO_PAGE = 100_000
EMBEDDING_BATCH_SIZE = int(os.getenv("EMBEDDING_BATCH_SIZE", "20"))
# Embedding 并发写入的最大线程数,需根据模型 API rate limit 调整
EMBEDDING_MAX_WORKERS = int(os.getenv("EMBEDDING_MAX_WORKERS", "3"))
# auto_questions LLM 并发调用的最大线程数
AUTO_QUESTIONS_MAX_WORKERS = int(os.getenv("AUTO_QUESTIONS_MAX_WORKERS", "5"))
# 模块级同步 Redis 连接池,供 Celery 任务共享使用 # 模块级同步 Redis 连接池,供 Celery 任务共享使用
# 连接 CELERY_BACKEND DB与 write_message:last_done 时间戳写入保持一致 # 连接 CELERY_BACKEND DB与 write_message:last_done 时间戳写入保持一致
# 使用连接池而非单例客户端,提供更好的并发性能和自动重连 # 使用连接池而非单例客户端,提供更好的并发性能和自动重连
@@ -178,67 +161,28 @@ def process_item(item: dict):
return result return result
def _build_vision_model(file_path: str, db_knowledge):
"""根据文件类型选择合适的视觉/音频模型,避免冗余初始化。"""
if AUDIO_PATTERN.search(file_path):
omni_key = os.getenv("QWEN3_OMNI_API_KEY", "")
omni_model = os.getenv("QWEN3_OMNI_MODEL_NAME", "qwen3-omni-flash")
omni_base = os.getenv("QWEN3_OMNI_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
return QWenSeq2txt(
key=omni_key,
model_name=omni_model,
lang=DEFAULT_PARSE_LANGUAGE,
base_url=omni_base,
)
if VIDEO_IMAGE_PATTERN.search(file_path):
omni_key = os.getenv("QWEN3_OMNI_API_KEY", "")
omni_model = os.getenv("QWEN3_OMNI_MODEL_NAME", "qwen3-omni-flash")
omni_base = os.getenv("QWEN3_OMNI_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
return QWenCV(
key=omni_key,
model_name=omni_model,
lang=DEFAULT_PARSE_LANGUAGE,
base_url=omni_base,
)
# 默认:使用知识库配置的 image2text 模型
return QWenCV(
key=db_knowledge.image2text.api_keys[0].api_key,
model_name=db_knowledge.image2text.api_keys[0].model_name,
lang=DEFAULT_PARSE_LANGUAGE,
base_url=db_knowledge.image2text.api_keys[0].api_base,
)
@celery_app.task(name="app.core.rag.tasks.parse_document") @celery_app.task(name="app.core.rag.tasks.parse_document")
def parse_document(file_path: str, document_id: uuid.UUID): def parse_document(file_path: str, document_id: uuid.UUID):
""" """
Document parsing, vectorization, and storage Document parsing, vectorization, and storage
""" """
# Force re-importing Trio in child processes (to avoid inheriting the state of the parent process)
import importlib
import trio
importlib.reload(trio)
db = next(get_db()) # Manually call the generator
db_document = None db_document = None
progress_lines: list[str] = [f"{datetime.now().strftime('%H:%M:%S')} Task has been received."] db_knowledge = None
progress_msg = f"{datetime.now().strftime('%H:%M:%S')} Task has been received.\n"
def _progress_msg() -> str: try:
return "\n".join(progress_lines) + "\n"
with get_db_context() as db:
try:
# Celery JSON 序列化会将 UUID 转为字符串,需要确保类型正确
if not isinstance(document_id, uuid.UUID):
document_id = uuid.UUID(str(document_id))
db_document = db.query(Document).filter(Document.id == document_id).first() db_document = db.query(Document).filter(Document.id == document_id).first()
if db_document is None:
raise ValueError(f"Document {document_id} not found")
db_knowledge = db.query(Knowledge).filter(Knowledge.id == db_document.kb_id).first() db_knowledge = db.query(Knowledge).filter(Knowledge.id == db_document.kb_id).first()
if db_knowledge is None:
raise ValueError(f"Knowledge {db_document.kb_id} not found")
# 1. Document parsing & segmentation # 1. Document parsing & segmentation
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} Start to parse.") progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Start to parse.\n"
start_time = time.time() start_time = time.time()
db_document.progress = 0.0 db_document.progress = 0.0
db_document.progress_msg = _progress_msg() db_document.progress_msg = progress_msg
db_document.process_begin_at = datetime.now(tz=timezone.utc) db_document.process_begin_at = datetime.now(tz=timezone.utc)
db_document.process_duration = 0.0 db_document.process_duration = 0.0
db_document.run = 1 db_document.run = 1
@@ -246,227 +190,220 @@ def parse_document(file_path: str, document_id: uuid.UUID):
db.refresh(db_document) db.refresh(db_document)
def progress_callback(prog=None, msg=None): def progress_callback(prog=None, msg=None):
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} parse progress: {prog} msg: {msg}.") nonlocal progress_msg # Declare the use of an external progress_msg variable
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} parse progress: {prog} msg: {msg}.\n"
# Prepare vision_model for parsing # Prepare to configure chat_mdl、embedding_model、vision_model information
vision_model = _build_vision_model(file_path, db_knowledge) chat_model = Base(
key=db_knowledge.llm.api_keys[0].api_key,
# 先将文件读入内存,避免解析过程中依赖 NFS 文件持续可访问 model_name=db_knowledge.llm.api_keys[0].model_name,
# python-docx 等库在 binary=None 时会用路径直接打开文件, base_url=db_knowledge.llm.api_keys[0].api_base
# 在 NFS/共享存储上可能因缓存失效导致 "Package not found" )
max_wait_seconds = 30 embedding_model = OpenAIEmbed(
wait_interval = 2 key=db_knowledge.embedding.api_keys[0].api_key,
waited = 0 model_name=db_knowledge.embedding.api_keys[0].model_name,
file_binary = None base_url=db_knowledge.embedding.api_keys[0].api_base
while waited <= max_wait_seconds: )
# os.listdir 强制 NFS 客户端刷新目录缓存 vision_model = QWenCV(
parent_dir = os.path.dirname(file_path) key=db_knowledge.image2text.api_keys[0].api_key,
try: model_name=db_knowledge.image2text.api_keys[0].model_name,
os.listdir(parent_dir) lang="Chinese",
except OSError: base_url=db_knowledge.image2text.api_keys[0].api_base
pass )
try: if re.search(r"\.(da|wave|wav|mp3|aac|flac|ogg|aiff|au|midi|wma|realaudio|vqf|oggvorbis|ape?)$", file_path,
with open(file_path, "rb") as f: re.IGNORECASE):
file_binary = f.read() vision_model = QWenSeq2txt(
if not file_binary: key=os.getenv("QWEN3_OMNI_API_KEY", ""),
# NFS 上文件存在但内容为空(可能还在同步中) model_name=os.getenv("QWEN3_OMNI_MODEL_NAME", "qwen3-omni-flash"),
raise IOError(f"File is empty (0 bytes), NFS may still be syncing: {file_path}") lang="Chinese",
break base_url=os.getenv("QWEN3_OMNI_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
except (FileNotFoundError, IOError) as e: )
if waited >= max_wait_seconds: elif re.search(r"\.(png|jpeg|jpg|gif|bmp|svg|mp4|mov|avi|flv|mpeg|mpg|webm|wmv|3gp|3gpp|mkv?)$", file_path,
raise type(e)( re.IGNORECASE):
f"File not accessible at '{file_path}' after waiting {max_wait_seconds}s: {e}" vision_model = QWenCV(
) key=os.getenv("QWEN3_OMNI_API_KEY", ""),
logger.warning(f"File not ready on this node, retrying in {wait_interval}s: {file_path} ({e})") model_name=os.getenv("QWEN3_OMNI_MODEL_NAME", "qwen3-omni-flash"),
time.sleep(wait_interval) lang="Chinese",
waited += wait_interval base_url=os.getenv("QWEN3_OMNI_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
)
else:
print(file_path)
from app.core.rag.app.naive import chunk from app.core.rag.app.naive import chunk
logger.info(f"[ParseDoc] file_binary size={len(file_binary)} bytes, type={type(file_binary).__name__}, bool={bool(file_binary)}")
res = chunk(filename=file_path, res = chunk(filename=file_path,
binary=file_binary,
from_page=0, from_page=0,
to_page=DEFAULT_PARSE_TO_PAGE, to_page=100000,
callback=progress_callback, callback=progress_callback,
vision_model=vision_model, vision_model=vision_model,
parser_config=db_document.parser_config, parser_config=db_document.parser_config,
is_root=False) is_root=False)
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} Finish parsing.") progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Finish parsing.\n"
db_document.progress = 0.8 db_document.progress = 0.8
db_document.progress_msg = _progress_msg() db_document.progress_msg = progress_msg
db.commit() db.commit()
db.refresh(db_document) db.refresh(db_document)
# 2. Document vectorization and storage # 2. Document vectorization and storage
total_chunks = len(res) total_chunks = len(res)
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} Generate {total_chunks} chunks.") progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Generate {total_chunks} chunks.\n"
batch_size = 100
total_batches = ceil(total_chunks / batch_size)
progress_per_batch = 0.2 / total_batches # Progress of each batch
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
# 2.1 Delete document vector index
vector_service.delete_by_metadata_field(key="document_id", value=str(document_id))
# 2.2 Vectorize and import batch documents
for batch_start in range(0, total_chunks, batch_size):
batch_end = min(batch_start + batch_size, total_chunks) # prevent out-of-bounds
batch = res[batch_start: batch_end] # Retrieve the current batch
chunks = []
if total_chunks == 0: # Process the current batch
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} No chunks generated, skipping vectorization.") for idx_in_batch, item in enumerate(batch):
else: global_idx = batch_start + idx_in_batch # Calculate global index
total_batches = ceil(total_chunks / EMBEDDING_BATCH_SIZE) metadata = {
progress_per_batch = 0.2 / total_batches "doc_id": uuid.uuid4().hex,
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge) "file_id": str(db_document.file_id),
# 2.1 Delete document vector index "file_name": db_document.file_name,
vector_service.delete_by_metadata_field(key="document_id", value=str(document_id)) "file_created_at": int(db_document.created_at.timestamp() * 1000),
# 2.2 Vectorize and import batch documents "document_id": str(db_document.id),
auto_questions_topn = db_document.parser_config.get("auto_questions", 0) "knowledge_id": str(db_document.kb_id),
chat_model = None "sort_id": global_idx,
if auto_questions_topn: "status": 1,
chat_model = Base(
key=db_knowledge.llm.api_keys[0].api_key,
model_name=db_knowledge.llm.api_keys[0].model_name,
base_url=db_knowledge.llm.api_keys[0].api_base,
)
# 预先构建所有 batch 的 chunks保证 sort_id 全局有序
all_batch_chunks: list[list[DocumentChunk]] = []
if auto_questions_topn:
# auto_questions 开启:先并发生成所有 chunk 的问题,再按 batch 分组
# 构建 (global_idx, item) 列表
indexed_items = list(enumerate(res))
def _generate_question(idx_item: tuple[int, dict]) -> tuple[int, str]:
"""为单个 chunk 生成问题(带缓存),返回 (global_idx, question_text)"""
global_idx, item = idx_item
content = item["content_with_weight"]
cached = get_llm_cache(chat_model.model_name, content, "question",
{"topn": auto_questions_topn})
if not cached:
cached = question_proposal(chat_model, content, auto_questions_topn)
set_llm_cache(chat_model.model_name, content, cached, "question",
{"topn": auto_questions_topn})
return global_idx, cached
# 并发调用 LLM 生成问题
question_map: dict[int, str] = {}
with ThreadPoolExecutor(max_workers=AUTO_QUESTIONS_MAX_WORKERS) as q_executor:
futures = {q_executor.submit(_generate_question, item): item[0]
for item in indexed_items}
for future in futures:
global_idx, cached = future.result()
question_map[global_idx] = cached
progress_lines.append(
f"{datetime.now().strftime('%H:%M:%S')} Auto questions generated for {total_chunks} chunks "
f"(workers={AUTO_QUESTIONS_MAX_WORKERS}).")
# 按 batch 分组组装 DocumentChunk
for batch_start in range(0, total_chunks, EMBEDDING_BATCH_SIZE):
batch_end = min(batch_start + EMBEDDING_BATCH_SIZE, total_chunks)
chunks = []
for global_idx in range(batch_start, batch_end):
item = res[global_idx]
metadata = {
"doc_id": uuid.uuid4().hex,
"file_id": str(db_document.file_id),
"file_name": db_document.file_name,
"file_created_at": int(db_document.created_at.timestamp() * 1000),
"document_id": str(db_document.id),
"knowledge_id": str(db_document.kb_id),
"sort_id": global_idx,
"status": 1,
}
cached = question_map[global_idx]
chunks.append(
DocumentChunk(
page_content=f"question: {cached} answer: {item['content_with_weight']}",
metadata=metadata))
all_batch_chunks.append(chunks)
else:
# 无 auto_questions直接构建 chunks
for batch_start in range(0, total_chunks, EMBEDDING_BATCH_SIZE):
batch_end = min(batch_start + EMBEDDING_BATCH_SIZE, total_chunks)
chunks = []
for global_idx in range(batch_start, batch_end):
item = res[global_idx]
metadata = {
"doc_id": uuid.uuid4().hex,
"file_id": str(db_document.file_id),
"file_name": db_document.file_name,
"file_created_at": int(db_document.created_at.timestamp() * 1000),
"document_id": str(db_document.id),
"knowledge_id": str(db_document.kb_id),
"sort_id": global_idx,
"status": 1,
}
chunks.append(DocumentChunk(page_content=item["content_with_weight"], metadata=metadata))
all_batch_chunks.append(chunks)
# 并发提交 embedding + ES 写入max_workers 控制模型 API 并发压力
batch_errors: dict[int, Exception] = {}
def _embed_and_store(batch_idx: int, batch_chunks: list[DocumentChunk]):
try:
vector_service.add_chunks(batch_chunks)
except Exception as exc:
logger.warning(f"[ParseDoc] batch {batch_idx} failed, retrying: {exc}")
try:
vector_service.add_chunks(batch_chunks)
except Exception as retry_exc:
logger.error(f"[ParseDoc] batch {batch_idx} retry failed: {retry_exc}", exc_info=True)
batch_errors[batch_idx] = retry_exc
with ThreadPoolExecutor(max_workers=EMBEDDING_MAX_WORKERS) as executor:
futures = {
executor.submit(_embed_and_store, i, batch_chunks): i
for i, batch_chunks in enumerate(all_batch_chunks)
} }
for future in futures: if db_document.parser_config.get("auto_questions", 0):
future.result() topn = db_document.parser_config["auto_questions"]
cached = get_llm_cache(chat_model.model_name, item["content_with_weight"], "question",
{"topn": topn})
if not cached:
cached = question_proposal(chat_model, item["content_with_weight"], topn)
set_llm_cache(chat_model.model_name, item["content_with_weight"], cached, "question",
{"topn": topn})
chunks.append(
DocumentChunk(page_content=f"question: {cached} answer: {item['content_with_weight']}",
metadata=metadata))
else:
chunks.append(DocumentChunk(page_content=item["content_with_weight"], metadata=metadata))
# 如果有 batch 失败,汇总抛出 # Bulk segmented vector import
if batch_errors: vector_service.add_chunks(chunks)
failed_detail = "; ".join(
f"batch {i}: {type(err).__name__}: {err}"
for i, err in sorted(batch_errors.items())
)
raise RuntimeError(f"Embedding failed for {len(batch_errors)}/{total_batches} batch(es). {failed_detail}")
# 所有 batch 完成后一次性更新进度 # Update progress
db_document.progress = 0.8 + 0.2 # 直接到 1.0 前的状态 db_document.progress += progress_per_batch
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} All {total_batches} batches embedded (workers={EMBEDDING_MAX_WORKERS}).") progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Embedding progress ({db_document.progress}).\n"
db_document.progress_msg = _progress_msg() db_document.progress_msg = progress_msg
db_document.process_duration = time.time() - start_time db_document.process_duration = time.time() - start_time
db_document.run = 0 db_document.run = 0
db.commit() db.commit()
db.refresh(db_document) db.refresh(db_document)
# Vectorization and data entry completed # Vectorization and data entry completed
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} Indexing done.") progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Indexing done.\n"
db_document.chunk_num = total_chunks db_document.chunk_num = total_chunks
db_document.progress = 1.0 db_document.progress = 1.0
db_document.process_duration = time.time() - start_time db_document.process_duration = time.time() - start_time
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} Task done ({db_document.process_duration}s).") progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Task done ({db_document.process_duration}s).\n"
db_document.progress_msg = _progress_msg() db_document.progress_msg = progress_msg
db_document.run = 0 db_document.run = 0
db.commit() db.commit()
# GraphRAG: 异步派发到独立队列,不阻塞文档解析流程 # using graphrag
if db_knowledge.parser_config and db_knowledge.parser_config.get("graphrag", {}).get("use_graphrag", False): if db_knowledge.parser_config and db_knowledge.parser_config.get("graphrag", {}).get("use_graphrag", False):
progress_lines.append(f"{datetime.now().strftime('%H:%M:%S')} GraphRAG enabled, dispatching async task.") graphrag_conf = db_knowledge.parser_config.get("graphrag", {})
db_document.progress_msg = _progress_msg() with_resolution = graphrag_conf.get("resolution", False)
with_community = graphrag_conf.get("community", False)
def callback(*args, msg=None, **kwargs):
nonlocal progress_msg
message = msg or (args[0] if args else "No message")
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} run graphrag msg: {message}.\n"
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Start to run graphrag.\n"
start_time = time.time()
db_document.progress_msg = progress_msg
db.commit() db.commit()
build_graphrag_for_document.delay(str(document_id), str(db_knowledge.id)) db.refresh(db_document)
task = {
"id": str(db_document.id),
"workspace_id": str(db_knowledge.workspace_id),
"kb_id": str(db_knowledge.id),
"parser_config": db_knowledge.parser_config,
}
# init_graphrag
vts, _ = embedding_model.encode(["ok"])
vector_size = len(vts[0])
init_graphrag(task, vector_size)
async def _run(
row: dict,
document_ids: list[str],
language: str,
parser_config: dict,
vector_service,
chat_model,
embedding_model,
callback,
with_resolution: bool = True,
with_community: bool = True
) -> dict:
await trio.sleep(5) # Delay for 10 seconds
nonlocal progress_msg # Declare the use of an external progress_msg variable
result = await run_graphrag_for_kb(
row=row,
document_ids=document_ids,
language=language,
parser_config=parser_config,
vector_service=vector_service,
chat_model=chat_model,
embedding_model=embedding_model,
callback=callback,
with_resolution=with_resolution,
with_community=with_community,
)
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} GraphRAG task result for task {task}:\n{result}\n"
return result
def sync_task():
trio.run(
lambda: _run(
row=task,
document_ids=[str(db_document.id)],
language="Chinese",
parser_config=db_knowledge.parser_config,
vector_service=vector_service,
chat_model=chat_model,
embedding_model=embedding_model,
callback=callback,
with_resolution=with_resolution,
with_community=with_community,
)
)
try:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(sync_task)
future.result() # Blocks until the task completes
except Exception as e:
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} GraphRAG task failed for task {task}:\n{str(e)}\n"
progress_msg += f"{datetime.now().strftime('%H:%M:%S')} Knowledge Graph done ({time.time() - start_time}s)"
db_document.progress_msg = progress_msg
db.commit()
db.refresh(db_document)
result = f"parse document '{db_document.file_name}' processed successfully." result = f"parse document '{db_document.file_name}' processed successfully."
logger.info(f"[ParseDoc] document={document_id} file='{db_document.file_name}' done in {db_document.process_duration:.1f}s, chunks={total_chunks}")
return result return result
except Exception as e: except Exception as e:
logger.error(f"[ParseDoc] document={document_id} failed: {e}", exc_info=True) if 'db_document' in locals():
if db_document is not None: db_document.progress_msg += f"Failed to vectorize and import the parsed document:{str(e)}\n"
try: db_document.run = 0
db.rollback() db.commit()
db_document.progress_msg = _progress_msg() + f"Failed to vectorize and import the parsed document:{str(e)}\n" result = f"parse document '{db_document.file_name}' failed."
db_document.run = 0 return result
db.commit() finally:
except Exception: db.close()
logger.warning(f"[ParseDoc] document={document_id} failed to update error status in DB", exc_info=True)
# db_document 可能处于 detached/expired 状态,用之前缓存的值或 document_id 兜底
file_name = getattr(db_document, 'file_name', None) if db_document else None
return f"parse document '{file_name or document_id}' failed."
@celery_app.task(name="app.core.rag.tasks.build_graphrag_for_kb") @celery_app.task(name="app.core.rag.tasks.build_graphrag_for_kb")
@@ -474,44 +411,51 @@ def build_graphrag_for_kb(kb_id: uuid.UUID):
""" """
build knowledge graph build knowledge graph
""" """
# Force re-importing Trio in child processes (to avoid inheriting the state of the parent process)
import importlib import importlib
import trio import trio
importlib.reload(trio) importlib.reload(trio)
db = next(get_db()) # Manually call the generator
db_documents = None
db_knowledge = None
try:
db_documents = db.query(Document).filter(Document.kb_id == kb_id).all()
db_knowledge = db.query(Knowledge).filter(Knowledge.id == kb_id).first()
# 1. Prepare to configure chat_mdl、embedding_model、vision_model information
chat_model = Base(
key=db_knowledge.llm.api_keys[0].api_key,
model_name=db_knowledge.llm.api_keys[0].model_name,
base_url=db_knowledge.llm.api_keys[0].api_base
)
embedding_model = OpenAIEmbed(
key=db_knowledge.embedding.api_keys[0].api_key,
model_name=db_knowledge.embedding.api_keys[0].model_name,
base_url=db_knowledge.embedding.api_keys[0].api_base
)
vision_model = QWenCV(
key=db_knowledge.image2text.api_keys[0].api_key,
model_name=db_knowledge.image2text.api_keys[0].model_name,
lang="Chinese",
base_url=db_knowledge.image2text.api_keys[0].api_base
)
with get_db_context() as db: # 2. get all document_ids from knowledge base
try: vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
if not isinstance(kb_id, uuid.UUID): total, items = vector_service.search_by_segment(document_id=None, query=None, pagesize=9999, page=1, asc=True)
kb_id = uuid.UUID(str(kb_id)) document_ids = [str(item.id) for item in db_documents]
db_knowledge = db.query(Knowledge).filter(Knowledge.id == kb_id).first()
if db_knowledge is None:
logger.error(f"[GraphRAG-KB] knowledge={kb_id} not found")
return "build knowledge graph failed: knowledge not found"
if not (db_knowledge.parser_config and
db_knowledge.parser_config.get("graphrag", {}).get("use_graphrag", False)):
return f"build knowledge graph '{db_knowledge.name}' skipped: graphrag not enabled"
db_documents = db.query(Document).filter(Document.kb_id == kb_id).all()
document_ids = [str(doc.id) for doc in db_documents]
chat_model = Base(
key=db_knowledge.llm.api_keys[0].api_key,
model_name=db_knowledge.llm.api_keys[0].model_name,
base_url=db_knowledge.llm.api_keys[0].api_base,
)
embedding_model = OpenAIEmbed(
key=db_knowledge.embedding.api_keys[0].api_key,
model_name=db_knowledge.embedding.api_keys[0].model_name,
base_url=db_knowledge.embedding.api_keys[0].api_base,
)
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
# 2. using graphrag
if db_knowledge.parser_config and db_knowledge.parser_config.get("graphrag", {}).get("use_graphrag", False):
graphrag_conf = db_knowledge.parser_config.get("graphrag", {}) graphrag_conf = db_knowledge.parser_config.get("graphrag", {})
with_resolution = graphrag_conf.get("resolution", False) with_resolution = graphrag_conf.get("resolution", False)
with_community = graphrag_conf.get("community", False) with_community = graphrag_conf.get("community", False)
def callback(*args, msg=None, **kwargs):
message = msg or (args[0] if args else "No message")
print(f"{datetime.now().strftime('%H:%M:%S')} run graphrag msg: {message}.\n")
start_time = time.time()
task = { task = {
"id": str(db_knowledge.id), "id": str(db_knowledge.id),
"workspace_id": str(db_knowledge.workspace_id), "workspace_id": str(db_knowledge.workspace_id),
@@ -524,18 +468,14 @@ def build_graphrag_for_kb(kb_id: uuid.UUID):
vector_size = len(vts[0]) vector_size = len(vts[0])
init_graphrag(task, vector_size) init_graphrag(task, vector_size)
def callback(*args, msg=None, **kwargs): async def _run(row: dict, document_ids: list[str], language: str, parser_config: dict, vector_service,
message = msg or (args[0] if args else "No message") chat_model, embedding_model, callback, with_resolution: bool = True,
logger.info(f"[GraphRAG-KB] kb={kb_id} msg: {message}") with_community: bool = True, ) -> dict:
result = await run_graphrag_for_kb(
start_time = time.time() row=row,
async def _run() -> dict:
return await run_graphrag_for_kb(
row=task,
document_ids=document_ids, document_ids=document_ids,
language=DEFAULT_PARSE_LANGUAGE, language=language,
parser_config=db_knowledge.parser_config, parser_config=parser_config,
vector_service=vector_service, vector_service=vector_service,
chat_model=chat_model, chat_model=chat_model,
embedding_model=embedding_model, embedding_model=embedding_model,
@@ -543,97 +483,46 @@ def build_graphrag_for_kb(kb_id: uuid.UUID):
with_resolution=with_resolution, with_resolution=with_resolution,
with_community=with_community, with_community=with_community,
) )
print(f"{datetime.now().strftime('%H:%M:%S')} GraphRAG task result for task {task}:\n{result}\n")
return result
result = trio.run(_run) def sync_task():
duration = time.time() - start_time trio.run(
logger.info(f"[GraphRAG-KB] kb={kb_id} done in {duration:.1f}s, result: {result}") lambda: _run(
row=task,
return f"build knowledge graph '{db_knowledge.name}' processed successfully." document_ids=document_ids,
except Exception as e: language="Chinese",
logger.error(f"[GraphRAG-KB] kb={kb_id} failed: {e}", exc_info=True) parser_config=db_knowledge.parser_config,
return f"build knowledge graph failed: {e}" vector_service=vector_service,
chat_model=chat_model,
embedding_model=embedding_model,
@celery_app.task(name="app.core.rag.tasks.build_graphrag_for_document") callback=callback,
def build_graphrag_for_document(document_id: str, knowledge_id: str): with_resolution=with_resolution,
""" with_community=with_community,
为单个文档构建 GraphRAG由 parse_document 异步派发。 )
"""
import importlib
import trio
importlib.reload(trio)
with get_db_context() as db:
try:
db_document = db.query(Document).filter(Document.id == uuid.UUID(document_id)).first()
db_knowledge = db.query(Knowledge).filter(Knowledge.id == uuid.UUID(knowledge_id)).first()
if db_document is None or db_knowledge is None:
logger.error(f"[GraphRAG] document={document_id} or knowledge={knowledge_id} not found")
return "build_graphrag_for_document failed: record not found"
graphrag_conf = db_knowledge.parser_config.get("graphrag", {})
with_resolution = graphrag_conf.get("resolution", False)
with_community = graphrag_conf.get("community", False)
chat_model = Base(
key=db_knowledge.llm.api_keys[0].api_key,
model_name=db_knowledge.llm.api_keys[0].model_name,
base_url=db_knowledge.llm.api_keys[0].api_base,
)
embedding_model = OpenAIEmbed(
key=db_knowledge.embedding.api_keys[0].api_key,
model_name=db_knowledge.embedding.api_keys[0].model_name,
base_url=db_knowledge.embedding.api_keys[0].api_base,
)
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
task = {
"id": document_id,
"workspace_id": str(db_knowledge.workspace_id),
"kb_id": str(db_knowledge.id),
"parser_config": db_knowledge.parser_config,
}
# init_graphrag
vts, _ = embedding_model.encode(["ok"])
vector_size = len(vts[0])
init_graphrag(task, vector_size)
def callback(*args, msg=None, **kwargs):
message = msg or (args[0] if args else "No message")
logger.info(f"[GraphRAG] doc={document_id} msg: {message}")
start_time = time.time()
async def _run() -> dict:
await trio.sleep(5)
return await run_graphrag_for_kb(
row=task,
document_ids=[document_id],
language=DEFAULT_PARSE_LANGUAGE,
parser_config=db_knowledge.parser_config,
vector_service=vector_service,
chat_model=chat_model,
embedding_model=embedding_model,
callback=callback,
with_resolution=with_resolution,
with_community=with_community,
) )
result = trio.run(_run) try:
duration = time.time() - start_time with ThreadPoolExecutor(max_workers=1) as executor:
logger.info(f"[GraphRAG] doc={document_id} done in {duration:.1f}s") future = executor.submit(sync_task)
future.result() # Blocks until the task completes
except Exception as e:
print(f"{datetime.now().strftime('%H:%M:%S')} GraphRAG task failed for task {task}:\n{str(e)}\n")
finally:
if db:
db.close()
print(f"{datetime.now().strftime('%H:%M:%S')} Knowledge Graph done ({time.time() - start_time}s)")
# 更新文档进度信息 result = f"build knowledge graph '{db_knowledge.name}' processed successfully."
db_document.progress_msg = (db_document.progress_msg or "") + \ return result
f"{datetime.now().strftime('%H:%M:%S')} Knowledge Graph done ({duration:.1f}s)\n" except Exception as e:
db.commit() if 'db_knowledge' in locals():
print(f"Failed to build knowledge grap:{str(e)}\n")
return f"build_graphrag_for_document '{document_id}' processed successfully." result = f"build knowledge grap '{db_knowledge.name}' failed."
except Exception as e: return result
logger.error(f"[GraphRAG] doc={document_id} failed: {e}", exc_info=True) finally:
return f"build_graphrag_for_document '{document_id}' failed: {e}" if db:
db.close()
@celery_app.task(name="app.core.rag.tasks.sync_knowledge_for_kb") @celery_app.task(name="app.core.rag.tasks.sync_knowledge_for_kb")
@@ -641,16 +530,10 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID):
""" """
sync knowledge document and Document parsing, vectorization, and storage sync knowledge document and Document parsing, vectorization, and storage
""" """
with get_db_context() as db: db = next(get_db()) # Manually call the generator
try: db_knowledge = None
if not isinstance(kb_id, uuid.UUID): try:
kb_id = uuid.UUID(str(kb_id))
db_knowledge = db.query(Knowledge).filter(Knowledge.id == kb_id).first() db_knowledge = db.query(Knowledge).filter(Knowledge.id == kb_id).first()
if db_knowledge is None:
logger.error(f"[SyncKB] knowledge={kb_id} not found")
return "sync knowledge failed: knowledge not found"
# 1. get vector_service # 1. get vector_service
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge) vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
@@ -785,7 +668,7 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID):
db.commit() db.commit()
except Exception as e: except Exception as e:
logger.error(f"[SyncKB] Error during crawl: {e}", exc_info=True) print(f"\n\nError during crawl: {e}")
case "Third-party": # Integration of knowledge bases from three parties case "Third-party": # Integration of knowledge bases from three parties
yuque_user_id = db_knowledge.parser_config.get("yuque_user_id", "") yuque_user_id = db_knowledge.parser_config.get("yuque_user_id", "")
feishu_app_id = db_knowledge.parser_config.get("feishu_app_id", "") feishu_app_id = db_knowledge.parser_config.get("feishu_app_id", "")
@@ -803,9 +686,13 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID):
# Get all files from all repos # Get all files from all repos
async def async_get_files(api_client: YuqueAPIClient): async def async_get_files(api_client: YuqueAPIClient):
async with api_client as client: async with api_client as client:
print("\n=== Fetching repositories ===")
repos = await client.get_user_repos() repos = await client.get_user_repos()
print(f"Found {len(repos)} repositories:")
all_files = [] all_files = []
for repo in repos: for repo in repos:
# Get documents from repository
print(f"\n=== Fetching documents from '{repo.name}' ===")
docs = await client.get_repo_docs(repo.id) docs = await client.get_repo_docs(repo.id)
all_files.extend(docs) all_files.extend(docs)
return all_files return all_files
@@ -951,7 +838,7 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID):
db.commit() db.commit()
except Exception as e: except Exception as e:
logger.error(f"[SyncKB] Error during fetch yuque: {e}", exc_info=True) print(f"\n\nError during fetch feishu: {e}")
if feishu_app_id: # Feishu Knowledge Base if feishu_app_id: # Feishu Knowledge Base
feishu_app_secret = db_knowledge.parser_config.get("feishu_app_secret", "") feishu_app_secret = db_knowledge.parser_config.get("feishu_app_secret", "")
feishu_folder_token = db_knowledge.parser_config.get("feishu_folder_token", "") feishu_folder_token = db_knowledge.parser_config.get("feishu_folder_token", "")
@@ -1113,16 +1000,19 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID):
db.commit() db.commit()
except Exception as e: except Exception as e:
logger.error(f"[SyncKB] Error during fetch feishu: {e}", exc_info=True) print(f"\n\nError during fetch feishu: {e}")
case _: # General case _: # General
logger.info(f"[SyncKB] kb={kb_id} type={db_knowledge.type}: no synchronization needed") print("General: No synchronization needed\n")
result = f"sync knowledge '{db_knowledge.name}' processed successfully." result = f"sync knowledge '{db_knowledge.name}' processed successfully."
return result return result
except Exception as e: except Exception as e:
logger.error(f"[SyncKB] kb={kb_id} failed: {e}", exc_info=True) if 'db_knowledge' in locals():
kb_name = db_knowledge.name if db_knowledge else kb_id print(f"Failed to sync knowledge:{str(e)}\n")
return f"sync knowledge '{kb_name}' failed: {e}" result = f"sync knowledge '{db_knowledge.name}' failed."
return result
finally:
db.close()
@celery_app.task(name="app.core.memory.agent.read_message", bind=True) @celery_app.task(name="app.core.memory.agent.read_message", bind=True)
@@ -3134,11 +3024,29 @@ def extract_user_metadata_task(
logger.info(f"[CELERY METADATA] No metadata extracted for end_user_id={end_user_id}") logger.info(f"[CELERY METADATA] No metadata extracted for end_user_id={end_user_id}")
return {"status": "SUCCESS", "result": "no_metadata_extracted"} return {"status": "SUCCESS", "result": "no_metadata_extracted"}
metadata_changes, aliases_to_add, aliases_to_remove = extract_result user_metadata, aliases_to_add, aliases_to_remove = extract_result
logger.info( logger.info(f"[CELERY METADATA] LLM 别名新增: {aliases_to_add}, 移除: {aliases_to_remove}")
f"[CELERY METADATA] LLM 元数据变更: {[c.model_dump() for c in metadata_changes]}, "
f"别名新增: {aliases_to_add}, 移除: {aliases_to_remove}" # 4. 清洗元数据、覆盖写入元数据和别名
) def clean_metadata(raw: dict) -> dict:
"""递归移除空字符串、空列表、空字典。"""
result = {}
for k, v in raw.items():
if v == "" or v == []:
continue
if isinstance(v, dict):
cleaned = clean_metadata(v)
if cleaned:
result[k] = cleaned
else:
result[k] = v
return result
raw_dict = user_metadata.model_dump(exclude_none=True) if user_metadata else {}
logger.info(f"[CELERY METADATA] LLM 输出完整元数据: {json.dumps(raw_dict, ensure_ascii=False)}")
cleaned = clean_metadata(raw_dict) if raw_dict else {}
logger.info(f"[CELERY METADATA] 清洗后元数据: {json.dumps(cleaned, ensure_ascii=False)}")
from datetime import datetime as dt, timezone as tz from datetime import datetime as dt, timezone as tz
now = dt.now(tz.utc).isoformat() now = dt.now(tz.utc).isoformat()
@@ -3166,49 +3074,15 @@ def extract_user_metadata_task(
end_user = EndUserRepository(db).get_by_id(end_user_uuid) end_user = EndUserRepository(db).get_by_id(end_user_uuid)
if info: if info:
# 4. 元数据增量更新(按 LLM 输出的变更操作逐条执行,所有字段均为列表类型) # 元数据覆盖写入
if metadata_changes: if cleaned:
# 深拷贝,确保 SQLAlchemy 能检测到变更 existing_meta = info.meta_data if info.meta_data else {}
import copy
existing_meta = copy.deepcopy(info.meta_data) if info.meta_data else {}
updated_at = dict(existing_meta.get("_updated_at", {})) updated_at = dict(existing_meta.get("_updated_at", {}))
_update_timestamps(existing_meta, cleaned, updated_at, now)
for change in metadata_changes: final = dict(cleaned)
field_path = change.field_path final["_updated_at"] = updated_at
action = change.action info.meta_data = final
value = change.value logger.info("[CELERY METADATA] 覆盖写入元数据")
if not value or not value.strip():
continue
# 定位到目标字段的父级节点
parts = field_path.split(".")
target = existing_meta
for part in parts[:-1]:
target = target.setdefault(part, {})
leaf = parts[-1]
current_list = target.get(leaf, [])
if action == "set":
if value not in current_list:
# 新值插入列表头部,保证按时间从新到旧排序
current_list.insert(0, value)
target[leaf] = current_list
logger.info(f"[CELERY METADATA] set {field_path} = {value}")
elif action == "remove":
if value in current_list:
current_list.remove(value)
target[leaf] = current_list
logger.info(f"[CELERY METADATA] remove {value} from {field_path}")
updated_at[field_path] = now
existing_meta["_updated_at"] = updated_at
# 赋值深拷贝后的新对象SQLAlchemy 会检测到字段变更并写入
info.meta_data = existing_meta
logger.info(f"[CELERY METADATA] 增量更新元数据完成: {json.dumps(existing_meta, ensure_ascii=False)}")
# 别名增量增删:(已有 - remove) + add # 别名增量增删:(已有 - remove) + add
old_aliases = info.aliases if info.aliases else [] old_aliases = info.aliases if info.aliases else []
@@ -3244,28 +3118,12 @@ def extract_user_metadata_task(
from app.models.end_user_info_model import EndUserInfo from app.models.end_user_info_model import EndUserInfo
initial_aliases = filtered_add # 新记录只有 add没有 remove initial_aliases = filtered_add # 新记录只有 add没有 remove
first_alias = initial_aliases[0] if initial_aliases else "" first_alias = initial_aliases[0] if initial_aliases else ""
if first_alias or cleaned:
# 从变更操作构建初始元数据(所有字段均为列表类型)
initial_meta = {}
for change in metadata_changes:
if change.action == "set" and change.value is not None and change.value.strip():
parts = change.field_path.split(".")
target = initial_meta
for part in parts[:-1]:
target = target.setdefault(part, {})
leaf = parts[-1]
current_list = target.get(leaf, [])
if change.value not in current_list:
# 新值插入列表头部,保证按时间从新到旧排序
current_list.insert(0, change.value)
target[leaf] = current_list
if first_alias or initial_meta:
new_info = EndUserInfo( new_info = EndUserInfo(
end_user_id=end_user_uuid, end_user_id=end_user_uuid,
other_name=first_alias or "", other_name=first_alias or "",
aliases=initial_aliases, aliases=initial_aliases,
meta_data=initial_meta if initial_meta else None, meta_data=cleaned if cleaned else None,
) )
db.add(new_info) db.add(new_info)
if end_user and first_alias and ( if end_user and first_alias and (

View File

@@ -1,40 +1,4 @@
{ {
"v0.3.0": {
"introduction": {
"codeName": "破晓",
"releaseDate": "2026-4-15",
"upgradePosition": "🐻 全面升级应用工作流、记忆智能与系统稳健性引入版本化API、多模态记忆感知及大量工作流增强打造更可靠、精准的 MemoryBear",
"coreUpgrades": [
"1. 应用与API增强<br>* 版本化API调用支持对外服务API支持指定版本调用<br>* 工作流检查清单:新增结构化验证步骤<br>* 深度思考参数精准控制:仅向支持深度推理的模型发送思考参数<br>* 提示器模型返回优化:优化提示器模型响应处理",
"2. 记忆智能 🧠<br>* 多模态记忆感知Agent支持多模态记忆读取与写入<br>* OpenClaw内置工具新增内置工具扩展Agent工具集",
"3. 用户体验 🎨<br>* 流式渲染稳定性优化解决LLM流式输出页面抖动问题<br>* 记忆中枢更名:「记忆相关」更名为「记忆中枢」",
"4. 工作流改进 ⚙️<br>* 三级变量模板转换:支持三级变量解析<br>* VL模型Token统计修复模型组合中VL模型Token未统计问题<br>* 导入工作流功能特性同步:正确同步开场白、引用等属性<br>* 会话变量名称唯一性校验:防止变量名冲突<br>* 文件类型提取修复正确提取file.type信息<br>* 条件分支显示修复值为0或会话变量时正确渲染<br>* Object/Array校验规则防止JSON序列化错误<br>* HTTP请求Body字段修正body字段从name改为key",
"5. 知识库 📚<br>* Embedding Token截断安全边界统一添加8000 token截断优化Excel独立chunk处理",
"6. 稳健性与缺陷修复 🔧<br>* 原子性更新与批量访问失败修复<br>* 对话别名提取错误修复<br>* 工作流别名提取修正区分用户和AI回复<br>* RAG记忆分页数据修复<br>* 隐式记忆详情显示修复<br>* 向量查询驱动关闭异常修复<br>* 用户管理启停异常修复<br>* 模型列表筛选不一致修复",
"<br>",
"v0.3.0 标志着 MemoryBear 向生产成熟度迈出坚实一步。后续版本将持续深化工作流表达力、记忆检索精度和跨模态理解能力强化复杂Agent编排支持稳固大规模生产部署基础。",
"<br>",
"MemoryBear — 破晓 🐻✨"
]
},
"introduction_en": {
"codeName": "PoXiao",
"releaseDate": "2026-4-15",
"upgradePosition": "🐻 Comprehensive upgrades across application workflows, memory intelligence, and system robustness — introducing versioned APIs, multimodal memory perception, and extensive workflow enhancements for a more reliable MemoryBear",
"coreUpgrades": [
"1. Application & API Enhancements<br>* Versioned API Support: External APIs now support version-specific calls<br>* Workflow Checklist: Structured validation steps before deployment<br>* Deep Thinking Parameter Control: Only send thinking params to supported models<br>* Prompt Optimizer Return Optimization: Improved prompt optimizer response handling",
"2. Memory Intelligence 🧠<br>* Multimodal Memory Perception Agent: Read/write multimodal memory<br>* OpenClaw Built-in Tool: New built-in tool for agent operations",
"3. User Experience 🎨<br>* Streaming Render Stabilization: Eliminated page jitter during LLM output<br>* Memory Hub Renaming: Renamed to better reflect central memory role",
"4. Workflow Improvements ⚙️<br>* Three-Level Variable Template Conversion: Support for three-level variable resolution<br>* VL Model Token Tracking: Fixed token tracking for VL models in model groups<br>* Imported Workflow Feature Sync: Properly sync opening messages, citations, etc.<br>* Session Variable Name Uniqueness: Prevent variable name conflicts<br>* File Type Extraction Fix: Correctly extract file.type information<br>* Condition Branch Display Fix: Correct rendering for value 0 or session variables<br>* Object/Array Validation Rules: Prevent JSON serialization save errors<br>* HTTP Request Body Key Fix: Body field uses key instead of name",
"5. Knowledge Base 📚<br>* Embedding Token Truncation Safety: Unified 8000-token boundary, optimized Excel chunk processing",
"6. Robustness & Bug Fixes 🔧<br>* Atomic update & batch access failure fixes<br>* Conversation alias extraction fix<br>* Workflow alias extraction correction (user vs AI distinction)<br>* RAG memory pagination fix<br>* Implicit memory detail display fix<br>* Vector query driver closed exception fix<br>* User management enable/disable fix<br>* Model list filter inconsistency fix",
"<br>",
"v0.3.0 marks a meaningful step toward production maturity for MemoryBear. Upcoming releases will deepen workflow expressiveness, memory retrieval precision, and cross-modal understanding while strengthening complex agent orchestration and large-scale deployment foundations.",
"<br>",
"MemoryBear — Daybreak 🐻✨"
]
}
},
"v0.2.10": { "v0.2.10": {
"introduction": { "introduction": {
"codeName": "炼剑", "codeName": "炼剑",

View File

@@ -93,8 +93,7 @@
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.45.0",
"unplugin-auto-import": "^20.2.0", "unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^29.1.0", "unplugin-vue-components": "^29.1.0",
"vite": "npm:rolldown-vite@7.1.14", "vite": "npm:rolldown-vite@7.1.14"
"vite-plugin-svgr": "^5.2.0"
}, },
"overrides": { "overrides": {
"vite": "npm:rolldown-vite@7.1.14" "vite": "npm:rolldown-vite@7.1.14"

View File

@@ -16,7 +16,7 @@ import {
ConfigProvider, ConfigProvider,
App as AntdApp App as AntdApp
} from 'antd'; } from 'antd';
import i18n from 'i18next'; import { useTranslation } from 'react-i18next';
import { lightTheme } from './styles/antdThemeConfig.ts' import { lightTheme } from './styles/antdThemeConfig.ts'
import router from './routes'; import router from './routes';
@@ -29,58 +29,11 @@ import 'dayjs/plugin/utc'
import { cookieUtils } from './utils/request'; import { cookieUtils } from './utils/request';
import { useUser } from '@/store/user'; import { useUser } from '@/store/user';
import menuJson from '@/store/menu.json';
type MenuEntry = { path: string; i18nKey: string };
function flattenMenuEntries(list: any[]): MenuEntry[] {
const result: MenuEntry[] = [];
for (const item of list) {
if (item.path && item.i18nKey && item.type !== 'group') result.push({ path: item.path, i18nKey: item.i18nKey });
if (item.subs?.length) result.push(...flattenMenuEntries(item.subs));
}
return result;
}
const menuEntries: MenuEntry[] = flattenMenuEntries([...menuJson.manage, ...menuJson.space]);
function pathMatches(pattern: string, path: string): boolean {
if (pattern === path) return true;
if (pattern.includes(':')) {
return new RegExp('^' + pattern.replace(/:[\w-]+/g, '[^/]+') + '$').test(path);
}
return false;
}
function getPageTitle(pathname: string): string {
const appName = i18n.t('memoryBear');
const entry = menuEntries.find(e => pathMatches(e.path, pathname));
if (!entry) return appName;
return `${i18n.t(entry.i18nKey)} - ${appName}`;
}
const SKIP_TITLE_PATTERNS = [
'/user-memory/detail/:id/:type',
'/forgetting-engine/:id',
'/memory-extraction-engine/:id',
'/emotion-engine/:id',
'/reflection-engine/:id',
];
function App() { function App() {
const { t } = useTranslation();
const { locale, language, timeZone } = useI18n() const { locale, language, timeZone } = useI18n()
const { checkJump } = useUser(); const { checkJump } = useUser();
useEffect(() => {
const unsubscribe = router.subscribe(({ location }) => {
if (SKIP_TITLE_PATTERNS.some(p => pathMatches(p, location.pathname))) return;
document.title = getPageTitle(location.pathname);
});
return () => unsubscribe();
}, [])
useEffect(() => { useEffect(() => {
const authToken = cookieUtils.get('authToken') const authToken = cookieUtils.get('authToken')
if (!authToken && !window.location.hash.includes('#/login') && !window.location.hash.includes('#/conversation/') && !window.location.hash.includes('#/jump') && !window.location.hash.includes('#/invite-register')) { if (!authToken && !window.location.hash.includes('#/login') && !window.location.hash.includes('#/conversation/') && !window.location.hash.includes('#/jump') && !window.location.hash.includes('#/invite-register')) {
@@ -91,9 +44,7 @@ function App() {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!SKIP_TITLE_PATTERNS.some(p => pathMatches(p, router.state.location.pathname))) { document.title = t('memoryBear')
document.title = getPageTitle(router.state.location.pathname)
}
dayjs.locale(language) dayjs.locale(language)
localStorage.setItem('language', language) localStorage.setItem('language', language)
}, [language]) }, [language])

View File

@@ -174,8 +174,4 @@ export const getAppLogsUrl = (app_id: string) => `/apps/${app_id}/logs`
// Get full conversation message history // Get full conversation message history
export const getAppLogDetail = (app_id: string, conversation_id: string) => { export const getAppLogDetail = (app_id: string, conversation_id: string) => {
return request.get(`/apps/${app_id}/logs/${conversation_id}`) return request.get(`/apps/${app_id}/logs/${conversation_id}`)
}
// Reset agent model config to default
export const resetAppModelConfig = (app_id: string) => {
return request.get(`/apps/${app_id}/model/parameters/default`)
} }

View File

@@ -1,8 +0,0 @@
import { request } from '@/utils/request'
import type { Package } from '@/views/Package/types'
// 套餐列表
export const getPackageListUrl = `/package-plans`
export const getPackageList = (query?: { category?: Package['category']; status?: boolean; }) => {
return request.get(getPackageListUrl, query)
}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 14:00:23 * @Date: 2026-02-03 14:00:23
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-14 18:36:01 * @Last Modified time: 2026-02-25 11:17:44
*/ */
import { request } from '@/utils/request' import { request } from '@/utils/request'
import type { CreateModalData, ChangeEmailModalForm } from '@/views/UserManagement/types' import type { CreateModalData, ChangeEmailModalForm } from '@/views/UserManagement/types'
@@ -56,9 +56,4 @@ export const sendEmailCode = (data: { email: string }) => {
// Verify code and change email // Verify code and change email
export const changeEmail = (data: ChangeEmailModalForm) => { export const changeEmail = (data: ChangeEmailModalForm) => {
return request.put('/users/change-email', data) return request.put('/users/change-email', data)
}
// 获取租户套餐信息
export const getTenantSubscription = () => {
return request.get('/tenant/subscription')
} }

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>导出</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
<g id="记忆库-个人记忆-感知记忆-文本" transform="translate(-573, -158)" stroke="#171719">
<g id="导出" transform="translate(573, 158)">
<g id="编组-54" transform="translate(3, 3)">
<path d="M10,6 L10,7.5 C10,8.88071187 8.88071187,10 7.5,10 L2.5,10 C1.11928813,10 0,8.88071187 0,7.5 L0,6 L0,6" id="路径"></path>
<g id="编组-11" transform="translate(2, 0)">
<line x1="3" y1="0.08499952" x2="3" y2="6.99635859" id="路径-24"></line>
<polyline id="路径-25" stroke-linejoin="round" points="0 3 2.98005548 6.08298138e-18 6 3"></polyline>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>导入</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
<g id="记忆库-个人记忆-感知记忆-文本" transform="translate(-555, -158)" stroke="#171719">
<g id="导入" transform="translate(555, 158)">
<g id="编组-54" transform="translate(3, 3)">
<path d="M10,6 L10,7.5 C10,8.88071187 8.88071187,10 7.5,10 L2.5,10 C1.11928813,10 0,8.88071187 0,7.5 L0,6 L0,6" id="路径"></path>
<g id="编组-11" transform="translate(5, 3.4982) scale(1, -1) translate(-5, -3.4982)translate(2, 0)">
<line x1="3" y1="0.08499952" x2="3" y2="6.99635859" id="路径-24"></line>
<polyline id="路径-25" stroke-linejoin="round" points="0 3 2.98005548 6.08298138e-18 6 3"></polyline>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>关闭</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="应用管理-My-Shares" transform="translate(-1396, -127)" fill="#5B6167" fill-rule="nonzero">
<g id="卡片1备份-2" transform="translate(1044, 108)">
<g id="编组-12" transform="translate(349, 16)">
<g id="关闭" transform="translate(3, 3)">
<polygon id="路径" points="9.00000098 8 13.3333333 12.3333324 12.3333324 13.3333333 8 9.00000098 3.66666764 13.3333333 2.66666667 12.3333324 6.99999902 8 2.66666667 3.66666764 3.66666764 2.66666667 8 6.99999902 12.3333324 2.66666667 13.3333333 3.66666764"></polygon>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1005 B

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组 5</title>
<g id="V1.1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="首页" transform="translate(-1229, -446)" stroke="#212332">
<g id="编组-13" transform="translate(1120, 300)">
<g id="编组-6" transform="translate(16, 138)">
<g id="编组-5" transform="translate(93, 8)">
<polyline id="路径" points="10 6 12 8 10 10"></polyline>
<line x1="12" y1="8" x2="2" y2="8" id="路径-2"></line>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 820 B

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>退出</title>
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="应用管理-编排-默认状态" transform="translate(-1262, -24)" stroke="#5B6167">
<g id="返回空间" transform="translate(1262, 24)">
<g id="退出" transform="translate(8, 8) scale(-1, 1) translate(-8, -8)">
<g id="编组-7" transform="translate(2.5, 2)">
<path d="M6,12 L1,12 C0.44771525,12 0,11.5522847 0,11 L0,1 C0,0.44771525 0.44771525,1.11022302e-16 1,0 L6,0 L6,0" id="路径"></path>
<line x1="11" y1="6" x2="3" y2="6" id="路径-6"></line>
<polyline id="路径" points="8 3 11 6 8 9"></polyline>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>退出</title>
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="空间配置" transform="translate(-22, -763)" stroke="#5B6167" stroke-width="1.2">
<g id="退出" transform="translate(0, 742)">
<g id="返回空间" transform="translate(12, 10)">
<g id="退出" transform="translate(10, 11)">
<g id="编组-7" transform="translate(2.5, 2)">
<path d="M6,12 L1,12 C0.44771525,12 0,11.5522847 0,11 L0,1 C0,0.44771525 0.44771525,1.11022302e-16 1,0 L6,0 L6,0" id="路径"></path>
<line x1="11" y1="6" x2="3" y2="6" id="路径-6"></line>
<polyline id="路径" points="8 3 11 6 8 9"></polyline>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>退出</title>
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="应用管理-编排-默认状态" transform="translate(-1262, -24)" stroke="#155EEF">
<g id="返回空间" transform="translate(1262, 24)">
<g id="退出" transform="translate(8, 8) scale(-1, 1) translate(-8, -8)">
<g id="编组-7" transform="translate(2.5, 2)">
<path d="M6,12 L1,12 C0.44771525,12 0,11.5522847 0,11 L0,1 C0,0.44771525 0.44771525,1.11022302e-16 1,0 L6,0 L6,0" id="路径"></path>
<line x1="11" y1="6" x2="3" y2="6" id="路径-6"></line>
<polyline id="路径" points="8 3 11 6 8 9"></polyline>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>频次</title>
<g id="空间外层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="平台管理-收费管理" transform="translate(-314, -750)" fill="currentColor" fill-rule="nonzero">
<g id="编组-5" transform="translate(288, 64)">
<g id="编组-13" transform="translate(0, 228)">
<g transform="translate(20, 16)" id="频次">
<g transform="translate(6, 442)">
<path d="M8.32397431,14.7174176 L13.3908989,7.29436898 C13.5898421,7.00271091 13.5091666,6.60853935 13.2103296,6.41423815 C13.1037093,6.3447846 12.9784064,6.30774783 12.8502468,6.30780564 L8.86631603,6.30780564 L8.86631603,1.63467602 C8.86631603,1.28423255 8.57550429,1 8.21668864,1 C7.99937181,1 7.79662724,1.10601998 7.67603646,1.28258243 L2.60911183,8.70563102 C2.41016872,8.99728909 2.49084416,9.39125438 2.78947001,9.58576185 C2.89614942,9.65527988 3.02151846,9.69238624 3.149764,9.69240062 L7.13369475,9.69240062 L7.13369475,14.365324 C7.13369475,14.7157675 7.42450649,15 7.78332213,15 C8.00063896,15 8.20359472,14.89398 8.32397431,14.7174176 Z" id="路径"></path>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>应用</title>
<g id="空间外层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="平台管理-收费管理" transform="translate(-314, -414)" fill="currentColor" fill-rule="nonzero">
<g id="编组-5" transform="translate(288, 64)">
<g id="编组-13" transform="translate(0, 228)">
<g transform="translate(20, 16)" id="应用">
<g transform="translate(6, 106)">
<path d="M5.73221919,1.5 L2.70920955,1.5 C2.04142437,1.5 1.5,2.0410081 1.5,2.70827986 L1.5,5.72897951 C1.5,6.39623705 2.04142437,6.93725937 2.70920955,6.93725937 L5.73223342,6.93725937 C6.40000437,6.93725937 6.94144297,6.39625128 6.94144297,5.72897951 L6.94144297,2.70826564 C6.94144297,2.0410081 6.40000437,1.5 5.73221919,1.5 L5.73221919,1.5 Z M12.7040542,1.5 L9.68104456,1.5 C9.01325938,1.5 8.47183501,2.0410081 8.47183501,2.70827986 L8.47183501,5.72897951 C8.47183501,6.39623705 9.01325938,6.93725937 9.68104456,6.93725937 L12.7040684,6.93725937 C13.3718536,6.93725937 13.913278,6.39625128 13.913278,5.72897951 L13.913278,2.70826564 C13.913278,2.0410081 13.3718394,1.5 12.7040542,1.5 L12.7040542,1.5 Z M5.73221919,8.4711823 L2.70920955,8.4711823 C2.04142437,8.4711823 1.5,9.01220462 1.5,9.67946216 L1.5,12.7001618 C1.5,13.3674336 2.04142437,13.9084417 2.70920955,13.9084417 L5.73223342,13.9084417 C6.40000437,13.9084417 6.94144297,13.3674336 6.94144297,12.7001618 L6.94144297,9.67946216 C6.94144297,9.01220462 6.40000437,8.4711823 5.73221919,8.4711823 L5.73221919,8.4711823 Z M14.1766032,10.5791939 L12.1883205,8.5402163 C11.7490465,8.08947578 11.0275174,8.07989009 10.5761312,8.51898273 L8.53500119,10.5057368 C8.08465397,10.944673 8.07520324,11.6656474 8.51434907,12.1163879 L10.5029307,14.1556499 C10.9422047,14.6063905 11.6640184,14.6158339 12.1148069,14.1768835 L14.1556522,12.1898308 C14.6067395,11.7505959 14.6155925,11.0296358 14.1766032,10.5791939 L14.1766032,10.5791939 Z" id="形状" transform="translate(8, 8) scale(1, -1) translate(-8, -8)"></path>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

Some files were not shown because too many files have changed in this diff Show More