[AC-API-UPDATE] feat(api): 更新 API 端点和实体模型

- 更新 dialogue API 支持新的对话功能
- 更新 share_page API 优化分享页面
- 更新 main.py 注册新的路由模块
- 更新 entities 模型添加新字段
This commit is contained in:
MerCry 2026-03-11 19:07:03 +08:00
parent 9196247578
commit 1490235b8f
4 changed files with 346 additions and 68 deletions

View File

@ -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,

View File

@ -176,14 +176,38 @@ async def share_chat_page(
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>对话分享</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {{
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f5f9;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-light: #eef2ff;
--border: #e2e8f0;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.08);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: #f8f9fa;
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
color: var(--text-primary);
line-height: 1.6;
}}
.welcome-screen {{
flex: 1;
@ -191,105 +215,221 @@ async def share_chat_page(
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
padding: 48px 24px;
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
}}
.welcome-screen.hidden {{ display: none; }}
.welcome-title {{
font-size: 28px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 32px;
letter-spacing: -0.02em;
}}
.welcome-input-wrapper {{
width: 100%;
max-width: 680px;
background: white;
border-radius: 16px;
padding: 16px 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
max-width: 600px;
background: var(--bg-secondary);
border-radius: var(--radius-xl);
padding: 8px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border);
display: flex;
align-items: flex-end;
gap: 8px;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}}
.welcome-input-wrapper:focus-within {{
box-shadow: var(--shadow-lg), 0 0 0 3px var(--accent-light);
border-color: var(--accent);
}}
.welcome-textarea, .input-textarea {{
width: 100%;
min-height: 56px;
border: 1px solid #e5e5e5;
border-radius: 12px;
padding: 12px 16px;
flex: 1;
min-height: 48px;
max-height: 200px;
border: none;
border-radius: var(--radius-lg);
padding: 14px 16px;
resize: none;
outline: none;
font-size: 15px;
line-height: 1.6;
line-height: 1.5;
font-family: inherit;
background: #fafafa;
transition: all 0.2s;
background: transparent;
color: var(--text-primary);
}}
.welcome-textarea:focus, .input-textarea:focus {{
border-color: #1677ff;
background: white;
.welcome-textarea::placeholder, .input-textarea::placeholder {{
color: var(--text-muted);
}}
.chat-screen {{ flex: 1; display: none; flex-direction: column; }}
.chat-screen {{ flex: 1; display: none; flex-direction: column; background: var(--bg-primary); }}
.chat-screen.active {{ display: flex; }}
.chat-list {{
flex: 1;
padding: 20px;
max-width: 800px;
padding: 24px;
max-width: 720px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
gap: 20px;
overflow-y: auto;
}}
.bubble {{ display: flex; gap: 10px; align-items: flex-start; }}
.bubble {{ display: flex; gap: 12px; align-items: flex-start; animation: fadeIn 0.3s ease; }}
@keyframes fadeIn {{
from {{ opacity: 0; transform: translateY(8px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
.bubble.user {{ flex-direction: row-reverse; }}
.avatar {{
width: 32px; height: 32px; border-radius: 50%;
background: white; display: flex; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: 50%;
background: var(--bg-tertiary);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
overflow: hidden;
}}
.bubble-content {{ max-width: 75%; }}
.avatar svg {{
width: 22px;
height: 22px;
}}
.bubble.user .avatar {{
background: var(--accent-light);
}}
.bubble.user .avatar svg path {{
fill: var(--accent);
}}
.bubble.bot .avatar svg path {{
fill: var(--accent);
}}
.bubble-content {{ max-width: 80%; min-width: 0; }}
.bubble-text {{
padding: 12px 16px; border-radius: 16px; white-space: pre-wrap; word-break: break-word;
font-size: 14px; line-height: 1.6;
padding: 14px 18px;
border-radius: var(--radius-lg);
white-space: pre-wrap;
word-break: break-word;
font-size: 14px;
line-height: 1.65;
}}
.bubble.user .bubble-text {{
background: var(--accent);
color: white;
border-bottom-right-radius: var(--radius-sm);
}}
.bubble.bot .bubble-text {{
background: var(--bg-secondary);
color: var(--text-primary);
border-bottom-left-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
}}
.bubble.error .bubble-text {{
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}}
.bubble.user .bubble-text {{ background: #1677ff; color: white; }}
.bubble.bot .bubble-text {{ background: white; color: #333; }}
.bubble.error .bubble-text {{ background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; }}
.thought-block {{
background: #f5f5f5;
color: #888;
padding: 12px 16px;
border-radius: 12px;
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 14px 18px;
border-radius: var(--radius-md);
margin-bottom: 12px;
font-size: 13px;
line-height: 1.6;
border-left: 3px solid #ddd;
line-height: 1.65;
border-left: 3px solid var(--text-muted);
}}
.thought-label {{
font-weight: 600;
color: #999;
margin-bottom: 6px;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
}}
.final-answer-block {{
background: white;
color: #333;
padding: 12px 16px;
border-radius: 12px;
background: var(--bg-secondary);
color: var(--text-primary);
padding: 14px 18px;
border-radius: var(--radius-md);
font-size: 14px;
line-height: 1.6;
line-height: 1.65;
}}
.final-answer-label {{
font-weight: 600;
color: #1677ff;
margin-bottom: 6px;
font-size: 12px;
color: var(--accent);
margin-bottom: 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
}}
.input-area {{
background: var(--bg-secondary);
padding: 16px 24px 24px;
border-top: 1px solid var(--border);
}}
.input-wrapper {{
max-width: 720px;
margin: 0 auto;
display: flex;
gap: 12px;
align-items: flex-end;
background: var(--bg-tertiary);
border-radius: var(--radius-lg);
padding: 6px 6px 6px 16px;
border: 1px solid var(--border);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}}
.input-wrapper:focus-within {{
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
}}
.input-textarea {{
background: transparent;
padding: 10px 0;
}}
.input-area {{ background: white; padding: 16px 20px 20px; border-top: 1px solid #eee; }}
.input-wrapper {{ max-width: 800px; margin: 0 auto; display: flex; gap: 12px; align-items: flex-end; }}
.send-btn, .welcome-send {{
width: 40px; height: 40px; border-radius: 50%; border: none; cursor: pointer;
background: #1677ff; color: white;
width: 44px; height: 44px;
border-radius: 50%;
border: none;
cursor: pointer;
background: var(--accent);
color: white;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease, transform 0.15s ease;
flex-shrink: 0;
}}
.send-btn:hover, .welcome-send:hover {{
background: var(--accent-hover);
transform: scale(1.05);
}}
.send-btn:active, .welcome-send:active {{
transform: scale(0.95);
}}
.send-btn:disabled, .welcome-send:disabled {{
background: var(--text-muted);
cursor: not-allowed;
transform: none;
}}
.status {{
text-align: center;
padding: 12px;
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}}
.status.error {{ color: #dc2626; }}
@media (max-width: 640px) {{
.welcome-screen {{ padding: 32px 16px; }}
.welcome-title {{ font-size: 22px; margin-bottom: 24px; }}
.chat-list {{ padding: 16px; gap: 16px; }}
.bubble-content {{ max-width: 85%; }}
.input-area {{ padding: 12px 16px 20px; }}
}}
.status {{ text-align: center; padding: 8px; font-size: 12px; color: #999; }}
.status.error {{ color: #ff4d4f; }}
</style>
</head>
<body>
<div class="welcome-screen" id="welcomeScreen">
<h1>今天有什么可以帮到你</h1>
<h1 class="welcome-title">今天有什么可以帮到你</h1>
<div class="welcome-input-wrapper">
<textarea class="welcome-textarea" id="welcomeInput" placeholder="输入消息,按 Enter 发送" rows="1"></textarea>
<button class="welcome-send" id="welcomeSendBtn"></button>
@ -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 = '<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M573.9 516.2L512 640l-61.9-123.8C232 546.4 64 733.6 64 960h896c0-226.4-168-413.6-386.1-443.8zM480 384h64c17.7 0 32.1 14.4 32.1 32.1 0 17.7-14.4 32.1-32.1 32.1h-64c-11.9 0-22.3-6.5-27.8-16.1H356c34.9 48.5 91.7 80 156 80 106 0 192-86 192-192s-86-192-192-192-192 86-192 192c0 28.5 6.2 55.6 17.4 80h114.8c5.5-9.6 15.9-16.1 27.8-16.1z"/><path d="M272 432.1h84c-4.2-5.9-8.1-12-11.7-18.4-2.3-4.1-4.4-8.3-6.4-12.5-0.2-0.4-0.4-0.7-0.5-1.1H288c-8.8 0-16-7.2-16-16v-48.4c0-64.1 25-124.3 70.3-169.6S447.9 95.8 512 95.8s124.3 25 169.7 70.3c38.3 38.3 62.1 87.2 68.5 140.2-8.4 4-14.2 12.5-14.2 22.4v78.6c0 13.7 11.1 24.8 24.8 24.8h14.6c13.7 0 24.8-11.1 24.8-24.8v-78.6c0-11.3-7.6-20.9-18-23.8-6.9-60.9-33.9-117.4-78-161.3C652.9 92.1 584.6 63.9 512 63.9s-140.9 28.3-192.3 79.6C268.3 194.8 240 263.1 240 335.7v64.4c0 17.7 14.3 32 32 32z"/></svg>';
const botSvg = '<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M894.1 355.6h-1.7C853 177.6 687.6 51.4 498.1 54.9S148.2 190.5 115.9 369.7c-35.2 5.6-61.1 36-61.1 71.7v143.4c0.9 40.4 34.3 72.5 74.7 71.7 21.7-0.3 42.2-10 56-26.7 33.6 84.5 99.9 152 183.8 187 1.1-2 2.3-3.9 3.7-5.7 0.9-1.5 2.4-2.6 4.1-3 1.3 0 2.5 0.5 3.6 1.2a318.46 318.46 0 0 1-105.3-187.1c-5.1-44.4 24.1-85.4 67.6-95.2 64.3-11.7 128.1-24.7 192.4-35.9 37.9-5.3 70.4-29.8 85.7-64.9 6.8-15.9 11-32.8 12.5-50 0.5-3.1 2.9-5.6 5.9-6.2 3.1-0.7 6.4 0.5 8.2 3l1.7-1.1c25.4 35.9 74.7 114.4 82.7 197.2 8.2 94.8 3.7 160-71.4 226.5-1.1 1.1-1.7 2.6-1.7 4.1 0.1 2 1.1 3.8 2.8 4.8h4.8l3.2-1.8c75.6-40.4 132.8-108.2 159.9-189.5 11.4 16.1 28.5 27.1 47.8 30.8C846 783.9 716.9 871.6 557.2 884.9c-12-28.6-42.5-44.8-72.9-38.6-33.6 5.4-56.6 37-51.2 70.6 4.4 27.6 26.8 48.8 54.5 51.6 30.6 4.6 60.3-13 70.8-42.2 184.9-14.5 333.2-120.8 364.2-286.9 27.8-10.8 46.3-37.4 46.6-67.2V428.7c-0.1-19.5-8.1-38.2-22.3-51.6-14.5-13.8-33.8-21.4-53.8-21.3l1-0.2zM825.9 397c-71.1-176.9-272.1-262.7-449-191.7-86.8 34.9-155.7 103.4-191 190-2.5-2.8-5.2-5.4-8-7.9 25.3-154.6 163.8-268.6 326.8-269.2s302.3 112.6 328.7 267c-2.9 3.8-5.4 7.7-7.5 11.8z"/></svg>';
const errorSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
const avatar = role === 'user' ? userSvg : (role === 'bot' ? botSvg : errorSvg);
let contentHtml;
if (role === 'bot') {{

View File

@ -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,16 +54,52 @@ 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",
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__)

View File

@ -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.