Merge branch 'develop' of http://47.106.207.27:3000/AI260626/Mutual_Fund into develop_feature_customer

# Conflicts:
#	.gitignore
#	api/deps.py
#	api/router.py
#	main.py
#	tests/test_customer_agent_router.py
#	tests/test_knowledge_router.py
This commit is contained in:
2026-09-11 14:25:37 +08:00
19 changed files with 302 additions and 87 deletions
+67
View File
@@ -62,6 +62,73 @@ data/files/
*.csv
*.xlsx
*.parquet
# Python 缓存与编译文件
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# 虚拟环境(非常重要,防止把本地依赖包提交上去)
venv/
env/
.idea
.venv/
ENV/
# 日志文件
*.log
# 操作系统文件
.DS_Store
Thumbs.db
# pytest 测试产物
.pytest_cache/
.coverage
coverage.xml
htmlcov/
junit.xml
# mypy 类型检查缓存
.mypy_cache/
# 向量库、本地数据库、模型文件(大文件不要提交git)
*.db
*.sqlite
*.milvus
*.neo4j
*.bin
*.pth
*.ckpt
*.onnx
# 本地导出数据、临时csv/excel
data/tmp/
data/output/
*.csv
*.xlsx
*.parquet
# 本地临时需求&开发计划文档
DK2_客服Agent模块完整开发计划(v1.1).md
DK4_客服Agent需求文档(修复完善版v1.4).md
客服Agent模块 · TODO List(最终修复版v1.2).md
DK2_客服Agent模块完整开发计划(v1.1).md
DK4_客服Agent需求文档(修复完善版v1.4).md
-10
View File
@@ -1,10 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
Generated
-1
View File
@@ -1 +0,0 @@
Mutual_Fund
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="com.codeverse.userSettings.MarscodeWorkspaceAppSettingsState">
<option name="chatAppRouterInfo" value="chat-session" />
<option name="progress" value="1.0" />
</component>
</project>
-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ClaudeCodeTabState">
<option name="tabSessions">
<map>
<entry key="0">
<value>
<TabSessionState>
<option name="provider" value="claude" />
<option name="sessionId" value="ed440cf1-c034-44b5-b5db-bc5afb824005" />
<option name="cwd" value="D:\Projects\py\Mutual_Fund" />
<option name="model" value="claude-sonnet-5[1m]" />
<option name="permissionMode" value="default" />
</TabSessionState>
</value>
</entry>
</map>
</option>
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Mutual_Fund.iml" filepath="$PROJECT_DIR$/.idea/Mutual_Fund.iml" />
</modules>
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+82
View File
@@ -0,0 +1,82 @@
"""Anonymous customer-service Agent HTTP endpoints."""
from __future__ import annotations
import json
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from service.customer_agent.chat import QueryTooLongError
from agent.customer_agent.session import SessionOwnershipError
from utils.request_id import get_request_id, new_request_id
from utils.response import fail, success
router = APIRouter()
def _runtime(request: Request):
runtime = getattr(request.app.state, "customer_agent_runtime", None)
if runtime is None:
raise RuntimeError("customer agent runtime is not configured")
return runtime
@router.post("/session/create")
async def create_session(request: Request):
runtime = _runtime(request)
session_id = await runtime.session_service.create_session()
return success({"session_id": session_id, "customer_id": None})
@router.get("/chat")
async def chat(request: Request, session_id: str, query: str):
runtime = _runtime(request)
trace_id = request.headers.get("X-Trace-Id") or get_request_id() or new_request_id()
try:
await runtime.session_service.verify_session_ownership(session_id)
retry_after = await runtime.session_service.consume_chat_quota(session_id)
if retry_after is not None:
response = fail(429, "请求过于频繁", {"retry_after": retry_after})
return JSONResponse(
status_code=429,
headers={"Retry-After": str(retry_after)},
content=response.model_dump(),
)
result = await runtime.agent.handle(session_id, query, trace_id=trace_id)
except SessionOwnershipError as exc:
return JSONResponse(
status_code=exc.code,
content=fail(exc.code, exc.message).model_dump(),
)
except QueryTooLongError as exc:
return JSONResponse(
status_code=400,
content=fail(400, str(exc)).model_dump(),
)
async def events():
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
return StreamingResponse(
events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}
)
@router.post("/session/end")
async def end_session(request: Request, body: dict):
runtime = _runtime(request)
session_id = body.get("session_id", "")
try:
await runtime.session_service.verify_session_ownership(session_id)
except SessionOwnershipError as exc:
return JSONResponse(
status_code=exc.code,
content=fail(exc.code, exc.message).model_dump(),
)
if hasattr(runtime.redis, "delete"):
await runtime.redis.delete(
f"session:{session_id}", f"session:{session_id}:messages",
f"rate:limit:anon:{session_id}:chat",
)
return success({"session_id": session_id, "archived": False})
+108
View File
@@ -0,0 +1,108 @@
"""Knowledge-base upload, preview, and confirmation endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from service.knowledge_base.upload import KnowledgeUploadService, UploadValidationError
from api.deps import require_knowledge_operator
from model.sys_user import SysUser
from utils.response import fail, success
router = APIRouter()
def _service(request: Request) -> KnowledgeUploadService:
service = getattr(request.app.state, "knowledge_upload_service", None)
if service is None:
raise RuntimeError("knowledge upload service is not configured")
return service
def _optional_int(value):
return None if value in (None, "") else int(value)
@router.get("/documents")
async def list_documents(
request: Request,
_: SysUser = Depends(require_knowledge_operator),
):
return success(await _service(request).list_documents())
@router.get("/documents/{doc_id}")
async def get_document(
request: Request,
doc_id: str,
_: SysUser = Depends(require_knowledge_operator),
):
try:
result = await _service(request).get_document(doc_id)
if result is None:
response = fail(404, "文档不存在")
return JSONResponse(status_code=404, content=response.model_dump())
return success(result)
except (UploadValidationError, ValueError) as exc:
response = fail(400, str(exc))
return JSONResponse(status_code=400, content=response.model_dump())
@router.post("/documents/preview")
async def preview_document_upload(
request: Request,
_: SysUser = Depends(require_knowledge_operator),
):
try:
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "read"):
raise UploadValidationError("缺少上传文件")
result = await _service(request).preview(
upload.filename,
await upload.read(),
strategy=form.get("strategy", ""),
chunk_size=_optional_int(form.get("chunk_size")),
chunk_overlap=_optional_int(form.get("chunk_overlap")),
)
return success(result)
except (UploadValidationError, ValueError) as exc:
response = fail(400, str(exc))
return JSONResponse(status_code=400, content=response.model_dump())
@router.post("/documents/confirm")
async def confirm_document_upload(
request: Request,
_: SysUser = Depends(require_knowledge_operator),
):
try:
body = await request.json()
result = await _service(request).confirm(
upload_id=body.get("upload_id", ""),
title=body.get("title", ""),
doc_id=body.get("doc_id", ""),
collection_name=body.get("collection_name", ""),
strategy=body.get("strategy", ""),
chunk_size=body.get("chunk_size"),
chunk_overlap=body.get("chunk_overlap"),
)
return success(result)
except (UploadValidationError, ValueError) as exc:
response = fail(400, str(exc))
return JSONResponse(status_code=400, content=response.model_dump())
@router.delete("/documents/{doc_id}")
async def delete_document(
request: Request,
doc_id: str,
_: SysUser = Depends(require_knowledge_operator),
):
try:
result = await _service(request).delete_document(doc_id)
return success(result)
except (UploadValidationError, ValueError) as exc:
response = fail(400, str(exc))
return JSONResponse(status_code=400, content=response.model_dump())
+10
View File
@@ -30,6 +30,16 @@ async def get_current_user(
return user
async def require_knowledge_operator(user: SysUser = Depends(get_current_user)) -> SysUser:
"""Allow only administrators or explicitly assigned knowledge operators."""
if user.user_type == "ADMIN":
return user
if user.user_type != "EMPLOYEE" or user.employee_role not in KNOWLEDGE_OPERATOR_ROLES:
raise ForbiddenError("仅运营人员可以管理知识库")
return user
async def require_knowledge_operator(
user: SysUser = Depends(get_current_user),
) -> SysUser:
+3
View File
@@ -3,6 +3,9 @@
"""
from fastapi import APIRouter
from api.chat import customer_agent, knowledge
from api.routers import product, questionnaire
from api.routers import account, auth, holdings, purchase, redeem
from api.routers import auth, customer_agent, knowledge, product, questionnaire
from api.routers import account, holdings, purchase, redeem
+10
View File
@@ -34,6 +34,16 @@ def client() -> AsyncMilvusClient:
return _client
async def ensure_database(milvus_client: AsyncMilvusClient | None = None) -> None:
"""Create the configured project database only when it does not exist."""
target = milvus_client or client()
database_name = settings.milvus.db_name
if not database_name:
raise RuntimeError("MILVUS_DB must be configured")
if database_name not in await target.list_databases():
await target.create_database(database_name)
async def init_db() -> None:
client() # 急切建连;失败由注册表重试(config/database/__init__.py)
View File
+15
View File
@@ -1,3 +1,18 @@
fastapi
python-multipart
uvicorn
httpx
pyjwt
pydantic
pydantic-settings
sqlalchemy
aiomysql
redis
pymilvus
neo4j
python-dotenv
pypdf
fastapi~=0.141.1
sqlalchemy~=2.0.52
httpx~=0.28.1
+4 -28
View File
@@ -265,6 +265,7 @@ CREATE TABLE IF NOT EXISTS conversation_archive (
role VARCHAR(16) NOT NULL COMMENT 'user/assistant/system',
content MEDIUMTEXT NULL COMMENT '对话内容',
tool_calls JSON NULL COMMENT '工具调用记录 [{"tool":"nl2sql",...}]',
trace_id VARCHAR(64) NULL COMMENT '请求链路追踪ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
trace_id VARCHAR(64) NULL COMMENT '请求链路追踪ID',
KEY idx_session (session_id),
@@ -341,7 +342,7 @@ CREATE TABLE IF NOT EXISTS audit_log (
target VARCHAR(128) NULL COMMENT '操作对象(单号/ID)',
detail TEXT NULL COMMENT '详情(JSON 字符串)',
ip VARCHAR(64) NULL,
trace_id VARCHAR(64) NULL COMMENT '链路号',
trace_id VARCHAR(32) NULL COMMENT '链路号',
status VARCHAR(8) NOT NULL DEFAULT '成功' COMMENT '成功/失败',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_user (user_id),
@@ -410,16 +411,7 @@ CREATE TABLE IF NOT EXISTS memory_unit (
status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/demoted/archived/deleted',
valid_until DATE NULL COMMENT '有效期',
KEY idx_customer_status (customer_id, status),
KEY idx_tag (tag),
session_id VARCHAR(64) NULL COMMENT '记忆来源会话',
agent_run_id VARCHAR(64) NULL COMMENT '产生该记忆的Agent运行ID',
evidence_ref VARCHAR(128) NULL COMMENT '证据引用',
milvus_id VARCHAR(128) NULL COMMENT 'Milvus向量ID',
graph_node_id VARCHAR(128) NULL COMMENT 'Neo4j节点或关系ID',
valid_from DATETIME NULL COMMENT '记忆生效时间',
last_verified_at DATETIME NULL COMMENT '最近确认时间',
KEY idx_customer_tag_status (customer_id, tag, status),
KEY idx_agent_run (agent_run_id);
KEY idx_tag (tag)
) COMMENT='记忆单元表(三层记忆中期主体,向量镜像在 Milvus customer_memory)';
-- ---------------------------------------------------------------------
@@ -450,20 +442,4 @@ CREATE TABLE IF NOT EXISTS portfolio_benchmark (
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_risk_level (risk_level)
) COMMENT='组合基准配置表(投顾Agent 再平衡参照,运营可调整)';
-- ---------------------------------------------------------------------
-- 25 客户资金账户表(现金账户,一人一户)
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fin_account (
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID,一对一关联 sys_user.id',
balance DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '账户总余额(元),含冻结部分;可用余额=balance-frozen_amount',
frozen_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '冻结金额(元),申购在途/待确认订单占用',
currency VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '币种,当前仅 CNY',
status VARCHAR(16) NOT NULL DEFAULT '正常' COMMENT '账户状态:正常/冻结/销户',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,扣款/入账 CAS 用',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (customer_id),
KEY idx_status (status)
) COMMENT='客户资金账户表(现金余额,申购扣款/赎回入账的账务载体)';
) COMMENT='组合基准配置表(投顾Agent 再平衡参照,运营可调整)';
+1
View File
@@ -5,6 +5,7 @@ from types import SimpleNamespace
import httpx
from fastapi import FastAPI
from api.chat.customer_agent import router
from api.routers.customer_agent import router
from rag.intent import Intent
from service.customer_agent.chat import AnonymousCustomerAgent
+1
View File
@@ -3,6 +3,7 @@ import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock
from api.chat.knowledge import (
from api.routers.knowledge import (
confirm_document_upload,
delete_document,
+1 -1
View File
@@ -15,7 +15,7 @@ class Code:
SERVER_ERROR = 500
LLM_FAIL = 1001 # LLM 调用失败
KB_NO_RESULT = 1002 # 知识库检索无结果
SQL_GEN_FAIL = 1003 # NL2SQL 生成失败
SQL_GEN_FAIL = 1003 # nl2sql 生成失败
RISK_TRIGGERED = 1004 # 风控规则触发 / 交易被拦截
NOT_SUITABLE = 1005 # 适当性不匹配