diff --git a/.gitignore b/.gitignore
index a7a4d11..91d8ad7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index b6b1ecf..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,10 +0,0 @@
-# 默认忽略的文件
-/shelf/
-/workspace.xml
-# 已忽略包含查询文件的默认文件夹
-/queries/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
-# 基于编辑器的 HTTP 客户端请求
-/httpRequests/
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index 540b297..0000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-Mutual_Fund
\ No newline at end of file
diff --git a/.idea/MarsCodeWorkspaceAppSettings.xml b/.idea/MarsCodeWorkspaceAppSettings.xml
deleted file mode 100644
index b26fdc6..0000000
--- a/.idea/MarsCodeWorkspaceAppSettings.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/claudeCodeTabState.xml b/.idea/claudeCodeTabState.xml
deleted file mode 100644
index 9ad4cd3..0000000
--- a/.idea/claudeCodeTabState.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
deleted file mode 100644
index 105ce2d..0000000
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 0fc3293..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/api/chat/customer_agent.py b/api/chat/customer_agent.py
new file mode 100644
index 0000000..9b0f47d
--- /dev/null
+++ b/api/chat/customer_agent.py
@@ -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})
diff --git a/api/chat/knowledge.py b/api/chat/knowledge.py
new file mode 100644
index 0000000..0ac8477
--- /dev/null
+++ b/api/chat/knowledge.py
@@ -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())
diff --git a/api/deps.py b/api/deps.py
index fb60ac5..9bf8a1b 100644
--- a/api/deps.py
+++ b/api/deps.py
@@ -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:
diff --git a/api/router.py b/api/router.py
index 0727990..d20801f 100644
--- a/api/router.py
+++ b/api/router.py
@@ -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
diff --git a/config/database/milvus.py b/config/database/milvus.py
index 0ea3bdc..bf36311 100644
--- a/config/database/milvus.py
+++ b/config/database/milvus.py
@@ -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)
diff --git a/nl2sql/__init__.py b/nl2sql/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/requirements.txt b/requirements.txt
index e844de6..a62e987 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/sql/schema.sql b/sql/schema.sql
index c78b503..d05feeb 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -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 再平衡参照,运营可调整)';
\ No newline at end of file
diff --git a/tests/test_customer_agent_router.py b/tests/test_customer_agent_router.py
index 2604a94..bd51a32 100644
--- a/tests/test_customer_agent_router.py
+++ b/tests/test_customer_agent_router.py
@@ -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
diff --git a/tests/test_knowledge_router.py b/tests/test_knowledge_router.py
index bc7e8d5..b699eb8 100644
--- a/tests/test_knowledge_router.py
+++ b/tests/test_knowledge_router.py
@@ -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,
diff --git a/utils/response.py b/utils/response.py
index f61ef0b..53a8062 100644
--- a/utils/response.py
+++ b/utils/response.py
@@ -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 # 适当性不匹配