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.