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 # 适当性不匹配