Merge branch 'release/v0.3.2' into feature/rag2
* release/v0.3.2: (245 commits) fix(conversation_schema): refine citations field type to Dict[str, Any] fix(tool_controller): re-raise HTTPException to preserve original status codes fix(workflow): add reasoning content, suggested questions, citations and audio status support feat(workflow): augment logging queries and ameliorate error handling fix(api_key): bypass publication check for SERVICE type API keys fix(multimodal_service): add '文档内容:' prefix to document text and simplify image placeholder text fix(api): convert config_id to string in write_router fix(api): convert end_user_id to string in write_router fix(multimodal_service): refactor image processing to use intermediate list before extending result fix(web): node status ui fix(api): correct import paths in memory_read and celery task command fix(api): correct import paths in memory_read and celery task command refactor(tool): flatten request body parameters for model exposure fix(api): correct import paths in memory_read and celery task command refactor(workflow): streamline node execution handling and log service logic feat(web): http request add process feat(web): workflow app logs fix(app_chat_service,draft_run_service): move system_prompt augmentation before LangChainAgent instantiation fix(app_chat_service,draft_run_service): move system_prompt augmentation before LangChainAgent instantiation refactor(http_request): simplify request handling and remove unused fields ... # Conflicts: # api/app/controllers/file_controller.py # api/app/tasks.py
This commit is contained in:
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.aioRedis import aio_redis
|
||||
from app.models.api_key_model import ApiKey
|
||||
from app.models.api_key_model import ApiKey, ApiKeyType
|
||||
from app.repositories.api_key_repository import ApiKeyRepository, ApiKeyLogRepository
|
||||
from app.schemas import api_key_schema
|
||||
from app.schemas.response_schema import PageData, PageMeta
|
||||
@@ -19,6 +19,7 @@ from app.core.exceptions import (
|
||||
)
|
||||
from app.core.error_codes import BizCode
|
||||
from app.core.logging_config import get_business_logger
|
||||
from app.models.app_model import App
|
||||
|
||||
logger = get_business_logger()
|
||||
|
||||
@@ -51,6 +52,25 @@ class ApiKeyService:
|
||||
if existing:
|
||||
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
|
||||
)
|
||||
|
||||
# SERVICE 类型的 resource_id 指向 workspace,非应用,跳过应用发布校验
|
||||
if data.resource_id and data.type != ApiKeyType.SERVICE.value:
|
||||
app = db.get(App, data.resource_id)
|
||||
if not app or not app.current_release_id:
|
||||
raise BusinessException("该应用未发布", BizCode.APP_NOT_PUBLISHED)
|
||||
|
||||
# 生成 API Key
|
||||
api_key = generate_api_key(data.type)
|
||||
|
||||
@@ -152,6 +172,20 @@ class ApiKeyService:
|
||||
if existing:
|
||||
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)
|
||||
ApiKeyRepository.update(db, api_key_id, update_data)
|
||||
db.commit()
|
||||
@@ -249,12 +283,13 @@ class RateLimiterService:
|
||||
self.redis = aio_redis
|
||||
|
||||
async def check_qps(self, api_key_id: uuid.UUID, limit: int) -> Tuple[bool, dict]:
|
||||
"""
|
||||
检查QPS限制
|
||||
"""检查QPS限制
|
||||
|
||||
Returns:
|
||||
(is_allowed, rate_limit_info)
|
||||
"""
|
||||
key = f"rate_limit:qps:{api_key_id}"
|
||||
|
||||
async with self.redis.pipeline() as pipe:
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, 1, nx=True) # 1 秒过期
|
||||
@@ -266,8 +301,9 @@ class RateLimiterService:
|
||||
|
||||
return current <= limit, {
|
||||
"limit": limit,
|
||||
"current": current,
|
||||
"remaining": remaining,
|
||||
"reset": reset_time
|
||||
"reset": reset_time,
|
||||
}
|
||||
|
||||
async def check_daily_requests(
|
||||
@@ -275,7 +311,9 @@ class RateLimiterService:
|
||||
api_key_id: uuid.UUID,
|
||||
limit: int
|
||||
) -> Tuple[bool, dict]:
|
||||
"""检查日调用量限制"""
|
||||
"""检查日调用量限制。
|
||||
使用原子 INCR,先写后判断,极低概率下允许轻微超限(并发场景下可接受)。
|
||||
"""
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
key = f"rate_limit:daily:{api_key_id}:{today}"
|
||||
|
||||
@@ -284,6 +322,7 @@ class RateLimiterService:
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
expire_seconds = int((tomorrow_0 - now).total_seconds())
|
||||
reset_time = int(tomorrow_0.timestamp())
|
||||
|
||||
async with self.redis.pipeline() as pipe:
|
||||
pipe.incr(key)
|
||||
@@ -291,36 +330,74 @@ class RateLimiterService:
|
||||
results = await pipe.execute()
|
||||
|
||||
current = results[0]
|
||||
remaining = max(0, limit - current)
|
||||
reset_time = int(tomorrow_0.timestamp())
|
||||
|
||||
return current <= limit, {
|
||||
if current > limit:
|
||||
return False, {
|
||||
"limit": limit,
|
||||
"remaining": 0,
|
||||
"reset": reset_time,
|
||||
}
|
||||
|
||||
return True, {
|
||||
"limit": limit,
|
||||
"remaining": remaining,
|
||||
"reset": reset_time
|
||||
"remaining": max(0, limit - current),
|
||||
"reset": reset_time,
|
||||
}
|
||||
|
||||
async def check_all_limits(
|
||||
self,
|
||||
api_key: ApiKey
|
||||
api_key: ApiKey,
|
||||
db: Optional[Session] = None,
|
||||
) -> Tuple[bool, str, dict]:
|
||||
"""
|
||||
检查所有限制
|
||||
Returns:
|
||||
(is_allowed, error_message, rate_limit_headers)
|
||||
检查所有限制,按以下顺序:
|
||||
1. API Key QPS:取 api_key.rate_limit 与套餐 api_ops_rate_limit 的最小值作为限额
|
||||
2. API Key 日调用量
|
||||
"""
|
||||
# Check QPS
|
||||
qps_ok, qps_info = await self.check_qps(
|
||||
api_key.id,
|
||||
api_key.rate_limit
|
||||
)
|
||||
# 1. 取套餐限额与 api_key 自身限额的最小值
|
||||
effective_limit = api_key.rate_limit
|
||||
if db is not None:
|
||||
try:
|
||||
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:
|
||||
return False, "QPS limit exceeded", {
|
||||
# 判断是套餐限额触发还是 api_key 自身限额触发
|
||||
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-Remaining-QPS": str(qps_info["remaining"]),
|
||||
"X-RateLimit-Reset": str(qps_info["reset"])
|
||||
}
|
||||
|
||||
# 2. 检查日调用量
|
||||
daily_ok, daily_info = await self.check_daily_requests(
|
||||
api_key.id,
|
||||
api_key.daily_request_limit
|
||||
@@ -332,14 +409,13 @@ class RateLimiterService:
|
||||
"X-RateLimit-Reset": str(daily_info["reset"])
|
||||
}
|
||||
|
||||
headers = {
|
||||
return True, "", {
|
||||
"X-RateLimit-Limit-QPS": str(qps_info["limit"]),
|
||||
"X-RateLimit-Remaining-QPS": str(qps_info["remaining"]),
|
||||
"X-RateLimit-Limit-Day": str(daily_info["limit"]),
|
||||
"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:
|
||||
@@ -373,6 +449,20 @@ class ApiKeyAuthService:
|
||||
|
||||
return api_key_obj
|
||||
|
||||
@staticmethod
|
||||
def check_app_published(db: Session, api_key_obj: ApiKey) -> None:
|
||||
"""
|
||||
检查应用是否已发布,未发布则抛出异常
|
||||
SERVICE 类型的 api_key 不绑定应用(resource_id 指向 workspace),跳过校验
|
||||
"""
|
||||
if not api_key_obj.resource_id:
|
||||
return
|
||||
if api_key_obj.type == ApiKeyType.SERVICE.value:
|
||||
return
|
||||
app = db.get(App, api_key_obj.resource_id)
|
||||
if not app or not app.current_release_id:
|
||||
raise BusinessException("应用未发布,不可用", BizCode.APP_NOT_PUBLISHED)
|
||||
|
||||
@staticmethod
|
||||
def check_scope(api_key: ApiKey, required_scope: str) -> bool:
|
||||
"""检查权限范围"""
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.models import MultiAgentConfig, AgentConfig, ModelType
|
||||
from app.models import WorkflowConfig
|
||||
from app.repositories.tool_repository import ToolRepository
|
||||
from app.schemas import DraftRunRequest
|
||||
from app.schemas.app_schema import FileInput
|
||||
from app.schemas.app_schema import FileInput, FileType
|
||||
from app.schemas.model_schema import ModelInfo
|
||||
from app.schemas.prompt_schema import render_prompt_message, PromptMessageRole
|
||||
from app.services.conversation_service import ConversationService
|
||||
@@ -26,6 +26,7 @@ from app.services.model_service import ModelApiKeyService
|
||||
from app.services.multi_agent_orchestrator import MultiAgentOrchestrator
|
||||
from app.services.multimodal_service import MultimodalService
|
||||
from app.services.workflow_service import WorkflowService
|
||||
from app.models.file_metadata_model import FileMetadata
|
||||
|
||||
logger = get_business_logger()
|
||||
|
||||
@@ -106,22 +107,6 @@ class AppChatService:
|
||||
# 获取模型参数
|
||||
model_parameters = config.model_parameters
|
||||
|
||||
# 创建 LangChain Agent
|
||||
agent = LangChainAgent(
|
||||
model_name=api_key_obj.model_name,
|
||||
api_key=api_key_obj.api_key,
|
||||
provider=api_key_obj.provider,
|
||||
api_base=api_key_obj.api_base,
|
||||
is_omni=api_key_obj.is_omni,
|
||||
temperature=model_parameters.get("temperature", 0.7),
|
||||
max_tokens=model_parameters.get("max_tokens", 2000),
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
deep_thinking=model_parameters.get("deep_thinking", False),
|
||||
thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"),
|
||||
capability=api_key_obj.capability or [],
|
||||
)
|
||||
|
||||
model_info = ModelInfo(
|
||||
model_name=api_key_obj.model_name,
|
||||
provider=api_key_obj.provider,
|
||||
@@ -163,8 +148,39 @@ class AppChatService:
|
||||
processed_files = None
|
||||
if files:
|
||||
multimodal_service = MultimodalService(self.db, model_info)
|
||||
processed_files = await multimodal_service.process_files(files)
|
||||
fu_config = features_config.get("file_upload", {})
|
||||
if hasattr(fu_config, "model_dump"):
|
||||
fu_config = fu_config.model_dump()
|
||||
doc_img_recognition = isinstance(fu_config, dict) and fu_config.get("document_image_recognition", False)
|
||||
processed_files = await multimodal_service.process_files(
|
||||
files, document_image_recognition=doc_img_recognition,
|
||||
workspace_id=workspace_id
|
||||
)
|
||||
logger.info(f"处理了 {len(processed_files)} 个文件")
|
||||
if doc_img_recognition and "vision" in (api_key_obj.capability or []) and any(
|
||||
f.type == FileType.DOCUMENT for f in files
|
||||
):
|
||||
system_prompt += (
|
||||
"\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式  展示对应图片。"
|
||||
)
|
||||
|
||||
# 创建 LangChain Agent
|
||||
agent = LangChainAgent(
|
||||
model_name=api_key_obj.model_name,
|
||||
api_key=api_key_obj.api_key,
|
||||
provider=api_key_obj.provider,
|
||||
api_base=api_key_obj.api_base,
|
||||
is_omni=api_key_obj.is_omni,
|
||||
temperature=model_parameters.get("temperature", 0.7),
|
||||
max_tokens=model_parameters.get("max_tokens", 2000),
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
deep_thinking=model_parameters.get("deep_thinking", False),
|
||||
thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"),
|
||||
json_output=model_parameters.get("json_output", False),
|
||||
capability=api_key_obj.capability or [],
|
||||
)
|
||||
|
||||
# 为需要运行时上下文的工具注入上下文
|
||||
for t in tools:
|
||||
if hasattr(t, 'tool_instance') and hasattr(t.tool_instance, 'set_runtime_context'):
|
||||
@@ -218,11 +234,29 @@ class AppChatService:
|
||||
"reasoning_content": result.get("reasoning_content")
|
||||
}
|
||||
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:
|
||||
# url = await MultimodalService(self.db).get_file_url(f)
|
||||
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({
|
||||
"type": f.type,
|
||||
"url": f.url
|
||||
"url": f.url,
|
||||
"name": name,
|
||||
"size": size,
|
||||
"file_type": f.file_type,
|
||||
})
|
||||
|
||||
if processed_files:
|
||||
@@ -283,7 +317,7 @@ class AppChatService:
|
||||
"suggested_questions": suggested_questions,
|
||||
"citations": filtered_citations,
|
||||
"audio_url": audio_url,
|
||||
"audio_status": "pending"
|
||||
"audio_status": "pending" if audio_url else None
|
||||
}
|
||||
|
||||
async def agnet_chat_stream(
|
||||
@@ -359,23 +393,6 @@ class AppChatService:
|
||||
# 获取模型参数
|
||||
model_parameters = config.model_parameters
|
||||
|
||||
# 创建 LangChain Agent
|
||||
agent = LangChainAgent(
|
||||
model_name=api_key_obj.model_name,
|
||||
api_key=api_key_obj.api_key,
|
||||
provider=api_key_obj.provider,
|
||||
api_base=api_key_obj.api_base,
|
||||
is_omni=api_key_obj.is_omni,
|
||||
temperature=model_parameters.get("temperature", 0.7),
|
||||
max_tokens=model_parameters.get("max_tokens", 2000),
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
streaming=True,
|
||||
deep_thinking=model_parameters.get("deep_thinking", False),
|
||||
thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"),
|
||||
capability=api_key_obj.capability or [],
|
||||
)
|
||||
|
||||
model_info = ModelInfo(
|
||||
model_name=api_key_obj.model_name,
|
||||
provider=api_key_obj.provider,
|
||||
@@ -417,8 +434,40 @@ class AppChatService:
|
||||
processed_files = None
|
||||
if files:
|
||||
multimodal_service = MultimodalService(self.db, model_info)
|
||||
processed_files = await multimodal_service.process_files(files)
|
||||
fu_config = features_config.get("file_upload", {})
|
||||
if hasattr(fu_config, "model_dump"):
|
||||
fu_config = fu_config.model_dump()
|
||||
doc_img_recognition = isinstance(fu_config, dict) and fu_config.get("document_image_recognition", False)
|
||||
processed_files = await multimodal_service.process_files(
|
||||
files, document_image_recognition=doc_img_recognition,
|
||||
workspace_id=workspace_id
|
||||
)
|
||||
logger.info(f"处理了 {len(processed_files)} 个文件")
|
||||
if doc_img_recognition and "vision" in (api_key_obj.capability or []) and any(
|
||||
f.type == FileType.DOCUMENT for f in files
|
||||
):
|
||||
from langchain.agents import create_agent
|
||||
system_prompt += (
|
||||
"\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式  展示对应图片。"
|
||||
)
|
||||
|
||||
# 创建 LangChain Agent
|
||||
agent = LangChainAgent(
|
||||
model_name=api_key_obj.model_name,
|
||||
api_key=api_key_obj.api_key,
|
||||
provider=api_key_obj.provider,
|
||||
api_base=api_key_obj.api_base,
|
||||
is_omni=api_key_obj.is_omni,
|
||||
temperature=model_parameters.get("temperature", 0.7),
|
||||
max_tokens=model_parameters.get("max_tokens", 2000),
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
streaming=True,
|
||||
deep_thinking=model_parameters.get("deep_thinking", False),
|
||||
thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"),
|
||||
json_output=model_parameters.get("json_output", False),
|
||||
capability=api_key_obj.capability or [],
|
||||
)
|
||||
|
||||
# 为需要运行时上下文的工具注入上下文
|
||||
for t in tools:
|
||||
@@ -509,10 +558,29 @@ class AppChatService:
|
||||
}
|
||||
|
||||
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:
|
||||
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({
|
||||
"type": f.type,
|
||||
"url": f.url
|
||||
"url": f.url,
|
||||
"name": name,
|
||||
"size": size,
|
||||
"file_type": f.file_type,
|
||||
})
|
||||
if processed_files:
|
||||
human_meta["history_files"] = {
|
||||
|
||||
@@ -14,12 +14,14 @@ from app.models.app_model import App, AppType
|
||||
from app.models.appshare_model import AppShare
|
||||
from app.models.app_release_model import AppRelease
|
||||
from app.models.knowledge_model import Knowledge
|
||||
from app.models.knowledgeshare_model import KnowledgeShare
|
||||
from app.models.models_model import ModelConfig
|
||||
from app.models.tool_model import ToolConfig as ToolConfigModel
|
||||
from app.models.skill_model import Skill
|
||||
from app.models.workflow_model import WorkflowConfig
|
||||
from app.services.workflow_service import WorkflowService
|
||||
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
|
||||
|
||||
|
||||
@@ -73,15 +75,14 @@ class AppDslService:
|
||||
AppType.MULTI_AGENT: "multi_agent_config",
|
||||
AppType.WORKFLOW: "workflow"
|
||||
}.get(app.type, "config")
|
||||
config_data = self._enrich_release_config(app.type, release.config or {})
|
||||
config_data = self._enrich_release_config(app.type, release.config or {}, release.default_model_config_id)
|
||||
dsl = {**meta, "app": app_meta, config_key: config_data}
|
||||
return yaml.dump(dsl, default_flow_style=False, allow_unicode=True), f"{release.name}_v{release.version_name}.yaml"
|
||||
|
||||
def _enrich_release_config(self, app_type: str, cfg: dict) -> dict:
|
||||
def _enrich_release_config(self, app_type: str, cfg: dict, default_model_config_id=None) -> dict:
|
||||
if app_type == AppType.AGENT:
|
||||
enriched = {**cfg}
|
||||
if "default_model_config_id" in cfg:
|
||||
enriched["default_model_config_ref"] = self._model_ref(cfg["default_model_config_id"])
|
||||
enriched["default_model_config_ref"] = self._model_ref(default_model_config_id)
|
||||
if "knowledge_retrieval" in cfg:
|
||||
enriched["knowledge_retrieval"] = self._enrich_knowledge_retrieval(cfg["knowledge_retrieval"])
|
||||
if "tools" in cfg:
|
||||
@@ -91,8 +92,7 @@ class AppDslService:
|
||||
return enriched
|
||||
if app_type == AppType.MULTI_AGENT:
|
||||
enriched = {**cfg}
|
||||
if "default_model_config_id" in cfg:
|
||||
enriched["default_model_config_ref"] = self._model_ref(cfg["default_model_config_id"])
|
||||
enriched["default_model_config_ref"] = self._model_ref(default_model_config_id)
|
||||
if "master_agent_id" in cfg:
|
||||
enriched["master_agent_ref"] = self._release_ref(cfg["master_agent_id"])
|
||||
if "sub_agents" in cfg:
|
||||
@@ -229,8 +229,11 @@ class AppDslService:
|
||||
workspace_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
app_id: Optional[uuid.UUID] = None,
|
||||
) -> tuple[App, list[str]]:
|
||||
"""解析 DSL,创建应用及配置,返回 (new_app, warnings)"""
|
||||
"""解析 DSL,创建或覆盖应用配置,返回 (app, warnings)。
|
||||
app_id 不为空时:校验类型一致后覆盖配置;为空时创建新应用。
|
||||
"""
|
||||
app_meta = dsl.get("app", {})
|
||||
app_type = app_meta.get("type")
|
||||
if app_type not in (AppType.AGENT, AppType.MULTI_AGENT, AppType.WORKFLOW):
|
||||
@@ -239,6 +242,9 @@ class AppDslService:
|
||||
warnings: list[str] = []
|
||||
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(
|
||||
id=uuid.uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
@@ -258,11 +264,57 @@ class AppDslService:
|
||||
self.db.add(new_app)
|
||||
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:
|
||||
cfg = dsl.get("agent_config") or {}
|
||||
self.db.add(AgentConfig(
|
||||
id=uuid.uuid4(),
|
||||
app_id=new_app.id,
|
||||
fields = dict(
|
||||
system_prompt=cfg.get("system_prompt"),
|
||||
model_parameters=cfg.get("model_parameters"),
|
||||
default_model_config_id=self._resolve_model(cfg.get("default_model_config_ref"), tenant_id, warnings),
|
||||
@@ -272,16 +324,21 @@ class AppDslService:
|
||||
tools=self._resolve_tools(cfg.get("tools", []), tenant_id, warnings),
|
||||
skills=self._resolve_skills(cfg.get("skills", {}), tenant_id, warnings),
|
||||
features=cfg.get("features", {}),
|
||||
is_active=True,
|
||||
created_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:
|
||||
cfg = dsl.get("multi_agent_config") or {}
|
||||
self.db.add(MultiAgentConfig(
|
||||
id=uuid.uuid4(),
|
||||
app_id=new_app.id,
|
||||
fields = dict(
|
||||
orchestration_mode=cfg.get("orchestration_mode", "collaboration"),
|
||||
master_agent_name=cfg.get("master_agent_name"),
|
||||
model_parameters=cfg.get("model_parameters"),
|
||||
@@ -291,13 +348,24 @@ class AppDslService:
|
||||
routing_rules=self._resolve_routing_rules(cfg.get("routing_rules"), warnings),
|
||||
execution_config=cfg.get("execution_config", {}),
|
||||
aggregation_strategy=cfg.get("aggregation_strategy", "merge"),
|
||||
is_active=True,
|
||||
created_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:
|
||||
adapter = MemoryBearAdapter(dsl)
|
||||
raw_wf = dsl.get("workflow") or {}
|
||||
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():
|
||||
raise BusinessException("工作流配置格式无效", BizCode.BAD_REQUEST)
|
||||
result = adapter.parse_workflow()
|
||||
@@ -305,21 +373,39 @@ class AppDslService:
|
||||
warnings.append(f"[节点错误] {e.node_name or e.node_id}: {e.detail}")
|
||||
for w in result.warnings:
|
||||
warnings.append(f"[节点警告] {w.node_name or w.node_id}: {w.detail}")
|
||||
wf = dsl.get("workflow") or {}
|
||||
WorkflowService(self.db).create_workflow_config(
|
||||
app_id=new_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=wf.get("execution_config", {}),
|
||||
features=wf.get("features", {}),
|
||||
triggers=wf.get("triggers", []),
|
||||
validate=False,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(new_app)
|
||||
return new_app, warnings
|
||||
wf_service = WorkflowService(self.db)
|
||||
if create:
|
||||
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,
|
||||
)
|
||||
else:
|
||||
existing = self.db.query(WorkflowConfig).filter(WorkflowConfig.app_id == app_id).first()
|
||||
if existing:
|
||||
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:
|
||||
"""生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用"""
|
||||
@@ -348,44 +434,98 @@ class AppDslService:
|
||||
def _resolve_model(self, ref: Optional[dict], tenant_id: uuid.UUID, warnings: list) -> Optional[uuid.UUID]:
|
||||
if not ref:
|
||||
return None
|
||||
q = self.db.query(ModelConfig).filter(
|
||||
ModelConfig.tenant_id == tenant_id,
|
||||
ModelConfig.name == ref.get("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 not m:
|
||||
warnings.append(f"模型 '{ref.get('name')}' 未匹配,已置空,请导入后手动配置")
|
||||
return m.id if m else None
|
||||
model_id = ref.get("id")
|
||||
if model_id:
|
||||
try:
|
||||
model_uuid = uuid.UUID(str(model_id))
|
||||
m = self.db.query(ModelConfig).filter(
|
||||
ModelConfig.id == model_uuid,
|
||||
ModelConfig.tenant_id == tenant_id,
|
||||
ModelConfig.is_active.is_(True)
|
||||
).first()
|
||||
if m:
|
||||
return str(m.id)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
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]:
|
||||
if not ref:
|
||||
return None
|
||||
kb = self.db.query(Knowledge).filter(
|
||||
Knowledge.workspace_id == workspace_id,
|
||||
Knowledge.name == ref.get("name")
|
||||
).first()
|
||||
if not kb:
|
||||
warnings.append(f"知识库 '{ref.get('name')}' 未匹配,已置空,请导入后手动配置")
|
||||
return str(kb.id) if kb else None
|
||||
kb_id = ref.get("id")
|
||||
if kb_id:
|
||||
try:
|
||||
kb_uuid = uuid.UUID(str(kb_id))
|
||||
kb_share = self.db.query(KnowledgeShare).filter(
|
||||
KnowledgeShare.target_workspace_id == workspace_id,
|
||||
KnowledgeShare.source_kb_id == kb_uuid
|
||||
).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]:
|
||||
if not ref:
|
||||
return None
|
||||
q = self.db.query(ToolConfigModel).filter(
|
||||
ToolConfigModel.tenant_id == tenant_id,
|
||||
ToolConfigModel.name == ref.get("name")
|
||||
)
|
||||
if ref.get("tool_type"):
|
||||
q = q.filter(ToolConfigModel.tool_type == ref["tool_type"])
|
||||
t = q.first()
|
||||
if not t:
|
||||
warnings.append(f"工具 '{ref.get('name')}' 未匹配,已置空,请导入后手动配置")
|
||||
return str(t.id) if t else None
|
||||
tool_id = ref.get("id")
|
||||
tool_name = ref.get("name")
|
||||
if tool_id:
|
||||
try:
|
||||
tool_uuid = uuid.UUID(str(tool_id))
|
||||
t = self.db.query(ToolConfigModel).filter(
|
||||
ToolConfigModel.id == tool_uuid,
|
||||
ToolConfigModel.tenant_id == tenant_id,
|
||||
ToolConfigModel.is_active.is_(True)
|
||||
).first()
|
||||
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]:
|
||||
if not ref:
|
||||
@@ -427,6 +567,88 @@ class AppDslService:
|
||||
result.append(entry)
|
||||
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]:
|
||||
if not kr:
|
||||
return kr
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""应用日志服务层"""
|
||||
import uuid
|
||||
import datetime as dt
|
||||
from typing import Optional, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging_config import get_business_logger
|
||||
from app.models.app_model import AppType
|
||||
from app.models.conversation_model import Conversation, Message
|
||||
from app.models.workflow_model import WorkflowExecution
|
||||
from app.repositories.conversation_repository import ConversationRepository, MessageRepository
|
||||
from app.schemas.app_log_schema import AppLogMessage, AppLogNodeExecution
|
||||
|
||||
logger = get_business_logger()
|
||||
|
||||
@@ -27,6 +31,8 @@ class AppLogService:
|
||||
page: int = 1,
|
||||
pagesize: int = 20,
|
||||
is_draft: Optional[bool] = None,
|
||||
keyword: Optional[str] = None,
|
||||
app_type: Optional[str] = None,
|
||||
) -> Tuple[list[Conversation], int]:
|
||||
"""
|
||||
查询应用日志会话列表
|
||||
@@ -36,7 +42,9 @@ class AppLogService:
|
||||
workspace_id: 工作空间 ID
|
||||
page: 页码(从 1 开始)
|
||||
pagesize: 每页数量
|
||||
is_draft: 是否草稿会话(None 表示不过滤)
|
||||
is_draft: 是否草稿会话(None表示返回全部)
|
||||
keyword: 搜索关键词(匹配消息内容)
|
||||
app_type: 应用类型(WORKFLOW 时关键词将从 workflow_executions 搜索)
|
||||
|
||||
Returns:
|
||||
Tuple[list[Conversation], int]: (会话列表,总数)
|
||||
@@ -48,7 +56,9 @@ class AppLogService:
|
||||
"workspace_id": str(workspace_id),
|
||||
"page": page,
|
||||
"pagesize": pagesize,
|
||||
"is_draft": is_draft
|
||||
"is_draft": is_draft,
|
||||
"keyword": keyword,
|
||||
"app_type": app_type,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -57,8 +67,10 @@ class AppLogService:
|
||||
app_id=app_id,
|
||||
workspace_id=workspace_id,
|
||||
is_draft=is_draft,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
pagesize=pagesize
|
||||
pagesize=pagesize,
|
||||
app_type=app_type,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -76,53 +88,325 @@ class AppLogService:
|
||||
self,
|
||||
app_id: uuid.UUID,
|
||||
conversation_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID
|
||||
) -> Conversation:
|
||||
workspace_id: uuid.UUID,
|
||||
app_type: str = AppType.AGENT
|
||||
) -> Tuple[Conversation, list, dict[str, list[AppLogNodeExecution]]]:
|
||||
"""
|
||||
查询会话详情(包含消息)
|
||||
|
||||
Args:
|
||||
app_id: 应用 ID
|
||||
conversation_id: 会话 ID
|
||||
workspace_id: 工作空间 ID
|
||||
查询会话详情
|
||||
|
||||
Returns:
|
||||
Conversation: 包含消息的会话对象
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: 当会话不存在时
|
||||
Tuple[Conversation, list[AppLogMessage|Message], dict[str, list[AppLogNodeExecution]]]
|
||||
"""
|
||||
logger.info(
|
||||
"查询应用日志会话详情",
|
||||
extra={
|
||||
"app_id": str(app_id),
|
||||
"conversation_id": str(conversation_id),
|
||||
"workspace_id": str(workspace_id)
|
||||
"workspace_id": str(workspace_id),
|
||||
"app_type": app_type
|
||||
}
|
||||
)
|
||||
|
||||
# 查询会话
|
||||
conversation = self.conversation_repository.get_conversation_for_app_log(
|
||||
conversation_id=conversation_id,
|
||||
app_id=app_id,
|
||||
workspace_id=workspace_id
|
||||
)
|
||||
|
||||
# 查询消息(按时间正序)
|
||||
messages = self.message_repository.get_messages_by_conversation(
|
||||
conversation_id=conversation_id
|
||||
)
|
||||
|
||||
# 将消息附加到会话对象
|
||||
conversation.messages = messages
|
||||
if app_type == AppType.WORKFLOW:
|
||||
messages, node_executions_map = self._get_workflow_messages_and_nodes(conversation_id)
|
||||
else:
|
||||
messages = self.message_repository.get_messages_by_conversation(
|
||||
conversation_id=conversation_id
|
||||
)
|
||||
node_executions_map = self._get_workflow_node_executions_with_map(
|
||||
conversation_id, messages
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"查询应用日志会话详情成功",
|
||||
extra={
|
||||
"app_id": str(app_id),
|
||||
"conversation_id": str(conversation_id),
|
||||
"message_count": len(messages)
|
||||
"message_count": len(messages),
|
||||
"message_with_nodes_count": len(node_executions_map)
|
||||
}
|
||||
)
|
||||
|
||||
return conversation
|
||||
return conversation, messages, node_executions_map
|
||||
|
||||
def _get_workflow_messages_and_nodes(
|
||||
self,
|
||||
conversation_id: uuid.UUID,
|
||||
) -> Tuple[list[AppLogMessage], dict[str, list[AppLogNodeExecution]]]:
|
||||
"""
|
||||
工作流应用专用:从 workflow_executions 构建 messages 和节点日志。
|
||||
|
||||
每条 WorkflowExecution 对应一轮对话:
|
||||
- user message:来自 execution.input_data(content 取 message 字段,files 放 meta_data)
|
||||
- assistant message:来自 execution.output_data(失败时内容为错误信息)
|
||||
开场白的 suggested_questions 合并到第一条 assistant message 的 meta_data 里。
|
||||
|
||||
Returns:
|
||||
(messages 列表, node_executions_map)
|
||||
"""
|
||||
stmt = (
|
||||
select(WorkflowExecution)
|
||||
.where(
|
||||
WorkflowExecution.conversation_id == conversation_id,
|
||||
WorkflowExecution.status.in_(["completed", "failed"])
|
||||
)
|
||||
.order_by(WorkflowExecution.started_at.asc())
|
||||
)
|
||||
executions = list(self.db.scalars(stmt).all())
|
||||
|
||||
# 查开场白:Message 表里 meta_data 含 suggested_questions 的第一条 assistant 消息
|
||||
opening_stmt = (
|
||||
select(Message)
|
||||
.where(
|
||||
Message.conversation_id == conversation_id,
|
||||
Message.role == "assistant",
|
||||
)
|
||||
.order_by(Message.created_at.asc())
|
||||
.limit(10)
|
||||
)
|
||||
early_messages = list(self.db.scalars(opening_stmt).all())
|
||||
suggested_questions: list = []
|
||||
for m in early_messages:
|
||||
if isinstance(m.meta_data, dict) and "suggested_questions" in m.meta_data:
|
||||
suggested_questions = m.meta_data.get("suggested_questions") or []
|
||||
break
|
||||
|
||||
messages: list[AppLogMessage] = []
|
||||
node_executions_map: dict[str, list[AppLogNodeExecution]] = {}
|
||||
|
||||
# 如果有开场白,作为第一条 assistant 消息插入
|
||||
if suggested_questions or early_messages:
|
||||
opening_msg = next(
|
||||
(m for m in early_messages
|
||||
if isinstance(m.meta_data, dict) and "suggested_questions" in m.meta_data),
|
||||
None
|
||||
)
|
||||
if opening_msg:
|
||||
messages.append(AppLogMessage(
|
||||
id=opening_msg.id,
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=opening_msg.content,
|
||||
status=None,
|
||||
meta_data={"suggested_questions": suggested_questions},
|
||||
created_at=opening_msg.created_at,
|
||||
))
|
||||
|
||||
for execution in executions:
|
||||
started_at = execution.started_at or dt.datetime.now()
|
||||
completed_at = execution.completed_at or started_at
|
||||
|
||||
# assistant message 的 id,同时作为 node_executions_map 的 key
|
||||
assistant_msg_id = uuid.uuid5(execution.id, "assistant")
|
||||
|
||||
# --- user message(输入)---
|
||||
input_data = execution.input_data or {}
|
||||
input_content = input_data.get("message") or _extract_text(input_data)
|
||||
|
||||
# 跳过没有用户输入的 execution(如开场白触发的记录)
|
||||
if not input_content or not input_content.strip():
|
||||
continue
|
||||
|
||||
files = input_data.get("files") or []
|
||||
user_msg = AppLogMessage(
|
||||
id=uuid.uuid5(execution.id, "user"),
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=input_content,
|
||||
meta_data={"files": files} if files else None,
|
||||
created_at=started_at,
|
||||
)
|
||||
messages.append(user_msg)
|
||||
|
||||
# --- assistant message(输出)---
|
||||
if execution.status == "completed":
|
||||
output_content = _extract_text(execution.output_data)
|
||||
meta = {"usage": execution.token_usage or {}, "elapsed_time": execution.elapsed_time}
|
||||
else:
|
||||
output_content = _extract_text(execution.output_data) or ""
|
||||
meta = {"error": execution.error_message, "error_node_id": execution.error_node_id}
|
||||
|
||||
assistant_msg = AppLogMessage(
|
||||
id=assistant_msg_id,
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=output_content,
|
||||
status=execution.status,
|
||||
meta_data=meta,
|
||||
created_at=completed_at,
|
||||
)
|
||||
messages.append(assistant_msg)
|
||||
|
||||
# --- 节点执行记录,从 workflow_executions.output_data["node_outputs"] 读取 ---
|
||||
execution_nodes = _build_nodes_from_output_data(execution.output_data)
|
||||
|
||||
if execution_nodes:
|
||||
node_executions_map[str(assistant_msg_id)] = execution_nodes
|
||||
|
||||
return messages, node_executions_map
|
||||
|
||||
def _get_workflow_node_executions_with_map(
|
||||
self,
|
||||
conversation_id: uuid.UUID,
|
||||
messages: list[Message]
|
||||
) -> dict[str, list[AppLogNodeExecution]]:
|
||||
"""
|
||||
从 workflow_executions 表中提取节点执行记录,并按 assistant message 分组
|
||||
|
||||
Args:
|
||||
conversation_id: 会话 ID
|
||||
messages: 消息列表
|
||||
|
||||
Returns:
|
||||
Tuple[list[AppLogNodeExecution], dict[str, list[AppLogNodeExecution]]]:
|
||||
(所有节点执行记录列表, 按 message_id 分组的节点执行记录字典)
|
||||
"""
|
||||
node_executions_map: dict[str, list[AppLogNodeExecution]] = {}
|
||||
|
||||
# 查询该会话关联的所有工作流执行记录(按时间正序)
|
||||
stmt = select(WorkflowExecution).where(
|
||||
WorkflowExecution.conversation_id == conversation_id,
|
||||
WorkflowExecution.status.in_(["completed", "failed"])
|
||||
).order_by(WorkflowExecution.started_at.asc())
|
||||
|
||||
executions = self.db.scalars(stmt).all()
|
||||
|
||||
logger.info(
|
||||
f"查询到 {len(executions)} 条工作流执行记录",
|
||||
extra={
|
||||
"conversation_id": str(conversation_id),
|
||||
"execution_count": len(executions),
|
||||
"execution_ids": [str(e.id) for e in executions]
|
||||
}
|
||||
)
|
||||
|
||||
# 筛选出 workflow 执行产生的 assistant 消息(排除开场白)
|
||||
# workflow 结果的 meta_data 包含 usage,而开场白包含 suggested_questions
|
||||
assistant_messages = [
|
||||
m for m in messages
|
||||
if m.role == "assistant" and m.meta_data and "usage" in m.meta_data
|
||||
]
|
||||
|
||||
# 通过时序匹配,将 execution 和 assistant message 关联
|
||||
used_message_ids: set[str] = set()
|
||||
|
||||
for execution in executions:
|
||||
# 构建节点执行记录列表,从 workflow_executions.output_data["node_outputs"] 读取
|
||||
execution_nodes = _build_nodes_from_output_data(execution.output_data)
|
||||
|
||||
if not execution_nodes:
|
||||
continue
|
||||
|
||||
# 失败的执行没有 assistant message,直接用 execution id 作为 key
|
||||
if execution.status == "failed":
|
||||
node_executions_map[f"execution_{str(execution.id)}"] = execution_nodes
|
||||
continue
|
||||
|
||||
# completed:通过时序匹配关联到对应的 assistant message
|
||||
# 逻辑:找 execution.started_at 之后最近的、未使用的 assistant message
|
||||
best_msg = None
|
||||
best_dt = None
|
||||
for msg in assistant_messages:
|
||||
msg_id_str = str(msg.id)
|
||||
if msg_id_str in used_message_ids:
|
||||
continue
|
||||
if msg.created_at and msg.created_at >= execution.started_at:
|
||||
delta = (msg.created_at - execution.started_at).total_seconds()
|
||||
if best_dt is None or delta < best_dt:
|
||||
best_dt = delta
|
||||
best_msg = msg
|
||||
|
||||
if not best_msg:
|
||||
continue
|
||||
|
||||
msg_id_str = str(best_msg.id)
|
||||
used_message_ids.add(msg_id_str)
|
||||
node_executions_map[msg_id_str] = execution_nodes
|
||||
|
||||
return node_executions_map
|
||||
|
||||
|
||||
def _extract_text(data: Optional[dict]) -> str:
|
||||
"""从 workflow execution 的 input_data / output_data 中提取可读文本。
|
||||
|
||||
优先取 'text'、'content'、'output' 字段;若都没有则 JSON 序列化整个 dict。
|
||||
"""
|
||||
if not data:
|
||||
return ""
|
||||
for key in ("message", "text", "content", "output", "result", "answer"):
|
||||
if key in data and isinstance(data[key], str):
|
||||
return data[key]
|
||||
import json
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
|
||||
|
||||
def _build_nodes_from_output_data(output_data: Optional[dict]) -> list[AppLogNodeExecution]:
|
||||
"""从 workflow_executions.output_data["node_outputs"] 构建节点执行记录列表。
|
||||
|
||||
output_data 结构:
|
||||
{
|
||||
"node_outputs": {
|
||||
"<node_id>": {
|
||||
"node_type": ...,
|
||||
"node_name": ...,
|
||||
"status": ...,
|
||||
"input": ...,
|
||||
"output": ...,
|
||||
"elapsed_time": ...,
|
||||
"token_usage": ...,
|
||||
"error": ...,
|
||||
"cycle_items": [...],
|
||||
...
|
||||
}
|
||||
},
|
||||
"error": ...,
|
||||
...
|
||||
}
|
||||
"""
|
||||
if not output_data:
|
||||
return []
|
||||
node_outputs: dict = output_data.get("node_outputs") or {}
|
||||
# 按 execution_order(节点执行时写入的单调递增序号)排序。
|
||||
# PostgreSQL JSONB 不保证 key 顺序,不能依赖 dict 插入顺序;
|
||||
# 缺失 execution_order 的历史数据退化到 0,保持在最前。
|
||||
ordered_items = sorted(
|
||||
node_outputs.items(),
|
||||
key=lambda kv: (kv[1] or {}).get("execution_order", 0)
|
||||
if isinstance(kv[1], dict) else 0
|
||||
)
|
||||
result = []
|
||||
for node_id, node_data in ordered_items:
|
||||
if not isinstance(node_data, dict):
|
||||
continue
|
||||
output = dict(node_data)
|
||||
cycle_items = output.pop("cycle_items", None)
|
||||
# 把已知的顶层字段剥离,剩余的作为 output
|
||||
node_type = output.pop("node_type", "unknown")
|
||||
node_name = output.pop("node_name", None)
|
||||
status = output.pop("status", "completed")
|
||||
error = output.pop("error", None)
|
||||
inp = output.pop("input", None)
|
||||
elapsed_time = output.pop("elapsed_time", None)
|
||||
token_usage = output.pop("token_usage", None)
|
||||
# execution_order 仅用于排序,不返回给前端
|
||||
output.pop("execution_order", None)
|
||||
result.append(AppLogNodeExecution(
|
||||
node_id=node_id,
|
||||
node_type=node_type,
|
||||
node_name=node_name,
|
||||
status=status,
|
||||
error=error,
|
||||
input=inp,
|
||||
process=None,
|
||||
output=output if output else None,
|
||||
cycle_items=cycle_items,
|
||||
elapsed_time=elapsed_time,
|
||||
token_usage=token_usage,
|
||||
))
|
||||
return result
|
||||
|
||||
@@ -1452,6 +1452,32 @@ class AppService:
|
||||
logger.debug("配置不存在,返回默认模板", extra={"app_id": str(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:
|
||||
"""创建默认的 Agent 配置模板(不保存到数据库)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, Tuple, Union
|
||||
import jwt
|
||||
@@ -130,7 +132,7 @@ def register_user_with_invite(
|
||||
email: str,
|
||||
password: str,
|
||||
invite_token: str,
|
||||
workspace_id: str,
|
||||
workspace_id: uuid.UUID,
|
||||
username: Optional[str] = None,
|
||||
) -> User:
|
||||
"""
|
||||
@@ -147,6 +149,7 @@ def register_user_with_invite(
|
||||
from app.schemas.user_schema import UserCreate
|
||||
from app.schemas.workspace_schema import InviteAcceptRequest
|
||||
from app.services import user_service, workspace_service
|
||||
from app.repositories import workspace_repository as ws_repo
|
||||
from app.core.logging_config import get_business_logger
|
||||
|
||||
logger = get_business_logger()
|
||||
@@ -159,7 +162,8 @@ def register_user_with_invite(
|
||||
password=password,
|
||||
username=email.split('@')[0] if not username else username
|
||||
)
|
||||
user = user_service.create_user(db=db, user=user_create)
|
||||
workspace = ws_repo.get_workspace_by_id(db=db, workspace_id=workspace_id)
|
||||
user = user_service.create_user(db=db, user=user_create, workspace=workspace)
|
||||
logger.info(f"用户创建成功: {user.email} (ID: {user.id})")
|
||||
|
||||
# 接受工作空间邀请(此时用户已成为工作空间成员,并且会 commit)
|
||||
|
||||
@@ -544,7 +544,7 @@ class ConversationService:
|
||||
api_key=api_key,
|
||||
base_url=api_base,
|
||||
is_omni=is_omni,
|
||||
support_thinking="thinking" in (capability or []),
|
||||
capability=capability,
|
||||
),
|
||||
type=ModelType(model_type)
|
||||
)
|
||||
|
||||
@@ -10,29 +10,29 @@ import time
|
||||
import uuid
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.core.agent.agent_middleware import AgentMiddleware
|
||||
from app.core.agent.langchain_agent import LangChainAgent
|
||||
from app.core.config import settings
|
||||
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.memory.enums import SearchStrategy
|
||||
from app.core.memory.memory_service import MemoryService
|
||||
from app.core.rag.nlp.search import knowledge_retrieval
|
||||
from app.db import get_db_context
|
||||
from app.models import AgentConfig, ModelConfig
|
||||
from app.repositories.tool_repository import ToolRepository
|
||||
from app.schemas.app_schema import FileInput, Citation
|
||||
from app.schemas.app_schema import FileInput, Citation, FileType
|
||||
from app.schemas.model_schema import ModelInfo
|
||||
from app.schemas.prompt_schema import PromptMessageRole, render_prompt_message
|
||||
from app.services import task_service
|
||||
from app.services.conversation_service import ConversationService
|
||||
from app.services.langchain_tool_server import Search
|
||||
from app.services.memory_agent_service import MemoryAgentService
|
||||
from app.services.model_parameter_merger import ModelParameterMerger
|
||||
from app.services.model_service import ModelApiKeyService
|
||||
from app.services.multimodal_service import MultimodalService
|
||||
@@ -107,38 +107,41 @@ def create_long_term_memory_tool(
|
||||
logger.info(f" 长期记忆工具被调用!question={question}, user={end_user_id}")
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
memory_content = asyncio.run(
|
||||
MemoryAgentService().read_memory(
|
||||
end_user_id=end_user_id,
|
||||
message=question,
|
||||
history=[],
|
||||
search_switch="2",
|
||||
config_id=config_id,
|
||||
db=db,
|
||||
storage_type=storage_type,
|
||||
user_rag_memory_id=user_rag_memory_id
|
||||
)
|
||||
)
|
||||
task = celery_app.send_task(
|
||||
"app.core.memory.agent.read_message",
|
||||
args=[end_user_id, question, [], "1", config_id, storage_type, user_rag_memory_id]
|
||||
)
|
||||
result = task_service.get_task_memory_read_result(task.id)
|
||||
status = result.get("status")
|
||||
logger.info(f"读取任务状态:{status}")
|
||||
if memory_content:
|
||||
memory_content = memory_content['answer']
|
||||
logger.info(f'用户ID:Agent:{end_user_id}')
|
||||
logger.debug("调用长期记忆 API", extra={"question": question, "end_user_id": end_user_id})
|
||||
memory_service = MemoryService(db, config_id, end_user_id)
|
||||
search_result = asyncio.run(memory_service.read(question, SearchStrategy.QUICK))
|
||||
|
||||
logger.info(
|
||||
"长期记忆检索成功",
|
||||
extra={
|
||||
"end_user_id": end_user_id,
|
||||
"content_length": len(str(memory_content))
|
||||
}
|
||||
)
|
||||
return f"检索到以下历史记忆:\n\n{memory_content}"
|
||||
# memory_content = asyncio.run(
|
||||
# MemoryAgentService().read_memory(
|
||||
# end_user_id=end_user_id,
|
||||
# message=question,
|
||||
# history=[],
|
||||
# search_switch="2",
|
||||
# config_id=config_id,
|
||||
# db=db,
|
||||
# storage_type=storage_type,
|
||||
# user_rag_memory_id=user_rag_memory_id
|
||||
# )
|
||||
# )
|
||||
# task = celery_app.send_task(
|
||||
# "app.core.memory.agent.read_message",
|
||||
# args=[end_user_id, question, [], "1", config_id, storage_type, user_rag_memory_id]
|
||||
# )
|
||||
# result = task_service.get_task_memory_read_result(task.id)
|
||||
# status = result.get("status")
|
||||
# logger.info(f"读取任务状态:{status}")
|
||||
# if memory_content:
|
||||
# memory_content = memory_content['answer']
|
||||
# logger.info(f'用户ID:Agent:{end_user_id}')
|
||||
# logger.debug("调用长期记忆 API", extra={"question": question, "end_user_id": end_user_id})
|
||||
#
|
||||
# logger.info(
|
||||
# "长期记忆检索成功",
|
||||
# extra={
|
||||
# "end_user_id": end_user_id,
|
||||
# "content_length": len(str(memory_content))
|
||||
# }
|
||||
# )
|
||||
return f"检索到以下历史记忆:\n\n{search_result.content}"
|
||||
except Exception as e:
|
||||
logger.error("长期记忆检索失败", extra={"error": str(e), "error_type": type(e).__name__})
|
||||
return f"记忆检索失败: {str(e)}"
|
||||
@@ -472,11 +475,19 @@ class AgentRunService:
|
||||
features_config: Dict[str, Any],
|
||||
citations: List[Citation]
|
||||
) -> List[Any]:
|
||||
"""根据 citation 开关决定是否返回引用来源"""
|
||||
"""根据 citation 开关决定是否返回引用来源,并根据 allow_download 附加下载链接"""
|
||||
citation_cfg = features_config.get("citation", {})
|
||||
if isinstance(citation_cfg, dict) and citation_cfg.get("enabled"):
|
||||
return [cit.model_dump() for cit in citations]
|
||||
return []
|
||||
if not (isinstance(citation_cfg, dict) and citation_cfg.get("enabled")):
|
||||
return []
|
||||
allow_download = citation_cfg.get("allow_download", False)
|
||||
result = []
|
||||
for cit in citations:
|
||||
item = cit.model_dump() if hasattr(cit, "model_dump") else dict(cit)
|
||||
if allow_download and item.get("document_id"):
|
||||
from app.core.config import settings
|
||||
item["download_url"] = f"{settings.FILE_LOCAL_SERVER_URL}/apps/citations/{item['document_id']}/download"
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@@ -584,22 +595,6 @@ class AgentRunService:
|
||||
)
|
||||
tools.extend(memory_tools)
|
||||
|
||||
# 4. 创建 LangChain Agent
|
||||
agent = LangChainAgent(
|
||||
model_name=api_key_config["model_name"],
|
||||
api_key=api_key_config["api_key"],
|
||||
provider=api_key_config.get("provider", "openai"),
|
||||
api_base=api_key_config.get("api_base"),
|
||||
is_omni=api_key_config.get("is_omni", False),
|
||||
temperature=effective_params.get("temperature", 0.7),
|
||||
max_tokens=effective_params.get("max_tokens", 2000),
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
deep_thinking=effective_params.get("deep_thinking", False),
|
||||
thinking_budget_tokens=effective_params.get("thinking_budget_tokens"),
|
||||
capability=api_key_config.get("capability", []),
|
||||
)
|
||||
|
||||
# 5. 处理会话ID(创建或验证),新会话时写入开场白
|
||||
is_new_conversation = not conversation_id
|
||||
opening, suggested_questions = None, None
|
||||
@@ -634,12 +629,46 @@ class AgentRunService:
|
||||
|
||||
# 6. 处理多模态文件
|
||||
processed_files = None
|
||||
has_doc_with_images = False
|
||||
if files:
|
||||
# 获取 provider 信息
|
||||
provider = api_key_config.get("provider", "openai")
|
||||
multimodal_service = MultimodalService(self.db, model_info)
|
||||
processed_files = await multimodal_service.process_files(files)
|
||||
fu_config = features_config.get("file_upload", {})
|
||||
if hasattr(fu_config, "model_dump"):
|
||||
fu_config = fu_config.model_dump()
|
||||
doc_img_recognition = isinstance(fu_config, dict) and fu_config.get("document_image_recognition", False)
|
||||
processed_files = await multimodal_service.process_files(
|
||||
files, document_image_recognition=doc_img_recognition,
|
||||
workspace_id=workspace_id
|
||||
)
|
||||
logger.info(f"处理了 {len(processed_files)} 个文件,provider={provider}")
|
||||
capability = api_key_config.get("capability", [])
|
||||
has_doc_with_images = (
|
||||
doc_img_recognition
|
||||
and "vision" in capability
|
||||
and any(f.type == FileType.DOCUMENT for f in files)
|
||||
)
|
||||
if has_doc_with_images:
|
||||
system_prompt += (
|
||||
"\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式  展示对应图片。"
|
||||
)
|
||||
|
||||
agent = LangChainAgent(
|
||||
model_name=api_key_config["model_name"],
|
||||
api_key=api_key_config["api_key"],
|
||||
provider=api_key_config.get("provider", "openai"),
|
||||
api_base=api_key_config.get("api_base"),
|
||||
is_omni=api_key_config.get("is_omni", False),
|
||||
temperature=effective_params.get("temperature", 0.7),
|
||||
max_tokens=effective_params.get("max_tokens", 2000),
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
deep_thinking=effective_params.get("deep_thinking", False),
|
||||
thinking_budget_tokens=effective_params.get("thinking_budget_tokens"),
|
||||
json_output=effective_params.get("json_output", False),
|
||||
capability=api_key_config.get("capability", []),
|
||||
)
|
||||
|
||||
# 为需要运行时上下文的工具注入上下文
|
||||
for t in tools:
|
||||
if hasattr(t, 'tool_instance') and hasattr(t.tool_instance, 'set_runtime_context'):
|
||||
@@ -725,7 +754,7 @@ class AgentRunService:
|
||||
) if not sub_agent else [],
|
||||
"citations": filtered_citations,
|
||||
"audio_url": audio_url,
|
||||
"audio_status": "pending"
|
||||
"audio_status": "pending" if audio_url else None
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -839,23 +868,6 @@ class AgentRunService:
|
||||
user_rag_memory_id)
|
||||
tools.extend(memory_tools)
|
||||
|
||||
# 4. 创建 LangChain Agent
|
||||
agent = LangChainAgent(
|
||||
model_name=api_key_config["model_name"],
|
||||
api_key=api_key_config["api_key"],
|
||||
provider=api_key_config.get("provider", "openai"),
|
||||
api_base=api_key_config.get("api_base"),
|
||||
is_omni=api_key_config.get("is_omni", False),
|
||||
temperature=effective_params.get("temperature", 0.7),
|
||||
max_tokens=effective_params.get("max_tokens", 2000),
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
streaming=True,
|
||||
deep_thinking=effective_params.get("deep_thinking", False),
|
||||
thinking_budget_tokens=effective_params.get("thinking_budget_tokens"),
|
||||
capability=api_key_config.get("capability", []),
|
||||
)
|
||||
|
||||
# 5. 处理会话ID(创建或验证),新会话时写入开场白
|
||||
is_new_conversation = not conversation_id
|
||||
opening, suggested_questions = None, None
|
||||
@@ -891,12 +903,48 @@ class AgentRunService:
|
||||
|
||||
# 6. 处理多模态文件
|
||||
processed_files = None
|
||||
has_doc_with_images = False
|
||||
if files:
|
||||
# 获取 provider 信息
|
||||
provider = api_key_config.get("provider", "openai")
|
||||
multimodal_service = MultimodalService(self.db, model_info)
|
||||
processed_files = await multimodal_service.process_files(files)
|
||||
fu_config = features_config.get("file_upload", {})
|
||||
if hasattr(fu_config, "model_dump"):
|
||||
fu_config = fu_config.model_dump()
|
||||
doc_img_recognition = isinstance(fu_config, dict) and fu_config.get("document_image_recognition", False)
|
||||
processed_files = await multimodal_service.process_files(
|
||||
files, document_image_recognition=doc_img_recognition,
|
||||
workspace_id=workspace_id
|
||||
)
|
||||
logger.info(f"处理了 {len(processed_files)} 个文件,provider={provider}")
|
||||
capability = api_key_config.get("capability", [])
|
||||
has_doc_with_images = (
|
||||
doc_img_recognition
|
||||
and "vision" in capability
|
||||
and any(f.type == FileType.DOCUMENT for f in files)
|
||||
)
|
||||
if has_doc_with_images:
|
||||
system_prompt += (
|
||||
"\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式  展示对应图片。"
|
||||
)
|
||||
|
||||
# 创建 LangChain Agent
|
||||
agent = LangChainAgent(
|
||||
model_name=api_key_config["model_name"],
|
||||
api_key=api_key_config["api_key"],
|
||||
provider=api_key_config.get("provider", "openai"),
|
||||
api_base=api_key_config.get("api_base"),
|
||||
is_omni=api_key_config.get("is_omni", False),
|
||||
temperature=effective_params.get("temperature", 0.7),
|
||||
max_tokens=effective_params.get("max_tokens", 2000),
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
streaming=True,
|
||||
deep_thinking=effective_params.get("deep_thinking", False),
|
||||
thinking_budget_tokens=effective_params.get("thinking_budget_tokens"),
|
||||
json_output=effective_params.get("json_output", False),
|
||||
capability=api_key_config.get("capability", []),
|
||||
)
|
||||
|
||||
# 为需要运行时上下文的工具注入上下文
|
||||
for t in tools:
|
||||
if hasattr(t, 'tool_instance') and hasattr(t.tool_instance, 'set_runtime_context'):
|
||||
@@ -1299,10 +1347,30 @@ class AgentRunService:
|
||||
"history_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:
|
||||
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({
|
||||
"type": f.type,
|
||||
"url": f.url
|
||||
"url": f.url,
|
||||
"file_type": f.file_type,
|
||||
"name": name,
|
||||
"size": size
|
||||
})
|
||||
|
||||
# 保存 history_files,包含 provider 和 is_omni 信息
|
||||
|
||||
@@ -679,9 +679,9 @@ class EmotionAnalyticsService:
|
||||
|
||||
# 查询用户的实体和标签
|
||||
query = """
|
||||
MATCH (e:Entity)
|
||||
MATCH (e:ExtractedEntity)
|
||||
WHERE e.end_user_id = $end_user_id
|
||||
RETURN e.name as name, e.type as type
|
||||
RETURN e.name as name, e.entity_type as type
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
|
||||
@@ -34,6 +34,7 @@ from app.schemas.implicit_memory_schema import (
|
||||
UserMemorySummary,
|
||||
)
|
||||
from app.schemas.memory_config_schema import MemoryConfig
|
||||
from app.services.memory_base_service import MIN_MEMORY_SUMMARY_COUNT
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -379,12 +380,59 @@ class ImplicitMemoryService:
|
||||
raise
|
||||
|
||||
|
||||
def _build_empty_profile(self) -> dict:
|
||||
"""构建 MemorySummary 不足时返回的固定空白画像数据"""
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
insufficient = "Insufficient data for analysis"
|
||||
|
||||
def _empty_dimension(name: str) -> dict:
|
||||
return {
|
||||
"evidence": [insufficient],
|
||||
"reasoning": f"No clear evidence found for {name} dimension",
|
||||
"percentage": 0.0,
|
||||
"dimension_name": name,
|
||||
"confidence_level": 20,
|
||||
}
|
||||
|
||||
def _empty_category(name: str) -> dict:
|
||||
return {
|
||||
"evidence": [insufficient],
|
||||
"percentage": 25.0,
|
||||
"category_name": name,
|
||||
"trending_direction": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"habits": [],
|
||||
"portrait": {
|
||||
"aesthetic": _empty_dimension("aesthetic"),
|
||||
"creativity": _empty_dimension("creativity"),
|
||||
"literature": _empty_dimension("literature"),
|
||||
"technology": _empty_dimension("technology"),
|
||||
"historical_trends": None,
|
||||
"analysis_timestamp": now_ms,
|
||||
"total_summaries_analyzed": 0,
|
||||
},
|
||||
"preferences": [],
|
||||
"interest_areas": {
|
||||
"art": _empty_category("art"),
|
||||
"tech": _empty_category("tech"),
|
||||
"music": _empty_category("music"),
|
||||
"lifestyle": _empty_category("lifestyle"),
|
||||
"analysis_timestamp": now_ms,
|
||||
"total_summaries_analyzed": 0,
|
||||
},
|
||||
}
|
||||
|
||||
async def generate_complete_profile(
|
||||
self,
|
||||
user_id: str
|
||||
) -> dict:
|
||||
"""生成完整的用户画像(包含所有4个模块)
|
||||
|
||||
需要该用户的 MemorySummary 节点数量 >= 5 才会真正调用 LLM 生成画像,
|
||||
否则返回固定的空白画像数据。
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
|
||||
@@ -394,6 +442,16 @@ class ImplicitMemoryService:
|
||||
logger.info(f"生成完整用户画像: user={user_id}")
|
||||
|
||||
try:
|
||||
# 前置检查:查询该用户有效的 MemorySummary 节点数量(排除孤立节点)
|
||||
from app.services.memory_base_service import MemoryBaseService
|
||||
base_service = MemoryBaseService()
|
||||
memory_summary_count = await base_service.get_valid_memory_summary_count(user_id)
|
||||
logger.info(f"用户 MemorySummary 节点数量: {memory_summary_count} (user={user_id})")
|
||||
|
||||
if memory_summary_count < MIN_MEMORY_SUMMARY_COUNT:
|
||||
logger.info(f"MemorySummary 数量不足 {MIN_MEMORY_SUMMARY_COUNT}(当前 {memory_summary_count}),返回空白画像: user={user_id}")
|
||||
return self._build_empty_profile()
|
||||
|
||||
# 并行调用4个分析方法
|
||||
preferences, portrait, interest_areas, habits = await asyncio.gather(
|
||||
self.get_preference_tags(user_id=user_id),
|
||||
|
||||
@@ -2,11 +2,13 @@ import uuid
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.user_model import User
|
||||
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.repositories import knowledge_repository
|
||||
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()
|
||||
|
||||
|
||||
@@ -60,13 +62,47 @@ def create_knowledge(
|
||||
db: Session, knowledge: KnowledgeCreate, current_user: User
|
||||
) -> Knowledge:
|
||||
business_logger.info(f"Create a knowledge base: {knowledge.name}, creator: {current_user.username}")
|
||||
|
||||
|
||||
try:
|
||||
knowledge.created_by = current_user.id
|
||||
if knowledge.workspace_id is None:
|
||||
knowledge.workspace_id = current_user.current_workspace_id
|
||||
if knowledge.parent_id is None:
|
||||
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}")
|
||||
db_knowledge = knowledge_repository.create_knowledge(
|
||||
db=db, knowledge=knowledge
|
||||
|
||||
@@ -415,9 +415,11 @@ class LLMRouter:
|
||||
api_key=api_key_config.api_key,
|
||||
base_url=api_key_config.api_base,
|
||||
is_omni=api_key_config.is_omni,
|
||||
support_thinking="thinking" in (api_key_config.capability or []),
|
||||
temperature=0.3,
|
||||
max_tokens=500
|
||||
capability=api_key_config.capability,
|
||||
extra_params={
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 500
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"创建 LLM 实例 - Provider: {api_key_config.provider}, Model: {api_key_config.model_name}")
|
||||
|
||||
@@ -393,7 +393,7 @@ class MasterAgentRouter:
|
||||
api_key=api_key_config.api_key,
|
||||
base_url=api_key_config.api_base,
|
||||
is_omni=api_key_config.is_omni,
|
||||
support_thinking="thinking" in (api_key_config.capability or []),
|
||||
capability=api_key_config.capability,
|
||||
extra_params = extra_params
|
||||
)
|
||||
|
||||
|
||||
@@ -405,7 +405,7 @@ class MemoryAgentService:
|
||||
self,
|
||||
end_user_id: str,
|
||||
message: str,
|
||||
history: List[Dict],
|
||||
history: List[Dict], # FIXME: unused parameter
|
||||
search_switch: str,
|
||||
config_id: Optional[uuid.UUID] | int,
|
||||
db: Session,
|
||||
@@ -505,8 +505,8 @@ class MemoryAgentService:
|
||||
initial_state = {
|
||||
"messages": [HumanMessage(content=message)],
|
||||
"search_switch": search_switch,
|
||||
"end_user_id": end_user_id
|
||||
, "storage_type": storage_type,
|
||||
"end_user_id": end_user_id,
|
||||
"storage_type": storage_type,
|
||||
"user_rag_memory_id": user_rag_memory_id,
|
||||
"memory_config": memory_config}
|
||||
# 获取节点更新信息
|
||||
@@ -642,6 +642,8 @@ class MemoryAgentService:
|
||||
"answer": summary,
|
||||
"intermediate_outputs": result
|
||||
}
|
||||
|
||||
# TODO: redis search -> answer
|
||||
except Exception as e:
|
||||
# Ensure proper error handling and logging
|
||||
error_msg = f"Read operation failed: {str(e)}"
|
||||
@@ -1280,7 +1282,7 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Successfully retrieved connected config: memory_config_id={memory_config_id}, workspace_id={app.workspace_id}")
|
||||
f"Successfully retrieved connected config: memory_config_id={memory_config_id}, workspace_id={end_user.workspace_id}")
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ This service validates inputs and delegates to MemoryAgentService for core memor
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.celery_task_scheduler import scheduler
|
||||
from app.core.error_codes import BizCode
|
||||
from app.core.exceptions import BusinessException, ResourceNotFoundException
|
||||
from app.core.logging_config import get_logger
|
||||
@@ -15,7 +18,6 @@ from app.models.app_model import App
|
||||
from app.models.end_user_model import EndUser
|
||||
from app.schemas.memory_config_schema import ConfigurationError
|
||||
from app.services.memory_agent_service import MemoryAgentService
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -124,7 +126,7 @@ class MemoryAPIService:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update memory_config_id for end_user {end_user_id}: {e}")
|
||||
|
||||
async def write_memory(
|
||||
def write_memory(
|
||||
self,
|
||||
workspace_id: uuid.UUID,
|
||||
end_user_id: str,
|
||||
@@ -133,27 +135,28 @@ class MemoryAPIService:
|
||||
storage_type: str = "neo4j",
|
||||
user_rag_memory_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Write memory with validation.
|
||||
|
||||
"""Submit a memory write task via Celery.
|
||||
|
||||
Validates end_user exists and belongs to workspace, updates the end user's
|
||||
memory_config_id, then delegates to MemoryAgentService.write_memory.
|
||||
|
||||
memory_config_id, then dispatches write_message_task to Celery for async
|
||||
processing with per-user fair locking.
|
||||
|
||||
Args:
|
||||
workspace_id: Workspace ID for resource validation
|
||||
end_user_id: End user identifier (used as end_user_id)
|
||||
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:
|
||||
Dict with status and end_user_id
|
||||
|
||||
Dict with task_id, status, and end_user_id
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If end_user not found
|
||||
BusinessException: If end_user not in authorized workspace or write fails
|
||||
BusinessException: If validation fails
|
||||
"""
|
||||
logger.info(f"Writing memory for end_user: {end_user_id}, workspace: {workspace_id}")
|
||||
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)
|
||||
@@ -161,9 +164,131 @@ class MemoryAPIService:
|
||||
# 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 "",
|
||||
# )
|
||||
task_id = scheduler.push_task(
|
||||
"app.core.memory.agent.write_message",
|
||||
end_user_id,
|
||||
{
|
||||
"end_user_id": end_user_id,
|
||||
"message": messages,
|
||||
"config_id": config_id,
|
||||
"storage_type": storage_type,
|
||||
"user_rag_memory_id": 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": "QUEUED",
|
||||
"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:
|
||||
Dict with status and end_user_id
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If end_user not found
|
||||
BusinessException: If write fails
|
||||
"""
|
||||
logger.info(f"Writing memory (sync) for end_user: {end_user_id}, workspace: {workspace_id}")
|
||||
|
||||
self.validate_end_user(end_user_id, workspace_id)
|
||||
self._update_end_user_config(end_user_id, config_id)
|
||||
|
||||
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}]
|
||||
result = await MemoryAgentService().write_memory(
|
||||
end_user_id=end_user_id,
|
||||
@@ -174,11 +299,8 @@ class MemoryAPIService:
|
||||
user_rag_memory_id=user_rag_memory_id or "",
|
||||
)
|
||||
|
||||
logger.info(f"Memory write successful for end_user: {end_user_id}")
|
||||
logger.info(f"Memory write (sync) 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):
|
||||
return {
|
||||
**result,
|
||||
@@ -192,20 +314,17 @@ class MemoryAPIService:
|
||||
|
||||
except ConfigurationError as 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:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Memory write failed for end_user {end_user_id}: {e}")
|
||||
logger.error(f"Memory write (sync) failed for end_user {end_user_id}: {e}")
|
||||
raise BusinessException(
|
||||
message=f"Memory write failed: {str(e)}",
|
||||
code=BizCode.MEMORY_WRITE_FAILED
|
||||
)
|
||||
|
||||
async def read_memory(
|
||||
async def read_memory_sync(
|
||||
self,
|
||||
workspace_id: uuid.UUID,
|
||||
end_user_id: str,
|
||||
@@ -215,37 +334,34 @@ class MemoryAPIService:
|
||||
storage_type: str = "neo4j",
|
||||
user_rag_memory_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Read memory with validation.
|
||||
|
||||
Validates end_user exists and belongs to workspace, updates the end user's
|
||||
memory_config_id, then delegates to MemoryAgentService.read_memory.
|
||||
|
||||
"""Read memory synchronously (inline, no Celery).
|
||||
|
||||
Validates end_user, then calls MemoryAgentService.read_memory directly.
|
||||
Blocks until the read completes. Use for cases where the caller needs
|
||||
the answer immediately.
|
||||
|
||||
Args:
|
||||
workspace_id: Workspace ID for resource validation
|
||||
end_user_id: End user identifier (used as end_user_id)
|
||||
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 answer, intermediate_outputs, and end_user_id
|
||||
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If end_user not found
|
||||
BusinessException: If end_user not in authorized workspace or read fails
|
||||
BusinessException: If read fails
|
||||
"""
|
||||
logger.info(f"Reading memory for end_user: {end_user_id}, workspace: {workspace_id}")
|
||||
logger.info(f"Reading memory (sync) 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)
|
||||
|
||||
try:
|
||||
# Delegate to MemoryAgentService
|
||||
result = await MemoryAgentService().read_memory(
|
||||
end_user_id=end_user_id,
|
||||
message=message,
|
||||
@@ -257,7 +373,7 @@ class MemoryAPIService:
|
||||
user_rag_memory_id=user_rag_memory_id or ""
|
||||
)
|
||||
|
||||
logger.info(f"Memory read successful for end_user: {end_user_id}")
|
||||
logger.info(f"Memory read (sync) successful for end_user: {end_user_id}")
|
||||
|
||||
return {
|
||||
"answer": result.get("answer", ""),
|
||||
@@ -267,14 +383,11 @@ class MemoryAPIService:
|
||||
|
||||
except ConfigurationError as 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:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Memory read failed for end_user {end_user_id}: {e}")
|
||||
logger.error(f"Memory read (sync) failed for end_user {end_user_id}: {e}")
|
||||
raise BusinessException(
|
||||
message=f"Memory read failed: {str(e)}",
|
||||
code=BizCode.MEMORY_READ_FAILED
|
||||
|
||||
@@ -265,12 +265,50 @@ async def Translation_English(modid, text, fields=None):
|
||||
# 其他类型(数字、布尔值、None等):原样返回
|
||||
else:
|
||||
return text
|
||||
# 隐性记忆画像生成所需的最低 MemorySummary 节点数量
|
||||
MIN_MEMORY_SUMMARY_COUNT = 5
|
||||
|
||||
|
||||
class MemoryBaseService:
|
||||
"""记忆服务基类,提供共享的辅助方法"""
|
||||
|
||||
def __init__(self):
|
||||
self.neo4j_connector = Neo4jConnector()
|
||||
|
||||
async def get_valid_memory_summary_count(
|
||||
self,
|
||||
end_user_id: str
|
||||
) -> int:
|
||||
"""获取用户有效的 MemorySummary 节点数量(排除孤立节点)。
|
||||
|
||||
只统计存在 DERIVED_FROM_STATEMENT 关系的 MemorySummary 节点。
|
||||
|
||||
Args:
|
||||
end_user_id: 终端用户ID
|
||||
|
||||
Returns:
|
||||
有效 MemorySummary 节点数量
|
||||
"""
|
||||
try:
|
||||
query = """
|
||||
MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement)
|
||||
WHERE n.end_user_id = $end_user_id
|
||||
RETURN count(DISTINCT n) as count
|
||||
"""
|
||||
result = await self.neo4j_connector.execute_query(
|
||||
query, end_user_id=end_user_id
|
||||
)
|
||||
count = result[0]["count"] if result and len(result) > 0 else 0
|
||||
logger.debug(
|
||||
f"有效 MemorySummary 节点数量: {count} (end_user_id={end_user_id})"
|
||||
)
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"获取有效 MemorySummary 数量失败: {str(e)}", exc_info=True
|
||||
)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def parse_timestamp(timestamp_value) -> Optional[int]:
|
||||
"""
|
||||
|
||||
@@ -163,7 +163,7 @@ class MemoryConfigService:
|
||||
|
||||
def load_memory_config(
|
||||
self,
|
||||
config_id: Optional[UUID] = None,
|
||||
config_id: UUID | str | int | None = None,
|
||||
workspace_id: Optional[UUID] = None,
|
||||
service_name: str = "MemoryConfigService",
|
||||
) -> MemoryConfig:
|
||||
@@ -187,16 +187,6 @@ class MemoryConfigService:
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
config_logger.info(
|
||||
"Starting memory configuration loading",
|
||||
extra={
|
||||
"operation": "load_memory_config",
|
||||
"service": service_name,
|
||||
"config_id": str(config_id) if config_id else None,
|
||||
"workspace_id": str(workspace_id) if workspace_id else None,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Loading memory configuration from database: config_id={config_id}, workspace_id={workspace_id}")
|
||||
|
||||
try:
|
||||
@@ -236,11 +226,7 @@ class MemoryConfigService:
|
||||
f"Configuration not found: config_id={config_id}, workspace_id={workspace_id}"
|
||||
)
|
||||
|
||||
# Get workspace for the config
|
||||
db_query_start = time.time()
|
||||
result = MemoryConfigRepository.get_config_with_workspace(self.db, memory_config.config_id)
|
||||
db_query_time = time.time() - db_query_start
|
||||
logger.info(f"[PERF] Config+Workspace query: {db_query_time:.4f}s")
|
||||
|
||||
if not result:
|
||||
raise ConfigurationError(
|
||||
|
||||
@@ -821,7 +821,7 @@ def get_rag_content(
|
||||
for document in documents:
|
||||
try:
|
||||
kb = knowledge_repository.get_knowledge_by_id(db, document.kb_id)
|
||||
if not kb:
|
||||
if not (kb and kb.status == 1):
|
||||
business_logger.warning(f"知识库不存在: kb_id={document.kb_id}")
|
||||
continue
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
处理显性记忆相关的业务逻辑,包括情景记忆和语义记忆的查询。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.core.logging_config import get_logger
|
||||
from app.services.memory_base_service import MemoryBaseService
|
||||
@@ -104,7 +104,7 @@ class MemoryExplicitService(MemoryBaseService):
|
||||
e.description AS core_definition
|
||||
ORDER BY e.name ASC
|
||||
"""
|
||||
|
||||
|
||||
semantic_result = await self.neo4j_connector.execute_query(
|
||||
semantic_query,
|
||||
end_user_id=end_user_id
|
||||
@@ -146,6 +146,209 @@ class MemoryExplicitService(MemoryBaseService):
|
||||
logger.error(f"获取显性记忆总览时出错: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
async def get_episodic_memory_list(
|
||||
self,
|
||||
end_user_id: str,
|
||||
page: int,
|
||||
pagesize: int,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
episodic_type: str = "all",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取情景记忆分页列表
|
||||
|
||||
Args:
|
||||
end_user_id: 终端用户ID
|
||||
page: 页码
|
||||
pagesize: 每页数量
|
||||
start_date: 开始时间戳(毫秒),可选
|
||||
end_date: 结束时间戳(毫秒),可选
|
||||
episodic_type: 情景类型筛选
|
||||
|
||||
Returns:
|
||||
{
|
||||
"total": int, # 该用户情景记忆总数(不受筛选影响)
|
||||
"items": [...], # 当前页数据
|
||||
"page": {
|
||||
"page": int,
|
||||
"pagesize": int,
|
||||
"total": int, # 筛选后总数
|
||||
"hasnext": bool
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"情景记忆分页查询: end_user_id={end_user_id}, "
|
||||
f"start_date={start_date}, end_date={end_date}, "
|
||||
f"episodic_type={episodic_type}, page={page}, pagesize={pagesize}"
|
||||
)
|
||||
|
||||
# 1. 查询情景记忆总数(不受筛选条件限制)
|
||||
total_all_query = """
|
||||
MATCH (s:MemorySummary)
|
||||
WHERE s.end_user_id = $end_user_id
|
||||
RETURN count(s) AS total
|
||||
"""
|
||||
total_all_result = await self.neo4j_connector.execute_query(
|
||||
total_all_query, end_user_id=end_user_id
|
||||
)
|
||||
total_all = total_all_result[0]["total"] if total_all_result else 0
|
||||
|
||||
# 2. 构建筛选条件
|
||||
where_clauses = ["s.end_user_id = $end_user_id"]
|
||||
params = {"end_user_id": end_user_id}
|
||||
|
||||
# 时间戳筛选(毫秒时间戳转为 UTC ISO 字符串,使用 Neo4j datetime() 精确比较)
|
||||
if start_date is not None and end_date is not None:
|
||||
from datetime import datetime, timezone
|
||||
start_dt = datetime.fromtimestamp(start_date / 1000, tz=timezone.utc)
|
||||
end_dt = datetime.fromtimestamp(end_date / 1000, tz=timezone.utc)
|
||||
# 开始时间取当天 UTC 00:00:00,结束时间取当天 UTC 23:59:59.999999
|
||||
start_iso = start_dt.strftime("%Y-%m-%dT") + "00:00:00.000000"
|
||||
end_iso = end_dt.strftime("%Y-%m-%dT") + "23:59:59.999999"
|
||||
|
||||
where_clauses.append("datetime(s.created_at) >= datetime($start_iso) AND datetime(s.created_at) <= datetime($end_iso)")
|
||||
params["start_iso"] = start_iso
|
||||
params["end_iso"] = end_iso
|
||||
|
||||
# 类型筛选下推到 Cypher(兼容中英文)
|
||||
if episodic_type != "all":
|
||||
type_mapping = {
|
||||
"conversation": "对话",
|
||||
"project_work": "项目/工作",
|
||||
"learning": "学习",
|
||||
"decision": "决策",
|
||||
"important_event": "重要事件"
|
||||
}
|
||||
chinese_type = type_mapping.get(episodic_type)
|
||||
if chinese_type:
|
||||
where_clauses.append(
|
||||
"(s.memory_type = $episodic_type OR s.memory_type = $chinese_type)"
|
||||
)
|
||||
params["episodic_type"] = episodic_type
|
||||
params["chinese_type"] = chinese_type
|
||||
else:
|
||||
where_clauses.append("s.memory_type = $episodic_type")
|
||||
params["episodic_type"] = episodic_type
|
||||
|
||||
where_str = " AND ".join(where_clauses)
|
||||
|
||||
# 3. 查询筛选后的总数
|
||||
count_query = f"""
|
||||
MATCH (s:MemorySummary)
|
||||
WHERE {where_str}
|
||||
RETURN count(s) AS total
|
||||
"""
|
||||
count_result = await self.neo4j_connector.execute_query(count_query, **params)
|
||||
filtered_total = count_result[0]["total"] if count_result else 0
|
||||
|
||||
# 4. 查询分页数据
|
||||
skip = (page - 1) * pagesize
|
||||
data_query = f"""
|
||||
MATCH (s:MemorySummary)
|
||||
WHERE {where_str}
|
||||
RETURN elementId(s) AS id,
|
||||
s.name AS title,
|
||||
s.memory_type AS memory_type,
|
||||
s.content AS content,
|
||||
s.created_at AS created_at
|
||||
ORDER BY s.created_at DESC
|
||||
SKIP $skip LIMIT $limit
|
||||
"""
|
||||
params["skip"] = skip
|
||||
params["limit"] = pagesize
|
||||
|
||||
result = await self.neo4j_connector.execute_query(data_query, **params)
|
||||
|
||||
# 5. 处理结果
|
||||
items = []
|
||||
if result:
|
||||
for record in result:
|
||||
raw_created_at = record.get("created_at")
|
||||
created_at_timestamp = self.parse_timestamp(raw_created_at)
|
||||
items.append({
|
||||
"id": record["id"],
|
||||
"title": record.get("title") or "未命名",
|
||||
"memory_type": record.get("memory_type") or "其他",
|
||||
"content": record.get("content") or "",
|
||||
"created_at": created_at_timestamp
|
||||
})
|
||||
|
||||
# 6. 构建返回结果
|
||||
return {
|
||||
"total": total_all,
|
||||
"items": items,
|
||||
"page": {
|
||||
"page": page,
|
||||
"pagesize": pagesize,
|
||||
"total": filtered_total,
|
||||
"hasnext": (page * pagesize) < filtered_total
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"情景记忆分页查询出错: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def get_semantic_memory_list(
|
||||
self,
|
||||
end_user_id: str
|
||||
) -> list:
|
||||
"""
|
||||
获取语义记忆全量列表
|
||||
|
||||
Args:
|
||||
end_user_id: 终端用户ID
|
||||
|
||||
Returns:
|
||||
[
|
||||
{
|
||||
"id": str,
|
||||
"name": str,
|
||||
"entity_type": str,
|
||||
"core_definition": str
|
||||
}
|
||||
]
|
||||
"""
|
||||
try:
|
||||
logger.info(f"语义记忆列表查询: end_user_id={end_user_id}")
|
||||
|
||||
semantic_query = """
|
||||
MATCH (e:ExtractedEntity)
|
||||
WHERE e.end_user_id = $end_user_id
|
||||
AND e.is_explicit_memory = true
|
||||
RETURN elementId(e) AS id,
|
||||
e.name AS name,
|
||||
e.entity_type AS entity_type,
|
||||
e.description AS core_definition
|
||||
ORDER BY e.name ASC
|
||||
"""
|
||||
|
||||
result = await self.neo4j_connector.execute_query(
|
||||
semantic_query, end_user_id=end_user_id
|
||||
)
|
||||
|
||||
items = []
|
||||
if result:
|
||||
for record in result:
|
||||
items.append({
|
||||
"id": record["id"],
|
||||
"name": record.get("name") or "未命名",
|
||||
"entity_type": record.get("entity_type") or "未分类",
|
||||
"core_definition": record.get("core_definition") or ""
|
||||
})
|
||||
|
||||
logger.info(f"语义记忆列表查询成功: end_user_id={end_user_id}, total={len(items)}")
|
||||
|
||||
return items
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"语义记忆列表查询出错: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def get_explicit_memory_details(
|
||||
self,
|
||||
end_user_id: str,
|
||||
|
||||
@@ -233,7 +233,7 @@ class MemoryPerceptualService:
|
||||
api_key=model_config.api_key,
|
||||
base_url=model_config.api_base,
|
||||
is_omni=model_config.is_omni,
|
||||
support_thinking="thinking" in (model_config.capability or []),
|
||||
capability=model_config.capability,
|
||||
)
|
||||
)
|
||||
return llm, model_config
|
||||
|
||||
@@ -47,7 +47,8 @@ class ModelParameterMerger:
|
||||
"n": 1,
|
||||
"stop": None,
|
||||
"deep_thinking": False,
|
||||
"thinking_budget_tokens": None
|
||||
"thinking_budget_tokens": None,
|
||||
"json_output": False
|
||||
}
|
||||
|
||||
# 合并参数:默认值 -> 模型配置 -> Agent 配置
|
||||
|
||||
@@ -125,9 +125,7 @@ class ModelConfigService:
|
||||
api_key=api_key,
|
||||
base_url=api_base,
|
||||
is_omni=is_omni,
|
||||
support_thinking="thinking" in (capability or []),
|
||||
temperature=0.7,
|
||||
max_tokens=100
|
||||
capability=capability
|
||||
)
|
||||
|
||||
# 根据模型类型选择不同的验证方式
|
||||
@@ -371,6 +369,15 @@ class ModelConfigService:
|
||||
raise BusinessException("模型名称已存在", BizCode.DUPLICATE_NAME)
|
||||
|
||||
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.refresh(model)
|
||||
return model
|
||||
@@ -729,10 +736,21 @@ class ModelApiKeyService:
|
||||
@staticmethod
|
||||
def delete_api_key(db: Session, api_key_id: uuid.UUID) -> bool:
|
||||
"""删除API Key"""
|
||||
if not ModelApiKeyRepository.get_by_id(db, api_key_id):
|
||||
api_key = ModelApiKeyRepository.get_by_id(db, api_key_id)
|
||||
if not api_key:
|
||||
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)
|
||||
|
||||
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()
|
||||
return success
|
||||
|
||||
|
||||
@@ -2616,9 +2616,11 @@ class MultiAgentOrchestrator:
|
||||
api_key=api_key_config.api_key,
|
||||
base_url=api_key_config.api_base,
|
||||
is_omni=api_key_config.is_omni,
|
||||
support_thinking="thinking" in (api_key_config.capability or []),
|
||||
temperature=0.7, # 整合任务使用中等温度
|
||||
max_tokens=2000
|
||||
capability=api_key_config.capability,
|
||||
extra_params={
|
||||
"temperature": 0.7, # 整合任务使用中等温度
|
||||
"max_tokens": 2000
|
||||
}
|
||||
)
|
||||
|
||||
# 创建 LLM 实例
|
||||
@@ -2795,10 +2797,12 @@ class MultiAgentOrchestrator:
|
||||
api_key=api_key_config.api_key,
|
||||
base_url=api_key_config.api_base,
|
||||
is_omni=api_key_config.is_omni,
|
||||
support_thinking="thinking" in (api_key_config.capability or []),
|
||||
temperature=0.7,
|
||||
max_tokens=2000,
|
||||
extra_params={"streaming": True} # 启用流式输出
|
||||
capability=api_key_config.capability,
|
||||
extra_params={
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 2000,
|
||||
"streaming": True # 启用流式输出
|
||||
}
|
||||
)
|
||||
|
||||
# 创建 LLM 实例
|
||||
|
||||
@@ -24,6 +24,7 @@ import chardet
|
||||
import httpx
|
||||
import magic
|
||||
import openpyxl
|
||||
import uuid
|
||||
from docx import Document
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -94,7 +95,7 @@ class DashScopeFormatStrategy(MultimodalFormatStrategy):
|
||||
"""通义千问文档格式"""
|
||||
return True, {
|
||||
"type": "text",
|
||||
"text": f"<document name=\"{file_name}\">\n{text}\n</document>"
|
||||
"text": f"<document name=\"{file_name}\">\n文档内容:\n{text}\n</document>"
|
||||
}
|
||||
|
||||
async def format_audio(
|
||||
@@ -166,6 +167,7 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
|
||||
async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
|
||||
"""Bedrock/Anthropic 文档格式(需要 base64 编码)"""
|
||||
# Bedrock 文档需要 base64 编码
|
||||
text = f"文档内容:\n{text}\n"
|
||||
text_bytes = text.encode('utf-8')
|
||||
base64_text = base64.b64encode(text_bytes).decode('utf-8')
|
||||
|
||||
@@ -222,7 +224,7 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
|
||||
"""OpenAI 文档格式"""
|
||||
return True, {
|
||||
"type": "text",
|
||||
"text": f"<document name=\"{file_name}\">\n{text}\n</document>"
|
||||
"text": f"<document name=\"{file_name}\">\n文档内容:\n{text}\n</document>"
|
||||
}
|
||||
|
||||
async def format_audio(
|
||||
@@ -344,6 +346,8 @@ class MultimodalService:
|
||||
async def process_files(
|
||||
self,
|
||||
files: Optional[List[FileInput]],
|
||||
workspace_id: uuid.UUID = None,
|
||||
document_image_recognition: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
处理文件列表,返回 LLM 可用的格式
|
||||
@@ -379,6 +383,36 @@ class MultimodalService:
|
||||
elif file.type == FileType.DOCUMENT:
|
||||
is_support, content = await self._process_document(file, strategy)
|
||||
result.append(content)
|
||||
# 仅当开关开启且模型支持视觉时,才提取文档内嵌图片
|
||||
if document_image_recognition and "vision" in self.capability:
|
||||
img_infos = await self.extract_document_images(file)
|
||||
from app.models.workspace_model import Workspace as WorkspaceModel
|
||||
ws = self.db.query(WorkspaceModel).filter(WorkspaceModel.id == workspace_id).first()
|
||||
tenant_id = ws.tenant_id if ws else None
|
||||
img_result = []
|
||||
for img_info in img_infos:
|
||||
page = img_info["page"]
|
||||
index = img_info["index"]
|
||||
ext = img_info.get("ext", "png")
|
||||
try:
|
||||
_, img_url = await self._save_doc_image_to_storage(img_info["bytes"], ext, tenant_id, workspace_id)
|
||||
placeholder = f"第{page}页 第{index + 1}张" if page > 0 else f"第{index + 1}张"
|
||||
# 在文本内容中追加图片位置标记
|
||||
if result and result[-1].get("type") in ("text", "document"):
|
||||
key = "text" if "text" in result[-1] else list(result[-1].keys())[-1]
|
||||
result[-1][key] = result[-1].get(key, "") + f"\n[图片 {placeholder}]: {img_url}"
|
||||
# 将图片以视觉格式追加到消息内容中
|
||||
img_file = FileInput(
|
||||
type=FileType.IMAGE,
|
||||
transfer_method=TransferMethod.REMOTE_URL,
|
||||
url=img_url,
|
||||
file_type="image/png",
|
||||
)
|
||||
_, img_content = await self._process_image(img_file, strategy_class(img_file))
|
||||
img_result.append(img_content)
|
||||
except Exception as img_err:
|
||||
logger.warning(f"文档图片处理失败: {img_err}")
|
||||
result.extend(img_result)
|
||||
elif file.type == FileType.AUDIO and "audio" in self.capability:
|
||||
is_support, content = await self._process_audio(file, strategy)
|
||||
result.append(content)
|
||||
@@ -431,12 +465,8 @@ class MultimodalService:
|
||||
"""
|
||||
处理文档文件(PDF、Word 等)
|
||||
|
||||
Args:
|
||||
file: 文档文件输入
|
||||
strategy: 格式化策略
|
||||
|
||||
Returns:
|
||||
Dict: 根据 provider 返回不同格式的文档内容
|
||||
仅返回文本内容(图片通过 process_files 中的额外步骤追加)
|
||||
"""
|
||||
if file.transfer_method == TransferMethod.REMOTE_URL:
|
||||
return True, {
|
||||
@@ -444,19 +474,57 @@ class MultimodalService:
|
||||
"text": f"<document url=\"{file.url}\">\n{await self.extract_document_text(file)}\n</document>"
|
||||
}
|
||||
else:
|
||||
# 本地文件,提取文本内容
|
||||
server_url = settings.FILE_LOCAL_SERVER_URL
|
||||
file.url = f"{server_url}/storage/permanent/{file.upload_file_id}"
|
||||
text = await self.extract_document_text(file)
|
||||
file_metadata = self.db.query(FileMetadata).filter(
|
||||
FileMetadata.id == file.upload_file_id
|
||||
).first()
|
||||
|
||||
file_name = file_metadata.file_name if file_metadata else "unknown"
|
||||
|
||||
# 使用策略格式化文档
|
||||
return await strategy.format_document(file_name, text)
|
||||
|
||||
@staticmethod
|
||||
async def _save_doc_image_to_storage(
|
||||
img_bytes: bytes,
|
||||
ext: str,
|
||||
tenant_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
将文档内嵌图片保存到存储后端,写入 FileMetadata。
|
||||
|
||||
Returns:
|
||||
(file_id_str, permanent_url)
|
||||
"""
|
||||
from app.services.file_storage_service import FileStorageService, generate_file_key
|
||||
from app.db import get_db_context
|
||||
|
||||
file_id = uuid.uuid4()
|
||||
file_ext = f".{ext}" if not ext.startswith(".") else ext
|
||||
content_type = f"image/{ext}"
|
||||
|
||||
file_key = generate_file_key(tenant_id, workspace_id, file_id, file_ext)
|
||||
storage_svc = FileStorageService()
|
||||
await storage_svc.storage.upload(file_key, img_bytes, content_type)
|
||||
|
||||
with get_db_context() as db:
|
||||
meta = FileMetadata(
|
||||
id=file_id,
|
||||
tenant_id=tenant_id,
|
||||
workspace_id=workspace_id,
|
||||
file_key=file_key,
|
||||
file_name=f"doc_image_{file_id}{file_ext}",
|
||||
file_ext=file_ext,
|
||||
file_size=len(img_bytes),
|
||||
content_type=content_type,
|
||||
status="completed",
|
||||
)
|
||||
db.add(meta)
|
||||
db.commit()
|
||||
|
||||
url = f"{settings.FILE_LOCAL_SERVER_URL}/storage/permanent/{file_id}"
|
||||
return str(file_id), url
|
||||
|
||||
async def _process_audio(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
处理音频文件
|
||||
@@ -582,6 +650,84 @@ class MultimodalService:
|
||||
logger.error(f"Failed to load file. - {e}")
|
||||
return "[Failed to load file.]"
|
||||
|
||||
async def extract_document_images(self, file: FileInput) -> list[dict]:
|
||||
"""
|
||||
提取文档中的内嵌图片(支持 PDF 和 DOCX),附带位置信息。
|
||||
|
||||
Returns:
|
||||
list[dict]: 每项包含:
|
||||
- bytes: 图片二进制
|
||||
- page: 所在页码(PDF 从 1 开始,DOCX 为 0)
|
||||
- index: 该页/文档内的图片序号(从 0 开始)
|
||||
- ext: 图片扩展名(如 png、jpeg)
|
||||
"""
|
||||
try:
|
||||
file_content = file.get_content()
|
||||
if not file_content:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(file.url, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
file_content = response.content
|
||||
file.set_content(file_content)
|
||||
|
||||
file_mime_type = magic.from_buffer(file_content, mime=True)
|
||||
if file_mime_type in PDF_MIME:
|
||||
return self._extract_pdf_images(file_content)
|
||||
elif self._is_word_file(file_content, file_mime_type):
|
||||
return self._extract_docx_images(file_content)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"提取文档图片失败: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _extract_pdf_images(file_content: bytes) -> list[dict]:
|
||||
"""从 PDF 提取内嵌图片,附带页码和序号"""
|
||||
images = []
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
doc = fitz.open(stream=file_content, filetype="pdf")
|
||||
for page_num, page in enumerate(doc, start=1):
|
||||
for idx, img in enumerate(page.get_images(full=True)):
|
||||
xref = img[0]
|
||||
base_image = doc.extract_image(xref)
|
||||
images.append({
|
||||
"bytes": base_image["image"],
|
||||
"ext": base_image.get("ext", "png"),
|
||||
"page": page_num,
|
||||
"index": idx,
|
||||
})
|
||||
doc.close()
|
||||
except ImportError:
|
||||
logger.warning("PyMuPDF 未安装,无法提取 PDF 图片,请执行: uv add pymupdf")
|
||||
except Exception as e:
|
||||
logger.error(f"提取 PDF 图片失败: {e}")
|
||||
return images
|
||||
|
||||
@staticmethod
|
||||
def _extract_docx_images(file_content: bytes) -> list[dict]:
|
||||
"""从 DOCX 提取内嵌图片,附带序号(DOCX 无页码概念,page 固定为 0)"""
|
||||
images = []
|
||||
try:
|
||||
if file_content[:2] != b'PK':
|
||||
return []
|
||||
with zipfile.ZipFile(io.BytesIO(file_content)) as zf:
|
||||
media_files = sorted(
|
||||
name for name in zf.namelist()
|
||||
if name.startswith("word/media/") and not name.endswith("/")
|
||||
)
|
||||
for idx, name in enumerate(media_files):
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "png"
|
||||
images.append({
|
||||
"bytes": zf.read(name),
|
||||
"ext": ext,
|
||||
"page": 0,
|
||||
"index": idx,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"提取 DOCX 图片失败: {e}")
|
||||
return images
|
||||
|
||||
@staticmethod
|
||||
async def _extract_pdf_text(file_content: bytes) -> str:
|
||||
"""提取 PDF 文本"""
|
||||
|
||||
@@ -34,7 +34,7 @@ Readability Guideline: Ensure optimized prompts have good readability and logica
|
||||
Constraint Handling Guideline: Do not mention variable-related limitations under the [Constraints] label.{% endraw %}{% endif %}
|
||||
|
||||
Constraints
|
||||
Output Constraint: Must output in JSON format including the fields "prompt" and "desc".
|
||||
Output Constraint: Must output in JSON format including the string fields "prompt" and "desc".
|
||||
Content Constraint: Must not include any explanations, analyses, or additional comments.
|
||||
Language Constraint: Must use clear and concise language.
|
||||
{% if skill != true %}Completeness Constraint: Must fully define all missing elements (input details, output format, constraints, etc.).{% endif %}
|
||||
|
||||
@@ -186,7 +186,7 @@ class PromptOptimizerService:
|
||||
api_key=api_config.api_key,
|
||||
base_url=api_config.api_base,
|
||||
is_omni=api_config.is_omni,
|
||||
support_thinking="thinking" in (api_config.capability or []),
|
||||
capability=api_config.capability,
|
||||
), type=ModelType(model_config.type))
|
||||
try:
|
||||
prompt_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'prompt')
|
||||
@@ -227,10 +227,20 @@ class PromptOptimizerService:
|
||||
content = getattr(chunk, "content", chunk)
|
||||
if not content:
|
||||
continue
|
||||
buffer += content
|
||||
if isinstance(content, str):
|
||||
buffer += content
|
||||
elif isinstance(content, list):
|
||||
for _ in content:
|
||||
buffer += _["text"]
|
||||
else:
|
||||
logger.error(f"Unsupported content type - {content}")
|
||||
raise Exception("Unsupported content type")
|
||||
cache = buffer[:-20]
|
||||
last_idx = 19
|
||||
while cache and cache[-1] == '\\' and last_idx > 0:
|
||||
cache = buffer[:-last_idx]
|
||||
last_idx -= 1
|
||||
|
||||
# 尝试找到 "prompt": " 开始位置
|
||||
if prompt_finished:
|
||||
continue
|
||||
|
||||
@@ -272,7 +282,7 @@ class PromptOptimizerService:
|
||||
def parser_prompt_variables(prompt: str):
|
||||
try:
|
||||
pattern = r'\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}'
|
||||
matches = re.findall(pattern, prompt)
|
||||
matches = re.findall(pattern, str(prompt))
|
||||
variables = list(set(matches))
|
||||
return variables
|
||||
except Exception as e:
|
||||
|
||||
@@ -250,7 +250,8 @@ class SharedChatService:
|
||||
tools=tools,
|
||||
deep_thinking=model_parameters.get("deep_thinking", False),
|
||||
thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"),
|
||||
capability=api_key_obj.capability or [],
|
||||
json_output=model_parameters.get("json_output", False),
|
||||
capability=api_key_obj.capability,
|
||||
)
|
||||
|
||||
# 加载历史消息
|
||||
@@ -455,6 +456,7 @@ class SharedChatService:
|
||||
streaming=True,
|
||||
deep_thinking=model_parameters.get("deep_thinking", False),
|
||||
thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"),
|
||||
json_output=model_parameters.get("json_output", False),
|
||||
capability=api_key_obj.capability or [],
|
||||
)
|
||||
|
||||
|
||||
@@ -815,11 +815,12 @@ class ToolService:
|
||||
"default": param_info.get("default")
|
||||
})
|
||||
|
||||
# 请求体参数
|
||||
# 请求体参数 — _extract_request_body 返回 {"schema": {...}, "required": bool, ...}
|
||||
request_body = operation.get("request_body")
|
||||
if request_body:
|
||||
schema_props = request_body.get("schema", {}).get("properties", {})
|
||||
required_props = request_body.get("schema", {}).get("required", [])
|
||||
body_schema = request_body.get("schema", {})
|
||||
schema_props = body_schema.get("properties", {})
|
||||
required_props = body_schema.get("required", [])
|
||||
|
||||
for prop_name, prop_schema in schema_props.items():
|
||||
parameters.append({
|
||||
|
||||
@@ -14,6 +14,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging_config import get_logger
|
||||
from app.core.memory.storage_services.extraction_engine.deduplication.deduped_and_disamb import _USER_PLACEHOLDER_NAMES
|
||||
from app.core.memory.utils.llm.llm_utils import MemoryClientFactory
|
||||
from app.db import get_db_context
|
||||
from app.repositories.conversation_repository import ConversationRepository
|
||||
@@ -21,7 +22,7 @@ from app.repositories.end_user_repository import EndUserRepository
|
||||
from app.repositories.neo4j.cypher_queries import Graph_Node_query
|
||||
from app.repositories.neo4j.neo4j_connector import Neo4jConnector
|
||||
from app.schemas.memory_episodic_schema import EmotionSubject, EmotionType, type_mapping
|
||||
from app.services.memory_base_service import MemoryBaseService
|
||||
from app.services.memory_base_service import MemoryBaseService, MIN_MEMORY_SUMMARY_COUNT
|
||||
from app.services.memory_config_service import MemoryConfigService
|
||||
from app.services.memory_perceptual_service import MemoryPerceptualService
|
||||
from app.services.memory_short_service import ShortService
|
||||
@@ -398,12 +399,25 @@ 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 = {
|
||||
"end_user_info_id": str(end_user_info_record.id),
|
||||
"end_user_id": str(end_user_info_record.end_user_id),
|
||||
"other_name": end_user_info_record.other_name,
|
||||
"aliases": end_user_info_record.aliases,
|
||||
"meta_data": end_user_info_record.meta_data,
|
||||
"profile": profile,
|
||||
"knowledge_tags": meta.get("knowledge_tags"),
|
||||
"behavioral_hints": meta.get("behavioral_hints"),
|
||||
"created_at": datetime_to_timestamp(end_user_info_record.created_at),
|
||||
"updated_at": datetime_to_timestamp(end_user_info_record.updated_at)
|
||||
}
|
||||
@@ -473,7 +487,7 @@ class UserMemoryService:
|
||||
allowed_fields = {'other_name', 'aliases', 'meta_data'}
|
||||
|
||||
# 用户占位名称黑名单,不允许作为 other_name 或出现在 aliases 中
|
||||
_user_placeholder_names = {'用户', '我', 'User', 'I'}
|
||||
_user_placeholder_names = _USER_PLACEHOLDER_NAMES
|
||||
|
||||
# 过滤 other_name:不允许设置为占位名称
|
||||
if 'other_name' in update_data and update_data['other_name'] and update_data['other_name'].strip() in _user_placeholder_names:
|
||||
@@ -1500,7 +1514,7 @@ async def analytics_memory_types(
|
||||
2. 工作记忆 (WORKING_MEMORY) = 会话数量(通过 ConversationRepository.get_conversation_by_user_id 获取)
|
||||
3. 短期记忆 (SHORT_TERM_MEMORY) = /short_term 接口返回的问答对数量
|
||||
4. 显性记忆 (EXPLICIT_MEMORY) = 情景记忆 + 语义记忆(通过 MemoryBaseService.get_explicit_memory_count 获取)
|
||||
5. 隐性记忆 (IMPLICIT_MEMORY) = Statement 节点数量的三分之一
|
||||
5. 隐性记忆 (IMPLICIT_MEMORY) = MemorySummary 节点数量(需 >= MIN_MEMORY_SUMMARY_COUNT 才显示,否则为 0)
|
||||
6. 情绪记忆 (EMOTIONAL_MEMORY) = 情绪标签统计总数(通过 MemoryBaseService.get_emotional_memory_count 获取)
|
||||
7. 情景记忆 (EPISODIC_MEMORY) = memory_summary(通过 MemoryBaseService.get_episodic_memory_count 获取)
|
||||
8. 遗忘记忆 (FORGET_MEMORY) = 激活值低于阈值的节点数(通过 MemoryBaseService.get_forget_memory_count 获取)
|
||||
@@ -1557,23 +1571,15 @@ async def analytics_memory_types(
|
||||
logger.warning(f"获取会话数量失败,工作记忆数量设为0: {str(e)}")
|
||||
work_count = 0
|
||||
|
||||
# 获取隐性记忆数量(基于 Statement 节点数量的三分之一)
|
||||
# 获取隐性记忆数量(基于有关联关系的 MemorySummary 节点数量,需 >= MIN_MEMORY_SUMMARY_COUNT 才计入)
|
||||
implicit_count = 0
|
||||
if end_user_id:
|
||||
try:
|
||||
# 查询 Statement 节点数量
|
||||
query = """
|
||||
MATCH (n:Statement)
|
||||
WHERE n.end_user_id = $end_user_id
|
||||
RETURN count(n) as count
|
||||
"""
|
||||
result = await _neo4j_connector.execute_query(query, end_user_id=end_user_id)
|
||||
statement_count = result[0]["count"] if result and len(result) > 0 else 0
|
||||
# 取三分之一作为隐性记忆数量
|
||||
implicit_count = round(statement_count / 3)
|
||||
logger.debug(f"隐性记忆数量(Statement数量的1/3): {implicit_count} (Statement总数={statement_count}, end_user_id={end_user_id})")
|
||||
memory_summary_count = await base_service.get_valid_memory_summary_count(end_user_id)
|
||||
implicit_count = memory_summary_count if memory_summary_count >= MIN_MEMORY_SUMMARY_COUNT else 0
|
||||
logger.debug(f"隐性记忆数量(有效MemorySummary节点数): {implicit_count} (有效MemorySummary总数={memory_summary_count}, end_user_id={end_user_id})")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取Statement数量失败,隐性记忆数量设为0: {str(e)}")
|
||||
logger.warning(f"获取MemorySummary数量失败,隐性记忆数量设为0: {str(e)}")
|
||||
implicit_count = 0
|
||||
|
||||
# 原有的基于行为习惯的统计方式(已注释)
|
||||
@@ -1639,7 +1645,7 @@ async def analytics_memory_types(
|
||||
"WORKING_MEMORY": work_count, # 工作记忆(基于会话数量)
|
||||
"SHORT_TERM_MEMORY": short_term_count, # 短期记忆(基于问答对数量)
|
||||
"EXPLICIT_MEMORY": explicit_count, # 显性记忆(情景记忆 + 语义记忆)
|
||||
"IMPLICIT_MEMORY": implicit_count, # 隐性记忆(Statement数量的1/3)
|
||||
"IMPLICIT_MEMORY": implicit_count, # 隐性记忆(MemorySummary节点数,需>=MIN_MEMORY_SUMMARY_COUNT)
|
||||
"EMOTIONAL_MEMORY": emotion_count, # 情绪记忆(使用情绪标签统计)
|
||||
"EPISODIC_MEMORY": episodic_count, # 情景记忆
|
||||
"FORGET_MEMORY": forget_count # 遗忘记忆(激活值低于阈值)
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
|
||||
import uuid
|
||||
|
||||
from app.aioRedis import aio_redis_set, aio_redis_get, aio_redis_delete
|
||||
from app.models import Workspace
|
||||
from app.models.user_model import User
|
||||
from app.repositories import user_repository
|
||||
from app.schemas.user_schema import UserCreate
|
||||
@@ -74,7 +75,7 @@ def create_initial_superuser(db: Session):
|
||||
)
|
||||
|
||||
|
||||
def create_user(db: Session, user: UserCreate) -> User:
|
||||
def create_user(db: Session, user: UserCreate, workspace: Workspace) -> User:
|
||||
business_logger.info(f"创建用户: {user.username}, email: {user.email}")
|
||||
|
||||
try:
|
||||
@@ -93,24 +94,9 @@ def create_user(db: Session, user: UserCreate) -> User:
|
||||
business_logger.debug(f"开始创建用户: {user.username}")
|
||||
hashed_password = get_password_hash(user.password)
|
||||
|
||||
# 获取默认租户(第一个活跃租户)
|
||||
from app.repositories.tenant_repository import TenantRepository
|
||||
tenant_repo = TenantRepository(db)
|
||||
tenants = tenant_repo.get_tenants(skip=0, limit=1, is_active=True)
|
||||
|
||||
if not tenants:
|
||||
business_logger.error("系统中没有可用的租户")
|
||||
raise BusinessException(
|
||||
"系统配置错误:没有可用的租户",
|
||||
code=BizCode.TENANT_NOT_FOUND,
|
||||
context={"username": user.username, "email": user.email}
|
||||
)
|
||||
|
||||
default_tenant = tenants[0]
|
||||
|
||||
new_user = user_repository.create_user(
|
||||
db=db, user=user, hashed_password=hashed_password,
|
||||
tenant_id=default_tenant.id, is_superuser=False
|
||||
tenant_id=workspace.tenant_id, is_superuser=False
|
||||
)
|
||||
|
||||
db.commit()
|
||||
@@ -285,7 +271,7 @@ def activate_user(db: Session, user_id_to_activate: uuid.UUID, current_user: Use
|
||||
try:
|
||||
# 查找用户
|
||||
business_logger.debug(f"查找待激活用户: {user_id_to_activate}")
|
||||
db_user = user_repository.get_user_by_id(db, user_id=user_id_to_activate)
|
||||
db_user = user_repository.get_user_by_id_regardless_active(db, user_id=user_id_to_activate)
|
||||
if not db_user:
|
||||
business_logger.warning(f"用户不存在: {user_id_to_activate}")
|
||||
raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND)
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.core.exceptions import BusinessException
|
||||
from app.core.workflow.adapters.base_adapter import WorkflowImportResult, WorkflowParserResult
|
||||
from app.core.workflow.adapters.errors import UnsupportedPlatform, InvalidConfiguration
|
||||
from app.core.workflow.adapters.registry import PlatformAdapterRegistry
|
||||
from app.models.app_model import AppType
|
||||
from app.schemas import AppCreate
|
||||
from app.schemas.workflow_schema import WorkflowConfigCreate
|
||||
from app.services.app_service import AppService
|
||||
@@ -86,11 +87,12 @@ class WorkflowImportService:
|
||||
if config is None:
|
||||
raise BusinessException("Configuration import timed out. Please try again.")
|
||||
config = json.loads(config)
|
||||
unique_name = self.app_service._unique_app_name(name, workspace_id, AppType.WORKFLOW)
|
||||
app = self.app_service.create_app(
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
data=AppCreate(
|
||||
name=name,
|
||||
name=unique_name,
|
||||
description=description,
|
||||
type="workflow",
|
||||
workflow_config=WorkflowConfigCreate(
|
||||
|
||||
@@ -17,8 +17,9 @@ from app.core.workflow.executor import execute_workflow, execute_workflow_stream
|
||||
from app.core.workflow.nodes.enums import NodeType
|
||||
from app.core.workflow.validator import validate_workflow_config
|
||||
from app.db import get_db
|
||||
from sqlalchemy import select
|
||||
from app.models import App
|
||||
from app.models.workflow_model import WorkflowConfig, WorkflowExecution
|
||||
from app.models.workflow_model import WorkflowConfig, WorkflowExecution, WorkflowNodeExecution
|
||||
from app.repositories import knowledge_repository
|
||||
from app.repositories.workflow_repository import (
|
||||
WorkflowConfigRepository,
|
||||
@@ -694,7 +695,8 @@ class WorkflowService:
|
||||
"nodes": config.nodes,
|
||||
"edges": config.edges,
|
||||
"variables": config.variables,
|
||||
"execution_config": config.execution_config
|
||||
"execution_config": config.execution_config,
|
||||
"features": feature_configs
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -772,9 +774,16 @@ class WorkflowService:
|
||||
# 过滤 citations
|
||||
citations = result.get("citations", [])
|
||||
citation_cfg = feature_configs.get("citation", {})
|
||||
filtered_citations = (
|
||||
citations if isinstance(citation_cfg, dict) and citation_cfg.get("enabled") else []
|
||||
)
|
||||
if isinstance(citation_cfg, dict) and citation_cfg.get("enabled"):
|
||||
allow_download = citation_cfg.get("allow_download", False)
|
||||
if allow_download:
|
||||
from app.core.config import settings
|
||||
for c in citations:
|
||||
if c.get("document_id"):
|
||||
c["download_url"] = f"{settings.FILE_LOCAL_SERVER_URL}/apps/citations/{c['document_id']}/download"
|
||||
filtered_citations = citations
|
||||
else:
|
||||
filtered_citations = []
|
||||
assistant_meta = {"usage": token_usage, "audio_url": None}
|
||||
if filtered_citations:
|
||||
assistant_meta["citations"] = filtered_citations
|
||||
@@ -894,7 +903,8 @@ class WorkflowService:
|
||||
"nodes": config.nodes,
|
||||
"edges": config.edges,
|
||||
"variables": config.variables,
|
||||
"execution_config": config.execution_config
|
||||
"execution_config": config.execution_config,
|
||||
"features": feature_configs
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -909,6 +919,7 @@ class WorkflowService:
|
||||
input_data["conv_messages"] = conv_messages
|
||||
init_message_length = len(input_data.get("conv_messages", []))
|
||||
message_id = uuid.uuid4()
|
||||
_cycle_items: dict[str, list] = {}
|
||||
|
||||
# 新会话时写入开场白
|
||||
is_new_conversation = init_message_length == 0
|
||||
@@ -939,6 +950,15 @@ class WorkflowService:
|
||||
memory_storage_type=storage_type,
|
||||
user_rag_memory_id=user_rag_memory_id
|
||||
):
|
||||
event_type = event.get("event")
|
||||
event_data = event.get("data", {})
|
||||
|
||||
if event_type == "cycle_item":
|
||||
cycle_id = event_data.get("cycle_id")
|
||||
if cycle_id not in _cycle_items:
|
||||
_cycle_items[cycle_id] = []
|
||||
_cycle_items[cycle_id].append(event_data)
|
||||
|
||||
if event.get("event") == "workflow_end":
|
||||
status = event.get("data", {}).get("status")
|
||||
token_usage = event.get("data", {}).get("token_usage", {}) or {}
|
||||
@@ -957,7 +977,10 @@ class WorkflowService:
|
||||
for file in message["content"]:
|
||||
human_meta["files"].append({
|
||||
"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":
|
||||
assistant_message = message["content"]
|
||||
@@ -970,9 +993,16 @@ class WorkflowService:
|
||||
# 过滤 citations
|
||||
citations = event.get("data", {}).get("citations", [])
|
||||
citation_cfg = feature_configs.get("citation", {})
|
||||
filtered_citations = (
|
||||
citations if isinstance(citation_cfg, dict) and citation_cfg.get("enabled") else []
|
||||
)
|
||||
if isinstance(citation_cfg, dict) and citation_cfg.get("enabled"):
|
||||
allow_download = citation_cfg.get("allow_download", False)
|
||||
if allow_download:
|
||||
from app.core.config import settings
|
||||
for c in citations:
|
||||
if c.get("document_id"):
|
||||
c["download_url"] = f"{settings.FILE_LOCAL_SERVER_URL}/apps/citations/{c['document_id']}/download"
|
||||
filtered_citations = citations
|
||||
else:
|
||||
filtered_citations = []
|
||||
assistant_meta = {"usage": token_usage, "audio_url": None}
|
||||
if filtered_citations:
|
||||
assistant_meta["citations"] = filtered_citations
|
||||
@@ -1000,6 +1030,18 @@ class WorkflowService:
|
||||
)
|
||||
else:
|
||||
logger.error(f"unexpect workflow run status, status: {status}")
|
||||
# 把积累的 cycle_item 写入 workflow_executions.output_data["node_outputs"]
|
||||
if _cycle_items and execution.output_data:
|
||||
import copy
|
||||
new_output_data = copy.deepcopy(execution.output_data)
|
||||
node_outputs = new_output_data.setdefault("node_outputs", {})
|
||||
for cycle_node_id, items in _cycle_items.items():
|
||||
if cycle_node_id in node_outputs:
|
||||
node_outputs[cycle_node_id]["cycle_items"] = items
|
||||
else:
|
||||
node_outputs[cycle_node_id] = {"cycle_items": items}
|
||||
execution.output_data = new_output_data
|
||||
self.db.commit()
|
||||
elif event.get("event") == "workflow_start":
|
||||
event["data"]["message_id"] = str(message_id)
|
||||
event = self._emit(public, event)
|
||||
|
||||
Reference in New Issue
Block a user