diff --git a/ai-service/app/api/mid/dialogue.py b/ai-service/app/api/mid/dialogue.py index 8cbc6a3..6b63f4b 100644 --- a/ai-service/app/api/mid/dialogue.py +++ b/ai-service/app/api/mid/dialogue.py @@ -580,6 +580,35 @@ async def respond_dialogue( f"guardrail={guardrail_result.triggered}, kb_hit={final_trace.kb_hit}" ) + if dialogue_request.user_id: + try: + from app.services.mid.memory_adapter import MemoryAdapter + from app.services.mid.memory_summary_generator import MemorySummaryGenerator + + memory_adapter = MemoryAdapter(session=session) + summary_generator = MemorySummaryGenerator() + + history_messages = [ + {"role": h.role, "content": h.content} + for h in (dialogue_request.history or []) + ] + assistant_reply = "\n".join(s.text for s in final_segments) + update_messages = history_messages + [ + {"role": "user", "content": dialogue_request.user_message}, + {"role": "assistant", "content": assistant_reply}, + ] + + await memory_adapter.update_with_summary_generation( + user_id=dialogue_request.user_id, + session_id=dialogue_request.session_id, + messages=update_messages, + tenant_id=tenant_id, + summary_generator=summary_generator, + recent_turns=8, + ) + except Exception as e: + logger.warning(f"[AC-IDMP-14] Memory update trigger failed: {e}") + return DialogueResponse( segments=final_segments, trace=final_trace, @@ -1429,10 +1458,38 @@ async def _execute_agent_mode( runtime_observer.record_react(request_id, react_ctx.iteration, react_ctx.tool_calls) + # 合并 tool_calls:优先使用 KB 工具内部的 trace(包含注入后的参数) + final_tool_calls = list(react_ctx.tool_calls) if react_ctx.tool_calls else [] + logger.info( + f"[TRACE-MERGE] Before merge: final_tool_calls count={len(final_tool_calls)}, " + f"kb_dynamic_result exists={kb_dynamic_result is not None}, " + f"kb_dynamic_result.tool_trace exists={kb_dynamic_result.tool_trace if kb_dynamic_result else None}" + ) + if kb_dynamic_result and kb_dynamic_result.tool_trace: + kb_trace = kb_dynamic_result.tool_trace + logger.info( + f"[TRACE-MERGE] KB trace arguments: {kb_trace.arguments}" + ) + for i, tc in enumerate(final_tool_calls): + logger.info( + f"[TRACE-MERGE] Checking tool_call[{i}]: tool_name={tc.tool_name}" + ) + if tc.tool_name == "kb_search_dynamic": + logger.info( + f"[TRACE-MERGE] Replacing trace at index {i}: old_args={tc.arguments}, new_args={kb_trace.arguments}" + ) + final_tool_calls[i] = kb_trace + break + else: + logger.info( + f"[TRACE-MERGE] Skipped merge: kb_dynamic_result={kb_dynamic_result is not None}, " + f"tool_trace={kb_dynamic_result.tool_trace if kb_dynamic_result else 'N/A'}" + ) + trace_logger.update_trace( request_id=request_id, react_iterations=react_ctx.iteration, - tool_calls=react_ctx.tool_calls, + tool_calls=final_tool_calls, ) segments = _text_to_segments(final_answer) @@ -1444,8 +1501,8 @@ async def _execute_agent_mode( request_id=trace.request_id, generation_id=trace.generation_id, react_iterations=react_ctx.iteration, - tools_used=[tc.tool_name for tc in react_ctx.tool_calls] if react_ctx.tool_calls else None, - tool_calls=react_ctx.tool_calls, + tools_used=[tc.tool_name for tc in final_tool_calls] if final_tool_calls else None, + tool_calls=final_tool_calls, timeout_profile=timeout_governor.profile, kb_tool_called=True, kb_hit=kb_success and len(kb_hits) > 0, diff --git a/ai-service/app/api/openapi/share_page.py b/ai-service/app/api/openapi/share_page.py index 71dcffb..b3e5f98 100644 --- a/ai-service/app/api/openapi/share_page.py +++ b/ai-service/app/api/openapi/share_page.py @@ -176,14 +176,38 @@ async def share_chat_page( 对话分享 + + +
-

今天有什么可以帮到你?

+

今天有什么可以帮到你?

@@ -367,7 +507,12 @@ function formatBotMessage(text) {{ function addMessage(role, text) {{ const div = document.createElement('div'); div.className = 'bubble ' + role; - const avatar = role === 'user' ? '👤' : (role === 'bot' ? '🤖' : '⚠️'); + + const userSvg = ''; + const botSvg = ''; + const errorSvg = ''; + + const avatar = role === 'user' ? userSvg : (role === 'bot' ? botSvg : errorSvg); let contentHtml; if (role === 'bot') {{ diff --git a/ai-service/app/main.py b/ai-service/app/main.py index 119ec44..336be0d 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -4,6 +4,8 @@ Main FastAPI application for AI Service. """ import logging +import os +from logging.handlers import RotatingFileHandler from contextlib import asynccontextmanager from fastapi import FastAPI, Request, status @@ -52,15 +54,51 @@ from app.core.qdrant_client import close_qdrant_client settings = get_settings() -logging.basicConfig( - level=getattr(logging, settings.log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) -logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING) -logging.getLogger("sqlalchemy.dialects").setLevel(logging.WARNING) -logging.getLogger("sqlalchemy.orm").setLevel(logging.WARNING) +def setup_logging(): + """ + 配置滚动日志文件。 + - 日志文件存储在 logs/ 目录 + - 单文件最大 2MB,超过则切分 + - 保留最近 7 天的日志(约 70 个备份文件) + - 同时输出到控制台 + """ + log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs") + os.makedirs(log_dir, exist_ok=True) + + log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + formatter = logging.Formatter(log_format) + + root_logger = logging.getLogger() + root_logger.setLevel(getattr(logging, settings.log_level.upper())) + + root_logger.handlers.clear() + + console_handler = logging.StreamHandler() + console_handler.setLevel(getattr(logging, settings.log_level.upper())) + console_handler.setFormatter(formatter) + root_logger.addHandler(console_handler) + + log_file = os.path.join(log_dir, "ai-service.log") + file_handler = RotatingFileHandler( + filename=log_file, + maxBytes=2 * 1024 * 1024, + backupCount=70, + encoding="utf-8", + ) + file_handler.setLevel(getattr(logging, settings.log_level.upper())) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + + logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.dialects").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.orm").setLevel(logging.WARNING) + + return log_dir + + +setup_logging() logger = logging.getLogger(__name__) diff --git a/ai-service/app/models/entities.py b/ai-service/app/models/entities.py index 445dc26..77f9a7d 100644 --- a/ai-service/app/models/entities.py +++ b/ai-service/app/models/entities.py @@ -116,6 +116,44 @@ class ChatMessageCreate(SQLModel): content: str +class UserMemory(SQLModel, table=True): + """ + [AC-IDMP-14] 用户级记忆存储(滚动摘要) + 支持多租户隔离,存储最新 summary + facts/preferences/open_issues + """ + + __tablename__ = "user_memories" + __table_args__ = ( + Index("ix_user_memories_tenant_user", "tenant_id", "user_id"), + Index("ix_user_memories_tenant_user_updated", "tenant_id", "user_id", "updated_at"), + ) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True) + user_id: str = Field(..., description="User ID for memory storage", index=True) + summary: str | None = Field(default=None, description="Rolling summary for user") + facts: list[str] | None = Field( + default=None, + sa_column=Column("facts", JSON, nullable=True), + description="Extracted stable facts list", + ) + preferences: dict[str, Any] | None = Field( + default=None, + sa_column=Column("preferences", JSON, nullable=True), + description="User preferences as structured JSON", + ) + open_issues: list[str] | None = Field( + default=None, + sa_column=Column("open_issues", JSON, nullable=True), + description="Open issues list", + ) + summary_version: int = Field(default=1, description="Summary version / update round") + last_turn_id: str | None = Field(default=None, description="Last turn identifier (optional)") + expires_at: datetime | None = Field(default=None, description="Expiration time (optional)") + created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time") + updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time") + + class SharedSession(SQLModel, table=True): """ [AC-IDMP-SHARE] Shared session entity for dialogue sharing.