Initial commit: iConsulting 香港移民咨询智能客服系统
项目架构: - Monorepo (pnpm + Turborepo) - 后端: NestJS 微服务 + Claude Agent SDK - 前端: React + Vite + Ant Design 包含服务: - conversation-service: 对话服务 (Claude AI) - user-service: 用户认证服务 - payment-service: 支付服务 (支付宝/微信/Stripe) - knowledge-service: 知识库服务 (RAG + Neo4j) - evolution-service: 自我进化服务 - web-client: 用户前端 - admin-client: 管理后台 基础设施: - PostgreSQL + Redis + Neo4j - Kong API Gateway - Nginx 反向代理 - Docker Compose 部署配置 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
a7add8ff90
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git add:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# ===========================================
|
||||
# Docker 忽略文件
|
||||
# ===========================================
|
||||
|
||||
# 依赖
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# 构建产物
|
||||
dist
|
||||
**/dist
|
||||
.next
|
||||
.nuxt
|
||||
|
||||
# 日志
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# 环境配置
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# 测试
|
||||
coverage
|
||||
.nyc_output
|
||||
test
|
||||
tests
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
|
||||
# 文档
|
||||
docs
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# 临时文件
|
||||
tmp
|
||||
temp
|
||||
.tmp
|
||||
.temp
|
||||
|
||||
# 备份
|
||||
backups
|
||||
*.bak
|
||||
|
||||
# 证书 (敏感)
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
nginx/ssl/*
|
||||
|
||||
# 其他
|
||||
.turbo
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
# ===========================================
|
||||
# iConsulting - Environment Configuration
|
||||
# ===========================================
|
||||
# 使用方法: 复制此文件为 .env 并填入实际值
|
||||
# cp .env.example .env
|
||||
# ===========================================
|
||||
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
APP_NAME=iConsulting
|
||||
|
||||
# ===========================================
|
||||
# 服务器网络配置
|
||||
# ===========================================
|
||||
# 对外服务 IP (用户访问)
|
||||
SERVER_PUBLIC_IP=14.215.128.96
|
||||
# Claude API 出口 IP
|
||||
CLAUDE_API_OUTBOUND_IP=154.84.135.121
|
||||
# Claude API 服务器
|
||||
CLAUDE_API_SERVER=67.223.119.33
|
||||
|
||||
# ===========================================
|
||||
# Anthropic Claude API
|
||||
# ===========================================
|
||||
ANTHROPIC_API_KEY=sk-ant-api03-xxx
|
||||
ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
|
||||
# ===========================================
|
||||
# OpenAI API (用于 Embedding)
|
||||
# ===========================================
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
|
||||
# ===========================================
|
||||
# PostgreSQL Database
|
||||
# ===========================================
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=your_secure_password
|
||||
POSTGRES_DB=iconsulting
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
|
||||
# ===========================================
|
||||
# Neo4j Graph Database
|
||||
# ===========================================
|
||||
NEO4J_URI=bolt://localhost:7687
|
||||
NEO4J_USER=neo4j
|
||||
NEO4J_PASSWORD=your_secure_password
|
||||
|
||||
# ===========================================
|
||||
# Redis Cache
|
||||
# ===========================================
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password
|
||||
REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
|
||||
|
||||
# ===========================================
|
||||
# Payment - Alipay
|
||||
# ===========================================
|
||||
ALIPAY_APP_ID=your_app_id
|
||||
ALIPAY_PRIVATE_KEY=your_private_key
|
||||
ALIPAY_PUBLIC_KEY=alipay_public_key
|
||||
ALIPAY_GATEWAY=https://openapi.alipay.com/gateway.do
|
||||
ALIPAY_NOTIFY_URL=https://your-domain.com/api/v1/payments/alipay/notify
|
||||
|
||||
# ===========================================
|
||||
# Payment - WeChat Pay
|
||||
# ===========================================
|
||||
WECHAT_APP_ID=your_app_id
|
||||
WECHAT_MCH_ID=your_merchant_id
|
||||
WECHAT_API_KEY=your_api_key
|
||||
WECHAT_CERT_PATH=/path/to/wechat/cert
|
||||
WECHAT_NOTIFY_URL=https://your-domain.com/api/v1/payments/wechat/notify
|
||||
|
||||
# ===========================================
|
||||
# Payment - Stripe (Credit Card)
|
||||
# ===========================================
|
||||
STRIPE_SECRET_KEY=sk_test_xxx
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
|
||||
# ===========================================
|
||||
# Payment Callback URLs
|
||||
# ===========================================
|
||||
PAYMENT_CALLBACK_BASE_URL=https://your-domain.com
|
||||
PAYMENT_SUCCESS_REDIRECT_URL=https://your-domain.com/payment/success
|
||||
PAYMENT_CANCEL_REDIRECT_URL=https://your-domain.com/payment/cancel
|
||||
|
||||
# ===========================================
|
||||
# JWT Authentication
|
||||
# ===========================================
|
||||
JWT_SECRET=your_super_secret_jwt_key_change_in_production
|
||||
JWT_EXPIRES_IN=7d
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
|
||||
# ===========================================
|
||||
# SMS Service (for phone verification)
|
||||
# ===========================================
|
||||
SMS_PROVIDER=aliyun
|
||||
ALIYUN_SMS_ACCESS_KEY_ID=your_access_key_id
|
||||
ALIYUN_SMS_ACCESS_KEY_SECRET=your_access_key_secret
|
||||
ALIYUN_SMS_SIGN_NAME=iConsulting
|
||||
ALIYUN_SMS_TEMPLATE_CODE=SMS_xxx
|
||||
|
||||
# ===========================================
|
||||
# Service Ports
|
||||
# ===========================================
|
||||
USER_SERVICE_PORT=3001
|
||||
PAYMENT_SERVICE_PORT=3002
|
||||
KNOWLEDGE_SERVICE_PORT=3003
|
||||
CONVERSATION_SERVICE_PORT=3004
|
||||
EVOLUTION_SERVICE_PORT=3005
|
||||
|
||||
# ===========================================
|
||||
# 服务间通信 URL
|
||||
# ===========================================
|
||||
USER_SERVICE_URL=http://localhost:3001
|
||||
PAYMENT_SERVICE_URL=http://localhost:3002
|
||||
KNOWLEDGE_SERVICE_URL=http://localhost:3003
|
||||
CONVERSATION_SERVICE_URL=http://localhost:3004
|
||||
EVOLUTION_SERVICE_URL=http://localhost:3005
|
||||
|
||||
# ===========================================
|
||||
# Kong API Gateway
|
||||
# ===========================================
|
||||
KONG_PROXY_PORT=8000
|
||||
KONG_ADMIN_PORT=8001
|
||||
|
||||
# ===========================================
|
||||
# Frontend URLs
|
||||
# ===========================================
|
||||
WEB_CLIENT_URL=http://localhost
|
||||
ADMIN_CLIENT_URL=http://localhost/admin
|
||||
|
||||
# ===========================================
|
||||
# CORS
|
||||
# ===========================================
|
||||
CORS_ORIGINS=http://localhost,http://14.215.128.96
|
||||
|
||||
# ===========================================
|
||||
# Rate Limiting
|
||||
# ===========================================
|
||||
RATE_LIMIT_TTL=60
|
||||
RATE_LIMIT_MAX=100
|
||||
|
||||
# ===========================================
|
||||
# Logging
|
||||
# ===========================================
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=json
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# ===========================================
|
||||
# iConsulting Git 忽略配置
|
||||
# ===========================================
|
||||
|
||||
# 依赖目录
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# 构建产物
|
||||
dist/
|
||||
**/dist/
|
||||
build/
|
||||
.next/
|
||||
.nuxt/
|
||||
.output/
|
||||
out/
|
||||
|
||||
# 环境变量 (包含敏感信息)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# 日志文件
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# 运行时数据
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# IDE 和编辑器
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# 测试覆盖率
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.lcov
|
||||
|
||||
# 缓存
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
.turbo/
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
*.tsbuildinfo
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
temp/
|
||||
.tmp/
|
||||
.temp/
|
||||
*.tmp
|
||||
|
||||
# 备份文件
|
||||
backups/
|
||||
*.bak
|
||||
*~
|
||||
|
||||
# SSL 证书 (敏感)
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.p12
|
||||
nginx/ssl/*
|
||||
!nginx/ssl/.gitkeep
|
||||
|
||||
# 数据库文件
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# PM2
|
||||
.pm2/
|
||||
|
||||
# 打包文件
|
||||
*.tar.gz
|
||||
*.zip
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,176 @@
|
|||
# iConsulting - 香港移民在线咨询系统
|
||||
|
||||
基于 Claude Agent SDK 的智能在线客服系统,专注于提供香港移民咨询服务。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **智能咨询**: 基于 Claude Agent SDK 的自然语言对话
|
||||
- **付费评估**: 移民资格评估服务,支持支付宝/微信/信用卡
|
||||
- **知识增强**: RAG + Neo4j 知识图谱
|
||||
- **自我进化**: 从对话中学习,根据管理员指令调整
|
||||
- **长期记忆**: 基于时间线的知识图谱记录
|
||||
- **多端支持**: PC Web / H5 响应式设计
|
||||
|
||||
## 支持的移民类别
|
||||
|
||||
1. **优才计划 (QMAS)** - 行业翘楚、精英人士
|
||||
2. **专才计划 (GEP)** - 专业人才
|
||||
3. **留学IANG** - 非本地毕业生
|
||||
4. **高才通 (TTPS)** - 高端人才
|
||||
5. **投资移民 (CIES)** - 投资者
|
||||
6. **科技人才 (TechTAS)** - 科技领域人才
|
||||
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
├── 前端 (Clean Architecture)
|
||||
│ ├── React 18 + TypeScript
|
||||
│ ├── TailwindCSS + Radix UI
|
||||
│ └── Zustand + React Query
|
||||
│
|
||||
├── 后端 (DDD + Hexagonal + 微服务)
|
||||
│ ├── NestJS
|
||||
│ ├── Claude Agent SDK
|
||||
│ └── TypeORM
|
||||
│
|
||||
└── 基础设施
|
||||
├── PostgreSQL + pgvector (RAG)
|
||||
├── Neo4j (知识图谱)
|
||||
├── Redis (缓存)
|
||||
└── Kafka (消息队列)
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
iconsulting/
|
||||
├── packages/
|
||||
│ ├── shared/ # 共享类型、常量、工具
|
||||
│ ├── web-client/ # 用户端 Web 应用
|
||||
│ ├── admin-client/ # 管理端 Web 应用
|
||||
│ └── services/ # 后端微服务
|
||||
│ ├── api-gateway/ # API 网关
|
||||
│ ├── user-service/ # 用户服务
|
||||
│ ├── conversation-service/ # 对话服务 (核心)
|
||||
│ ├── knowledge-service/ # 知识服务
|
||||
│ ├── payment-service/ # 支付服务
|
||||
│ ├── admin-service/ # 管理服务
|
||||
│ └── evolution-service/ # 进化服务
|
||||
│
|
||||
├── infrastructure/
|
||||
│ └── docker/ # Docker 配置
|
||||
│
|
||||
├── DEVELOPMENT_GUIDE.md # 详细开发指导
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# 安装 pnpm (如果没有)
|
||||
npm install -g pnpm
|
||||
|
||||
# 安装项目依赖
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 启动基础设施
|
||||
|
||||
```bash
|
||||
# 启动 Docker 容器 (PostgreSQL, Neo4j, Redis, Kafka)
|
||||
pnpm docker:dev
|
||||
```
|
||||
|
||||
### 3. 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量示例文件
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件,填入必要的配置
|
||||
# 特别是 ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
### 4. 运行数据库迁移
|
||||
|
||||
```bash
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
### 5. 启动开发服务器
|
||||
|
||||
```bash
|
||||
# 启动所有服务
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问:
|
||||
- 用户端: http://localhost:5173
|
||||
- 管理端: http://localhost:5174
|
||||
- API: http://localhost:3000
|
||||
|
||||
## 环境变量
|
||||
|
||||
关键配置项:
|
||||
|
||||
```env
|
||||
# Claude API
|
||||
ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
|
||||
# 数据库
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=iconsulting
|
||||
POSTGRES_PASSWORD=your_password
|
||||
POSTGRES_DB=iconsulting
|
||||
|
||||
# Neo4j
|
||||
NEO4J_URI=bolt://localhost:7687
|
||||
NEO4J_USER=neo4j
|
||||
NEO4J_PASSWORD=your_password
|
||||
|
||||
# 支付 (支付宝/微信)
|
||||
ALIPAY_APP_ID=xxx
|
||||
WECHAT_APP_ID=xxx
|
||||
```
|
||||
|
||||
完整配置请参考 `.env.example`
|
||||
|
||||
## 开发指南
|
||||
|
||||
详细的开发指导请参考 [DEVELOPMENT_GUIDE.md](./DEVELOPMENT_GUIDE.md)
|
||||
|
||||
## 开发进度
|
||||
|
||||
### 已完成
|
||||
|
||||
- [x] 项目架构设计
|
||||
- [x] 开发指导文档
|
||||
- [x] Monorepo 配置
|
||||
- [x] 共享类型定义
|
||||
- [x] Docker 基础设施配置
|
||||
- [x] 数据库 Schema
|
||||
- [x] 对话服务 (Claude Agent SDK 集成)
|
||||
- [x] 用户端前端基础框架
|
||||
|
||||
### 进行中
|
||||
|
||||
- [ ] 用户服务
|
||||
- [ ] 知识服务 (RAG + Neo4j)
|
||||
- [ ] 支付服务
|
||||
- [ ] 管理服务 (自我进化)
|
||||
- [ ] 管理后台前端
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建特性分支 (`git checkout -b feature/amazing-feature`)
|
||||
3. 提交更改 (`git commit -m 'Add some amazing feature'`)
|
||||
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
## 许可证
|
||||
|
||||
私有项目,保留所有权利。
|
||||
|
|
@ -0,0 +1,903 @@
|
|||
#!/bin/bash
|
||||
|
||||
#===============================================================================
|
||||
# iConsulting 部署管理脚本
|
||||
#
|
||||
# 用法: ./deploy.sh <command> [service] [options]
|
||||
#
|
||||
# 命令:
|
||||
# build - 编译构建
|
||||
# start - 启动服务
|
||||
# stop - 停止服务
|
||||
# restart - 重启服务
|
||||
# status - 查看状态
|
||||
# logs - 查看日志
|
||||
# clean - 清理构建产物
|
||||
# deploy - 完整部署(构建+启动)
|
||||
# db - 数据库操作
|
||||
# help - 显示帮助
|
||||
#
|
||||
# 服务:
|
||||
# all - 所有服务
|
||||
# web-client - 用户前端
|
||||
# admin-client - 管理后台前端
|
||||
# conversation - 对话服务
|
||||
# user - 用户服务
|
||||
# payment - 支付服务
|
||||
# knowledge - 知识库服务
|
||||
# evolution - 进化服务
|
||||
# kong - API网关
|
||||
# postgres - PostgreSQL数据库
|
||||
# redis - Redis缓存
|
||||
# neo4j - Neo4j图数据库
|
||||
# nginx - Nginx静态服务
|
||||
#
|
||||
#===============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
PURPLE='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 项目根目录
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# 配置
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
ENV_FILE=".env"
|
||||
|
||||
# 服务端口配置
|
||||
declare -A SERVICE_PORTS=(
|
||||
["conversation"]=3004
|
||||
["user"]=3001
|
||||
["payment"]=3002
|
||||
["knowledge"]=3003
|
||||
["evolution"]=3005
|
||||
["kong"]=8000
|
||||
["postgres"]=5432
|
||||
["redis"]=6379
|
||||
["neo4j"]=7474
|
||||
["nginx"]=80
|
||||
)
|
||||
|
||||
# 服务目录映射
|
||||
declare -A SERVICE_DIRS=(
|
||||
["conversation"]="packages/services/conversation-service"
|
||||
["user"]="packages/services/user-service"
|
||||
["payment"]="packages/services/payment-service"
|
||||
["knowledge"]="packages/services/knowledge-service"
|
||||
["evolution"]="packages/services/evolution-service"
|
||||
["web-client"]="packages/web-client"
|
||||
["admin-client"]="packages/admin-client"
|
||||
["shared"]="packages/shared"
|
||||
)
|
||||
|
||||
# Docker服务名映射
|
||||
declare -A DOCKER_SERVICES=(
|
||||
["conversation"]="conversation-service"
|
||||
["user"]="user-service"
|
||||
["payment"]="payment-service"
|
||||
["knowledge"]="knowledge-service"
|
||||
["evolution"]="evolution-service"
|
||||
["web-client"]="web-client"
|
||||
["admin-client"]="admin-client"
|
||||
["kong"]="kong"
|
||||
["postgres"]="postgres"
|
||||
["redis"]="redis"
|
||||
["neo4j"]="neo4j"
|
||||
["nginx"]="nginx"
|
||||
)
|
||||
|
||||
#===============================================================================
|
||||
# 工具函数
|
||||
#===============================================================================
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
log_step() {
|
||||
echo -e "${PURPLE}[STEP]${NC} $1"
|
||||
}
|
||||
|
||||
# 检查命令是否存在
|
||||
check_command() {
|
||||
if ! command -v "$1" &> /dev/null; then
|
||||
log_error "$1 未安装,请先安装"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查环境
|
||||
check_environment() {
|
||||
log_step "检查运行环境..."
|
||||
|
||||
check_command "node"
|
||||
check_command "pnpm"
|
||||
check_command "docker"
|
||||
check_command "docker-compose"
|
||||
|
||||
# 检查 Node 版本
|
||||
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
||||
if [ "$NODE_VERSION" -lt 18 ]; then
|
||||
log_error "Node.js 版本需要 >= 18,当前版本: $(node -v)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "环境检查通过"
|
||||
}
|
||||
|
||||
# 加载环境变量
|
||||
load_env() {
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
export $(grep -v '^#' "$ENV_FILE" | xargs)
|
||||
fi
|
||||
}
|
||||
|
||||
# 等待服务就绪
|
||||
wait_for_service() {
|
||||
local host=$1
|
||||
local port=$2
|
||||
local service=$3
|
||||
local max_attempts=${4:-30}
|
||||
local attempt=1
|
||||
|
||||
log_info "等待 $service ($host:$port) 就绪..."
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if nc -z "$host" "$port" 2>/dev/null; then
|
||||
log_success "$service 已就绪"
|
||||
return 0
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
log_error "$service 启动超时"
|
||||
return 1
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
# 构建函数
|
||||
#===============================================================================
|
||||
|
||||
# 安装依赖
|
||||
install_deps() {
|
||||
log_step "安装项目依赖..."
|
||||
pnpm install
|
||||
log_success "依赖安装完成"
|
||||
}
|
||||
|
||||
# 构建共享包
|
||||
build_shared() {
|
||||
log_step "构建 shared 包..."
|
||||
cd "$PROJECT_ROOT/${SERVICE_DIRS[shared]}"
|
||||
pnpm run build
|
||||
cd "$PROJECT_ROOT"
|
||||
log_success "shared 构建完成"
|
||||
}
|
||||
|
||||
# 构建单个后端服务
|
||||
build_backend_service() {
|
||||
local service=$1
|
||||
local dir="${SERVICE_DIRS[$service]}"
|
||||
|
||||
if [ -z "$dir" ]; then
|
||||
log_error "未知服务: $service"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_step "构建 $service..."
|
||||
cd "$PROJECT_ROOT/$dir"
|
||||
|
||||
# 清理旧构建
|
||||
rm -rf dist
|
||||
|
||||
# TypeScript 编译
|
||||
pnpm run build
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
log_success "$service 构建完成"
|
||||
}
|
||||
|
||||
# 构建单个前端
|
||||
build_frontend() {
|
||||
local service=$1
|
||||
local dir="${SERVICE_DIRS[$service]}"
|
||||
|
||||
if [ -z "$dir" ]; then
|
||||
log_error "未知服务: $service"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_step "构建 $service..."
|
||||
cd "$PROJECT_ROOT/$dir"
|
||||
|
||||
# 清理旧构建
|
||||
rm -rf dist
|
||||
|
||||
# Vite 构建
|
||||
pnpm run build
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
log_success "$service 构建完成"
|
||||
}
|
||||
|
||||
# 构建所有后端服务
|
||||
build_all_backend() {
|
||||
build_shared
|
||||
|
||||
for service in conversation user payment knowledge evolution; do
|
||||
build_backend_service "$service"
|
||||
done
|
||||
}
|
||||
|
||||
# 构建所有前端
|
||||
build_all_frontend() {
|
||||
for service in web-client admin-client; do
|
||||
build_frontend "$service"
|
||||
done
|
||||
}
|
||||
|
||||
# 构建所有
|
||||
build_all() {
|
||||
log_info "开始构建所有服务..."
|
||||
install_deps
|
||||
build_all_backend
|
||||
build_all_frontend
|
||||
log_success "所有服务构建完成"
|
||||
}
|
||||
|
||||
# 构建入口
|
||||
do_build() {
|
||||
local target=${1:-all}
|
||||
|
||||
case $target in
|
||||
all)
|
||||
build_all
|
||||
;;
|
||||
shared)
|
||||
build_shared
|
||||
;;
|
||||
backend)
|
||||
build_all_backend
|
||||
;;
|
||||
frontend)
|
||||
build_all_frontend
|
||||
;;
|
||||
web-client|admin-client)
|
||||
build_frontend "$target"
|
||||
;;
|
||||
conversation|user|payment|knowledge|evolution)
|
||||
build_backend_service "$target"
|
||||
;;
|
||||
*)
|
||||
log_error "未知构建目标: $target"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
# Docker 操作函数
|
||||
#===============================================================================
|
||||
|
||||
# 构建 Docker 镜像
|
||||
build_docker_images() {
|
||||
local service=${1:-}
|
||||
|
||||
log_step "构建 Docker 镜像..."
|
||||
|
||||
if [ -n "$service" ] && [ "$service" != "all" ]; then
|
||||
local docker_service="${DOCKER_SERVICES[$service]}"
|
||||
if [ -n "$docker_service" ]; then
|
||||
docker-compose build "$docker_service"
|
||||
else
|
||||
log_error "未知服务: $service"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
docker-compose build
|
||||
fi
|
||||
|
||||
log_success "Docker 镜像构建完成"
|
||||
}
|
||||
|
||||
# 启动基础设施
|
||||
start_infrastructure() {
|
||||
log_step "启动基础设施服务..."
|
||||
|
||||
docker-compose up -d postgres redis neo4j
|
||||
|
||||
# 等待数据库就绪
|
||||
wait_for_service localhost 5432 "PostgreSQL"
|
||||
wait_for_service localhost 6379 "Redis"
|
||||
wait_for_service localhost 7474 "Neo4j"
|
||||
|
||||
log_success "基础设施启动完成"
|
||||
}
|
||||
|
||||
# 启动 Kong 网关
|
||||
start_kong() {
|
||||
log_step "启动 Kong API 网关..."
|
||||
|
||||
docker-compose up -d kong-database
|
||||
sleep 5
|
||||
|
||||
# Kong 数据库迁移
|
||||
docker-compose run --rm kong kong migrations bootstrap || true
|
||||
|
||||
docker-compose up -d kong
|
||||
wait_for_service localhost 8000 "Kong"
|
||||
|
||||
log_success "Kong 启动完成"
|
||||
}
|
||||
|
||||
# 启动后端服务 (非 Docker 模式)
|
||||
start_backend_service_local() {
|
||||
local service=$1
|
||||
local dir="${SERVICE_DIRS[$service]}"
|
||||
local port="${SERVICE_PORTS[$service]}"
|
||||
|
||||
if [ -z "$dir" ]; then
|
||||
log_error "未知服务: $service"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_step "启动 $service (端口: $port)..."
|
||||
|
||||
cd "$PROJECT_ROOT/$dir"
|
||||
|
||||
# 检查是否已构建
|
||||
if [ ! -d "dist" ]; then
|
||||
log_warning "$service 未构建,先进行构建..."
|
||||
pnpm run build
|
||||
fi
|
||||
|
||||
# 使用 PM2 或直接启动
|
||||
if command -v pm2 &> /dev/null; then
|
||||
pm2 start dist/main.js --name "iconsulting-$service" --cwd "$PROJECT_ROOT/$dir"
|
||||
else
|
||||
# 后台启动
|
||||
nohup node dist/main.js > "$PROJECT_ROOT/logs/$service.log" 2>&1 &
|
||||
echo $! > "$PROJECT_ROOT/pids/$service.pid"
|
||||
fi
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
sleep 2
|
||||
wait_for_service localhost "$port" "$service" 15
|
||||
}
|
||||
|
||||
# 启动后端服务 (Docker 模式)
|
||||
start_backend_service_docker() {
|
||||
local service=$1
|
||||
local docker_service="${DOCKER_SERVICES[$service]}"
|
||||
|
||||
log_step "启动 $service (Docker)..."
|
||||
docker-compose up -d "$docker_service"
|
||||
|
||||
local port="${SERVICE_PORTS[$service]}"
|
||||
wait_for_service localhost "$port" "$service"
|
||||
}
|
||||
|
||||
# 启动所有后端服务
|
||||
start_all_backend() {
|
||||
local mode=${1:-docker}
|
||||
|
||||
for service in user payment knowledge conversation evolution; do
|
||||
if [ "$mode" = "docker" ]; then
|
||||
start_backend_service_docker "$service"
|
||||
else
|
||||
start_backend_service_local "$service"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 启动 Nginx (静态文件服务)
|
||||
start_nginx() {
|
||||
log_step "启动 Nginx..."
|
||||
docker-compose up -d nginx
|
||||
wait_for_service localhost 80 "Nginx"
|
||||
log_success "Nginx 启动完成"
|
||||
}
|
||||
|
||||
# 启动所有服务
|
||||
start_all() {
|
||||
local mode=${1:-docker}
|
||||
|
||||
log_info "开始启动所有服务 (模式: $mode)..."
|
||||
|
||||
# 创建必要目录
|
||||
mkdir -p "$PROJECT_ROOT/logs"
|
||||
mkdir -p "$PROJECT_ROOT/pids"
|
||||
|
||||
start_infrastructure
|
||||
start_kong
|
||||
start_all_backend "$mode"
|
||||
start_nginx
|
||||
|
||||
log_success "所有服务启动完成"
|
||||
do_status
|
||||
}
|
||||
|
||||
# 启动入口
|
||||
do_start() {
|
||||
local target=${1:-all}
|
||||
local mode=${2:-docker}
|
||||
|
||||
load_env
|
||||
|
||||
case $target in
|
||||
all)
|
||||
start_all "$mode"
|
||||
;;
|
||||
infra|infrastructure)
|
||||
start_infrastructure
|
||||
;;
|
||||
kong)
|
||||
start_kong
|
||||
;;
|
||||
nginx)
|
||||
start_nginx
|
||||
;;
|
||||
postgres|redis|neo4j)
|
||||
docker-compose up -d "$target"
|
||||
;;
|
||||
conversation|user|payment|knowledge|evolution)
|
||||
if [ "$mode" = "docker" ]; then
|
||||
start_backend_service_docker "$target"
|
||||
else
|
||||
start_backend_service_local "$target"
|
||||
fi
|
||||
;;
|
||||
backend)
|
||||
start_all_backend "$mode"
|
||||
;;
|
||||
*)
|
||||
log_error "未知启动目标: $target"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
# 停止函数
|
||||
#===============================================================================
|
||||
|
||||
# 停止单个服务 (本地模式)
|
||||
stop_service_local() {
|
||||
local service=$1
|
||||
|
||||
log_step "停止 $service..."
|
||||
|
||||
if command -v pm2 &> /dev/null; then
|
||||
pm2 stop "iconsulting-$service" 2>/dev/null || true
|
||||
pm2 delete "iconsulting-$service" 2>/dev/null || true
|
||||
else
|
||||
local pid_file="$PROJECT_ROOT/pids/$service.pid"
|
||||
if [ -f "$pid_file" ]; then
|
||||
kill $(cat "$pid_file") 2>/dev/null || true
|
||||
rm -f "$pid_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "$service 已停止"
|
||||
}
|
||||
|
||||
# 停止单个服务 (Docker 模式)
|
||||
stop_service_docker() {
|
||||
local service=$1
|
||||
local docker_service="${DOCKER_SERVICES[$service]}"
|
||||
|
||||
if [ -n "$docker_service" ]; then
|
||||
log_step "停止 $service..."
|
||||
docker-compose stop "$docker_service"
|
||||
log_success "$service 已停止"
|
||||
fi
|
||||
}
|
||||
|
||||
# 停止所有服务
|
||||
stop_all() {
|
||||
local mode=${1:-docker}
|
||||
|
||||
log_info "停止所有服务..."
|
||||
|
||||
if [ "$mode" = "docker" ]; then
|
||||
docker-compose down
|
||||
else
|
||||
for service in conversation user payment knowledge evolution; do
|
||||
stop_service_local "$service"
|
||||
done
|
||||
docker-compose down
|
||||
fi
|
||||
|
||||
log_success "所有服务已停止"
|
||||
}
|
||||
|
||||
# 停止入口
|
||||
do_stop() {
|
||||
local target=${1:-all}
|
||||
local mode=${2:-docker}
|
||||
|
||||
case $target in
|
||||
all)
|
||||
stop_all "$mode"
|
||||
;;
|
||||
infra|infrastructure)
|
||||
docker-compose stop postgres redis neo4j
|
||||
;;
|
||||
conversation|user|payment|knowledge|evolution)
|
||||
if [ "$mode" = "docker" ]; then
|
||||
stop_service_docker "$target"
|
||||
else
|
||||
stop_service_local "$target"
|
||||
fi
|
||||
;;
|
||||
kong|postgres|redis|neo4j|nginx)
|
||||
docker-compose stop "$target"
|
||||
;;
|
||||
*)
|
||||
log_error "未知停止目标: $target"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
# 重启函数
|
||||
#===============================================================================
|
||||
|
||||
do_restart() {
|
||||
local target=${1:-all}
|
||||
local mode=${2:-docker}
|
||||
|
||||
log_info "重启 $target..."
|
||||
do_stop "$target" "$mode"
|
||||
sleep 2
|
||||
do_start "$target" "$mode"
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
# 状态查看
|
||||
#===============================================================================
|
||||
|
||||
do_status() {
|
||||
echo ""
|
||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${CYAN} iConsulting 服务状态 ${NC}"
|
||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
# Docker 服务状态
|
||||
echo -e "${PURPLE}Docker 容器状态:${NC}"
|
||||
docker-compose ps
|
||||
echo ""
|
||||
|
||||
# 端口检查
|
||||
echo -e "${PURPLE}服务端口检查:${NC}"
|
||||
printf "%-20s %-10s %-10s\n" "服务" "端口" "状态"
|
||||
echo "----------------------------------------"
|
||||
|
||||
for service in "${!SERVICE_PORTS[@]}"; do
|
||||
local port="${SERVICE_PORTS[$service]}"
|
||||
if nc -z localhost "$port" 2>/dev/null; then
|
||||
printf "%-20s %-10s ${GREEN}%-10s${NC}\n" "$service" "$port" "运行中"
|
||||
else
|
||||
printf "%-20s %-10s ${RED}%-10s${NC}\n" "$service" "$port" "未运行"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# PM2 状态 (如果使用)
|
||||
if command -v pm2 &> /dev/null; then
|
||||
echo -e "${PURPLE}PM2 进程状态:${NC}"
|
||||
pm2 list 2>/dev/null | grep iconsulting || echo "无 PM2 管理的服务"
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
# 日志查看
|
||||
#===============================================================================
|
||||
|
||||
do_logs() {
|
||||
local service=${1:-all}
|
||||
local lines=${2:-100}
|
||||
|
||||
if [ "$service" = "all" ]; then
|
||||
docker-compose logs -f --tail="$lines"
|
||||
else
|
||||
local docker_service="${DOCKER_SERVICES[$service]}"
|
||||
if [ -n "$docker_service" ]; then
|
||||
docker-compose logs -f --tail="$lines" "$docker_service"
|
||||
else
|
||||
# 本地日志
|
||||
local log_file="$PROJECT_ROOT/logs/$service.log"
|
||||
if [ -f "$log_file" ]; then
|
||||
tail -f -n "$lines" "$log_file"
|
||||
else
|
||||
log_error "日志文件不存在: $log_file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
# 清理函数
|
||||
#===============================================================================
|
||||
|
||||
do_clean() {
|
||||
local target=${1:-build}
|
||||
|
||||
case $target in
|
||||
build)
|
||||
log_step "清理构建产物..."
|
||||
for dir in "${SERVICE_DIRS[@]}"; do
|
||||
rm -rf "$PROJECT_ROOT/$dir/dist"
|
||||
done
|
||||
log_success "构建产物已清理"
|
||||
;;
|
||||
deps)
|
||||
log_step "清理依赖..."
|
||||
rm -rf node_modules
|
||||
for dir in "${SERVICE_DIRS[@]}"; do
|
||||
rm -rf "$PROJECT_ROOT/$dir/node_modules"
|
||||
done
|
||||
log_success "依赖已清理"
|
||||
;;
|
||||
docker)
|
||||
log_step "清理 Docker 资源..."
|
||||
docker-compose down -v --rmi local
|
||||
docker system prune -f
|
||||
log_success "Docker 资源已清理"
|
||||
;;
|
||||
logs)
|
||||
log_step "清理日志..."
|
||||
rm -rf "$PROJECT_ROOT/logs/*"
|
||||
log_success "日志已清理"
|
||||
;;
|
||||
all)
|
||||
do_clean build
|
||||
do_clean deps
|
||||
do_clean docker
|
||||
do_clean logs
|
||||
;;
|
||||
*)
|
||||
log_error "未知清理目标: $target (可选: build, deps, docker, logs, all)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
# 完整部署
|
||||
#===============================================================================
|
||||
|
||||
do_deploy() {
|
||||
local mode=${1:-docker}
|
||||
|
||||
log_info "开始完整部署 (模式: $mode)..."
|
||||
|
||||
check_environment
|
||||
|
||||
# 构建
|
||||
do_build all
|
||||
|
||||
# 如果是 Docker 模式,构建镜像
|
||||
if [ "$mode" = "docker" ]; then
|
||||
build_docker_images
|
||||
fi
|
||||
|
||||
# 启动
|
||||
do_start all "$mode"
|
||||
|
||||
log_success "部署完成!"
|
||||
echo ""
|
||||
echo -e "${CYAN}访问地址:${NC}"
|
||||
echo " 用户前端: http://localhost"
|
||||
echo " 管理后台: http://localhost/admin"
|
||||
echo " API 网关: http://localhost:8000"
|
||||
echo " Kong 管理: http://localhost:8001"
|
||||
echo ""
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
# 数据库操作
|
||||
#===============================================================================
|
||||
|
||||
do_db() {
|
||||
local action=${1:-status}
|
||||
|
||||
case $action in
|
||||
migrate)
|
||||
log_step "执行数据库迁移..."
|
||||
# 可以添加 TypeORM 迁移命令
|
||||
for service in user payment knowledge conversation evolution; do
|
||||
local dir="${SERVICE_DIRS[$service]}"
|
||||
cd "$PROJECT_ROOT/$dir"
|
||||
pnpm run migration:run 2>/dev/null || log_warning "$service 无迁移或迁移失败"
|
||||
cd "$PROJECT_ROOT"
|
||||
done
|
||||
log_success "数据库迁移完成"
|
||||
;;
|
||||
seed)
|
||||
log_step "初始化种子数据..."
|
||||
# 添加种子数据脚本
|
||||
log_success "种子数据初始化完成"
|
||||
;;
|
||||
backup)
|
||||
local backup_dir="$PROJECT_ROOT/backups/$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "$backup_dir"
|
||||
|
||||
log_step "备份数据库..."
|
||||
docker-compose exec -T postgres pg_dump -U postgres iconsulting > "$backup_dir/postgres.sql"
|
||||
log_success "数据库备份到: $backup_dir"
|
||||
;;
|
||||
restore)
|
||||
local backup_file=$2
|
||||
if [ -z "$backup_file" ]; then
|
||||
log_error "请指定备份文件: ./deploy.sh db restore <backup_file>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_step "恢复数据库..."
|
||||
docker-compose exec -T postgres psql -U postgres iconsulting < "$backup_file"
|
||||
log_success "数据库恢复完成"
|
||||
;;
|
||||
reset)
|
||||
log_warning "这将删除所有数据!"
|
||||
read -p "确认继续? (y/N) " confirm
|
||||
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
|
||||
docker-compose down -v
|
||||
docker-compose up -d postgres redis neo4j
|
||||
wait_for_service localhost 5432 "PostgreSQL"
|
||||
do_db migrate
|
||||
log_success "数据库已重置"
|
||||
fi
|
||||
;;
|
||||
status)
|
||||
echo -e "${PURPLE}数据库状态:${NC}"
|
||||
docker-compose exec postgres psql -U postgres -c "SELECT version();" 2>/dev/null || echo "PostgreSQL 未运行"
|
||||
docker-compose exec redis redis-cli ping 2>/dev/null || echo "Redis 未运行"
|
||||
curl -s http://localhost:7474 > /dev/null && echo "Neo4j 运行中" || echo "Neo4j 未运行"
|
||||
;;
|
||||
*)
|
||||
log_error "未知数据库操作: $action (可选: migrate, seed, backup, restore, reset, status)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
# 帮助信息
|
||||
#===============================================================================
|
||||
|
||||
show_help() {
|
||||
cat << 'EOF'
|
||||
|
||||
╔═══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ iConsulting 部署管理脚本 ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
用法: ./deploy.sh <command> [target] [options]
|
||||
|
||||
命令:
|
||||
build [target] 编译构建
|
||||
target: all, shared, backend, frontend,
|
||||
conversation, user, payment, knowledge, evolution,
|
||||
web-client, admin-client
|
||||
|
||||
start [target] [mode] 启动服务
|
||||
target: all, infra, kong, nginx, backend,
|
||||
conversation, user, payment, knowledge, evolution,
|
||||
postgres, redis, neo4j
|
||||
mode: docker (默认), local
|
||||
|
||||
stop [target] [mode] 停止服务
|
||||
(target 同上)
|
||||
|
||||
restart [target] [mode] 重启服务
|
||||
(target 同上)
|
||||
|
||||
status 查看所有服务状态
|
||||
|
||||
logs [service] [lines] 查看日志
|
||||
service: 服务名或 all
|
||||
lines: 显示行数 (默认 100)
|
||||
|
||||
clean [target] 清理
|
||||
target: build, deps, docker, logs, all
|
||||
|
||||
deploy [mode] 完整部署 (构建 + 启动)
|
||||
mode: docker (默认), local
|
||||
|
||||
db <action> 数据库操作
|
||||
action: migrate, seed, backup, restore, reset, status
|
||||
|
||||
help 显示此帮助信息
|
||||
|
||||
示例:
|
||||
./deploy.sh deploy # 完整部署
|
||||
./deploy.sh build conversation # 只构建对话服务
|
||||
./deploy.sh start backend local # 本地模式启动所有后端
|
||||
./deploy.sh restart user docker # 重启用户服务 (Docker)
|
||||
./deploy.sh logs conversation 200 # 查看对话服务最近200行日志
|
||||
./deploy.sh clean all # 清理所有构建产物和依赖
|
||||
./deploy.sh db backup # 备份数据库
|
||||
./deploy.sh db migrate # 执行数据库迁移
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
# 主入口
|
||||
#===============================================================================
|
||||
|
||||
main() {
|
||||
local command=${1:-help}
|
||||
shift || true
|
||||
|
||||
case $command in
|
||||
build)
|
||||
do_build "$@"
|
||||
;;
|
||||
start)
|
||||
do_start "$@"
|
||||
;;
|
||||
stop)
|
||||
do_stop "$@"
|
||||
;;
|
||||
restart)
|
||||
do_restart "$@"
|
||||
;;
|
||||
status)
|
||||
do_status
|
||||
;;
|
||||
logs)
|
||||
do_logs "$@"
|
||||
;;
|
||||
clean)
|
||||
do_clean "$@"
|
||||
;;
|
||||
deploy)
|
||||
do_deploy "$@"
|
||||
;;
|
||||
db)
|
||||
do_db "$@"
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
log_error "未知命令: $command"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
#===============================================================================
|
||||
# iConsulting Docker Compose 配置
|
||||
#
|
||||
# 服务架构:
|
||||
# - 基础设施: PostgreSQL, Redis, Neo4j
|
||||
# - API网关: Kong
|
||||
# - 后端服务: conversation, user, payment, knowledge, evolution
|
||||
# - 前端服务: nginx (托管 web-client 和 admin-client)
|
||||
#
|
||||
# 网络配置:
|
||||
# - 对外网卡: 14.215.128.96 (用户访问)
|
||||
# - 出口网卡: 154.84.135.121 (Claude API 调用)
|
||||
#
|
||||
#===============================================================================
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
#=============================================================================
|
||||
# 基础设施服务
|
||||
#=============================================================================
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: iconsulting-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-iconsulting}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: iconsulting-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redis123}
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
neo4j:
|
||||
image: neo4j:5-community
|
||||
container_name: iconsulting-neo4j
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-neo4j123}
|
||||
NEO4J_PLUGINS: '["apoc"]'
|
||||
NEO4J_dbms_memory_heap_max__size: 1G
|
||||
ports:
|
||||
- "7474:7474" # HTTP
|
||||
- "7687:7687" # Bolt
|
||||
volumes:
|
||||
- neo4j_data:/data
|
||||
- neo4j_logs:/logs
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7474 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
#=============================================================================
|
||||
# Kong API 网关
|
||||
#=============================================================================
|
||||
|
||||
kong-database:
|
||||
image: postgres:15-alpine
|
||||
container_name: iconsulting-kong-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: kong
|
||||
POSTGRES_PASSWORD: kong
|
||||
POSTGRES_DB: kong
|
||||
volumes:
|
||||
- kong_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kong"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
kong:
|
||||
image: kong:3.4-alpine
|
||||
container_name: iconsulting-kong
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
kong-database:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
KONG_DATABASE: postgres
|
||||
KONG_PG_HOST: kong-database
|
||||
KONG_PG_USER: kong
|
||||
KONG_PG_PASSWORD: kong
|
||||
KONG_PG_DATABASE: kong
|
||||
KONG_PROXY_ACCESS_LOG: /dev/stdout
|
||||
KONG_ADMIN_ACCESS_LOG: /dev/stdout
|
||||
KONG_PROXY_ERROR_LOG: /dev/stderr
|
||||
KONG_ADMIN_ERROR_LOG: /dev/stderr
|
||||
KONG_ADMIN_LISTEN: 0.0.0.0:8001
|
||||
KONG_PROXY_LISTEN: 0.0.0.0:8000, 0.0.0.0:8443 ssl
|
||||
ports:
|
||||
- "8000:8000" # Proxy
|
||||
- "8443:8443" # Proxy SSL
|
||||
- "8001:8001" # Admin API
|
||||
healthcheck:
|
||||
test: ["CMD", "kong", "health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
#=============================================================================
|
||||
# 后端微服务
|
||||
#=============================================================================
|
||||
|
||||
user-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/services/user-service/Dockerfile
|
||||
container_name: iconsulting-user
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting}
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379
|
||||
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key}
|
||||
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
|
||||
ports:
|
||||
- "3001:3001"
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
payment-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/services/payment-service/Dockerfile
|
||||
container_name: iconsulting-payment
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3002
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting}
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379
|
||||
ALIPAY_APP_ID: ${ALIPAY_APP_ID}
|
||||
ALIPAY_PRIVATE_KEY: ${ALIPAY_PRIVATE_KEY}
|
||||
WECHAT_APP_ID: ${WECHAT_APP_ID}
|
||||
WECHAT_MCH_ID: ${WECHAT_MCH_ID}
|
||||
WECHAT_API_KEY: ${WECHAT_API_KEY}
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||
ports:
|
||||
- "3002:3002"
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
knowledge-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/services/knowledge-service/Dockerfile
|
||||
container_name: iconsulting-knowledge
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
neo4j:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3003
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting}
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379
|
||||
NEO4J_URI: bolt://neo4j:7687
|
||||
NEO4J_USER: ${NEO4J_USER:-neo4j}
|
||||
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-neo4j123}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
ports:
|
||||
- "3003:3003"
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
conversation-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/services/conversation-service/Dockerfile
|
||||
container_name: iconsulting-conversation
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
knowledge-service:
|
||||
condition: service_started
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3004
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting}
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com}
|
||||
KNOWLEDGE_SERVICE_URL: http://knowledge-service:3003
|
||||
# Claude API 出口配置 (如需指定出口IP,在宿主机配置路由)
|
||||
ports:
|
||||
- "3004:3004"
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
evolution-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/services/evolution-service/Dockerfile
|
||||
container_name: iconsulting-evolution
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3005
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting}
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com}
|
||||
ports:
|
||||
- "3005:3005"
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
#=============================================================================
|
||||
# 前端 Nginx
|
||||
#=============================================================================
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: iconsulting-nginx
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- kong
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./packages/web-client/dist:/usr/share/nginx/html/web:ro
|
||||
- ./packages/admin-client/dist:/usr/share/nginx/html/admin:ro
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
#===============================================================================
|
||||
# 网络配置
|
||||
#===============================================================================
|
||||
|
||||
networks:
|
||||
iconsulting-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
#===============================================================================
|
||||
# 数据卷
|
||||
#===============================================================================
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
neo4j_data:
|
||||
driver: local
|
||||
neo4j_logs:
|
||||
driver: local
|
||||
kong_data:
|
||||
driver: local
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
|
|
@ -0,0 +1,134 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL with pgvector extension
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
container_name: iconsulting-postgres
|
||||
environment:
|
||||
POSTGRES_USER: iconsulting
|
||||
POSTGRES_PASSWORD: dev_password_123
|
||||
POSTGRES_DB: iconsulting
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./services/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U iconsulting"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
# Neo4j Graph Database
|
||||
neo4j:
|
||||
image: neo4j:5
|
||||
container_name: iconsulting-neo4j
|
||||
environment:
|
||||
NEO4J_AUTH: neo4j/dev_password_123
|
||||
NEO4J_PLUGINS: '["apoc"]'
|
||||
NEO4J_dbms_security_procedures_unrestricted: apoc.*
|
||||
ports:
|
||||
- "7474:7474" # HTTP
|
||||
- "7687:7687" # Bolt
|
||||
volumes:
|
||||
- neo4j_data:/data
|
||||
- neo4j_logs:/logs
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7474 || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
# Redis Cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: iconsulting-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
# Zookeeper for Kafka
|
||||
zookeeper:
|
||||
image: confluentinc/cp-zookeeper:7.4.0
|
||||
container_name: iconsulting-zookeeper
|
||||
environment:
|
||||
ZOOKEEPER_CLIENT_PORT: 2181
|
||||
ZOOKEEPER_TICK_TIME: 2000
|
||||
ports:
|
||||
- "2181:2181"
|
||||
volumes:
|
||||
- zookeeper_data:/var/lib/zookeeper/data
|
||||
- zookeeper_logs:/var/lib/zookeeper/log
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
# Kafka Message Broker
|
||||
kafka:
|
||||
image: confluentinc/cp-kafka:7.4.0
|
||||
container_name: iconsulting-kafka
|
||||
depends_on:
|
||||
- zookeeper
|
||||
ports:
|
||||
- "9092:9092"
|
||||
- "29092:29092"
|
||||
environment:
|
||||
KAFKA_BROKER_ID: 1
|
||||
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
|
||||
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
|
||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
|
||||
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
|
||||
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
|
||||
KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
|
||||
volumes:
|
||||
- kafka_data:/var/lib/kafka/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
# Kafka UI (optional, for debugging)
|
||||
kafka-ui:
|
||||
image: provectuslabs/kafka-ui:latest
|
||||
container_name: iconsulting-kafka-ui
|
||||
depends_on:
|
||||
- kafka
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
KAFKA_CLUSTERS_0_NAME: iconsulting
|
||||
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
|
||||
KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181
|
||||
networks:
|
||||
- iconsulting-network
|
||||
|
||||
networks:
|
||||
iconsulting-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
neo4j_data:
|
||||
neo4j_logs:
|
||||
redis_data:
|
||||
zookeeper_data:
|
||||
zookeeper_logs:
|
||||
kafka_data:
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,130 @@
|
|||
#===============================================================================
|
||||
# iConsulting Nginx 配置
|
||||
#
|
||||
# 路由规则:
|
||||
# / -> web-client (用户前端)
|
||||
# /admin -> admin-client (管理后台)
|
||||
# /api/v1/* -> Kong API Gateway
|
||||
# /ws/* -> WebSocket (conversation-service)
|
||||
#
|
||||
#===============================================================================
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# 健康检查端点
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 'OK';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# 用户前端 (web-client)
|
||||
location / {
|
||||
root /usr/share/nginx/html/web;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# 缓存静态资源
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# 管理后台 (admin-client)
|
||||
location /admin {
|
||||
alias /usr/share/nginx/html/admin;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
|
||||
# 缓存静态资源
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# API 请求代理到 Kong
|
||||
location /api/ {
|
||||
proxy_pass http://kong_upstream/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# 缓冲设置
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
}
|
||||
|
||||
# WebSocket 代理
|
||||
location /ws/ {
|
||||
proxy_pass http://websocket_upstream/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket 超时 (保持长连接)
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
}
|
||||
|
||||
# Socket.IO 专用路径
|
||||
location /socket.io/ {
|
||||
proxy_pass http://websocket_upstream/socket.io/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
}
|
||||
|
||||
# 禁止访问隐藏文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS 配置 (如有SSL证书,取消注释)
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name _;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||
# ssl_session_timeout 1d;
|
||||
# ssl_session_cache shared:SSL:50m;
|
||||
# ssl_session_tickets off;
|
||||
#
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||
# ssl_prefer_server_ciphers off;
|
||||
#
|
||||
# # HSTS
|
||||
# add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
#
|
||||
# # 其他配置同上...
|
||||
# include /etc/nginx/conf.d/locations.conf;
|
||||
# }
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Gzip 压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# 请求大小限制
|
||||
client_max_body_size 50M;
|
||||
|
||||
# 上游服务定义
|
||||
upstream kong_upstream {
|
||||
server kong:8000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
upstream websocket_upstream {
|
||||
server conversation-service:3004;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# 此目录用于存放 SSL 证书
|
||||
# 请将 fullchain.pem 和 privkey.pem 放置于此
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "iconsulting",
|
||||
"version": "1.0.0",
|
||||
"description": "Hong Kong Immigration Consulting System based on Claude Agent SDK",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"packages/services/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test",
|
||||
"clean": "turbo run clean && rm -rf node_modules",
|
||||
"db:migrate": "turbo run db:migrate",
|
||||
"docker:dev": "docker-compose -f infrastructure/docker/docker-compose.dev.yml up -d",
|
||||
"docker:down": "docker-compose -f infrastructure/docker/docker-compose.dev.yml down"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"prettier": "^3.1.0",
|
||||
"eslint": "^8.55.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"pnpm": ">=8.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@8.15.0"
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>iConsulting 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "@iconsulting/admin-client",
|
||||
"version": "0.1.0",
|
||||
"description": "iConsulting 管理后台",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"antd": "^5.12.8",
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"axios": "^1.6.5",
|
||||
"dayjs": "^1.11.10",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"zustand": "^4.4.7",
|
||||
"recharts": "^2.10.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.47",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.18.1",
|
||||
"@typescript-eslint/parser": "^6.18.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { MainLayout } from './shared/components/MainLayout';
|
||||
import { ProtectedRoute } from './shared/components/ProtectedRoute';
|
||||
import { LoginPage } from './features/auth/presentation/pages/LoginPage';
|
||||
import { DashboardPage } from './features/dashboard/presentation/pages/DashboardPage';
|
||||
import { KnowledgePage } from './features/knowledge/presentation/pages/KnowledgePage';
|
||||
import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* 登录页 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 需要认证的路由 */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="knowledge" element={<KnowledgePage />} />
|
||||
<Route path="experience" element={<ExperiencePage />} />
|
||||
<Route path="users" element={<div className="p-6">用户管理(开发中)</div>} />
|
||||
<Route path="settings" element={<div className="p-6">系统设置(开发中)</div>} />
|
||||
</Route>
|
||||
|
||||
{/* 未匹配路由重定向 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Form, Input, Button, Card, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { useAuth } from '../../../../shared/hooks/useAuth';
|
||||
|
||||
interface LoginFormValues {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const login = useAuth((state) => state.login);
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(values.username, values.password);
|
||||
message.success('登录成功');
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
message.error('用户名或密码错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<Card className="w-96 shadow-lg">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-800">iConsulting</h1>
|
||||
<p className="text-gray-500">管理后台</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="text-center text-gray-400 text-sm">
|
||||
默认账号: admin / admin123
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, Row, Col, Statistic, Tag, Progress, List, Typography } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
MessageOutlined,
|
||||
DollarOutlined,
|
||||
RobotOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import api from '../../../../shared/utils/api';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// Mock数据 - 实际应该从API获取
|
||||
const mockTrendData = [
|
||||
{ date: '01-01', conversations: 120, users: 45 },
|
||||
{ date: '01-02', conversations: 150, users: 52 },
|
||||
{ date: '01-03', conversations: 180, users: 68 },
|
||||
{ date: '01-04', conversations: 145, users: 55 },
|
||||
{ date: '01-05', conversations: 200, users: 75 },
|
||||
{ date: '01-06', conversations: 230, users: 88 },
|
||||
{ date: '01-07', conversations: 210, users: 82 },
|
||||
];
|
||||
|
||||
const mockCategoryData = [
|
||||
{ name: 'QMAS', value: 35, color: '#1890ff' },
|
||||
{ name: 'GEP', value: 25, color: '#52c41a' },
|
||||
{ name: 'IANG', value: 20, color: '#faad14' },
|
||||
{ name: 'TTPS', value: 10, color: '#722ed1' },
|
||||
{ name: 'CIES', value: 7, color: '#eb2f96' },
|
||||
{ name: 'TechTAS', value: 3, color: '#13c2c2' },
|
||||
];
|
||||
|
||||
export function DashboardPage() {
|
||||
const { data: evolutionStats } = useQuery({
|
||||
queryKey: ['evolution-stats'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/evolution/statistics');
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: healthReport } = useQuery({
|
||||
queryKey: ['system-health'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/evolution/health');
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const getHealthColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'success';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
case 'critical':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Title level={4} className="mb-6">仪表盘</Title>
|
||||
|
||||
{/* 核心指标 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日用户"
|
||||
value={156}
|
||||
prefix={<UserOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
较昨日 <span className="text-green-500">+12%</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日对话"
|
||||
value={428}
|
||||
prefix={<MessageOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
较昨日 <span className="text-green-500">+8%</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日收入"
|
||||
value={3580}
|
||||
prefix={<DollarOutlined />}
|
||||
suffix="元"
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
较昨日 <span className="text-green-500">+15%</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="转化率"
|
||||
value={8.5}
|
||||
suffix="%"
|
||||
prefix={<RobotOutlined />}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
较昨日 <span className="text-red-500">-2%</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 趋势图表 */}
|
||||
<Row gutter={[16, 16]} className="mt-4">
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="对话趋势">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={mockTrendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="conversations"
|
||||
stroke="#1890ff"
|
||||
name="对话数"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="users"
|
||||
stroke="#52c41a"
|
||||
name="用户数"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="类别分布">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={mockCategoryData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label={({ name, percent }) =>
|
||||
`${name} ${(percent * 100).toFixed(0)}%`
|
||||
}
|
||||
>
|
||||
{mockCategoryData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 系统状态 */}
|
||||
<Row gutter={[16, 16]} className="mt-4">
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>系统健康</span>
|
||||
<Tag color={getHealthColor(healthReport?.overall || 'healthy')}>
|
||||
{healthReport?.overall === 'healthy'
|
||||
? '健康'
|
||||
: healthReport?.overall === 'warning'
|
||||
? '警告'
|
||||
: '异常'}
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<List
|
||||
dataSource={healthReport?.metrics || []}
|
||||
renderItem={(item: { name: string; value: number; threshold: number; status: string }) => (
|
||||
<List.Item>
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between mb-1">
|
||||
<Text>{item.name}</Text>
|
||||
<Text type="secondary">
|
||||
{item.value} / {item.threshold}
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.min(100, (item.value / item.threshold) * 100)}
|
||||
status={
|
||||
item.status === 'good'
|
||||
? 'success'
|
||||
: item.status === 'warning'
|
||||
? 'exception'
|
||||
: 'active'
|
||||
}
|
||||
showInfo={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
{healthReport?.recommendations?.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-yellow-50 rounded">
|
||||
<Text type="warning">建议:</Text>
|
||||
<ul className="mt-2 ml-4 text-sm text-gray-600">
|
||||
{healthReport.recommendations.map((rec: string, i: number) => (
|
||||
<li key={i}>{rec}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="经验学习进度">
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="总经验"
|
||||
value={evolutionStats?.totalExperiences || 0}
|
||||
prefix={<RobotOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="活跃经验"
|
||||
value={evolutionStats?.activeExperiences || 0}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="待审核"
|
||||
value={evolutionStats?.pendingExperiences || 0}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="已审核"
|
||||
value={evolutionStats?.approvedExperiences || 0}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="mt-4">
|
||||
<Text type="secondary">经验类型分布</Text>
|
||||
<div className="mt-2">
|
||||
{evolutionStats?.topExperienceTypes?.map(
|
||||
(item: { type: string; count: number }) => (
|
||||
<Tag key={item.type} className="mb-1">
|
||||
{item.type}: {item.count}
|
||||
</Tag>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,393 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Select,
|
||||
Tag,
|
||||
Space,
|
||||
Modal,
|
||||
message,
|
||||
Tabs,
|
||||
Typography,
|
||||
Statistic,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
EyeOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../../../../shared/utils/api';
|
||||
import { useAuth } from '../../../../shared/hooks/useAuth';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const EXPERIENCE_TYPES = [
|
||||
{ value: 'COMMON_QUESTION', label: '常见问题' },
|
||||
{ value: 'ANSWER_TEMPLATE', label: '回答模板' },
|
||||
{ value: 'CLARIFICATION', label: '澄清方式' },
|
||||
{ value: 'USER_PATTERN', label: '用户模式' },
|
||||
{ value: 'CONVERSION_TRIGGER', label: '转化触发' },
|
||||
{ value: 'KNOWLEDGE_GAP', label: '知识缺口' },
|
||||
{ value: 'CONVERSATION_SKILL', label: '对话技巧' },
|
||||
{ value: 'OBJECTION_HANDLING', label: '异议处理' },
|
||||
];
|
||||
|
||||
interface Experience {
|
||||
id: string;
|
||||
experienceType: string;
|
||||
content: string;
|
||||
scenario: string;
|
||||
confidence: number;
|
||||
relatedCategory: string;
|
||||
sourceConversationIds: string[];
|
||||
verificationStatus: string;
|
||||
usageCount: number;
|
||||
positiveCount: number;
|
||||
negativeCount: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function ExperiencePage() {
|
||||
const [activeTab, setActiveTab] = useState('pending');
|
||||
const [typeFilter, setTypeFilter] = useState<string>();
|
||||
const [selectedExperience, setSelectedExperience] = useState<Experience | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const admin = useAuth((state) => state.admin);
|
||||
|
||||
const { data: pendingData, isLoading: pendingLoading } = useQuery({
|
||||
queryKey: ['pending-experiences', typeFilter],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (typeFilter) params.append('type', typeFilter);
|
||||
const response = await api.get(`/memory/experience/pending?${params}`);
|
||||
return response.data.data;
|
||||
},
|
||||
enabled: activeTab === 'pending',
|
||||
});
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['experience-stats'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/memory/experience/statistics');
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api.post(`/memory/experience/${id}/approve`, { adminId: admin?.id }),
|
||||
onSuccess: () => {
|
||||
message.success('经验已批准');
|
||||
queryClient.invalidateQueries({ queryKey: ['pending-experiences'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['experience-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api.post(`/memory/experience/${id}/reject`, { adminId: admin?.id }),
|
||||
onSuccess: () => {
|
||||
message.success('经验已拒绝');
|
||||
queryClient.invalidateQueries({ queryKey: ['pending-experiences'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['experience-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
const runEvolutionMutation = useMutation({
|
||||
mutationFn: () => api.post('/evolution/run', { hoursBack: 24, limit: 50 }),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data.data;
|
||||
message.success(
|
||||
`进化任务完成:分析了${result.conversationsAnalyzed}个对话,提取了${result.experiencesExtracted}条经验`
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['pending-experiences'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['experience-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleView = (exp: Experience) => {
|
||||
setSelectedExperience(exp);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
return EXPERIENCE_TYPES.find((t) => t.value === type)?.label || type;
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; label: string }> = {
|
||||
PENDING: { color: 'orange', label: '待审核' },
|
||||
APPROVED: { color: 'green', label: '已通过' },
|
||||
REJECTED: { color: 'red', label: '已拒绝' },
|
||||
DEPRECATED: { color: 'default', label: '已弃用' },
|
||||
};
|
||||
const s = statusMap[status] || { color: 'default', label: status };
|
||||
return <Tag color={s.color}>{s.label}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'experienceType',
|
||||
key: 'experienceType',
|
||||
render: (type: string) => <Tag>{getTypeLabel(type)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '场景',
|
||||
dataIndex: 'scenario',
|
||||
key: 'scenario',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
ellipsis: true,
|
||||
render: (text: string) => (
|
||||
<Text ellipsis style={{ maxWidth: 200 }}>
|
||||
{text}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidence',
|
||||
key: 'confidence',
|
||||
render: (confidence: number) => (
|
||||
<span
|
||||
className={
|
||||
confidence >= 70
|
||||
? 'text-green-600'
|
||||
: confidence >= 40
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
}
|
||||
>
|
||||
{confidence}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '来源对话',
|
||||
dataIndex: 'sourceConversationIds',
|
||||
key: 'sources',
|
||||
render: (ids: string[]) => <span>{ids?.length || 0}个</span>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'verificationStatus',
|
||||
key: 'status',
|
||||
render: getStatusTag,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: Experience) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleView(record)}
|
||||
/>
|
||||
{record.verificationStatus === 'PENDING' && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CheckOutlined />}
|
||||
className="text-green-600"
|
||||
onClick={() => approveMutation.mutate(record.id)}
|
||||
loading={approveMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
danger
|
||||
onClick={() => rejectMutation.mutate(record.id)}
|
||||
loading={rejectMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Title level={4} className="mb-0">系统经验管理</Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => runEvolutionMutation.mutate()}
|
||||
loading={runEvolutionMutation.isPending}
|
||||
>
|
||||
运行进化任务
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={[16, 16]} className="mb-4">
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic title="总经验" value={stats?.total || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="待审核"
|
||||
value={stats?.byStatus?.PENDING || 0}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已通过"
|
||||
value={stats?.byStatus?.APPROVED || 0}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已拒绝"
|
||||
value={stats?.byStatus?.REJECTED || 0}
|
||||
valueStyle={{ color: '#ff4d4f' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ key: 'pending', label: '待审核' },
|
||||
{ key: 'approved', label: '已通过' },
|
||||
{ key: 'rejected', label: '已拒绝' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<Select
|
||||
placeholder="筛选类型"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
options={EXPERIENCE_TYPES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pendingData?.items || []}
|
||||
rowKey="id"
|
||||
loading={pendingLoading}
|
||||
pagination={{
|
||||
total: pendingData?.total || 0,
|
||||
pageSize: 20,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title="经验详情"
|
||||
open={isModalOpen}
|
||||
onCancel={() => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedExperience(null);
|
||||
}}
|
||||
footer={
|
||||
selectedExperience?.verificationStatus === 'PENDING'
|
||||
? [
|
||||
<Button
|
||||
key="reject"
|
||||
danger
|
||||
onClick={() => {
|
||||
rejectMutation.mutate(selectedExperience.id);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
>
|
||||
拒绝
|
||||
</Button>,
|
||||
<Button
|
||||
key="approve"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
approveMutation.mutate(selectedExperience.id);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
>
|
||||
批准
|
||||
</Button>,
|
||||
]
|
||||
: null
|
||||
}
|
||||
width={600}
|
||||
>
|
||||
{selectedExperience && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Tag>{getTypeLabel(selectedExperience.experienceType)}</Tag>
|
||||
{getStatusTag(selectedExperience.verificationStatus)}
|
||||
{selectedExperience.isActive && <Tag color="blue">激活中</Tag>}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Text type="secondary">场景</Text>
|
||||
<Paragraph>{selectedExperience.scenario}</Paragraph>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Text type="secondary">内容</Text>
|
||||
<Paragraph>{selectedExperience.content}</Paragraph>
|
||||
</div>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="置信度"
|
||||
value={selectedExperience.confidence}
|
||||
suffix="%"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="使用次数"
|
||||
value={selectedExperience.usageCount}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="来源对话"
|
||||
value={selectedExperience.sourceConversationIds?.length || 0}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{selectedExperience.relatedCategory && (
|
||||
<div className="mt-4">
|
||||
<Text type="secondary">相关类别: </Text>
|
||||
<Tag color="blue">{selectedExperience.relatedCategory}</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,404 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
message,
|
||||
Popconfirm,
|
||||
Typography,
|
||||
Drawer,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
CheckOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../../../../shared/utils/api';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'QMAS', label: '优秀人才入境计划' },
|
||||
{ value: 'GEP', label: '一般就业政策' },
|
||||
{ value: 'IANG', label: '非本地毕业生留港/回港就业安排' },
|
||||
{ value: 'TTPS', label: '科技人才入境计划' },
|
||||
{ value: 'CIES', label: '资本投资者入境计划' },
|
||||
{ value: 'TechTAS', label: '顶尖人才通行证计划' },
|
||||
{ value: 'GENERAL', label: '通用知识' },
|
||||
];
|
||||
|
||||
interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
source: string;
|
||||
isPublished: boolean;
|
||||
citationCount: number;
|
||||
qualityScore: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function KnowledgePage() {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['knowledge-articles', categoryFilter],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryFilter) params.append('category', categoryFilter);
|
||||
const response = await api.get(`/knowledge/articles?${params}`);
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values: Partial<Article>) =>
|
||||
api.post('/knowledge/articles', values),
|
||||
onSuccess: () => {
|
||||
message.success('文章创建成功');
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, ...values }: { id: string } & Partial<Article>) =>
|
||||
api.put(`/knowledge/articles/${id}`, values),
|
||||
onSuccess: () => {
|
||||
message.success('文章更新成功');
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
setSelectedArticle(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/knowledge/articles/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('文章已删除');
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||
},
|
||||
});
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/knowledge/articles/${id}/publish`),
|
||||
onSuccess: () => {
|
||||
message.success('文章已发布');
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||
},
|
||||
});
|
||||
|
||||
const unpublishMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/knowledge/articles/${id}/unpublish`),
|
||||
onSuccess: () => {
|
||||
message.success('文章已取消发布');
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (article: Article) => {
|
||||
setSelectedArticle(article);
|
||||
form.setFieldsValue({
|
||||
title: article.title,
|
||||
content: article.content,
|
||||
category: article.category,
|
||||
tags: article.tags,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleView = (article: Article) => {
|
||||
setSelectedArticle(article);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (values: Partial<Article>) => {
|
||||
if (selectedArticle) {
|
||||
updateMutation.mutate({ id: selectedArticle.id, ...values });
|
||||
} else {
|
||||
createMutation.mutate(values);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (text: string, record: Article) => (
|
||||
<a onClick={() => handleView(record)}>{text}</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类别',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
render: (category: string) => {
|
||||
const cat = CATEGORIES.find((c) => c.value === category);
|
||||
return <Tag color="blue">{cat?.label || category}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
render: (source: string) => {
|
||||
const sourceMap: Record<string, { color: string; label: string }> = {
|
||||
MANUAL: { color: 'green', label: '手动' },
|
||||
CRAWL: { color: 'orange', label: '爬取' },
|
||||
EXTRACT: { color: 'purple', label: '提取' },
|
||||
IMPORT: { color: 'cyan', label: '导入' },
|
||||
};
|
||||
const s = sourceMap[source] || { color: 'default', label: source };
|
||||
return <Tag color={s.color}>{s.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'isPublished',
|
||||
key: 'isPublished',
|
||||
render: (isPublished: boolean) =>
|
||||
isPublished ? (
|
||||
<Tag color="success">已发布</Tag>
|
||||
) : (
|
||||
<Tag color="default">草稿</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '质量分',
|
||||
dataIndex: 'qualityScore',
|
||||
key: 'qualityScore',
|
||||
render: (score: number) => (
|
||||
<span
|
||||
className={
|
||||
score >= 70
|
||||
? 'text-green-600'
|
||||
: score >= 40
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '引用次数',
|
||||
dataIndex: 'citationCount',
|
||||
key: 'citationCount',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: Article) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleView(record)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
/>
|
||||
{record.isPublished ? (
|
||||
<Popconfirm
|
||||
title="确定取消发布?"
|
||||
onConfirm={() => unpublishMutation.mutate(record.id)}
|
||||
>
|
||||
<Button type="text" icon={<StopOutlined />} />
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => publishMutation.mutate(record.id)}
|
||||
/>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定删除此文章?"
|
||||
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Title level={4} className="mb-6">知识库管理</Title>
|
||||
|
||||
<Card>
|
||||
<div className="flex justify-between mb-4">
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="搜索文章"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="选择类别"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
options={CATEGORIES}
|
||||
/>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedArticle(null);
|
||||
form.resetFields();
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建文章
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
total: data?.total || 0,
|
||||
pageSize: 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 编辑/新建弹窗 */}
|
||||
<Modal
|
||||
title={selectedArticle ? '编辑文章' : '新建文章'}
|
||||
open={isModalOpen}
|
||||
onCancel={() => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedArticle(null);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="标题"
|
||||
rules={[{ required: true, message: '请输入标题' }]}
|
||||
>
|
||||
<Input placeholder="文章标题" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="类别"
|
||||
rules={[{ required: true, message: '请选择类别' }]}
|
||||
>
|
||||
<Select options={CATEGORIES} placeholder="选择移民类别" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="tags" label="标签">
|
||||
<Select mode="tags" placeholder="输入标签后回车" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="内容"
|
||||
rules={[{ required: true, message: '请输入内容' }]}
|
||||
>
|
||||
<TextArea rows={12} placeholder="支持Markdown格式" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0 text-right">
|
||||
<Space>
|
||||
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 预览抽屉 */}
|
||||
<Drawer
|
||||
title={selectedArticle?.title}
|
||||
open={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
setSelectedArticle(null);
|
||||
}}
|
||||
width={600}
|
||||
>
|
||||
{selectedArticle && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Tag color="blue">
|
||||
{CATEGORIES.find((c) => c.value === selectedArticle.category)
|
||||
?.label || selectedArticle.category}
|
||||
</Tag>
|
||||
{selectedArticle.isPublished ? (
|
||||
<Tag color="success">已发布</Tag>
|
||||
) : (
|
||||
<Tag>草稿</Tag>
|
||||
)}
|
||||
<span className="ml-2 text-gray-500 text-sm">
|
||||
质量分: {selectedArticle.qualityScore} | 引用:{' '}
|
||||
{selectedArticle.citationCount}次
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedArticle.tags?.length > 0 && (
|
||||
<div className="mb-4">
|
||||
{selectedArticle.tags.map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Paragraph className="text-gray-600 mb-4">
|
||||
{selectedArticle.summary}
|
||||
</Paragraph>
|
||||
|
||||
<div className="whitespace-pre-wrap">{selectedArticle.content}</div>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#1890ff',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { Layout, Menu, Avatar, Dropdown, Typography } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
BookOutlined,
|
||||
RobotOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '/',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '仪表盘',
|
||||
},
|
||||
{
|
||||
key: '/knowledge',
|
||||
icon: <BookOutlined />,
|
||||
label: '知识库',
|
||||
},
|
||||
{
|
||||
key: '/experience',
|
||||
icon: <RobotOutlined />,
|
||||
label: '系统经验',
|
||||
},
|
||||
{
|
||||
key: '/users',
|
||||
icon: <UserOutlined />,
|
||||
label: '用户管理',
|
||||
},
|
||||
{
|
||||
key: '/settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统设置',
|
||||
},
|
||||
];
|
||||
|
||||
export function MainLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { admin, logout } = useAuth();
|
||||
|
||||
const handleMenuClick = (e: { key: string }) => {
|
||||
navigate(e.key);
|
||||
};
|
||||
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人设置',
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
if (key === 'logout') {
|
||||
logout();
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout className="min-h-screen">
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
theme="light"
|
||||
className="shadow-sm"
|
||||
>
|
||||
<div className="h-16 flex items-center justify-center border-b">
|
||||
<Text strong className="text-lg">
|
||||
{collapsed ? 'iC' : 'iConsulting'}
|
||||
</Text>
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="border-none"
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="bg-white px-4 flex items-center justify-between shadow-sm">
|
||||
<div
|
||||
className="cursor-pointer text-lg"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
<Dropdown
|
||||
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<div className="flex items-center cursor-pointer">
|
||||
<Avatar icon={<UserOutlined />} className="mr-2" />
|
||||
<Text>{admin?.name || 'Admin'}</Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Content className="bg-gray-100 min-h-0 overflow-auto">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const [checking, setChecking] = useState(true);
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, checkAuth } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const verify = async () => {
|
||||
await checkAuth();
|
||||
setChecking(false);
|
||||
};
|
||||
verify();
|
||||
}, [checkAuth]);
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import api from '../utils/api';
|
||||
|
||||
interface AdminInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
admin: AdminInfo | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<boolean>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
admin: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: async (username: string, password: string) => {
|
||||
const response = await api.post('/admin/login', { username, password });
|
||||
const { data } = response.data;
|
||||
|
||||
localStorage.setItem('admin_token', data.token);
|
||||
|
||||
set({
|
||||
admin: data.admin,
|
||||
token: data.token,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('admin_token');
|
||||
set({
|
||||
admin: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (!token) {
|
||||
set({ isAuthenticated: false, admin: null, token: null });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get('/admin/verify');
|
||||
if (response.data.success) {
|
||||
set({
|
||||
admin: response.data.data,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem('admin_token');
|
||||
}
|
||||
|
||||
set({ isAuthenticated: false, admin: null, token: null });
|
||||
return false;
|
||||
},
|
||||
|
||||
hasPermission: (permission: string) => {
|
||||
const { admin } = get();
|
||||
if (!admin) return false;
|
||||
|
||||
const permissions = admin.permissions || [];
|
||||
|
||||
// 超管拥有所有权限
|
||||
if (permissions.includes('*')) return true;
|
||||
|
||||
// 完全匹配
|
||||
if (permissions.includes(permission)) return true;
|
||||
|
||||
// 通配符匹配
|
||||
const [resource] = permission.split(':');
|
||||
if (permissions.includes(`${resource}:*`)) return true;
|
||||
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
admin: state.admin,
|
||||
token: state.token,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器 - 添加Token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器 - 处理认证错误
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('admin_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
corePlugins: {
|
||||
preflight: false, // 禁用Tailwind的reset,避免与Antd冲突
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3005',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# ===========================================
|
||||
# iConsulting Conversation Service Dockerfile
|
||||
# ===========================================
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/services/conversation-service/package.json ./packages/services/conversation-service/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY packages/services/conversation-service ./packages/services/conversation-service
|
||||
COPY tsconfig.base.json ./
|
||||
|
||||
RUN pnpm --filter @iconsulting/shared build
|
||||
RUN pnpm --filter @iconsulting/conversation-service build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nestjs
|
||||
|
||||
COPY --from=builder /app/packages/services/conversation-service/dist ./dist
|
||||
COPY --from=builder /app/packages/services/conversation-service/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3004
|
||||
|
||||
USER nestjs
|
||||
EXPOSE 3004
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3004/health || exit 1
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "@iconsulting/conversation-service",
|
||||
"version": "1.0.0",
|
||||
"description": "Conversation service with Claude Agent SDK integration",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
"@iconsulting/shared": "workspace:*",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/platform-socket.io": "^10.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/websockets": "^10.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"ioredis": "^5.3.0",
|
||||
"kafkajs": "^2.2.4",
|
||||
"pg": "^8.11.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"typeorm": "^0.3.19",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/socket.io": "^3.0.2",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConversationModule } from './conversation/conversation.module';
|
||||
import { ClaudeModule } from './infrastructure/claude/claude.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
|
||||
// Database
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: configService.get('POSTGRES_HOST', 'localhost'),
|
||||
port: configService.get<number>('POSTGRES_PORT', 5432),
|
||||
username: configService.get('POSTGRES_USER', 'iconsulting'),
|
||||
password: configService.get('POSTGRES_PASSWORD'),
|
||||
database: configService.get('POSTGRES_DB', 'iconsulting'),
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
synchronize: configService.get('NODE_ENV') === 'development',
|
||||
logging: configService.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
}),
|
||||
|
||||
// Feature modules
|
||||
ConversationModule,
|
||||
ClaudeModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Body,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ConversationService } from './conversation.service';
|
||||
|
||||
class CreateConversationDto {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
class SendMessageDto {
|
||||
content: string;
|
||||
}
|
||||
|
||||
@Controller('conversations')
|
||||
export class ConversationController {
|
||||
constructor(private conversationService: ConversationService) {}
|
||||
|
||||
/**
|
||||
* Create a new conversation
|
||||
*/
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createConversation(
|
||||
@Headers('x-user-id') userId: string,
|
||||
@Body() dto: CreateConversationDto,
|
||||
) {
|
||||
const conversation = await this.conversationService.createConversation({
|
||||
userId,
|
||||
title: dto.title,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: conversation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's conversations
|
||||
*/
|
||||
@Get()
|
||||
async getConversations(@Headers('x-user-id') userId: string) {
|
||||
const conversations = await this.conversationService.getUserConversations(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: conversations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific conversation
|
||||
*/
|
||||
@Get(':id')
|
||||
async getConversation(
|
||||
@Headers('x-user-id') userId: string,
|
||||
@Param('id') conversationId: string,
|
||||
) {
|
||||
const conversation = await this.conversationService.getConversation(
|
||||
conversationId,
|
||||
userId,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: conversation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation messages
|
||||
*/
|
||||
@Get(':id/messages')
|
||||
async getMessages(
|
||||
@Headers('x-user-id') userId: string,
|
||||
@Param('id') conversationId: string,
|
||||
) {
|
||||
const messages = await this.conversationService.getMessages(
|
||||
conversationId,
|
||||
userId,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: messages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message (non-streaming HTTP endpoint)
|
||||
* For streaming, use WebSocket
|
||||
*/
|
||||
@Post(':id/messages')
|
||||
async sendMessage(
|
||||
@Headers('x-user-id') userId: string,
|
||||
@Param('id') conversationId: string,
|
||||
@Body() dto: SendMessageDto,
|
||||
) {
|
||||
let fullResponse = '';
|
||||
|
||||
for await (const chunk of this.conversationService.sendMessage({
|
||||
conversationId,
|
||||
userId,
|
||||
content: dto.content,
|
||||
})) {
|
||||
if (chunk.type === 'text' && chunk.content) {
|
||||
fullResponse += chunk.content;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
response: fullResponse,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* End a conversation
|
||||
*/
|
||||
@Post(':id/end')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async endConversation(
|
||||
@Headers('x-user-id') userId: string,
|
||||
@Param('id') conversationId: string,
|
||||
) {
|
||||
await this.conversationService.endConversation(conversationId, userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Conversation ended',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { ConversationService } from './conversation.service';
|
||||
|
||||
interface SendMessagePayload {
|
||||
conversationId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:5173'],
|
||||
credentials: true,
|
||||
},
|
||||
namespace: '/ws/conversation',
|
||||
})
|
||||
export class ConversationGateway
|
||||
implements OnGatewayConnection, OnGatewayDisconnect
|
||||
{
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
// Map socket ID to user ID
|
||||
private connections = new Map<string, string>();
|
||||
|
||||
constructor(private conversationService: ConversationService) {}
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
// Extract user ID from query or headers
|
||||
const userId =
|
||||
(client.handshake.query.userId as string) ||
|
||||
(client.handshake.headers['x-user-id'] as string);
|
||||
|
||||
if (!userId) {
|
||||
console.log(`Client ${client.id} connected without user ID`);
|
||||
client.emit('error', { message: 'User ID is required' });
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.connections.set(client.id, userId);
|
||||
console.log(`Client ${client.id} connected as user ${userId}`);
|
||||
|
||||
client.emit('connected', { userId, socketId: client.id });
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
const userId = this.connections.get(client.id);
|
||||
this.connections.delete(client.id);
|
||||
console.log(`Client ${client.id} (user ${userId}) disconnected`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('message')
|
||||
async handleMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: SendMessagePayload,
|
||||
) {
|
||||
const userId = this.connections.get(client.id);
|
||||
|
||||
if (!userId) {
|
||||
client.emit('error', { message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { conversationId, content } = payload;
|
||||
|
||||
if (!conversationId || !content) {
|
||||
client.emit('error', { message: 'conversationId and content are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate unique message ID for this response
|
||||
const messageId = `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Emit stream start
|
||||
client.emit('stream_start', { messageId, conversationId });
|
||||
|
||||
let chunkIndex = 0;
|
||||
|
||||
// Stream the response
|
||||
for await (const chunk of this.conversationService.sendMessage({
|
||||
conversationId,
|
||||
userId,
|
||||
content,
|
||||
})) {
|
||||
if (chunk.type === 'text' && chunk.content) {
|
||||
client.emit('stream_chunk', {
|
||||
messageId,
|
||||
conversationId,
|
||||
content: chunk.content,
|
||||
index: chunkIndex++,
|
||||
});
|
||||
} else if (chunk.type === 'tool_use') {
|
||||
client.emit('tool_call', {
|
||||
messageId,
|
||||
conversationId,
|
||||
tool: chunk.toolName,
|
||||
input: chunk.toolInput,
|
||||
});
|
||||
} else if (chunk.type === 'tool_result') {
|
||||
client.emit('tool_result', {
|
||||
messageId,
|
||||
conversationId,
|
||||
tool: chunk.toolName,
|
||||
result: chunk.toolResult,
|
||||
});
|
||||
} else if (chunk.type === 'end') {
|
||||
client.emit('stream_end', {
|
||||
messageId,
|
||||
conversationId,
|
||||
isComplete: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
client.emit('error', {
|
||||
message: error instanceof Error ? error.message : 'Failed to process message',
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('typing_start')
|
||||
handleTypingStart(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: { conversationId: string },
|
||||
) {
|
||||
// Could be used to show typing indicator to admin monitoring
|
||||
console.log(`User typing in conversation ${payload.conversationId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('typing_end')
|
||||
handleTypingEnd(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: { conversationId: string },
|
||||
) {
|
||||
console.log(`User stopped typing in conversation ${payload.conversationId}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConversationEntity } from '../domain/entities/conversation.entity';
|
||||
import { MessageEntity } from '../domain/entities/message.entity';
|
||||
import { ConversationService } from './conversation.service';
|
||||
import { ConversationController } from './conversation.controller';
|
||||
import { ConversationGateway } from './conversation.gateway';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ConversationEntity, MessageEntity])],
|
||||
controllers: [ConversationController],
|
||||
providers: [ConversationService, ConversationGateway],
|
||||
exports: [ConversationService],
|
||||
})
|
||||
export class ConversationModule {}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
ConversationEntity,
|
||||
ConversationStatus,
|
||||
} from '../domain/entities/conversation.entity';
|
||||
import {
|
||||
MessageEntity,
|
||||
MessageRole,
|
||||
MessageType,
|
||||
} from '../domain/entities/message.entity';
|
||||
import {
|
||||
ClaudeAgentService,
|
||||
ConversationContext,
|
||||
StreamChunk,
|
||||
} from '../infrastructure/claude/claude-agent.service';
|
||||
|
||||
export interface CreateConversationDto {
|
||||
userId: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageDto {
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ConversationService {
|
||||
constructor(
|
||||
@InjectRepository(ConversationEntity)
|
||||
private conversationRepo: Repository<ConversationEntity>,
|
||||
@InjectRepository(MessageEntity)
|
||||
private messageRepo: Repository<MessageEntity>,
|
||||
private claudeAgentService: ClaudeAgentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new conversation
|
||||
*/
|
||||
async createConversation(dto: CreateConversationDto): Promise<ConversationEntity> {
|
||||
const conversation = this.conversationRepo.create({
|
||||
userId: dto.userId,
|
||||
title: dto.title || '新对话',
|
||||
status: ConversationStatus.ACTIVE,
|
||||
});
|
||||
|
||||
return this.conversationRepo.save(conversation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation by ID
|
||||
*/
|
||||
async getConversation(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): Promise<ConversationEntity> {
|
||||
const conversation = await this.conversationRepo.findOne({
|
||||
where: { id: conversationId, userId },
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
throw new NotFoundException('Conversation not found');
|
||||
}
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's conversations
|
||||
*/
|
||||
async getUserConversations(userId: string): Promise<ConversationEntity[]> {
|
||||
return this.conversationRepo.find({
|
||||
where: { userId },
|
||||
order: { updatedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation messages
|
||||
*/
|
||||
async getMessages(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): Promise<MessageEntity[]> {
|
||||
// Verify user owns the conversation
|
||||
await this.getConversation(conversationId, userId);
|
||||
|
||||
return this.messageRepo.find({
|
||||
where: { conversationId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message and get streaming response
|
||||
*/
|
||||
async *sendMessage(dto: SendMessageDto): AsyncGenerator<StreamChunk> {
|
||||
// Verify conversation exists and belongs to user
|
||||
const conversation = await this.getConversation(dto.conversationId, dto.userId);
|
||||
|
||||
if (conversation.status !== ConversationStatus.ACTIVE) {
|
||||
throw new Error('Conversation is not active');
|
||||
}
|
||||
|
||||
// Save user message
|
||||
const userMessage = this.messageRepo.create({
|
||||
conversationId: dto.conversationId,
|
||||
role: MessageRole.USER,
|
||||
type: MessageType.TEXT,
|
||||
content: dto.content,
|
||||
});
|
||||
await this.messageRepo.save(userMessage);
|
||||
|
||||
// Get previous messages for context
|
||||
const previousMessages = await this.messageRepo.find({
|
||||
where: { conversationId: dto.conversationId },
|
||||
order: { createdAt: 'ASC' },
|
||||
take: 20, // Last 20 messages for context
|
||||
});
|
||||
|
||||
// Build context
|
||||
const context: ConversationContext = {
|
||||
userId: dto.userId,
|
||||
conversationId: dto.conversationId,
|
||||
previousMessages: previousMessages.map((m) => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
})),
|
||||
};
|
||||
|
||||
// Collect full response for saving
|
||||
let fullResponse = '';
|
||||
const toolCalls: Array<{ name: string; input: unknown; result: unknown }> = [];
|
||||
|
||||
// Stream response from Claude
|
||||
for await (const chunk of this.claudeAgentService.sendMessage(
|
||||
dto.content,
|
||||
context,
|
||||
)) {
|
||||
if (chunk.type === 'text' && chunk.content) {
|
||||
fullResponse += chunk.content;
|
||||
} else if (chunk.type === 'tool_use') {
|
||||
toolCalls.push({
|
||||
name: chunk.toolName!,
|
||||
input: chunk.toolInput!,
|
||||
result: null,
|
||||
});
|
||||
} else if (chunk.type === 'tool_result') {
|
||||
const lastToolCall = toolCalls[toolCalls.length - 1];
|
||||
if (lastToolCall) {
|
||||
lastToolCall.result = chunk.toolResult;
|
||||
}
|
||||
}
|
||||
|
||||
yield chunk;
|
||||
}
|
||||
|
||||
// Save assistant response
|
||||
const assistantMessage = this.messageRepo.create({
|
||||
conversationId: dto.conversationId,
|
||||
role: MessageRole.ASSISTANT,
|
||||
type: MessageType.TEXT,
|
||||
content: fullResponse,
|
||||
metadata: toolCalls.length > 0 ? { toolCalls } : undefined,
|
||||
});
|
||||
await this.messageRepo.save(assistantMessage);
|
||||
|
||||
// Update conversation title if first message
|
||||
if (conversation.messageCount === 0) {
|
||||
const title = await this.generateTitle(dto.content);
|
||||
await this.conversationRepo.update(conversation.id, { title });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End a conversation
|
||||
*/
|
||||
async endConversation(conversationId: string, userId: string): Promise<void> {
|
||||
const conversation = await this.getConversation(conversationId, userId);
|
||||
|
||||
await this.conversationRepo.update(conversation.id, {
|
||||
status: ConversationStatus.ENDED,
|
||||
endedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a title from the first message
|
||||
*/
|
||||
private async generateTitle(firstMessage: string): Promise<string> {
|
||||
// Simple title generation - take first 50 chars
|
||||
const title = firstMessage.substring(0, 50);
|
||||
return title.length < firstMessage.length ? `${title}...` : title;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { MessageEntity } from './message.entity';
|
||||
|
||||
export enum ConversationStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
ENDED = 'ENDED',
|
||||
ARCHIVED = 'ARCHIVED',
|
||||
}
|
||||
|
||||
@Entity('conversations')
|
||||
export class ConversationEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ConversationStatus,
|
||||
default: ConversationStatus.ACTIVE,
|
||||
})
|
||||
status: ConversationStatus;
|
||||
|
||||
@Column({ nullable: true })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
summary: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
category: string;
|
||||
|
||||
@Column({ name: 'message_count', default: 0 })
|
||||
messageCount: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'ended_at', nullable: true })
|
||||
endedAt: Date;
|
||||
|
||||
@OneToMany(() => MessageEntity, (message) => message.conversation)
|
||||
messages: MessageEntity[];
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ConversationEntity } from './conversation.entity';
|
||||
|
||||
export enum MessageRole {
|
||||
USER = 'user',
|
||||
ASSISTANT = 'assistant',
|
||||
SYSTEM = 'system',
|
||||
}
|
||||
|
||||
export enum MessageType {
|
||||
TEXT = 'TEXT',
|
||||
TOOL_CALL = 'TOOL_CALL',
|
||||
TOOL_RESULT = 'TOOL_RESULT',
|
||||
PAYMENT_REQUEST = 'PAYMENT_REQUEST',
|
||||
ASSESSMENT_START = 'ASSESSMENT_START',
|
||||
ASSESSMENT_RESULT = 'ASSESSMENT_RESULT',
|
||||
}
|
||||
|
||||
@Entity('messages')
|
||||
export class MessageEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'conversation_id', type: 'uuid' })
|
||||
conversationId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MessageRole,
|
||||
})
|
||||
role: MessageRole;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MessageType,
|
||||
default: MessageType.TEXT,
|
||||
})
|
||||
type: MessageType;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
content: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ManyToOne(() => ConversationEntity, (conversation) => conversation.messages)
|
||||
@JoinColumn({ name: 'conversation_id' })
|
||||
conversation: ConversationEntity;
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { ImmigrationToolsService } from './tools/immigration-tools.service';
|
||||
import { buildSystemPrompt, SystemPromptConfig } from './prompts/system-prompt';
|
||||
|
||||
export interface ConversationContext {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
userMemory?: string[];
|
||||
previousMessages?: Array<{
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface StreamChunk {
|
||||
type: 'text' | 'tool_use' | 'tool_result' | 'end';
|
||||
content?: string;
|
||||
toolName?: string;
|
||||
toolInput?: Record<string, unknown>;
|
||||
toolResult?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClaudeAgentService implements OnModuleInit {
|
||||
private client: Anthropic;
|
||||
private systemPromptConfig: SystemPromptConfig;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private immigrationToolsService: ImmigrationToolsService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.client = new Anthropic({
|
||||
apiKey: this.configService.get<string>('ANTHROPIC_API_KEY'),
|
||||
});
|
||||
|
||||
// Initialize with default config
|
||||
this.systemPromptConfig = {
|
||||
identity: '专业、友善、耐心的香港移民顾问',
|
||||
conversationStyle: '专业但不生硬,用简洁明了的语言解答',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update system prompt configuration (for evolution)
|
||||
*/
|
||||
updateSystemPromptConfig(config: Partial<SystemPromptConfig>) {
|
||||
this.systemPromptConfig = {
|
||||
...this.systemPromptConfig,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message and get streaming response
|
||||
*/
|
||||
async *sendMessage(
|
||||
message: string,
|
||||
context: ConversationContext,
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
const tools = this.immigrationToolsService.getTools();
|
||||
const systemPrompt = buildSystemPrompt(this.systemPromptConfig);
|
||||
|
||||
// Build messages array
|
||||
const messages: Anthropic.MessageParam[] = [];
|
||||
|
||||
// Add previous messages if any
|
||||
if (context.previousMessages) {
|
||||
for (const msg of context.previousMessages) {
|
||||
messages.push({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add current message
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: message,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create streaming message
|
||||
const stream = await this.client.messages.stream({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
tools: tools as Anthropic.Tool[],
|
||||
});
|
||||
|
||||
let currentToolUse: {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
} | null = null;
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === 'content_block_start') {
|
||||
if (event.content_block.type === 'tool_use') {
|
||||
currentToolUse = {
|
||||
id: event.content_block.id,
|
||||
name: event.content_block.name,
|
||||
input: {},
|
||||
};
|
||||
}
|
||||
} else if (event.type === 'content_block_delta') {
|
||||
if (event.delta.type === 'text_delta') {
|
||||
yield {
|
||||
type: 'text',
|
||||
content: event.delta.text,
|
||||
};
|
||||
} else if (event.delta.type === 'input_json_delta' && currentToolUse) {
|
||||
// Accumulate tool input
|
||||
try {
|
||||
const partialInput = JSON.parse(event.delta.partial_json || '{}');
|
||||
currentToolUse.input = {
|
||||
...currentToolUse.input,
|
||||
...partialInput,
|
||||
};
|
||||
} catch {
|
||||
// Ignore parse errors for partial JSON
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'content_block_stop') {
|
||||
if (currentToolUse) {
|
||||
yield {
|
||||
type: 'tool_use',
|
||||
toolName: currentToolUse.name,
|
||||
toolInput: currentToolUse.input,
|
||||
};
|
||||
|
||||
// Execute the tool
|
||||
const toolResult = await this.immigrationToolsService.executeTool(
|
||||
currentToolUse.name,
|
||||
currentToolUse.input,
|
||||
context,
|
||||
);
|
||||
|
||||
yield {
|
||||
type: 'tool_result',
|
||||
toolName: currentToolUse.name,
|
||||
toolResult,
|
||||
};
|
||||
|
||||
currentToolUse = null;
|
||||
}
|
||||
} else if (event.type === 'message_stop') {
|
||||
yield { type: 'end' };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Claude API error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming message for simple queries
|
||||
*/
|
||||
async sendMessageSync(
|
||||
message: string,
|
||||
context: ConversationContext,
|
||||
): Promise<string> {
|
||||
const tools = this.immigrationToolsService.getTools();
|
||||
const systemPrompt = buildSystemPrompt(this.systemPromptConfig);
|
||||
|
||||
const messages: Anthropic.MessageParam[] = [];
|
||||
|
||||
if (context.previousMessages) {
|
||||
for (const msg of context.previousMessages) {
|
||||
messages.push({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: message,
|
||||
});
|
||||
|
||||
const response = await this.client.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
tools: tools as Anthropic.Tool[],
|
||||
});
|
||||
|
||||
// Extract text response
|
||||
let result = '';
|
||||
for (const block of response.content) {
|
||||
if (block.type === 'text') {
|
||||
result += block.text;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze content (for evolution service)
|
||||
*/
|
||||
async analyze(prompt: string): Promise<string> {
|
||||
const response = await this.client.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 8192,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let result = '';
|
||||
for (const block of response.content) {
|
||||
if (block.type === 'text') {
|
||||
result += block.text;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ClaudeAgentService } from './claude-agent.service';
|
||||
import { ImmigrationToolsService } from './tools/immigration-tools.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [ClaudeAgentService, ImmigrationToolsService],
|
||||
exports: [ClaudeAgentService, ImmigrationToolsService],
|
||||
})
|
||||
export class ClaudeModule {}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* System prompt builder for the immigration consultant agent
|
||||
*/
|
||||
|
||||
export interface SystemPromptConfig {
|
||||
identity?: string;
|
||||
conversationStyle?: string;
|
||||
accumulatedExperience?: string;
|
||||
adminInstructions?: string;
|
||||
}
|
||||
|
||||
export const buildSystemPrompt = (config: SystemPromptConfig): string => `
|
||||
你是 iConsulting 的香港移民咨询顾问,专门为用户提供香港各类移民政策的咨询服务。
|
||||
|
||||
## 身份定位
|
||||
${config.identity || '专业、友善、耐心的移民顾问'}
|
||||
|
||||
## 服务范围
|
||||
你只回答与香港移民相关的问题,包括以下6大类别:
|
||||
|
||||
### 1. 优才计划 (QMAS - Quality Migrant Admission Scheme)
|
||||
- **目的**: 吸纳优秀人才来港定居
|
||||
- **审核标准**: 年龄、教育经历、工作经验、语言能力、年收入证明、良好品格、是否持有公司等
|
||||
- **人才类型**: 行业翘楚、精英人士
|
||||
- **名额**: 暂无名额限制
|
||||
- **申请要点**: 年龄、教育经历、工作经验、年收入证明、良好品格等方面的要求(共12项),满足其中6项为基本门槛,择优批准
|
||||
|
||||
### 2. 专才计划 (GEP - General Employment Policy)
|
||||
- **目的**: 吸引专业人才来港就业
|
||||
- **审核标准**: 雇佣企业的行业背景、行业内的地位、政策扶持力度、所属行业经济地位
|
||||
- **人才类型**: 专业人士(≥200万年薪,属于人才清单)
|
||||
- **名额**: 无配额限制(不限行业)
|
||||
- **申请要点**: 必须有雇主担保、要保持工作连续性、工作和居住在非粤港澳、申请前至少有一年半社保记录
|
||||
|
||||
### 3. 留学IANG (Immigration Arrangements for Non-local Graduates)
|
||||
- **目的**: 吸引优秀学生人才来港续读
|
||||
- **审核标准**: 有无完成学业、是否有雇主担保
|
||||
- **人才类型**: 成绩优异、拥有国际化视野的学生
|
||||
- **名额**: 扩展到本港大学大湾区校区毕业生,为期2年、1年后续签
|
||||
- **申请要点**: 必须有雇主担保、要保持工作连续性
|
||||
|
||||
### 4. 高才通计划 (TTPS - Top Talent Pass Scheme)
|
||||
- **目的**: 吸引高端人才来港
|
||||
- **审核标准**:
|
||||
- A类: 年薪≥250万港币
|
||||
- B类: 毕业于世界前100名大学,5年内有3年以上工作经验
|
||||
- C类: 毕业于世界前100名大学,5年内有少于3年工作经验(年度限额10,000名)
|
||||
- **人才类型**: 顶尖人才
|
||||
- **名额**: 无限期计划
|
||||
|
||||
### 5. 投资移民 (新资本投资者入境计划 CIES)
|
||||
- **目的**: 吸引投资者
|
||||
- **审核标准**: 投资不少于3,000万港币(或等值外币)净资产于其指定对实益拥有者的许可投资
|
||||
- **人才类型**: 投资者
|
||||
- **名额**: 无名额限制
|
||||
|
||||
### 6. 科技人才入境计划 (TechTAS)
|
||||
- **目的**: 吸引科技人才来港就业
|
||||
- **审核标准**: 从事先进通讯技术、人工智能、生物科技、网络安全、数据分析、金融科技等领域研究工作,薪酬不低于香港市场水平
|
||||
- **人才类型**: 科技人才
|
||||
- **名额**: 需用公司高新创新科技营配出有效配额
|
||||
|
||||
## 行为准则
|
||||
1. 如果用户询问与移民无关的问题,礼貌地说明你专注于香港移民咨询,并引导回移民话题
|
||||
2. 对于复杂的评估需求,主动建议使用付费评估服务
|
||||
3. 提供信息时注明信息来源和更新时间(如适用)
|
||||
4. 不做任何法律承诺或申请成功率的保证
|
||||
5. 对于敏感问题(如政治、法律纠纷),建议用户咨询专业律师
|
||||
|
||||
## 付费服务
|
||||
当用户需要个性化移民评估时,介绍付费评估服务:
|
||||
- 先了解用户的基本情况(年龄、学历、工作经验、收入等)
|
||||
- 说明评估服务的内容和价值
|
||||
- 确认用户意愿后,使用工具生成支付码
|
||||
|
||||
## 对话风格
|
||||
${config.conversationStyle || '专业但不生硬,用简洁明了的语言解答'}
|
||||
|
||||
## 工具使用说明
|
||||
你有以下工具可以使用:
|
||||
1. **search_knowledge**: 搜索知识库获取最新的移民政策信息
|
||||
2. **check_off_topic**: 检查用户问题是否与移民相关
|
||||
3. **collect_assessment_info**: 收集用户信息用于评估
|
||||
4. **generate_payment**: 为付费服务生成支付二维码
|
||||
5. **save_user_memory**: 保存用户的重要信息以便后续对话记忆
|
||||
|
||||
## 已积累的经验
|
||||
${config.accumulatedExperience || '暂无'}
|
||||
|
||||
## 管理员特别指示
|
||||
${config.adminInstructions || '暂无'}
|
||||
|
||||
请始终保持专业、热情的态度,帮助用户了解香港移民政策,并在适当时机引导用户使用付费评估服务。
|
||||
`;
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConversationContext } from '../claude-agent.service';
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: {
|
||||
type: string;
|
||||
properties: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ImmigrationToolsService {
|
||||
/**
|
||||
* Get all available tools for the agent
|
||||
*/
|
||||
getTools(): Tool[] {
|
||||
return [
|
||||
{
|
||||
name: 'search_knowledge',
|
||||
description: '搜索香港移民相关知识库,获取最新的政策信息和常见问题解答',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: '搜索查询内容',
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
|
||||
description: '移民类别代码(可选)',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'check_off_topic',
|
||||
description: '检查用户的问题是否与香港移民相关,用于判断是否需要拒绝回答',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: {
|
||||
type: 'string',
|
||||
description: '用户的问题',
|
||||
},
|
||||
},
|
||||
required: ['question'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'collect_assessment_info',
|
||||
description: '收集用户的个人信息用于移民资格评估',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
|
||||
description: '用户感兴趣的移民类别',
|
||||
},
|
||||
age: {
|
||||
type: 'number',
|
||||
description: '用户年龄',
|
||||
},
|
||||
education: {
|
||||
type: 'string',
|
||||
description: '最高学历',
|
||||
},
|
||||
university: {
|
||||
type: 'string',
|
||||
description: '毕业院校',
|
||||
},
|
||||
yearsOfExperience: {
|
||||
type: 'number',
|
||||
description: '工作年限',
|
||||
},
|
||||
currentJobTitle: {
|
||||
type: 'string',
|
||||
description: '当前职位',
|
||||
},
|
||||
industry: {
|
||||
type: 'string',
|
||||
description: '所属行业',
|
||||
},
|
||||
annualIncome: {
|
||||
type: 'number',
|
||||
description: '年收入(人民币)',
|
||||
},
|
||||
hasHKEmployer: {
|
||||
type: 'boolean',
|
||||
description: '是否有香港雇主',
|
||||
},
|
||||
},
|
||||
required: ['category'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'generate_payment',
|
||||
description: '为付费评估服务生成支付二维码',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
serviceType: {
|
||||
type: 'string',
|
||||
enum: ['ASSESSMENT'],
|
||||
description: '服务类型',
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
|
||||
description: '移民类别',
|
||||
},
|
||||
paymentMethod: {
|
||||
type: 'string',
|
||||
enum: ['ALIPAY', 'WECHAT', 'CREDIT_CARD'],
|
||||
description: '支付方式',
|
||||
},
|
||||
},
|
||||
required: ['serviceType', 'category', 'paymentMethod'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'save_user_memory',
|
||||
description: '保存用户的重要信息到长期记忆,以便后续对话中记住用户情况',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
memoryType: {
|
||||
type: 'string',
|
||||
enum: ['FACT', 'PREFERENCE', 'INTENT'],
|
||||
description: '记忆类型:FACT-用户陈述的事实,PREFERENCE-用户偏好,INTENT-用户意图',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: '要记住的内容',
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
|
||||
description: '相关的移民类别(可选)',
|
||||
},
|
||||
},
|
||||
required: ['memoryType', 'content'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool and return the result
|
||||
*/
|
||||
async executeTool(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
context: ConversationContext,
|
||||
): Promise<unknown> {
|
||||
switch (toolName) {
|
||||
case 'search_knowledge':
|
||||
return this.searchKnowledge(input);
|
||||
|
||||
case 'check_off_topic':
|
||||
return this.checkOffTopic(input);
|
||||
|
||||
case 'collect_assessment_info':
|
||||
return this.collectAssessmentInfo(input, context);
|
||||
|
||||
case 'generate_payment':
|
||||
return this.generatePayment(input, context);
|
||||
|
||||
case 'save_user_memory':
|
||||
return this.saveUserMemory(input, context);
|
||||
|
||||
default:
|
||||
return { error: `Unknown tool: ${toolName}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search knowledge base
|
||||
*/
|
||||
private async searchKnowledge(input: Record<string, unknown>): Promise<unknown> {
|
||||
const { query, category } = input as { query: string; category?: string };
|
||||
|
||||
// TODO: Implement actual RAG search via Knowledge Service
|
||||
// For now, return a placeholder response
|
||||
console.log(`[Knowledge Search] Query: ${query}, Category: ${category || 'all'}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
results: [
|
||||
{
|
||||
content: '这里将返回从知识库检索到的相关信息',
|
||||
source: '香港入境事务处官网',
|
||||
relevance: 0.95,
|
||||
},
|
||||
],
|
||||
message: '知识库搜索功能即将上线,目前请基于内置知识回答',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if question is off-topic
|
||||
*/
|
||||
private async checkOffTopic(input: Record<string, unknown>): Promise<unknown> {
|
||||
const { question } = input as { question: string };
|
||||
|
||||
// Simple keyword-based check
|
||||
const immigrationKeywords = [
|
||||
'移民', '签证', '香港', '优才', '专才', '高才通', '投资', '留学',
|
||||
'IANG', 'QMAS', 'GEP', 'TTPS', '入境', '定居', '永居', '身份',
|
||||
'申请', '条件', '资格', '评估', '审核', '批准', '拒签',
|
||||
];
|
||||
|
||||
const isRelated = immigrationKeywords.some((keyword) =>
|
||||
question.toLowerCase().includes(keyword.toLowerCase()),
|
||||
);
|
||||
|
||||
return {
|
||||
isOffTopic: !isRelated,
|
||||
confidence: isRelated ? 0.9 : 0.7,
|
||||
suggestion: isRelated
|
||||
? null
|
||||
: '这个问题似乎与香港移民无关。作为移民咨询顾问,我专注于香港各类移民政策的咨询。请问您有香港移民相关的问题吗?',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect assessment info
|
||||
*/
|
||||
private async collectAssessmentInfo(
|
||||
input: Record<string, unknown>,
|
||||
context: ConversationContext,
|
||||
): Promise<unknown> {
|
||||
const info = input as {
|
||||
category: string;
|
||||
age?: number;
|
||||
education?: string;
|
||||
university?: string;
|
||||
yearsOfExperience?: number;
|
||||
currentJobTitle?: string;
|
||||
industry?: string;
|
||||
annualIncome?: number;
|
||||
hasHKEmployer?: boolean;
|
||||
};
|
||||
|
||||
console.log(`[Assessment Info] User ${context.userId} - Category: ${info.category}`);
|
||||
|
||||
// Store the collected info for later use
|
||||
// TODO: Save to database via User Service
|
||||
|
||||
return {
|
||||
success: true,
|
||||
collectedInfo: info,
|
||||
message: '已记录您的信息。如需完整评估,请选择付费评估服务。',
|
||||
nextStep: 'payment',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate payment QR code
|
||||
*/
|
||||
private async generatePayment(
|
||||
input: Record<string, unknown>,
|
||||
context: ConversationContext,
|
||||
): Promise<unknown> {
|
||||
const { serviceType, category, paymentMethod } = input as {
|
||||
serviceType: string;
|
||||
category: string;
|
||||
paymentMethod: string;
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[Payment] User ${context.userId} - ${serviceType} for ${category} via ${paymentMethod}`,
|
||||
);
|
||||
|
||||
// TODO: Call Payment Service to generate actual payment
|
||||
// For now, return a placeholder
|
||||
|
||||
const priceMap: Record<string, number> = {
|
||||
QMAS: 99,
|
||||
GEP: 99,
|
||||
IANG: 79,
|
||||
TTPS: 99,
|
||||
CIES: 199,
|
||||
TECHTAS: 99,
|
||||
};
|
||||
|
||||
const price = priceMap[category] || 99;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderId: `ORD_${Date.now()}`,
|
||||
amount: price,
|
||||
currency: 'CNY',
|
||||
paymentMethod,
|
||||
qrCodeUrl: `https://placeholder-payment-qr.com/${paymentMethod.toLowerCase()}/${Date.now()}`,
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 minutes
|
||||
message: `请扫描二维码支付 ¥${price} 完成${category}类别的移民资格评估服务`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user memory
|
||||
*/
|
||||
private async saveUserMemory(
|
||||
input: Record<string, unknown>,
|
||||
context: ConversationContext,
|
||||
): Promise<unknown> {
|
||||
const { memoryType, content, category } = input as {
|
||||
memoryType: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[Memory] User ${context.userId} - Type: ${memoryType}, Content: ${content}`,
|
||||
);
|
||||
|
||||
// TODO: Save to Neo4j via Knowledge Service
|
||||
|
||||
return {
|
||||
success: true,
|
||||
memoryId: `MEM_${Date.now()}`,
|
||||
message: '已记住您的信息',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Enable CORS
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:5173'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// API prefix
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('CONVERSATION_SERVICE_PORT') || 3002;
|
||||
|
||||
await app.listen(port);
|
||||
console.log(`Conversation Service is running on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./",
|
||||
"strictPropertyInitialization": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# ===========================================
|
||||
# iConsulting Evolution Service Dockerfile
|
||||
# ===========================================
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/services/evolution-service/package.json ./packages/services/evolution-service/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY packages/services/evolution-service ./packages/services/evolution-service
|
||||
COPY tsconfig.base.json ./
|
||||
|
||||
RUN pnpm --filter @iconsulting/shared build
|
||||
RUN pnpm --filter @iconsulting/evolution-service build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nestjs
|
||||
|
||||
COPY --from=builder /app/packages/services/evolution-service/dist ./dist
|
||||
COPY --from=builder /app/packages/services/evolution-service/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3005
|
||||
|
||||
USER nestjs
|
||||
EXPOSE 3005
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3005/health || exit 1
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "@iconsulting/evolution-service",
|
||||
"version": "0.1.0",
|
||||
"description": "进化服务 - 系统自我学习与进化引擎",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.11.3",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.19",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { AdminService, AdminRole, LoginResult } from './admin.service';
|
||||
|
||||
// ========== DTOs ==========
|
||||
|
||||
class LoginDto {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
class CreateAdminDto {
|
||||
username: string;
|
||||
password: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role: AdminRole;
|
||||
}
|
||||
|
||||
class UpdateAdminDto {
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role?: AdminRole;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
class ChangePasswordDto {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
class ResetPasswordDto {
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
// ========== Controller ==========
|
||||
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
constructor(private adminService: AdminService) {}
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
*/
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() dto: LoginDto): Promise<{ success: boolean; data: LoginResult }> {
|
||||
try {
|
||||
const result = await this.adminService.login(dto.username, dto.password);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Token
|
||||
*/
|
||||
@Get('verify')
|
||||
async verifyToken(@Headers('authorization') auth: string) {
|
||||
const token = auth?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing token');
|
||||
}
|
||||
|
||||
const result = await this.adminService.verifyToken(token);
|
||||
if (!result.valid) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.admin,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前管理员信息
|
||||
*/
|
||||
@Get('me')
|
||||
async getCurrentAdmin(@Headers('authorization') auth: string) {
|
||||
const token = auth?.replace('Bearer ', '');
|
||||
const result = await this.adminService.verifyToken(token);
|
||||
|
||||
if (!result.valid) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.admin,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建管理员(需要ADMIN权限)
|
||||
*/
|
||||
@Post('admins')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createAdmin(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() dto: CreateAdminDto,
|
||||
) {
|
||||
await this.checkPermission(auth, 'admin:create');
|
||||
|
||||
try {
|
||||
const admin = await this.adminService.createAdmin(dto);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
name: admin.name,
|
||||
role: admin.role,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取管理员列表
|
||||
*/
|
||||
@Get('admins')
|
||||
async listAdmins(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('role') role?: AdminRole,
|
||||
@Query('isActive') isActive?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
await this.checkPermission(auth, 'admin:read');
|
||||
|
||||
const result = await this.adminService.listAdmins({
|
||||
role,
|
||||
isActive: isActive === undefined ? undefined : isActive === 'true',
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: result.items.map(a => ({
|
||||
id: a.id,
|
||||
username: a.username,
|
||||
name: a.name,
|
||||
email: a.email,
|
||||
phone: a.phone,
|
||||
role: a.role,
|
||||
isActive: a.isActive,
|
||||
lastLoginAt: a.lastLoginAt,
|
||||
createdAt: a.createdAt,
|
||||
})),
|
||||
total: result.total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新管理员
|
||||
*/
|
||||
@Put('admins/:id')
|
||||
async updateAdmin(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAdminDto,
|
||||
) {
|
||||
await this.checkPermission(auth, 'admin:update');
|
||||
|
||||
try {
|
||||
const admin = await this.adminService.updateAdmin(id, dto);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
name: admin.name,
|
||||
role: admin.role,
|
||||
isActive: admin.isActive,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码(当前用户)
|
||||
*/
|
||||
@Post('change-password')
|
||||
async changePassword(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
) {
|
||||
const token = auth?.replace('Bearer ', '');
|
||||
const result = await this.adminService.verifyToken(token);
|
||||
|
||||
if (!result.valid || !result.admin) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.adminService.changePassword(
|
||||
result.admin.id,
|
||||
dto.oldPassword,
|
||||
dto.newPassword,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: '密码修改成功',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码(超管功能)
|
||||
*/
|
||||
@Post('admins/:id/reset-password')
|
||||
async resetPassword(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ResetPasswordDto,
|
||||
) {
|
||||
// 只有超管可以重置密码
|
||||
const token = auth?.replace('Bearer ', '');
|
||||
const result = await this.adminService.verifyToken(token);
|
||||
|
||||
if (!result.valid || !result.admin) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
if (result.admin.role !== AdminRole.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Only super admin can reset passwords');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.adminService.resetPassword(id, dto.newPassword);
|
||||
return {
|
||||
success: true,
|
||||
message: '密码重置成功',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查权限
|
||||
*/
|
||||
private async checkPermission(auth: string, permission: string): Promise<void> {
|
||||
const token = auth?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing token');
|
||||
}
|
||||
|
||||
const result = await this.adminService.verifyToken(token);
|
||||
if (!result.valid || !result.admin) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
if (!this.adminService.hasPermission(result.admin.permissions, permission)) {
|
||||
throw new ForbiddenException(`Permission denied: ${permission}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminORM } from '../infrastructure/database/entities/admin.orm';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AdminORM]),
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
exports: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AdminORM } from '../infrastructure/database/entities/admin.orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* 管理员角色
|
||||
*/
|
||||
export enum AdminRole {
|
||||
SUPER_ADMIN = 'SUPER_ADMIN',
|
||||
ADMIN = 'ADMIN',
|
||||
OPERATOR = 'OPERATOR',
|
||||
VIEWER = 'VIEWER',
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色权限映射
|
||||
*/
|
||||
const ROLE_PERMISSIONS: Record<AdminRole, string[]> = {
|
||||
[AdminRole.SUPER_ADMIN]: ['*'],
|
||||
[AdminRole.ADMIN]: [
|
||||
'knowledge:*',
|
||||
'experience:*',
|
||||
'user:read',
|
||||
'conversation:read',
|
||||
'statistics:*',
|
||||
'admin:read',
|
||||
],
|
||||
[AdminRole.OPERATOR]: [
|
||||
'knowledge:read',
|
||||
'knowledge:create',
|
||||
'knowledge:update',
|
||||
'experience:read',
|
||||
'experience:approve',
|
||||
'user:read',
|
||||
'conversation:read',
|
||||
'statistics:read',
|
||||
],
|
||||
[AdminRole.VIEWER]: [
|
||||
'knowledge:read',
|
||||
'experience:read',
|
||||
'user:read',
|
||||
'conversation:read',
|
||||
'statistics:read',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录结果
|
||||
*/
|
||||
export interface LoginResult {
|
||||
admin: {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
};
|
||||
token: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员服务
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private readonly jwtSecret: string;
|
||||
private readonly jwtExpiresIn: number = 24 * 60 * 60; // 24小时
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AdminORM)
|
||||
private adminRepo: Repository<AdminORM>,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.jwtSecret = this.configService.get('JWT_SECRET') || 'iconsulting-secret-key';
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
*/
|
||||
async login(username: string, password: string, ip?: string): Promise<LoginResult> {
|
||||
const admin = await this.adminRepo.findOne({ where: { username } });
|
||||
|
||||
if (!admin || !admin.isActive) {
|
||||
throw new Error('用户名或密码错误');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, admin.passwordHash);
|
||||
if (!isPasswordValid) {
|
||||
throw new Error('用户名或密码错误');
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
admin.lastLoginAt = new Date();
|
||||
admin.lastLoginIp = ip;
|
||||
await this.adminRepo.save(admin);
|
||||
|
||||
// 生成Token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
sub: admin.id,
|
||||
username: admin.username,
|
||||
role: admin.role,
|
||||
},
|
||||
this.jwtSecret,
|
||||
{ expiresIn: this.jwtExpiresIn },
|
||||
);
|
||||
|
||||
// 获取权限
|
||||
const permissions = this.getPermissions(admin.role as AdminRole, admin.permissions);
|
||||
|
||||
return {
|
||||
admin: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
name: admin.name,
|
||||
role: admin.role,
|
||||
permissions,
|
||||
},
|
||||
token,
|
||||
expiresIn: this.jwtExpiresIn,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Token
|
||||
*/
|
||||
async verifyToken(token: string): Promise<{
|
||||
valid: boolean;
|
||||
admin?: {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
};
|
||||
}> {
|
||||
try {
|
||||
const decoded = jwt.verify(token, this.jwtSecret) as {
|
||||
sub: string;
|
||||
username: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
const admin = await this.adminRepo.findOne({ where: { id: decoded.sub } });
|
||||
if (!admin || !admin.isActive) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
const permissions = this.getPermissions(admin.role as AdminRole, admin.permissions);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
admin: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
role: admin.role,
|
||||
permissions,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建管理员
|
||||
*/
|
||||
async createAdmin(params: {
|
||||
username: string;
|
||||
password: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role: AdminRole;
|
||||
}): Promise<AdminORM> {
|
||||
// 检查用户名是否存在
|
||||
const existing = await this.adminRepo.findOne({
|
||||
where: { username: params.username },
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error('用户名已存在');
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const passwordHash = await bcrypt.hash(params.password, 10);
|
||||
|
||||
const admin = this.adminRepo.create({
|
||||
id: uuidv4(),
|
||||
username: params.username,
|
||||
passwordHash,
|
||||
name: params.name,
|
||||
email: params.email,
|
||||
phone: params.phone,
|
||||
role: params.role,
|
||||
permissions: ROLE_PERMISSIONS[params.role],
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await this.adminRepo.save(admin);
|
||||
|
||||
return admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取管理员列表
|
||||
*/
|
||||
async listAdmins(options?: {
|
||||
role?: AdminRole;
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{
|
||||
items: AdminORM[];
|
||||
total: number;
|
||||
}> {
|
||||
const page = options?.page || 1;
|
||||
const pageSize = options?.pageSize || 20;
|
||||
|
||||
const query = this.adminRepo.createQueryBuilder('admin');
|
||||
|
||||
if (options?.role) {
|
||||
query.andWhere('admin.role = :role', { role: options.role });
|
||||
}
|
||||
|
||||
if (options?.isActive !== undefined) {
|
||||
query.andWhere('admin.isActive = :active', { active: options.isActive });
|
||||
}
|
||||
|
||||
query.orderBy('admin.createdAt', 'DESC');
|
||||
|
||||
const [items, total] = await query
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新管理员
|
||||
*/
|
||||
async updateAdmin(
|
||||
adminId: string,
|
||||
params: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role?: AdminRole;
|
||||
isActive?: boolean;
|
||||
},
|
||||
): Promise<AdminORM> {
|
||||
const admin = await this.adminRepo.findOne({ where: { id: adminId } });
|
||||
if (!admin) {
|
||||
throw new Error('管理员不存在');
|
||||
}
|
||||
|
||||
if (params.name) admin.name = params.name;
|
||||
if (params.email !== undefined) admin.email = params.email;
|
||||
if (params.phone !== undefined) admin.phone = params.phone;
|
||||
if (params.role) {
|
||||
admin.role = params.role;
|
||||
admin.permissions = ROLE_PERMISSIONS[params.role];
|
||||
}
|
||||
if (params.isActive !== undefined) admin.isActive = params.isActive;
|
||||
|
||||
await this.adminRepo.save(admin);
|
||||
|
||||
return admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
async changePassword(
|
||||
adminId: string,
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
const admin = await this.adminRepo.findOne({ where: { id: adminId } });
|
||||
if (!admin) {
|
||||
throw new Error('管理员不存在');
|
||||
}
|
||||
|
||||
const isOldPasswordValid = await bcrypt.compare(oldPassword, admin.passwordHash);
|
||||
if (!isOldPasswordValid) {
|
||||
throw new Error('原密码错误');
|
||||
}
|
||||
|
||||
admin.passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
await this.adminRepo.save(admin);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码(超管功能)
|
||||
*/
|
||||
async resetPassword(adminId: string, newPassword: string): Promise<void> {
|
||||
const admin = await this.adminRepo.findOne({ where: { id: adminId } });
|
||||
if (!admin) {
|
||||
throw new Error('管理员不存在');
|
||||
}
|
||||
|
||||
admin.passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
await this.adminRepo.save(admin);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查权限
|
||||
*/
|
||||
hasPermission(adminPermissions: string[], requiredPermission: string): boolean {
|
||||
// 超管拥有所有权限
|
||||
if (adminPermissions.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 完全匹配
|
||||
if (adminPermissions.includes(requiredPermission)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 通配符匹配 (如 knowledge:* 匹配 knowledge:read)
|
||||
const [resource, action] = requiredPermission.split(':');
|
||||
if (adminPermissions.includes(`${resource}:*`)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取管理员权限列表
|
||||
*/
|
||||
private getPermissions(role: AdminRole, customPermissions?: string[]): string[] {
|
||||
const rolePermissions = ROLE_PERMISSIONS[role] || [];
|
||||
if (customPermissions && customPermissions.length > 0) {
|
||||
return [...new Set([...rolePermissions, ...customPermissions])];
|
||||
}
|
||||
return rolePermissions;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { EvolutionModule } from './evolution/evolution.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 配置模块
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
|
||||
// 数据库连接
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: config.get('DB_HOST', 'localhost'),
|
||||
port: config.get('DB_PORT', 5432),
|
||||
username: config.get('DB_USER', 'iconsulting'),
|
||||
password: config.get('DB_PASSWORD', 'iconsulting_dev'),
|
||||
database: config.get('DB_NAME', 'iconsulting'),
|
||||
autoLoadEntities: true,
|
||||
synchronize: config.get('NODE_ENV') !== 'production',
|
||||
logging: config.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
}),
|
||||
|
||||
// 功能模块
|
||||
EvolutionModule,
|
||||
AdminModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { EvolutionService } from './evolution.service';
|
||||
|
||||
// ========== DTOs ==========
|
||||
|
||||
class RunEvolutionTaskDto {
|
||||
hoursBack?: number;
|
||||
limit?: number;
|
||||
minMessageCount?: number;
|
||||
}
|
||||
|
||||
// ========== Controller ==========
|
||||
|
||||
@Controller('evolution')
|
||||
export class EvolutionController {
|
||||
constructor(private evolutionService: EvolutionService) {}
|
||||
|
||||
/**
|
||||
* 手动触发进化任务
|
||||
*/
|
||||
@Post('run')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async runEvolutionTask(@Body() dto: RunEvolutionTaskDto) {
|
||||
const result = await this.evolutionService.runEvolutionTask(dto);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进化统计信息
|
||||
*/
|
||||
@Get('statistics')
|
||||
async getStatistics() {
|
||||
const stats = await this.evolutionService.getEvolutionStatistics();
|
||||
return {
|
||||
success: true,
|
||||
data: stats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统健康报告
|
||||
*/
|
||||
@Get('health')
|
||||
async getHealthReport() {
|
||||
const report = await this.evolutionService.getSystemHealthReport();
|
||||
return {
|
||||
success: true,
|
||||
data: report,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { EvolutionController } from './evolution.controller';
|
||||
import { EvolutionService } from './evolution.service';
|
||||
import { ExperienceExtractorService } from '../infrastructure/claude/experience-extractor.service';
|
||||
import { ConversationORM } from '../infrastructure/database/entities/conversation.orm';
|
||||
import { MessageORM } from '../infrastructure/database/entities/message.orm';
|
||||
import { SystemExperienceORM } from '../infrastructure/database/entities/system-experience.orm';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
ConversationORM,
|
||||
MessageORM,
|
||||
SystemExperienceORM,
|
||||
]),
|
||||
],
|
||||
controllers: [EvolutionController],
|
||||
providers: [
|
||||
EvolutionService,
|
||||
ExperienceExtractorService,
|
||||
],
|
||||
exports: [EvolutionService],
|
||||
})
|
||||
export class EvolutionModule {}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, MoreThan, LessThan } from 'typeorm';
|
||||
import { ExperienceExtractorService } from '../infrastructure/claude/experience-extractor.service';
|
||||
import { ConversationORM } from '../infrastructure/database/entities/conversation.orm';
|
||||
import { MessageORM } from '../infrastructure/database/entities/message.orm';
|
||||
import { SystemExperienceORM } from '../infrastructure/database/entities/system-experience.orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* 进化任务结果
|
||||
*/
|
||||
export interface EvolutionTaskResult {
|
||||
taskId: string;
|
||||
status: 'success' | 'partial' | 'failed';
|
||||
conversationsAnalyzed: number;
|
||||
experiencesExtracted: number;
|
||||
knowledgeGapsFound: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 进化服务
|
||||
* 负责系统的自我学习和进化
|
||||
*/
|
||||
@Injectable()
|
||||
export class EvolutionService {
|
||||
constructor(
|
||||
@InjectRepository(ConversationORM)
|
||||
private conversationRepo: Repository<ConversationORM>,
|
||||
@InjectRepository(MessageORM)
|
||||
private messageRepo: Repository<MessageORM>,
|
||||
@InjectRepository(SystemExperienceORM)
|
||||
private experienceRepo: Repository<SystemExperienceORM>,
|
||||
private experienceExtractor: ExperienceExtractorService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 执行进化任务 - 分析最近的对话并提取经验
|
||||
*/
|
||||
async runEvolutionTask(options?: {
|
||||
hoursBack?: number;
|
||||
limit?: number;
|
||||
minMessageCount?: number;
|
||||
}): Promise<EvolutionTaskResult> {
|
||||
const taskId = uuidv4();
|
||||
const hoursBack = options?.hoursBack || 24;
|
||||
const limit = options?.limit || 50;
|
||||
const minMessageCount = options?.minMessageCount || 4;
|
||||
|
||||
const result: EvolutionTaskResult = {
|
||||
taskId,
|
||||
status: 'success',
|
||||
conversationsAnalyzed: 0,
|
||||
experiencesExtracted: 0,
|
||||
knowledgeGapsFound: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
console.log(`[Evolution] Starting task ${taskId}`);
|
||||
|
||||
try {
|
||||
// 1. 获取待分析的对话
|
||||
const cutoffTime = new Date();
|
||||
cutoffTime.setHours(cutoffTime.getHours() - hoursBack);
|
||||
|
||||
const conversations = await this.conversationRepo.find({
|
||||
where: {
|
||||
status: 'ENDED',
|
||||
createdAt: MoreThan(cutoffTime),
|
||||
messageCount: MoreThan(minMessageCount),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
console.log(`[Evolution] Found ${conversations.length} conversations to analyze`);
|
||||
|
||||
// 2. 分析每个对话
|
||||
const allKnowledgeGaps: string[] = [];
|
||||
|
||||
for (const conversation of conversations) {
|
||||
try {
|
||||
// 获取对话消息
|
||||
const messages = await this.messageRepo.find({
|
||||
where: { conversationId: conversation.id },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
// 分析对话
|
||||
const analysis = await this.experienceExtractor.analyzeConversation({
|
||||
conversationId: conversation.id,
|
||||
messages: messages.map(m => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
})),
|
||||
category: conversation.category,
|
||||
hasConverted: conversation.hasConverted,
|
||||
rating: conversation.rating,
|
||||
});
|
||||
|
||||
// 保存提取的经验
|
||||
for (const exp of analysis.experiences) {
|
||||
await this.saveExperience({
|
||||
experienceType: exp.type,
|
||||
content: exp.content,
|
||||
scenario: exp.scenario,
|
||||
confidence: exp.confidence,
|
||||
relatedCategory: exp.relatedCategory,
|
||||
sourceConversationId: conversation.id,
|
||||
});
|
||||
result.experiencesExtracted++;
|
||||
}
|
||||
|
||||
// 收集知识缺口
|
||||
allKnowledgeGaps.push(...analysis.knowledgeGaps);
|
||||
|
||||
result.conversationsAnalyzed++;
|
||||
} catch (error) {
|
||||
result.errors.push(`Conversation ${conversation.id}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 汇总知识缺口
|
||||
const uniqueGaps = [...new Set(allKnowledgeGaps)];
|
||||
result.knowledgeGapsFound = uniqueGaps.length;
|
||||
|
||||
// 可以在这里调用知识服务创建待处理的知识缺口任务
|
||||
|
||||
console.log(`[Evolution] Task ${taskId} completed:`, result);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
result.status = 'partial';
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[Evolution] Task ${taskId} failed:`, error);
|
||||
result.status = 'failed';
|
||||
result.errors.push((error as Error).message);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存经验(带去重逻辑)
|
||||
*/
|
||||
private async saveExperience(params: {
|
||||
experienceType: string;
|
||||
content: string;
|
||||
scenario: string;
|
||||
confidence: number;
|
||||
relatedCategory?: string;
|
||||
sourceConversationId: string;
|
||||
}): Promise<void> {
|
||||
// 查找相似经验
|
||||
const existingExperiences = await this.experienceRepo.find({
|
||||
where: {
|
||||
experienceType: params.experienceType,
|
||||
relatedCategory: params.relatedCategory,
|
||||
},
|
||||
});
|
||||
|
||||
// 简单的相似度检查(实际应该用向量相似度)
|
||||
const similar = existingExperiences.find(
|
||||
exp => this.simpleSimilarity(exp.content, params.content) > 0.8,
|
||||
);
|
||||
|
||||
if (similar) {
|
||||
// 合并到现有经验
|
||||
if (!similar.sourceConversationIds.includes(params.sourceConversationId)) {
|
||||
similar.sourceConversationIds.push(params.sourceConversationId);
|
||||
similar.confidence = Math.min(100, similar.confidence + 5);
|
||||
await this.experienceRepo.save(similar);
|
||||
}
|
||||
} else {
|
||||
// 创建新经验
|
||||
const newExperience = this.experienceRepo.create({
|
||||
id: uuidv4(),
|
||||
experienceType: params.experienceType,
|
||||
content: params.content,
|
||||
scenario: params.scenario,
|
||||
confidence: params.confidence,
|
||||
relatedCategory: params.relatedCategory,
|
||||
sourceConversationIds: [params.sourceConversationId],
|
||||
verificationStatus: 'PENDING',
|
||||
isActive: false,
|
||||
});
|
||||
await this.experienceRepo.save(newExperience);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的文本相似度计算
|
||||
*/
|
||||
private simpleSimilarity(a: string, b: string): number {
|
||||
const aWords = new Set(a.toLowerCase().split(/\s+/));
|
||||
const bWords = new Set(b.toLowerCase().split(/\s+/));
|
||||
const intersection = [...aWords].filter(x => bWords.has(x)).length;
|
||||
const union = new Set([...aWords, ...bWords]).size;
|
||||
return intersection / union;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进化统计信息
|
||||
*/
|
||||
async getEvolutionStatistics(): Promise<{
|
||||
totalExperiences: number;
|
||||
pendingExperiences: number;
|
||||
approvedExperiences: number;
|
||||
activeExperiences: number;
|
||||
recentConversationsAnalyzed: number;
|
||||
topExperienceTypes: Array<{ type: string; count: number }>;
|
||||
}> {
|
||||
const [total, pending, approved, active] = await Promise.all([
|
||||
this.experienceRepo.count(),
|
||||
this.experienceRepo.count({ where: { verificationStatus: 'PENDING' } }),
|
||||
this.experienceRepo.count({ where: { verificationStatus: 'APPROVED' } }),
|
||||
this.experienceRepo.count({ where: { isActive: true } }),
|
||||
]);
|
||||
|
||||
// 获取最近分析的对话数(过去7天)
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
const recentConversations = await this.conversationRepo.count({
|
||||
where: {
|
||||
status: 'ENDED',
|
||||
updatedAt: MoreThan(weekAgo),
|
||||
},
|
||||
});
|
||||
|
||||
// 获取经验类型分布
|
||||
const typeDistribution = await this.experienceRepo
|
||||
.createQueryBuilder('exp')
|
||||
.select('exp.experienceType', 'type')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('exp.experienceType')
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(5)
|
||||
.getRawMany();
|
||||
|
||||
return {
|
||||
totalExperiences: total,
|
||||
pendingExperiences: pending,
|
||||
approvedExperiences: approved,
|
||||
activeExperiences: active,
|
||||
recentConversationsAnalyzed: recentConversations,
|
||||
topExperienceTypes: typeDistribution.map(t => ({
|
||||
type: t.type,
|
||||
count: parseInt(t.count),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统健康报告
|
||||
*/
|
||||
async getSystemHealthReport(): Promise<{
|
||||
overall: 'healthy' | 'warning' | 'critical';
|
||||
metrics: Array<{
|
||||
name: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
status: 'good' | 'warning' | 'critical';
|
||||
}>;
|
||||
recommendations: string[];
|
||||
}> {
|
||||
const stats = await this.getEvolutionStatistics();
|
||||
const metrics: Array<{
|
||||
name: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
status: 'good' | 'warning' | 'critical';
|
||||
}> = [];
|
||||
const recommendations: string[] = [];
|
||||
|
||||
// 检查待验证经验堆积
|
||||
const pendingRatio = stats.pendingExperiences / Math.max(1, stats.totalExperiences);
|
||||
metrics.push({
|
||||
name: '待验证经验比例',
|
||||
value: Math.round(pendingRatio * 100),
|
||||
threshold: 50,
|
||||
status: pendingRatio > 0.5 ? 'warning' : 'good',
|
||||
});
|
||||
if (pendingRatio > 0.5) {
|
||||
recommendations.push('待验证经验过多,建议及时审核');
|
||||
}
|
||||
|
||||
// 检查活跃经验数量
|
||||
metrics.push({
|
||||
name: '活跃经验数量',
|
||||
value: stats.activeExperiences,
|
||||
threshold: 10,
|
||||
status: stats.activeExperiences < 10 ? 'warning' : 'good',
|
||||
});
|
||||
if (stats.activeExperiences < 10) {
|
||||
recommendations.push('活跃经验较少,系统学习能力有限');
|
||||
}
|
||||
|
||||
// 检查最近分析的对话
|
||||
metrics.push({
|
||||
name: '近7天分析对话数',
|
||||
value: stats.recentConversationsAnalyzed,
|
||||
threshold: 50,
|
||||
status: stats.recentConversationsAnalyzed < 50 ? 'warning' : 'good',
|
||||
});
|
||||
|
||||
// 计算总体健康状态
|
||||
const criticalCount = metrics.filter(m => m.status === 'critical').length;
|
||||
const warningCount = metrics.filter(m => m.status === 'warning').length;
|
||||
|
||||
let overall: 'healthy' | 'warning' | 'critical' = 'healthy';
|
||||
if (criticalCount > 0) {
|
||||
overall = 'critical';
|
||||
} else if (warningCount > 1) {
|
||||
overall = 'warning';
|
||||
}
|
||||
|
||||
return { overall, metrics, recommendations };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
/**
|
||||
* 提取的经验结构
|
||||
*/
|
||||
export interface ExtractedExperience {
|
||||
type: string;
|
||||
content: string;
|
||||
scenario: string;
|
||||
confidence: number;
|
||||
relatedCategory?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话分析结果
|
||||
*/
|
||||
export interface ConversationAnalysis {
|
||||
experiences: ExtractedExperience[];
|
||||
userInsights: UserInsight[];
|
||||
knowledgeGaps: string[];
|
||||
conversionSignals: ConversionSignal[];
|
||||
}
|
||||
|
||||
export interface UserInsight {
|
||||
type: string;
|
||||
content: string;
|
||||
importance: number;
|
||||
}
|
||||
|
||||
export interface ConversionSignal {
|
||||
type: 'positive' | 'negative';
|
||||
signal: string;
|
||||
context: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 经验提取服务
|
||||
* 使用Claude分析对话,提取有价值的系统经验
|
||||
*/
|
||||
@Injectable()
|
||||
export class ExperienceExtractorService implements OnModuleInit {
|
||||
private client: Anthropic;
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const apiKey = this.configService.get<string>('ANTHROPIC_API_KEY');
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('[ExperienceExtractor] ANTHROPIC_API_KEY not set');
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = new Anthropic({ apiKey });
|
||||
console.log('[ExperienceExtractor] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析对话并提取经验
|
||||
*/
|
||||
async analyzeConversation(params: {
|
||||
conversationId: string;
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
category?: string;
|
||||
hasConverted: boolean;
|
||||
rating?: number;
|
||||
}): Promise<ConversationAnalysis> {
|
||||
if (!this.client) {
|
||||
return this.getMockAnalysis();
|
||||
}
|
||||
|
||||
const prompt = this.buildAnalysisPrompt(params);
|
||||
|
||||
try {
|
||||
const response = await this.client.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 2000,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
});
|
||||
|
||||
const content = response.content[0];
|
||||
if (content.type !== 'text') {
|
||||
return this.getMockAnalysis();
|
||||
}
|
||||
|
||||
return this.parseAnalysisResult(content.text);
|
||||
} catch (error) {
|
||||
console.error('[ExperienceExtractor] Analysis failed:', error);
|
||||
return this.getMockAnalysis();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建分析提示词
|
||||
*/
|
||||
private buildAnalysisPrompt(params: {
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
category?: string;
|
||||
hasConverted: boolean;
|
||||
rating?: number;
|
||||
}): string {
|
||||
const conversationText = params.messages
|
||||
.map(m => `${m.role === 'user' ? '用户' : '助手'}: ${m.content}`)
|
||||
.join('\n\n');
|
||||
|
||||
return `你是一个专门分析香港移民咨询对话的AI专家。请分析以下对话,提取有价值的系统经验。
|
||||
|
||||
## 对话背景
|
||||
- 移民类别: ${params.category || '未知'}
|
||||
- 是否转化付费: ${params.hasConverted ? '是' : '否'}
|
||||
- 用户评分: ${params.rating || '未评分'}
|
||||
|
||||
## 对话内容
|
||||
${conversationText}
|
||||
|
||||
## 分析任务
|
||||
请提取以下信息,以JSON格式返回:
|
||||
|
||||
1. **experiences** (系统经验数组):
|
||||
- type: 经验类型 (COMMON_QUESTION/ANSWER_TEMPLATE/CLARIFICATION/USER_PATTERN/CONVERSION_TRIGGER/KNOWLEDGE_GAP/CONVERSATION_SKILL/OBJECTION_HANDLING)
|
||||
- content: 经验内容
|
||||
- scenario: 适用场景
|
||||
- confidence: 置信度 (0-100)
|
||||
- relatedCategory: 相关移民类别
|
||||
|
||||
2. **userInsights** (用户洞察数组):
|
||||
- type: 洞察类型 (PERSONAL_INFO/WORK_EXPERIENCE/EDUCATION/LANGUAGE/IMMIGRATION_INTENT/CONCERN)
|
||||
- content: 洞察内容
|
||||
- importance: 重要性 (0-100)
|
||||
|
||||
3. **knowledgeGaps** (知识缺口数组):
|
||||
- 对话中暴露出的知识库缺失内容
|
||||
|
||||
4. **conversionSignals** (转化信号数组):
|
||||
- type: positive/negative
|
||||
- signal: 信号描述
|
||||
- context: 上下文
|
||||
|
||||
请只返回JSON,不要其他内容。示例格式:
|
||||
{
|
||||
"experiences": [...],
|
||||
"userInsights": [...],
|
||||
"knowledgeGaps": [...],
|
||||
"conversionSignals": [...]
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析分析结果
|
||||
*/
|
||||
private parseAnalysisResult(text: string): ConversationAnalysis {
|
||||
try {
|
||||
// 尝试从文本中提取JSON
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No JSON found in response');
|
||||
}
|
||||
|
||||
const result = JSON.parse(jsonMatch[0]);
|
||||
|
||||
return {
|
||||
experiences: result.experiences || [],
|
||||
userInsights: result.userInsights || [],
|
||||
knowledgeGaps: result.knowledgeGaps || [],
|
||||
conversionSignals: result.conversionSignals || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ExperienceExtractor] Failed to parse result:', error);
|
||||
return this.getMockAnalysis();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock分析结果(开发用)
|
||||
*/
|
||||
private getMockAnalysis(): ConversationAnalysis {
|
||||
return {
|
||||
experiences: [],
|
||||
userInsights: [],
|
||||
knowledgeGaps: [],
|
||||
conversionSignals: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多个对话中总结经验
|
||||
*/
|
||||
async summarizeExperiences(
|
||||
experiences: ExtractedExperience[],
|
||||
): Promise<ExtractedExperience[]> {
|
||||
if (!this.client || experiences.length < 3) {
|
||||
return experiences;
|
||||
}
|
||||
|
||||
const prompt = `你是一个经验总结专家。请分析以下从多个对话中提取的经验,找出共同模式并合并相似经验。
|
||||
|
||||
## 原始经验
|
||||
${JSON.stringify(experiences, null, 2)}
|
||||
|
||||
## 任务
|
||||
1. 合并相似的经验
|
||||
2. 提高置信度(多次出现的经验置信度更高)
|
||||
3. 移除重复或低质量的经验
|
||||
|
||||
请返回优化后的经验数组(JSON格式):`;
|
||||
|
||||
try {
|
||||
const response = await this.client.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 2000,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
});
|
||||
|
||||
const content = response.content[0];
|
||||
if (content.type !== 'text') {
|
||||
return experiences;
|
||||
}
|
||||
|
||||
const jsonMatch = content.text.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
return experiences;
|
||||
}
|
||||
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
} catch (error) {
|
||||
console.error('[ExperienceExtractor] Summary failed:', error);
|
||||
return experiences;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析知识库缺口
|
||||
*/
|
||||
async analyzeKnowledgeGaps(
|
||||
gaps: string[],
|
||||
existingArticles: string[],
|
||||
): Promise<Array<{
|
||||
topic: string;
|
||||
priority: number;
|
||||
suggestedContent: string;
|
||||
}>> {
|
||||
if (!this.client || gaps.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prompt = `你是香港移民知识库的内容规划专家。
|
||||
|
||||
## 现有知识库文章标题
|
||||
${existingArticles.join('\n')}
|
||||
|
||||
## 对话中发现的知识缺口
|
||||
${gaps.join('\n')}
|
||||
|
||||
## 任务
|
||||
分析哪些知识缺口是真正需要补充的,并按优先级排序。
|
||||
对于每个需要补充的主题,提供建议的内容大纲。
|
||||
|
||||
请返回JSON数组:
|
||||
[
|
||||
{
|
||||
"topic": "主题",
|
||||
"priority": 1-100,
|
||||
"suggestedContent": "建议的内容大纲"
|
||||
}
|
||||
]`;
|
||||
|
||||
try {
|
||||
const response = await this.client.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 2000,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
});
|
||||
|
||||
const content = response.content[0];
|
||||
if (content.type !== 'text') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const jsonMatch = content.text.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
} catch (error) {
|
||||
console.error('[ExperienceExtractor] Gap analysis failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('admins')
|
||||
export class AdminORM {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ length: 50, unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ name: 'password_hash', length: 255 })
|
||||
passwordHash: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ length: 255, nullable: true })
|
||||
email: string;
|
||||
|
||||
@Column({ length: 20, nullable: true })
|
||||
phone: string;
|
||||
|
||||
@Column({ length: 20, default: 'OPERATOR' })
|
||||
role: string;
|
||||
|
||||
@Column('jsonb', { default: '[]' })
|
||||
permissions: string[];
|
||||
|
||||
@Column({ length: 500, nullable: true })
|
||||
avatar: string;
|
||||
|
||||
@Column({ name: 'last_login_at', nullable: true })
|
||||
lastLoginAt: Date;
|
||||
|
||||
@Column({ name: 'last_login_ip', length: 50, nullable: true })
|
||||
lastLoginIp?: string;
|
||||
|
||||
@Column({ name: 'is_active', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('conversations')
|
||||
export class ConversationORM {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id', nullable: true })
|
||||
userId: string;
|
||||
|
||||
@Column({ length: 20, default: 'ACTIVE' })
|
||||
status: string;
|
||||
|
||||
@Column({ length: 255, nullable: true })
|
||||
title: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
summary: string;
|
||||
|
||||
@Column({ length: 50, nullable: true })
|
||||
category: string;
|
||||
|
||||
@Column({ name: 'message_count', default: 0 })
|
||||
messageCount: number;
|
||||
|
||||
@Column({ name: 'user_message_count', default: 0 })
|
||||
userMessageCount: number;
|
||||
|
||||
@Column({ name: 'assistant_message_count', default: 0 })
|
||||
assistantMessageCount: number;
|
||||
|
||||
@Column({ name: 'total_input_tokens', default: 0 })
|
||||
totalInputTokens: number;
|
||||
|
||||
@Column({ name: 'total_output_tokens', default: 0 })
|
||||
totalOutputTokens: number;
|
||||
|
||||
@Column({ type: 'smallint', nullable: true })
|
||||
rating: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
feedback: string;
|
||||
|
||||
@Column({ name: 'has_converted', default: false })
|
||||
hasConverted: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'ended_at', nullable: true })
|
||||
endedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('messages')
|
||||
export class MessageORM {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'conversation_id' })
|
||||
conversationId: string;
|
||||
|
||||
@Column({ length: 20 })
|
||||
role: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column({ name: 'input_tokens', default: 0 })
|
||||
inputTokens: number;
|
||||
|
||||
@Column({ name: 'output_tokens', default: 0 })
|
||||
outputTokens: number;
|
||||
|
||||
@Column('jsonb', { nullable: true })
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('system_experiences')
|
||||
export class SystemExperienceORM {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'experience_type', length: 30 })
|
||||
experienceType: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column({ default: 50 })
|
||||
confidence: number;
|
||||
|
||||
@Column('text')
|
||||
scenario: string;
|
||||
|
||||
@Column({ name: 'related_category', length: 50, nullable: true })
|
||||
relatedCategory: string;
|
||||
|
||||
@Column('uuid', { name: 'source_conversation_ids', array: true, default: '{}' })
|
||||
sourceConversationIds: string[];
|
||||
|
||||
@Column({ name: 'verification_status', length: 20, default: 'PENDING' })
|
||||
verificationStatus: string;
|
||||
|
||||
@Column({ name: 'verified_by', nullable: true })
|
||||
verifiedBy: string;
|
||||
|
||||
@Column({ name: 'verified_at', nullable: true })
|
||||
verifiedAt: Date;
|
||||
|
||||
@Column({ name: 'usage_count', default: 0 })
|
||||
usageCount: number;
|
||||
|
||||
@Column({ name: 'positive_count', default: 0 })
|
||||
positiveCount: number;
|
||||
|
||||
@Column({ name: 'negative_count', default: 0 })
|
||||
negativeCount: number;
|
||||
|
||||
@Column('float', { array: true, nullable: true })
|
||||
embedding: number[];
|
||||
|
||||
@Column({ name: 'is_active', default: false })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 设置全局前缀
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
// 启用CORS
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3005;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ 🧬 iConsulting Evolution Service ║
|
||||
║ ║
|
||||
║ Server running at: http://localhost:${port} ║
|
||||
║ API prefix: /api/v1 ║
|
||||
║ ║
|
||||
║ Endpoints: ║
|
||||
║ - POST /api/v1/admin/login Admin login ║
|
||||
║ - GET /api/v1/admin/me Current admin ║
|
||||
║ - POST /api/v1/admin/admins Create admin ║
|
||||
║ - GET /api/v1/admin/admins List admins ║
|
||||
║ - POST /api/v1/evolution/run Run evolution ║
|
||||
║ - GET /api/v1/evolution/statistics Evolution stats ║
|
||||
║ - GET /api/v1/evolution/health System health ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# ===========================================
|
||||
# iConsulting Knowledge Service Dockerfile
|
||||
# ===========================================
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/services/knowledge-service/package.json ./packages/services/knowledge-service/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY packages/services/knowledge-service ./packages/services/knowledge-service
|
||||
COPY tsconfig.base.json ./
|
||||
|
||||
RUN pnpm --filter @iconsulting/shared build
|
||||
RUN pnpm --filter @iconsulting/knowledge-service build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nestjs
|
||||
|
||||
COPY --from=builder /app/packages/services/knowledge-service/dist ./dist
|
||||
COPY --from=builder /app/packages/services/knowledge-service/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3003
|
||||
|
||||
USER nestjs
|
||||
EXPOSE 3003
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/health || exit 1
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@iconsulting/knowledge-service",
|
||||
"version": "0.1.0",
|
||||
"description": "知识服务 - RAG检索增强生成 + Neo4j知识图谱",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"neo4j-driver": "^5.17.0",
|
||||
"openai": "^4.28.0",
|
||||
"pg": "^8.11.3",
|
||||
"pgvector": "^0.1.8",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.19",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { KnowledgeModule } from './knowledge/knowledge.module';
|
||||
import { MemoryModule } from './memory/memory.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 配置模块
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
|
||||
// 数据库连接
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: config.get('DB_HOST', 'localhost'),
|
||||
port: config.get('DB_PORT', 5432),
|
||||
username: config.get('DB_USER', 'iconsulting'),
|
||||
password: config.get('DB_PASSWORD', 'iconsulting_dev'),
|
||||
database: config.get('DB_NAME', 'iconsulting'),
|
||||
autoLoadEntities: true,
|
||||
synchronize: config.get('NODE_ENV') !== 'production',
|
||||
logging: config.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
}),
|
||||
|
||||
// 功能模块
|
||||
KnowledgeModule,
|
||||
MemoryModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
KnowledgeChunkEntity,
|
||||
ChunkType,
|
||||
ChunkMetadata,
|
||||
} from '../../domain/entities/knowledge-chunk.entity';
|
||||
import { KnowledgeArticleEntity } from '../../domain/entities/knowledge-article.entity';
|
||||
|
||||
/**
|
||||
* 文本分块策略配置
|
||||
*/
|
||||
export interface ChunkingConfig {
|
||||
/** 最大块大小(字符数) */
|
||||
maxChunkSize: number;
|
||||
/** 块重叠大小 */
|
||||
overlapSize: number;
|
||||
/** 是否按语义边界分割 */
|
||||
semanticSplit: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ChunkingConfig = {
|
||||
maxChunkSize: 500,
|
||||
overlapSize: 50,
|
||||
semanticSplit: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 文本分块服务
|
||||
* 将长文本智能分割为适合检索的小块
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChunkingService {
|
||||
/**
|
||||
* 将文章内容分割为块
|
||||
*/
|
||||
chunkArticle(
|
||||
article: KnowledgeArticleEntity,
|
||||
config: ChunkingConfig = DEFAULT_CONFIG,
|
||||
): KnowledgeChunkEntity[] {
|
||||
const content = article.content;
|
||||
|
||||
// 检测内容格式
|
||||
if (this.isMarkdown(content)) {
|
||||
return this.chunkMarkdown(article.id, content, config);
|
||||
}
|
||||
|
||||
// 纯文本分块
|
||||
return this.chunkPlainText(article.id, content, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为Markdown格式
|
||||
*/
|
||||
private isMarkdown(content: string): boolean {
|
||||
const markdownPatterns = [
|
||||
/^#+\s/m, // 标题
|
||||
/\*\*.+\*\*/, // 加粗
|
||||
/\[.+\]\(.+\)/, // 链接
|
||||
/^-\s/m, // 列表
|
||||
/^\d+\.\s/m, // 有序列表
|
||||
/```/, // 代码块
|
||||
];
|
||||
|
||||
return markdownPatterns.some(pattern => pattern.test(content));
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown内容分块
|
||||
*/
|
||||
private chunkMarkdown(
|
||||
articleId: string,
|
||||
content: string,
|
||||
config: ChunkingConfig,
|
||||
): KnowledgeChunkEntity[] {
|
||||
const chunks: KnowledgeChunkEntity[] = [];
|
||||
const sections = this.splitByHeadings(content);
|
||||
|
||||
let chunkIndex = 0;
|
||||
let currentSectionTitle = '';
|
||||
|
||||
for (const section of sections) {
|
||||
// 更新当前章节标题
|
||||
const headingMatch = section.match(/^(#+)\s+(.+)$/m);
|
||||
if (headingMatch) {
|
||||
currentSectionTitle = headingMatch[2];
|
||||
}
|
||||
|
||||
// 如果章节内容过长,进一步分割
|
||||
if (section.length > config.maxChunkSize) {
|
||||
const subChunks = this.splitLongSection(section, config);
|
||||
|
||||
for (const subContent of subChunks) {
|
||||
const chunkType = this.detectChunkType(subContent);
|
||||
const metadata: ChunkMetadata = {
|
||||
sectionTitle: currentSectionTitle,
|
||||
headingLevel: headingMatch ? headingMatch[1].length : undefined,
|
||||
};
|
||||
|
||||
const chunk = KnowledgeChunkEntity.create({
|
||||
articleId,
|
||||
content: subContent.trim(),
|
||||
chunkIndex,
|
||||
chunkType,
|
||||
metadata,
|
||||
});
|
||||
|
||||
// 设置前后链接
|
||||
if (chunks.length > 0) {
|
||||
metadata.prevChunkId = chunks[chunks.length - 1].id;
|
||||
chunks[chunks.length - 1].metadata.nextChunkId = chunk.id;
|
||||
}
|
||||
|
||||
chunks.push(chunk);
|
||||
chunkIndex++;
|
||||
}
|
||||
} else if (section.trim()) {
|
||||
const chunkType = this.detectChunkType(section);
|
||||
const metadata: ChunkMetadata = {
|
||||
sectionTitle: currentSectionTitle,
|
||||
headingLevel: headingMatch ? headingMatch[1].length : undefined,
|
||||
};
|
||||
|
||||
const chunk = KnowledgeChunkEntity.create({
|
||||
articleId,
|
||||
content: section.trim(),
|
||||
chunkIndex,
|
||||
chunkType,
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (chunks.length > 0) {
|
||||
metadata.prevChunkId = chunks[chunks.length - 1].id;
|
||||
chunks[chunks.length - 1].metadata.nextChunkId = chunk.id;
|
||||
}
|
||||
|
||||
chunks.push(chunk);
|
||||
chunkIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按标题分割Markdown
|
||||
*/
|
||||
private splitByHeadings(content: string): string[] {
|
||||
// 在标题前分割,但保留标题
|
||||
const parts = content.split(/(?=^#+\s)/m);
|
||||
return parts.filter(part => part.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 分割过长的章节
|
||||
*/
|
||||
private splitLongSection(content: string, config: ChunkingConfig): string[] {
|
||||
const chunks: string[] = [];
|
||||
|
||||
if (config.semanticSplit) {
|
||||
// 优先按段落分割
|
||||
const paragraphs = content.split(/\n\n+/);
|
||||
let currentChunk = '';
|
||||
|
||||
for (const para of paragraphs) {
|
||||
if (currentChunk.length + para.length > config.maxChunkSize) {
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk);
|
||||
}
|
||||
// 如果单个段落就超过限制,按句子分割
|
||||
if (para.length > config.maxChunkSize) {
|
||||
chunks.push(...this.splitBySentences(para, config));
|
||||
} else {
|
||||
currentChunk = para;
|
||||
}
|
||||
} else {
|
||||
currentChunk = currentChunk ? `${currentChunk}\n\n${para}` : para;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk);
|
||||
}
|
||||
} else {
|
||||
// 简单按字符数分割(带重叠)
|
||||
chunks.push(...this.splitBySize(content, config));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按句子分割
|
||||
*/
|
||||
private splitBySentences(content: string, config: ChunkingConfig): string[] {
|
||||
// 中英文句子结束符
|
||||
const sentenceEndings = /([。!?.!?])/g;
|
||||
const sentences = content.split(sentenceEndings);
|
||||
|
||||
const chunks: string[] = [];
|
||||
let currentChunk = '';
|
||||
|
||||
for (let i = 0; i < sentences.length; i += 2) {
|
||||
const sentence = sentences[i] + (sentences[i + 1] || '');
|
||||
|
||||
if (currentChunk.length + sentence.length > config.maxChunkSize) {
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk);
|
||||
}
|
||||
currentChunk = sentence;
|
||||
} else {
|
||||
currentChunk += sentence;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按大小分割(带重叠)
|
||||
*/
|
||||
private splitBySize(content: string, config: ChunkingConfig): string[] {
|
||||
const chunks: string[] = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < content.length) {
|
||||
const end = Math.min(start + config.maxChunkSize, content.length);
|
||||
chunks.push(content.slice(start, end));
|
||||
start = end - config.overlapSize;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯文本分块
|
||||
*/
|
||||
private chunkPlainText(
|
||||
articleId: string,
|
||||
content: string,
|
||||
config: ChunkingConfig,
|
||||
): KnowledgeChunkEntity[] {
|
||||
const textChunks = config.semanticSplit
|
||||
? this.splitLongSection(content, config)
|
||||
: this.splitBySize(content, config);
|
||||
|
||||
return textChunks.map((text, index) =>
|
||||
KnowledgeChunkEntity.create({
|
||||
articleId,
|
||||
content: text.trim(),
|
||||
chunkIndex: index,
|
||||
chunkType: ChunkType.PARAGRAPH,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测块类型
|
||||
*/
|
||||
private detectChunkType(content: string): ChunkType {
|
||||
// 标题
|
||||
if (/^#+\s/.test(content)) {
|
||||
return ChunkType.TITLE;
|
||||
}
|
||||
|
||||
// 代码块
|
||||
if (/```[\s\S]*```/.test(content)) {
|
||||
return ChunkType.CODE;
|
||||
}
|
||||
|
||||
// 列表
|
||||
if (/^[-*]\s/m.test(content) || /^\d+\.\s/m.test(content)) {
|
||||
return ChunkType.LIST;
|
||||
}
|
||||
|
||||
// 表格
|
||||
if (/\|.+\|/.test(content) && /\|-+\|/.test(content)) {
|
||||
return ChunkType.TABLE;
|
||||
}
|
||||
|
||||
// FAQ(问答格式)
|
||||
if (/^[问Q][::]/.test(content) || /^[答A][::]/.test(content)) {
|
||||
return ChunkType.FAQ;
|
||||
}
|
||||
|
||||
return ChunkType.PARAGRAPH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为FAQ内容创建专门的块
|
||||
*/
|
||||
createFAQChunks(
|
||||
articleId: string,
|
||||
faqs: Array<{ question: string; answer: string }>,
|
||||
): KnowledgeChunkEntity[] {
|
||||
return faqs.map((faq, index) =>
|
||||
KnowledgeChunkEntity.create({
|
||||
articleId,
|
||||
content: `问:${faq.question}\n答:${faq.answer}`,
|
||||
chunkIndex: index,
|
||||
chunkType: ChunkType.FAQ,
|
||||
metadata: {
|
||||
hasKeywords: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { EmbeddingService } from '../../infrastructure/embedding/embedding.service';
|
||||
import {
|
||||
IKnowledgeRepository,
|
||||
KNOWLEDGE_REPOSITORY,
|
||||
} from '../../domain/repositories/knowledge.repository.interface';
|
||||
import {
|
||||
IUserMemoryRepository,
|
||||
ISystemExperienceRepository,
|
||||
USER_MEMORY_REPOSITORY,
|
||||
SYSTEM_EXPERIENCE_REPOSITORY,
|
||||
} from '../../domain/repositories/memory.repository.interface';
|
||||
import { KnowledgeArticleEntity } from '../../domain/entities/knowledge-article.entity';
|
||||
import { KnowledgeChunkEntity } from '../../domain/entities/knowledge-chunk.entity';
|
||||
|
||||
/**
|
||||
* RAG检索结果
|
||||
*/
|
||||
export interface RAGResult {
|
||||
/** 检索到的知识内容 */
|
||||
content: string;
|
||||
/** 来源(用于引用) */
|
||||
sources: Array<{
|
||||
articleId: string;
|
||||
title: string;
|
||||
similarity: number;
|
||||
}>;
|
||||
/** 用户相关记忆 */
|
||||
userMemories?: string[];
|
||||
/** 系统经验 */
|
||||
systemExperiences?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* RAG服务 - 检索增强生成
|
||||
* 负责从知识库中检索相关内容,增强AI回答
|
||||
*/
|
||||
@Injectable()
|
||||
export class RAGService {
|
||||
constructor(
|
||||
private embeddingService: EmbeddingService,
|
||||
@Inject(KNOWLEDGE_REPOSITORY)
|
||||
private knowledgeRepo: IKnowledgeRepository,
|
||||
@Inject(USER_MEMORY_REPOSITORY)
|
||||
private memoryRepo: IUserMemoryRepository,
|
||||
@Inject(SYSTEM_EXPERIENCE_REPOSITORY)
|
||||
private experienceRepo: ISystemExperienceRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 检索相关知识
|
||||
*/
|
||||
async retrieve(params: {
|
||||
query: string;
|
||||
userId?: string;
|
||||
category?: string;
|
||||
includeMemories?: boolean;
|
||||
includeExperiences?: boolean;
|
||||
topK?: number;
|
||||
}): Promise<RAGResult> {
|
||||
const {
|
||||
query,
|
||||
userId,
|
||||
category,
|
||||
includeMemories = true,
|
||||
includeExperiences = true,
|
||||
topK = 5,
|
||||
} = params;
|
||||
|
||||
// 1. 生成查询向量
|
||||
const queryEmbedding = await this.embeddingService.getEmbedding(query);
|
||||
|
||||
// 2. 并行检索
|
||||
const [chunkResults, memoryResults, experienceResults] = await Promise.all([
|
||||
// 检索知识块
|
||||
this.knowledgeRepo.searchChunksByVector(queryEmbedding, {
|
||||
category,
|
||||
limit: topK,
|
||||
minSimilarity: 0.6,
|
||||
}),
|
||||
// 检索用户记忆
|
||||
includeMemories && userId
|
||||
? this.memoryRepo.searchByVector(userId, queryEmbedding, {
|
||||
limit: 3,
|
||||
minSimilarity: 0.7,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
// 检索系统经验
|
||||
includeExperiences
|
||||
? this.experienceRepo.searchByVector(queryEmbedding, {
|
||||
activeOnly: true,
|
||||
limit: 3,
|
||||
minSimilarity: 0.75,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
// 3. 获取完整文章信息(用于引用)
|
||||
const articleIds = [...new Set(chunkResults.map(r => r.chunk.articleId))];
|
||||
const articles = await Promise.all(
|
||||
articleIds.map(id => this.knowledgeRepo.findArticleById(id)),
|
||||
);
|
||||
const articleMap = new Map(
|
||||
articles.filter(Boolean).map(a => [a!.id, a!]),
|
||||
);
|
||||
|
||||
// 4. 组装结果
|
||||
const content = this.formatRetrievedContent(chunkResults.map(r => r.chunk), articleMap);
|
||||
|
||||
const sources = chunkResults.map(r => {
|
||||
const article = articleMap.get(r.chunk.articleId);
|
||||
return {
|
||||
articleId: r.chunk.articleId,
|
||||
title: article?.title || 'Unknown',
|
||||
similarity: r.similarity,
|
||||
};
|
||||
});
|
||||
|
||||
const userMemories = memoryResults.map(r => r.memory.content);
|
||||
const systemExperiences = experienceResults.map(r => r.experience.content);
|
||||
|
||||
// 5. 更新引用计数
|
||||
this.updateCitationCounts(articleIds);
|
||||
|
||||
return {
|
||||
content,
|
||||
sources,
|
||||
userMemories: userMemories.length > 0 ? userMemories : undefined,
|
||||
systemExperiences: systemExperiences.length > 0 ? systemExperiences : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索并格式化为提示词上下文
|
||||
*/
|
||||
async retrieveForPrompt(params: {
|
||||
query: string;
|
||||
userId?: string;
|
||||
category?: string;
|
||||
}): Promise<string> {
|
||||
const result = await this.retrieve(params);
|
||||
|
||||
let context = '';
|
||||
|
||||
// 知识库内容
|
||||
if (result.content) {
|
||||
context += `## 相关知识\n${result.content}\n\n`;
|
||||
}
|
||||
|
||||
// 用户记忆
|
||||
if (result.userMemories?.length) {
|
||||
context += `## 用户背景信息\n`;
|
||||
result.userMemories.forEach((m, i) => {
|
||||
context += `${i + 1}. ${m}\n`;
|
||||
});
|
||||
context += '\n';
|
||||
}
|
||||
|
||||
// 系统经验
|
||||
if (result.systemExperiences?.length) {
|
||||
context += `## 参考经验\n`;
|
||||
result.systemExperiences.forEach((e, i) => {
|
||||
context += `${i + 1}. ${e}\n`;
|
||||
});
|
||||
context += '\n';
|
||||
}
|
||||
|
||||
// 来源引用
|
||||
if (result.sources.length > 0) {
|
||||
context += `## 来源\n`;
|
||||
result.sources.forEach((s, i) => {
|
||||
context += `[${i + 1}] ${s.title}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化检索到的内容
|
||||
*/
|
||||
private formatRetrievedContent(
|
||||
chunks: KnowledgeChunkEntity[],
|
||||
articleMap: Map<string, KnowledgeArticleEntity>,
|
||||
): string {
|
||||
if (chunks.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 按文章分组
|
||||
const groupedByArticle = new Map<string, KnowledgeChunkEntity[]>();
|
||||
chunks.forEach(chunk => {
|
||||
const existing = groupedByArticle.get(chunk.articleId) || [];
|
||||
existing.push(chunk);
|
||||
groupedByArticle.set(chunk.articleId, existing);
|
||||
});
|
||||
|
||||
let content = '';
|
||||
let articleIndex = 1;
|
||||
|
||||
groupedByArticle.forEach((articleChunks, articleId) => {
|
||||
const article = articleMap.get(articleId);
|
||||
if (!article) return;
|
||||
|
||||
content += `### [${articleIndex}] ${article.title}\n`;
|
||||
|
||||
// 按块序号排序
|
||||
articleChunks.sort((a, b) => a.chunkIndex - b.chunkIndex);
|
||||
|
||||
articleChunks.forEach(chunk => {
|
||||
content += `${chunk.content}\n\n`;
|
||||
});
|
||||
|
||||
articleIndex++;
|
||||
});
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步更新引用计数
|
||||
*/
|
||||
private async updateCitationCounts(articleIds: string[]): Promise<void> {
|
||||
// 异步执行,不阻塞检索
|
||||
setImmediate(async () => {
|
||||
for (const id of articleIds) {
|
||||
try {
|
||||
const article = await this.knowledgeRepo.findArticleById(id);
|
||||
if (article) {
|
||||
article.incrementCitation();
|
||||
await this.knowledgeRepo.updateArticle(article);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to update citation count for article ${id}:`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为离题问题
|
||||
*/
|
||||
async checkOffTopic(query: string): Promise<{
|
||||
isOffTopic: boolean;
|
||||
confidence: number;
|
||||
reason?: string;
|
||||
}> {
|
||||
// 使用向量相似度检查是否与知识库相关
|
||||
const queryEmbedding = await this.embeddingService.getEmbedding(query);
|
||||
|
||||
const results = await this.knowledgeRepo.searchChunksByVector(queryEmbedding, {
|
||||
limit: 1,
|
||||
minSimilarity: 0.3, // 使用较低阈值
|
||||
});
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
isOffTopic: true,
|
||||
confidence: 0.8,
|
||||
reason: '问题与香港移民主题无关',
|
||||
};
|
||||
}
|
||||
|
||||
const maxSimilarity = results[0].similarity;
|
||||
|
||||
if (maxSimilarity < 0.5) {
|
||||
return {
|
||||
isOffTopic: true,
|
||||
confidence: 0.9 - maxSimilarity,
|
||||
reason: '问题与香港移民主题相关性较低',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isOffTopic: false,
|
||||
confidence: maxSimilarity,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* 知识文章实体 - 存储移民相关的知识内容
|
||||
*/
|
||||
export class KnowledgeArticleEntity {
|
||||
id: string;
|
||||
|
||||
/** 文章标题 */
|
||||
title: string;
|
||||
|
||||
/** 文章内容(纯文本或Markdown) */
|
||||
content: string;
|
||||
|
||||
/** 内容摘要(用于预览) */
|
||||
summary: string;
|
||||
|
||||
/** 移民类别: QMAS, GEP, IANG, TTPS, CIES, TechTAS */
|
||||
category: string;
|
||||
|
||||
/** 内容标签 */
|
||||
tags: string[];
|
||||
|
||||
/** 来源: MANUAL(手动添加), CRAWL(爬取), EXTRACT(对话提取) */
|
||||
source: KnowledgeSource;
|
||||
|
||||
/** 来源URL(如果是爬取的) */
|
||||
sourceUrl?: string;
|
||||
|
||||
/** 内容向量(用于语义搜索) */
|
||||
embedding?: number[];
|
||||
|
||||
/** 是否已发布 */
|
||||
isPublished: boolean;
|
||||
|
||||
/** 引用次数(被对话引用) */
|
||||
citationCount: number;
|
||||
|
||||
/** 点赞数(用户反馈有用) */
|
||||
helpfulCount: number;
|
||||
|
||||
/** 点踩数(用户反馈无用) */
|
||||
unhelpfulCount: number;
|
||||
|
||||
/** 质量评分 0-100 */
|
||||
qualityScore: number;
|
||||
|
||||
/** 创建者ID */
|
||||
createdBy?: string;
|
||||
|
||||
/** 最后更新者ID */
|
||||
updatedBy?: string;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static create(params: {
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
source: KnowledgeSource;
|
||||
sourceUrl?: string;
|
||||
createdBy?: string;
|
||||
}): KnowledgeArticleEntity {
|
||||
const article = new KnowledgeArticleEntity();
|
||||
article.id = uuidv4();
|
||||
article.title = params.title;
|
||||
article.content = params.content;
|
||||
article.summary = KnowledgeArticleEntity.generateSummary(params.content);
|
||||
article.category = params.category;
|
||||
article.tags = params.tags || [];
|
||||
article.source = params.source;
|
||||
article.sourceUrl = params.sourceUrl;
|
||||
article.isPublished = false;
|
||||
article.citationCount = 0;
|
||||
article.helpfulCount = 0;
|
||||
article.unhelpfulCount = 0;
|
||||
article.qualityScore = 50; // 默认中等质量
|
||||
article.createdBy = params.createdBy;
|
||||
article.createdAt = new Date();
|
||||
article.updatedAt = new Date();
|
||||
return article;
|
||||
}
|
||||
|
||||
static fromPersistence(data: Partial<KnowledgeArticleEntity>): KnowledgeArticleEntity {
|
||||
const article = new KnowledgeArticleEntity();
|
||||
Object.assign(article, data);
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成内容摘要
|
||||
*/
|
||||
private static generateSummary(content: string, maxLength = 200): string {
|
||||
const plainText = content
|
||||
.replace(/#+\s/g, '') // 移除Markdown标题
|
||||
.replace(/\*\*/g, '') // 移除加粗
|
||||
.replace(/\n+/g, ' ') // 换行转空格
|
||||
.trim();
|
||||
|
||||
if (plainText.length <= maxLength) {
|
||||
return plainText;
|
||||
}
|
||||
return plainText.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新内容
|
||||
*/
|
||||
updateContent(title: string, content: string, updatedBy?: string): void {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.summary = KnowledgeArticleEntity.generateSummary(content);
|
||||
this.updatedBy = updatedBy;
|
||||
this.updatedAt = new Date();
|
||||
// 内容更新后需要重新生成向量
|
||||
this.embedding = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置内容向量
|
||||
*/
|
||||
setEmbedding(embedding: number[]): void {
|
||||
this.embedding = embedding;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布文章
|
||||
*/
|
||||
publish(): void {
|
||||
this.isPublished = true;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消发布
|
||||
*/
|
||||
unpublish(): void {
|
||||
this.isPublished = false;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加引用次数
|
||||
*/
|
||||
incrementCitation(): void {
|
||||
this.citationCount++;
|
||||
this.recalculateQualityScore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户反馈
|
||||
*/
|
||||
recordFeedback(helpful: boolean): void {
|
||||
if (helpful) {
|
||||
this.helpfulCount++;
|
||||
} else {
|
||||
this.unhelpfulCount++;
|
||||
}
|
||||
this.recalculateQualityScore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新计算质量评分
|
||||
*/
|
||||
private recalculateQualityScore(): void {
|
||||
// 基础分50分
|
||||
let score = 50;
|
||||
|
||||
// 引用次数加分(最多+20)
|
||||
score += Math.min(this.citationCount * 2, 20);
|
||||
|
||||
// 用户反馈(最多±30)
|
||||
const totalFeedback = this.helpfulCount + this.unhelpfulCount;
|
||||
if (totalFeedback > 0) {
|
||||
const helpfulRatio = this.helpfulCount / totalFeedback;
|
||||
score += Math.round((helpfulRatio - 0.5) * 60);
|
||||
}
|
||||
|
||||
this.qualityScore = Math.max(0, Math.min(100, score));
|
||||
}
|
||||
}
|
||||
|
||||
export enum KnowledgeSource {
|
||||
MANUAL = 'MANUAL', // 手动添加
|
||||
CRAWL = 'CRAWL', // 网页爬取
|
||||
EXTRACT = 'EXTRACT', // 对话提取
|
||||
IMPORT = 'IMPORT', // 批量导入
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* 知识块实体 - 将文章拆分为更小的检索单元
|
||||
* 用于RAG检索时提高精确度
|
||||
*/
|
||||
export class KnowledgeChunkEntity {
|
||||
id: string;
|
||||
|
||||
/** 所属文章ID */
|
||||
articleId: string;
|
||||
|
||||
/** 块内容 */
|
||||
content: string;
|
||||
|
||||
/** 块序号(在文章中的位置) */
|
||||
chunkIndex: number;
|
||||
|
||||
/** 块类型 */
|
||||
chunkType: ChunkType;
|
||||
|
||||
/** 内容向量 */
|
||||
embedding?: number[];
|
||||
|
||||
/** 元数据(如标题层级、列表项等) */
|
||||
metadata: ChunkMetadata;
|
||||
|
||||
/** Token数量(估算) */
|
||||
tokenCount: number;
|
||||
|
||||
createdAt: Date;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static create(params: {
|
||||
articleId: string;
|
||||
content: string;
|
||||
chunkIndex: number;
|
||||
chunkType: ChunkType;
|
||||
metadata?: ChunkMetadata;
|
||||
}): KnowledgeChunkEntity {
|
||||
const chunk = new KnowledgeChunkEntity();
|
||||
chunk.id = uuidv4();
|
||||
chunk.articleId = params.articleId;
|
||||
chunk.content = params.content;
|
||||
chunk.chunkIndex = params.chunkIndex;
|
||||
chunk.chunkType = params.chunkType;
|
||||
chunk.metadata = params.metadata || {};
|
||||
chunk.tokenCount = KnowledgeChunkEntity.estimateTokens(params.content);
|
||||
chunk.createdAt = new Date();
|
||||
return chunk;
|
||||
}
|
||||
|
||||
static fromPersistence(data: Partial<KnowledgeChunkEntity>): KnowledgeChunkEntity {
|
||||
const chunk = new KnowledgeChunkEntity();
|
||||
Object.assign(chunk, data);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算Token数量(简单估算:中文字符约1.5token,英文单词约1.3token)
|
||||
*/
|
||||
private static estimateTokens(content: string): number {
|
||||
const chineseChars = (content.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const englishWords = (content.match(/[a-zA-Z]+/g) || []).length;
|
||||
return Math.ceil(chineseChars * 1.5 + englishWords * 1.3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置向量
|
||||
*/
|
||||
setEmbedding(embedding: number[]): void {
|
||||
this.embedding = embedding;
|
||||
}
|
||||
}
|
||||
|
||||
export enum ChunkType {
|
||||
TITLE = 'TITLE', // 标题
|
||||
PARAGRAPH = 'PARAGRAPH', // 段落
|
||||
LIST = 'LIST', // 列表
|
||||
TABLE = 'TABLE', // 表格
|
||||
CODE = 'CODE', // 代码块
|
||||
FAQ = 'FAQ', // 问答对
|
||||
}
|
||||
|
||||
export interface ChunkMetadata {
|
||||
/** 所属章节标题 */
|
||||
sectionTitle?: string;
|
||||
/** 标题层级 */
|
||||
headingLevel?: number;
|
||||
/** 是否包含重要关键词 */
|
||||
hasKeywords?: boolean;
|
||||
/** 前一个块ID(用于上下文) */
|
||||
prevChunkId?: string;
|
||||
/** 后一个块ID */
|
||||
nextChunkId?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* 系统经验实体 - 存储系统从对话中学习到的经验
|
||||
* 用于系统自我进化
|
||||
*/
|
||||
export class SystemExperienceEntity {
|
||||
id: string;
|
||||
|
||||
/** 经验类型 */
|
||||
experienceType: ExperienceType;
|
||||
|
||||
/** 经验内容/描述 */
|
||||
content: string;
|
||||
|
||||
/** 经验的置信度 0-100 */
|
||||
confidence: number;
|
||||
|
||||
/** 应用场景描述 */
|
||||
scenario: string;
|
||||
|
||||
/** 相关移民类别 */
|
||||
relatedCategory?: string;
|
||||
|
||||
/** 来源对话ID列表 */
|
||||
sourceConversationIds: string[];
|
||||
|
||||
/** 验证状态 */
|
||||
verificationStatus: VerificationStatus;
|
||||
|
||||
/** 验证者(管理员)ID */
|
||||
verifiedBy?: string;
|
||||
|
||||
/** 验证时间 */
|
||||
verifiedAt?: Date;
|
||||
|
||||
/** 使用次数(被应用到对话中) */
|
||||
usageCount: number;
|
||||
|
||||
/** 正面反馈次数 */
|
||||
positiveCount: number;
|
||||
|
||||
/** 负面反馈次数 */
|
||||
negativeCount: number;
|
||||
|
||||
/** 内容向量 */
|
||||
embedding?: number[];
|
||||
|
||||
/** 是否激活使用 */
|
||||
isActive: boolean;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static create(params: {
|
||||
experienceType: ExperienceType;
|
||||
content: string;
|
||||
scenario: string;
|
||||
relatedCategory?: string;
|
||||
sourceConversationId: string;
|
||||
confidence?: number;
|
||||
}): SystemExperienceEntity {
|
||||
const experience = new SystemExperienceEntity();
|
||||
experience.id = uuidv4();
|
||||
experience.experienceType = params.experienceType;
|
||||
experience.content = params.content;
|
||||
experience.scenario = params.scenario;
|
||||
experience.relatedCategory = params.relatedCategory;
|
||||
experience.sourceConversationIds = [params.sourceConversationId];
|
||||
experience.confidence = params.confidence ?? 50;
|
||||
experience.verificationStatus = VerificationStatus.PENDING;
|
||||
experience.usageCount = 0;
|
||||
experience.positiveCount = 0;
|
||||
experience.negativeCount = 0;
|
||||
experience.isActive = false; // 默认不激活,需要验证后才激活
|
||||
experience.createdAt = new Date();
|
||||
experience.updatedAt = new Date();
|
||||
return experience;
|
||||
}
|
||||
|
||||
static fromPersistence(data: Partial<SystemExperienceEntity>): SystemExperienceEntity {
|
||||
const experience = new SystemExperienceEntity();
|
||||
Object.assign(experience, data);
|
||||
return experience;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加来源对话
|
||||
*/
|
||||
addSourceConversation(conversationId: string): void {
|
||||
if (!this.sourceConversationIds.includes(conversationId)) {
|
||||
this.sourceConversationIds.push(conversationId);
|
||||
// 多个对话都产生相同经验,提高置信度
|
||||
this.confidence = Math.min(100, this.confidence + 5);
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置向量
|
||||
*/
|
||||
setEmbedding(embedding: number[]): void {
|
||||
this.embedding = embedding;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员验证通过
|
||||
*/
|
||||
approve(adminId: string): void {
|
||||
this.verificationStatus = VerificationStatus.APPROVED;
|
||||
this.verifiedBy = adminId;
|
||||
this.verifiedAt = new Date();
|
||||
this.isActive = true;
|
||||
this.confidence = Math.min(100, this.confidence + 20);
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员验证拒绝
|
||||
*/
|
||||
reject(adminId: string): void {
|
||||
this.verificationStatus = VerificationStatus.REJECTED;
|
||||
this.verifiedBy = adminId;
|
||||
this.verifiedAt = new Date();
|
||||
this.isActive = false;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录使用
|
||||
*/
|
||||
recordUsage(): void {
|
||||
this.usageCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录反馈
|
||||
*/
|
||||
recordFeedback(positive: boolean): void {
|
||||
if (positive) {
|
||||
this.positiveCount++;
|
||||
this.confidence = Math.min(100, this.confidence + 2);
|
||||
} else {
|
||||
this.negativeCount++;
|
||||
this.confidence = Math.max(0, this.confidence - 5);
|
||||
|
||||
// 负面反馈过多则自动停用
|
||||
if (this.negativeCount > 5 && this.negativeCount > this.positiveCount * 2) {
|
||||
this.isActive = false;
|
||||
this.verificationStatus = VerificationStatus.DEPRECATED;
|
||||
}
|
||||
}
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并相似经验
|
||||
*/
|
||||
mergeWith(other: SystemExperienceEntity): void {
|
||||
// 合并来源对话
|
||||
other.sourceConversationIds.forEach(id => {
|
||||
if (!this.sourceConversationIds.includes(id)) {
|
||||
this.sourceConversationIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
// 合并统计数据
|
||||
this.usageCount += other.usageCount;
|
||||
this.positiveCount += other.positiveCount;
|
||||
this.negativeCount += other.negativeCount;
|
||||
|
||||
// 重新计算置信度
|
||||
this.confidence = Math.min(100, (this.confidence + other.confidence) / 2 + 10);
|
||||
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
export enum ExperienceType {
|
||||
// 问答相关
|
||||
COMMON_QUESTION = 'COMMON_QUESTION', // 常见问题
|
||||
ANSWER_TEMPLATE = 'ANSWER_TEMPLATE', // 回答模板
|
||||
CLARIFICATION = 'CLARIFICATION', // 澄清方式
|
||||
|
||||
// 用户行为
|
||||
USER_PATTERN = 'USER_PATTERN', // 用户行为模式
|
||||
CONVERSION_TRIGGER = 'CONVERSION_TRIGGER', // 转化触发点
|
||||
|
||||
// 知识相关
|
||||
KNOWLEDGE_GAP = 'KNOWLEDGE_GAP', // 知识缺口
|
||||
KNOWLEDGE_UPDATE = 'KNOWLEDGE_UPDATE', // 知识更新
|
||||
|
||||
// 对话技巧
|
||||
CONVERSATION_SKILL = 'CONVERSATION_SKILL', // 对话技巧
|
||||
OBJECTION_HANDLING = 'OBJECTION_HANDLING', // 异议处理
|
||||
|
||||
// 其他
|
||||
CUSTOM = 'CUSTOM', // 自定义经验
|
||||
}
|
||||
|
||||
export enum VerificationStatus {
|
||||
PENDING = 'PENDING', // 待验证
|
||||
APPROVED = 'APPROVED', // 已通过
|
||||
REJECTED = 'REJECTED', // 已拒绝
|
||||
DEPRECATED = 'DEPRECATED', // 已弃用
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* 用户记忆实体 - 存储用户的长期记忆
|
||||
* 用于个性化对话和用户画像
|
||||
*/
|
||||
export class UserMemoryEntity {
|
||||
id: string;
|
||||
|
||||
/** 用户ID(可以是匿名用户ID) */
|
||||
userId: string;
|
||||
|
||||
/** 记忆类型 */
|
||||
memoryType: MemoryType;
|
||||
|
||||
/** 记忆内容 */
|
||||
content: string;
|
||||
|
||||
/** 记忆的重要性 0-100 */
|
||||
importance: number;
|
||||
|
||||
/** 记忆来源(哪次对话提取) */
|
||||
sourceConversationId?: string;
|
||||
|
||||
/** 相关移民类别 */
|
||||
relatedCategory?: string;
|
||||
|
||||
/** 内容向量 */
|
||||
embedding?: number[];
|
||||
|
||||
/** 访问次数(记忆被检索使用) */
|
||||
accessCount: number;
|
||||
|
||||
/** 最后访问时间 */
|
||||
lastAccessedAt?: Date;
|
||||
|
||||
/** 是否已过期(用户情况已变化) */
|
||||
isExpired: boolean;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static create(params: {
|
||||
userId: string;
|
||||
memoryType: MemoryType;
|
||||
content: string;
|
||||
importance?: number;
|
||||
sourceConversationId?: string;
|
||||
relatedCategory?: string;
|
||||
}): UserMemoryEntity {
|
||||
const memory = new UserMemoryEntity();
|
||||
memory.id = uuidv4();
|
||||
memory.userId = params.userId;
|
||||
memory.memoryType = params.memoryType;
|
||||
memory.content = params.content;
|
||||
memory.importance = params.importance ?? 50;
|
||||
memory.sourceConversationId = params.sourceConversationId;
|
||||
memory.relatedCategory = params.relatedCategory;
|
||||
memory.accessCount = 0;
|
||||
memory.isExpired = false;
|
||||
memory.createdAt = new Date();
|
||||
memory.updatedAt = new Date();
|
||||
return memory;
|
||||
}
|
||||
|
||||
static fromPersistence(data: Partial<UserMemoryEntity>): UserMemoryEntity {
|
||||
const memory = new UserMemoryEntity();
|
||||
Object.assign(memory, data);
|
||||
return memory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置向量
|
||||
*/
|
||||
setEmbedding(embedding: number[]): void {
|
||||
this.embedding = embedding;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录访问
|
||||
*/
|
||||
recordAccess(): void {
|
||||
this.accessCount++;
|
||||
this.lastAccessedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为过期
|
||||
*/
|
||||
markAsExpired(): void {
|
||||
this.isExpired = true;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新重要性
|
||||
*/
|
||||
updateImportance(importance: number): void {
|
||||
this.importance = Math.max(0, Math.min(100, importance));
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
export enum MemoryType {
|
||||
// 用户基本信息
|
||||
PERSONAL_INFO = 'PERSONAL_INFO', // 个人信息(年龄、学历等)
|
||||
WORK_EXPERIENCE = 'WORK_EXPERIENCE', // 工作经历
|
||||
EDUCATION = 'EDUCATION', // 教育背景
|
||||
LANGUAGE = 'LANGUAGE', // 语言能力
|
||||
|
||||
// 移民相关
|
||||
IMMIGRATION_INTENT = 'IMMIGRATION_INTENT', // 移民意向
|
||||
PREFERRED_CATEGORY = 'PREFERRED_CATEGORY', // 倾向的移民类别
|
||||
ASSESSMENT_RESULT = 'ASSESSMENT_RESULT', // 评估结果
|
||||
|
||||
// 对话相关
|
||||
QUESTION_ASKED = 'QUESTION_ASKED', // 问过的问题
|
||||
CONCERN = 'CONCERN', // 关注点/顾虑
|
||||
PREFERENCE = 'PREFERENCE', // 偏好设置
|
||||
|
||||
// 其他
|
||||
CUSTOM = 'CUSTOM', // 自定义记忆
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { KnowledgeArticleEntity } from '../entities/knowledge-article.entity';
|
||||
import { KnowledgeChunkEntity } from '../entities/knowledge-chunk.entity';
|
||||
|
||||
/**
|
||||
* 知识库仓储接口
|
||||
*/
|
||||
export interface IKnowledgeRepository {
|
||||
// ========== 文章操作 ==========
|
||||
|
||||
/** 保存文章 */
|
||||
saveArticle(article: KnowledgeArticleEntity): Promise<void>;
|
||||
|
||||
/** 根据ID获取文章 */
|
||||
findArticleById(id: string): Promise<KnowledgeArticleEntity | null>;
|
||||
|
||||
/** 根据类别获取文章列表 */
|
||||
findArticlesByCategory(category: string, options?: {
|
||||
publishedOnly?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<KnowledgeArticleEntity[]>;
|
||||
|
||||
/** 搜索文章(关键词) */
|
||||
searchArticles(query: string, options?: {
|
||||
category?: string;
|
||||
publishedOnly?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<KnowledgeArticleEntity[]>;
|
||||
|
||||
/** 向量相似度搜索文章 */
|
||||
searchArticlesByVector(embedding: number[], options?: {
|
||||
category?: string;
|
||||
publishedOnly?: boolean;
|
||||
limit?: number;
|
||||
minSimilarity?: number;
|
||||
}): Promise<Array<{ article: KnowledgeArticleEntity; similarity: number }>>;
|
||||
|
||||
/** 更新文章 */
|
||||
updateArticle(article: KnowledgeArticleEntity): Promise<void>;
|
||||
|
||||
/** 删除文章 */
|
||||
deleteArticle(id: string): Promise<void>;
|
||||
|
||||
/** 获取文章总数 */
|
||||
countArticles(options?: {
|
||||
category?: string;
|
||||
publishedOnly?: boolean;
|
||||
}): Promise<number>;
|
||||
|
||||
// ========== 块操作 ==========
|
||||
|
||||
/** 保存知识块 */
|
||||
saveChunk(chunk: KnowledgeChunkEntity): Promise<void>;
|
||||
|
||||
/** 批量保存知识块 */
|
||||
saveChunks(chunks: KnowledgeChunkEntity[]): Promise<void>;
|
||||
|
||||
/** 根据文章ID获取所有块 */
|
||||
findChunksByArticleId(articleId: string): Promise<KnowledgeChunkEntity[]>;
|
||||
|
||||
/** 向量相似度搜索块 */
|
||||
searchChunksByVector(embedding: number[], options?: {
|
||||
category?: string;
|
||||
limit?: number;
|
||||
minSimilarity?: number;
|
||||
}): Promise<Array<{ chunk: KnowledgeChunkEntity; similarity: number }>>;
|
||||
|
||||
/** 删除文章的所有块 */
|
||||
deleteChunksByArticleId(articleId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const KNOWLEDGE_REPOSITORY = Symbol('IKnowledgeRepository');
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { UserMemoryEntity, MemoryType } from '../entities/user-memory.entity';
|
||||
import { SystemExperienceEntity, ExperienceType, VerificationStatus } from '../entities/system-experience.entity';
|
||||
|
||||
/**
|
||||
* 用户记忆仓储接口
|
||||
*/
|
||||
export interface IUserMemoryRepository {
|
||||
/** 保存用户记忆 */
|
||||
save(memory: UserMemoryEntity): Promise<void>;
|
||||
|
||||
/** 根据ID获取记忆 */
|
||||
findById(id: string): Promise<UserMemoryEntity | null>;
|
||||
|
||||
/** 获取用户所有记忆 */
|
||||
findByUserId(userId: string, options?: {
|
||||
memoryType?: MemoryType;
|
||||
includeExpired?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<UserMemoryEntity[]>;
|
||||
|
||||
/** 向量相似度搜索用户记忆 */
|
||||
searchByVector(userId: string, embedding: number[], options?: {
|
||||
memoryType?: MemoryType;
|
||||
limit?: number;
|
||||
minSimilarity?: number;
|
||||
}): Promise<Array<{ memory: UserMemoryEntity; similarity: number }>>;
|
||||
|
||||
/** 获取用户最重要的记忆 */
|
||||
findTopMemories(userId: string, limit: number): Promise<UserMemoryEntity[]>;
|
||||
|
||||
/** 更新记忆 */
|
||||
update(memory: UserMemoryEntity): Promise<void>;
|
||||
|
||||
/** 删除记忆 */
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/** 删除用户所有记忆 */
|
||||
deleteByUserId(userId: string): Promise<void>;
|
||||
|
||||
/** 标记用户过期的记忆 */
|
||||
markExpiredMemories(userId: string, olderThanDays: number): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统经验仓储接口
|
||||
*/
|
||||
export interface ISystemExperienceRepository {
|
||||
/** 保存系统经验 */
|
||||
save(experience: SystemExperienceEntity): Promise<void>;
|
||||
|
||||
/** 根据ID获取经验 */
|
||||
findById(id: string): Promise<SystemExperienceEntity | null>;
|
||||
|
||||
/** 获取待验证的经验 */
|
||||
findPendingExperiences(options?: {
|
||||
experienceType?: ExperienceType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<SystemExperienceEntity[]>;
|
||||
|
||||
/** 获取已激活的经验 */
|
||||
findActiveExperiences(options?: {
|
||||
experienceType?: ExperienceType;
|
||||
category?: string;
|
||||
minConfidence?: number;
|
||||
limit?: number;
|
||||
}): Promise<SystemExperienceEntity[]>;
|
||||
|
||||
/** 向量相似度搜索经验 */
|
||||
searchByVector(embedding: number[], options?: {
|
||||
experienceType?: ExperienceType;
|
||||
activeOnly?: boolean;
|
||||
limit?: number;
|
||||
minSimilarity?: number;
|
||||
}): Promise<Array<{ experience: SystemExperienceEntity; similarity: number }>>;
|
||||
|
||||
/** 查找相似经验(用于合并) */
|
||||
findSimilarExperiences(embedding: number[], threshold: number): Promise<SystemExperienceEntity[]>;
|
||||
|
||||
/** 更新经验 */
|
||||
update(experience: SystemExperienceEntity): Promise<void>;
|
||||
|
||||
/** 删除经验 */
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/** 获取统计数据 */
|
||||
getStatistics(): Promise<{
|
||||
total: number;
|
||||
byStatus: Record<VerificationStatus, number>;
|
||||
byType: Record<ExperienceType, number>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const USER_MEMORY_REPOSITORY = Symbol('IUserMemoryRepository');
|
||||
export const SYSTEM_EXPERIENCE_REPOSITORY = Symbol('ISystemExperienceRepository');
|
||||
|
|
@ -0,0 +1,422 @@
|
|||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import neo4j, { Driver, Session, Transaction } from 'neo4j-driver';
|
||||
|
||||
/**
|
||||
* Neo4j知识图谱服务
|
||||
* 用于存储和查询用户时间线、实体关系等
|
||||
*/
|
||||
@Injectable()
|
||||
export class Neo4jService implements OnModuleInit, OnModuleDestroy {
|
||||
private driver: Driver;
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const uri = this.configService.get<string>('NEO4J_URI') || 'bolt://localhost:7687';
|
||||
const user = this.configService.get<string>('NEO4J_USER') || 'neo4j';
|
||||
const password = this.configService.get<string>('NEO4J_PASSWORD') || 'password';
|
||||
|
||||
this.driver = neo4j.driver(uri, neo4j.auth.basic(user, password), {
|
||||
maxConnectionPoolSize: 50,
|
||||
connectionAcquisitionTimeout: 30000,
|
||||
});
|
||||
|
||||
// 验证连接
|
||||
try {
|
||||
await this.driver.verifyConnectivity();
|
||||
console.log('[Neo4j] Connected successfully');
|
||||
|
||||
// 初始化schema
|
||||
await this.initializeSchema();
|
||||
} catch (error) {
|
||||
console.error('[Neo4j] Connection failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.driver?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话
|
||||
*/
|
||||
getSession(): Session {
|
||||
return this.driver.session();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行读查询
|
||||
*/
|
||||
async read<T>(query: string, params?: Record<string, unknown>): Promise<T[]> {
|
||||
const session = this.getSession();
|
||||
try {
|
||||
const result = await session.run(query, params);
|
||||
return result.records.map(record => record.toObject() as T);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行写查询
|
||||
*/
|
||||
async write<T>(query: string, params?: Record<string, unknown>): Promise<T[]> {
|
||||
const session = this.getSession();
|
||||
try {
|
||||
const result = await session.executeWrite(tx => tx.run(query, params));
|
||||
return result.records.map(record => record.toObject() as T);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化图谱Schema(约束和索引)
|
||||
*/
|
||||
private async initializeSchema() {
|
||||
const session = this.getSession();
|
||||
try {
|
||||
// 用户节点约束
|
||||
await session.run(`
|
||||
CREATE CONSTRAINT user_id IF NOT EXISTS
|
||||
FOR (u:User) REQUIRE u.id IS UNIQUE
|
||||
`);
|
||||
|
||||
// 对话节点约束
|
||||
await session.run(`
|
||||
CREATE CONSTRAINT conversation_id IF NOT EXISTS
|
||||
FOR (c:Conversation) REQUIRE c.id IS UNIQUE
|
||||
`);
|
||||
|
||||
// 知识实体约束
|
||||
await session.run(`
|
||||
CREATE CONSTRAINT entity_id IF NOT EXISTS
|
||||
FOR (e:Entity) REQUIRE e.id IS UNIQUE
|
||||
`);
|
||||
|
||||
// 事件节点约束
|
||||
await session.run(`
|
||||
CREATE CONSTRAINT event_id IF NOT EXISTS
|
||||
FOR (e:Event) REQUIRE e.id IS UNIQUE
|
||||
`);
|
||||
|
||||
// 时间索引
|
||||
await session.run(`
|
||||
CREATE INDEX event_timestamp IF NOT EXISTS
|
||||
FOR (e:Event) ON (e.timestamp)
|
||||
`);
|
||||
|
||||
// 类别索引
|
||||
await session.run(`
|
||||
CREATE INDEX entity_category IF NOT EXISTS
|
||||
FOR (e:Entity) ON (e.category)
|
||||
`);
|
||||
|
||||
console.log('[Neo4j] Schema initialized');
|
||||
} catch (error) {
|
||||
// 约束可能已存在,忽略错误
|
||||
console.log('[Neo4j] Schema initialization skipped (may already exist)');
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 用户时间线操作 ==========
|
||||
|
||||
/**
|
||||
* 创建用户节点
|
||||
*/
|
||||
async createUserNode(userId: string, properties?: Record<string, unknown>): Promise<void> {
|
||||
await this.write(
|
||||
`
|
||||
MERGE (u:User {id: $userId})
|
||||
SET u += $properties, u.updatedAt = datetime()
|
||||
`,
|
||||
{ userId, properties: properties || {} },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户事件(时间线)
|
||||
*/
|
||||
async recordUserEvent(params: {
|
||||
userId: string;
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
content: string;
|
||||
timestamp?: Date;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const { userId, eventId, eventType, content, metadata } = params;
|
||||
const timestamp = params.timestamp || new Date();
|
||||
|
||||
await this.write(
|
||||
`
|
||||
MATCH (u:User {id: $userId})
|
||||
CREATE (e:Event {
|
||||
id: $eventId,
|
||||
type: $eventType,
|
||||
content: $content,
|
||||
timestamp: datetime($timestamp),
|
||||
metadata: $metadata
|
||||
})
|
||||
CREATE (u)-[:HAS_EVENT]->(e)
|
||||
|
||||
WITH u, e
|
||||
OPTIONAL MATCH (u)-[:HAS_EVENT]->(prev:Event)
|
||||
WHERE prev.timestamp < e.timestamp AND prev.id <> e.id
|
||||
WITH e, prev
|
||||
ORDER BY prev.timestamp DESC
|
||||
LIMIT 1
|
||||
FOREACH (_ IN CASE WHEN prev IS NOT NULL THEN [1] ELSE [] END |
|
||||
CREATE (prev)-[:FOLLOWED_BY]->(e)
|
||||
)
|
||||
`,
|
||||
{
|
||||
userId,
|
||||
eventId,
|
||||
eventType,
|
||||
content,
|
||||
timestamp: timestamp.toISOString(),
|
||||
metadata: JSON.stringify(metadata || {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户时间线
|
||||
*/
|
||||
async getUserTimeline(
|
||||
userId: string,
|
||||
options?: { limit?: number; beforeDate?: Date; eventTypes?: string[] },
|
||||
): Promise<Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
metadata: Record<string, unknown>;
|
||||
}>> {
|
||||
let query = `
|
||||
MATCH (u:User {id: $userId})-[:HAS_EVENT]->(e:Event)
|
||||
`;
|
||||
|
||||
const params: Record<string, unknown> = { userId };
|
||||
|
||||
if (options?.beforeDate) {
|
||||
query += ` WHERE e.timestamp < datetime($beforeDate)`;
|
||||
params.beforeDate = options.beforeDate.toISOString();
|
||||
}
|
||||
|
||||
if (options?.eventTypes?.length) {
|
||||
const typeCondition = options.beforeDate ? ' AND' : ' WHERE';
|
||||
query += `${typeCondition} e.type IN $eventTypes`;
|
||||
params.eventTypes = options.eventTypes;
|
||||
}
|
||||
|
||||
query += `
|
||||
RETURN e.id as id, e.type as type, e.content as content,
|
||||
e.timestamp as timestamp, e.metadata as metadata
|
||||
ORDER BY e.timestamp DESC
|
||||
LIMIT $limit
|
||||
`;
|
||||
params.limit = options?.limit || 20;
|
||||
|
||||
const results = await this.read<{
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
timestamp: { toString: () => string };
|
||||
metadata: string;
|
||||
}>(query, params);
|
||||
|
||||
return results.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
content: r.content,
|
||||
timestamp: new Date(r.timestamp.toString()),
|
||||
metadata: JSON.parse(r.metadata || '{}'),
|
||||
}));
|
||||
}
|
||||
|
||||
// ========== 知识图谱操作 ==========
|
||||
|
||||
/**
|
||||
* 创建知识实体
|
||||
*/
|
||||
async createEntity(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
category?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
await this.write(
|
||||
`
|
||||
MERGE (e:Entity {id: $id})
|
||||
SET e.name = $name,
|
||||
e.type = $type,
|
||||
e.category = $category,
|
||||
e += $properties,
|
||||
e.updatedAt = datetime()
|
||||
`,
|
||||
{
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
type: params.type,
|
||||
category: params.category || '',
|
||||
properties: params.properties || {},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建实体关系
|
||||
*/
|
||||
async createRelation(params: {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
relationType: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
// 动态创建关系类型
|
||||
const relationQuery = `
|
||||
MATCH (from:Entity {id: $fromId})
|
||||
MATCH (to:Entity {id: $toId})
|
||||
MERGE (from)-[r:${params.relationType}]->(to)
|
||||
SET r += $properties, r.updatedAt = datetime()
|
||||
`;
|
||||
|
||||
await this.write(relationQuery, {
|
||||
fromId: params.fromId,
|
||||
toId: params.toId,
|
||||
properties: params.properties || {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询相关实体
|
||||
*/
|
||||
async findRelatedEntities(
|
||||
entityId: string,
|
||||
options?: { relationTypes?: string[]; maxDepth?: number; limit?: number },
|
||||
): Promise<Array<{
|
||||
entity: { id: string; name: string; type: string };
|
||||
relation: string;
|
||||
depth: number;
|
||||
}>> {
|
||||
const maxDepth = options?.maxDepth || 2;
|
||||
const limit = options?.limit || 20;
|
||||
|
||||
let relationPattern = '*1..' + maxDepth;
|
||||
if (options?.relationTypes?.length) {
|
||||
relationPattern = options.relationTypes.join('|') + relationPattern;
|
||||
}
|
||||
|
||||
const query = `
|
||||
MATCH path = (start:Entity {id: $entityId})-[r:${relationPattern}]-(related:Entity)
|
||||
WHERE start <> related
|
||||
RETURN related.id as id, related.name as name, related.type as type,
|
||||
type(r) as relation, length(path) as depth
|
||||
ORDER BY depth ASC
|
||||
LIMIT $limit
|
||||
`;
|
||||
|
||||
const results = await this.read<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
relation: string;
|
||||
depth: number;
|
||||
}>(query, { entityId, limit });
|
||||
|
||||
return results.map(r => ({
|
||||
entity: { id: r.id, name: r.name, type: r.type },
|
||||
relation: r.relation,
|
||||
depth: r.depth,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索实体
|
||||
*/
|
||||
async searchEntities(
|
||||
keyword: string,
|
||||
options?: { category?: string; type?: string; limit?: number },
|
||||
): Promise<Array<{ id: string; name: string; type: string; score: number }>> {
|
||||
let query = `
|
||||
MATCH (e:Entity)
|
||||
WHERE e.name CONTAINS $keyword
|
||||
`;
|
||||
|
||||
const params: Record<string, unknown> = { keyword };
|
||||
|
||||
if (options?.category) {
|
||||
query += ` AND e.category = $category`;
|
||||
params.category = options.category;
|
||||
}
|
||||
|
||||
if (options?.type) {
|
||||
query += ` AND e.type = $type`;
|
||||
params.type = options.type;
|
||||
}
|
||||
|
||||
query += `
|
||||
RETURN e.id as id, e.name as name, e.type as type,
|
||||
CASE WHEN e.name STARTS WITH $keyword THEN 1.0 ELSE 0.5 END as score
|
||||
ORDER BY score DESC
|
||||
LIMIT $limit
|
||||
`;
|
||||
params.limit = options?.limit || 10;
|
||||
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
// ========== 对话关联 ==========
|
||||
|
||||
/**
|
||||
* 关联对话与实体
|
||||
*/
|
||||
async linkConversationToEntity(
|
||||
conversationId: string,
|
||||
entityId: string,
|
||||
mentionType: 'MENTIONED' | 'DISCUSSED' | 'ASKED_ABOUT',
|
||||
): Promise<void> {
|
||||
await this.write(
|
||||
`
|
||||
MERGE (c:Conversation {id: $conversationId})
|
||||
WITH c
|
||||
MATCH (e:Entity {id: $entityId})
|
||||
MERGE (c)-[r:${mentionType}]->(e)
|
||||
SET r.timestamp = datetime()
|
||||
`,
|
||||
{ conversationId, entityId },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话相关实体
|
||||
*/
|
||||
async getConversationEntities(conversationId: string): Promise<Array<{
|
||||
entity: { id: string; name: string; type: string };
|
||||
mentionType: string;
|
||||
}>> {
|
||||
const results = await this.read<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
mentionType: string;
|
||||
}>(
|
||||
`
|
||||
MATCH (c:Conversation {id: $conversationId})-[r]->(e:Entity)
|
||||
RETURN e.id as id, e.name as name, e.type as type, type(r) as mentionType
|
||||
`,
|
||||
{ conversationId },
|
||||
);
|
||||
|
||||
return results.map(r => ({
|
||||
entity: { id: r.id, name: r.name, type: r.type },
|
||||
mentionType: r.mentionType,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('knowledge_articles')
|
||||
@Index('idx_knowledge_articles_category', ['category'])
|
||||
@Index('idx_knowledge_articles_published', ['isPublished'])
|
||||
export class KnowledgeArticleORM {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ length: 500 })
|
||||
title: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
summary: string;
|
||||
|
||||
@Column({ length: 50 })
|
||||
category: string;
|
||||
|
||||
@Column('text', { array: true, default: '{}' })
|
||||
tags: string[];
|
||||
|
||||
@Column({ length: 20 })
|
||||
source: string;
|
||||
|
||||
@Column({ name: 'source_url', length: 1000, nullable: true })
|
||||
sourceUrl?: string;
|
||||
|
||||
@Column('float', { array: true, nullable: true })
|
||||
embedding?: number[];
|
||||
|
||||
@Column({ name: 'is_published', default: false })
|
||||
isPublished: boolean;
|
||||
|
||||
@Column({ name: 'citation_count', default: 0 })
|
||||
citationCount: number;
|
||||
|
||||
@Column({ name: 'helpful_count', default: 0 })
|
||||
helpfulCount: number;
|
||||
|
||||
@Column({ name: 'unhelpful_count', default: 0 })
|
||||
unhelpfulCount: number;
|
||||
|
||||
@Column({ name: 'quality_score', default: 50 })
|
||||
qualityScore: number;
|
||||
|
||||
@Column({ name: 'created_by', nullable: true })
|
||||
createdBy?: string;
|
||||
|
||||
@Column({ name: 'updated_by', nullable: true })
|
||||
updatedBy?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('knowledge_chunks')
|
||||
@Index('idx_knowledge_chunks_article', ['articleId'])
|
||||
export class KnowledgeChunkORM {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'article_id' })
|
||||
articleId: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column({ name: 'chunk_index' })
|
||||
chunkIndex: number;
|
||||
|
||||
@Column({ name: 'chunk_type', length: 20 })
|
||||
chunkType: string;
|
||||
|
||||
@Column('float', { array: true, nullable: true })
|
||||
embedding?: number[];
|
||||
|
||||
@Column('jsonb', { default: '{}' })
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
@Column({ name: 'token_count', default: 0 })
|
||||
tokenCount: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('system_experiences')
|
||||
@Index('idx_system_experiences_type', ['experienceType'])
|
||||
@Index('idx_system_experiences_status', ['verificationStatus'])
|
||||
@Index('idx_system_experiences_active', ['isActive'])
|
||||
export class SystemExperienceORM {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'experience_type', length: 30 })
|
||||
experienceType: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column({ default: 50 })
|
||||
confidence: number;
|
||||
|
||||
@Column('text')
|
||||
scenario: string;
|
||||
|
||||
@Column({ name: 'related_category', length: 50, nullable: true })
|
||||
relatedCategory?: string;
|
||||
|
||||
@Column('text', { name: 'source_conversation_ids', array: true, default: '{}' })
|
||||
sourceConversationIds: string[];
|
||||
|
||||
@Column({ name: 'verification_status', length: 20, default: 'PENDING' })
|
||||
verificationStatus: string;
|
||||
|
||||
@Column({ name: 'verified_by', nullable: true })
|
||||
verifiedBy?: string;
|
||||
|
||||
@Column({ name: 'verified_at', nullable: true })
|
||||
verifiedAt?: Date;
|
||||
|
||||
@Column({ name: 'usage_count', default: 0 })
|
||||
usageCount: number;
|
||||
|
||||
@Column({ name: 'positive_count', default: 0 })
|
||||
positiveCount: number;
|
||||
|
||||
@Column({ name: 'negative_count', default: 0 })
|
||||
negativeCount: number;
|
||||
|
||||
@Column('float', { array: true, nullable: true })
|
||||
embedding?: number[];
|
||||
|
||||
@Column({ name: 'is_active', default: false })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('user_memories')
|
||||
@Index('idx_user_memories_user', ['userId'])
|
||||
@Index('idx_user_memories_type', ['memoryType'])
|
||||
export class UserMemoryORM {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
userId: string;
|
||||
|
||||
@Column({ name: 'memory_type', length: 30 })
|
||||
memoryType: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column({ default: 50 })
|
||||
importance: number;
|
||||
|
||||
@Column({ name: 'source_conversation_id', nullable: true })
|
||||
sourceConversationId?: string;
|
||||
|
||||
@Column({ name: 'related_category', length: 50, nullable: true })
|
||||
relatedCategory?: string;
|
||||
|
||||
@Column('float', { array: true, nullable: true })
|
||||
embedding?: number[];
|
||||
|
||||
@Column({ name: 'access_count', default: 0 })
|
||||
accessCount: number;
|
||||
|
||||
@Column({ name: 'last_accessed_at', nullable: true })
|
||||
lastAccessedAt?: Date;
|
||||
|
||||
@Column({ name: 'is_expired', default: false })
|
||||
isExpired: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, ILike } from 'typeorm';
|
||||
import { IKnowledgeRepository } from '../../../domain/repositories/knowledge.repository.interface';
|
||||
import { KnowledgeArticleEntity, KnowledgeSource } from '../../../domain/entities/knowledge-article.entity';
|
||||
import { KnowledgeChunkEntity, ChunkType } from '../../../domain/entities/knowledge-chunk.entity';
|
||||
import { KnowledgeArticleORM } from './entities/knowledge-article.orm';
|
||||
import { KnowledgeChunkORM } from './entities/knowledge-chunk.orm';
|
||||
|
||||
@Injectable()
|
||||
export class KnowledgePostgresRepository implements IKnowledgeRepository {
|
||||
constructor(
|
||||
@InjectRepository(KnowledgeArticleORM)
|
||||
private articleRepo: Repository<KnowledgeArticleORM>,
|
||||
@InjectRepository(KnowledgeChunkORM)
|
||||
private chunkRepo: Repository<KnowledgeChunkORM>,
|
||||
) {}
|
||||
|
||||
// ========== 文章操作 ==========
|
||||
|
||||
async saveArticle(article: KnowledgeArticleEntity): Promise<void> {
|
||||
const orm = this.toArticleORM(article);
|
||||
await this.articleRepo.save(orm);
|
||||
}
|
||||
|
||||
async findArticleById(id: string): Promise<KnowledgeArticleEntity | null> {
|
||||
const orm = await this.articleRepo.findOne({ where: { id } });
|
||||
return orm ? this.toArticleEntity(orm) : null;
|
||||
}
|
||||
|
||||
async findArticlesByCategory(
|
||||
category: string,
|
||||
options?: { publishedOnly?: boolean; limit?: number; offset?: number },
|
||||
): Promise<KnowledgeArticleEntity[]> {
|
||||
const query = this.articleRepo.createQueryBuilder('article')
|
||||
.where('article.category = :category', { category });
|
||||
|
||||
if (options?.publishedOnly) {
|
||||
query.andWhere('article.isPublished = true');
|
||||
}
|
||||
|
||||
query.orderBy('article.qualityScore', 'DESC')
|
||||
.addOrderBy('article.createdAt', 'DESC');
|
||||
|
||||
if (options?.limit) {
|
||||
query.take(options.limit);
|
||||
}
|
||||
if (options?.offset) {
|
||||
query.skip(options.offset);
|
||||
}
|
||||
|
||||
const orms = await query.getMany();
|
||||
return orms.map(orm => this.toArticleEntity(orm));
|
||||
}
|
||||
|
||||
async searchArticles(
|
||||
queryStr: string,
|
||||
options?: { category?: string; publishedOnly?: boolean; limit?: number },
|
||||
): Promise<KnowledgeArticleEntity[]> {
|
||||
const query = this.articleRepo.createQueryBuilder('article')
|
||||
.where('(article.title ILIKE :search OR article.content ILIKE :search)', {
|
||||
search: `%${queryStr}%`,
|
||||
});
|
||||
|
||||
if (options?.category) {
|
||||
query.andWhere('article.category = :category', { category: options.category });
|
||||
}
|
||||
|
||||
if (options?.publishedOnly) {
|
||||
query.andWhere('article.isPublished = true');
|
||||
}
|
||||
|
||||
query.orderBy('article.qualityScore', 'DESC')
|
||||
.take(options?.limit || 10);
|
||||
|
||||
const orms = await query.getMany();
|
||||
return orms.map(orm => this.toArticleEntity(orm));
|
||||
}
|
||||
|
||||
async searchArticlesByVector(
|
||||
embedding: number[],
|
||||
options?: {
|
||||
category?: string;
|
||||
publishedOnly?: boolean;
|
||||
limit?: number;
|
||||
minSimilarity?: number;
|
||||
},
|
||||
): Promise<Array<{ article: KnowledgeArticleEntity; similarity: number }>> {
|
||||
const embeddingStr = `[${embedding.join(',')}]`;
|
||||
const limit = options?.limit || 5;
|
||||
const minSimilarity = options?.minSimilarity || 0.7;
|
||||
|
||||
let sql = `
|
||||
SELECT *,
|
||||
1 - (embedding <=> '${embeddingStr}'::vector) as similarity
|
||||
FROM knowledge_articles
|
||||
WHERE embedding IS NOT NULL
|
||||
`;
|
||||
|
||||
if (options?.category) {
|
||||
sql += ` AND category = '${options.category}'`;
|
||||
}
|
||||
|
||||
if (options?.publishedOnly) {
|
||||
sql += ` AND is_published = true`;
|
||||
}
|
||||
|
||||
sql += `
|
||||
HAVING 1 - (embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity}
|
||||
ORDER BY similarity DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
// 使用原生查询以利用pgvector
|
||||
const results = await this.articleRepo.query(sql);
|
||||
|
||||
return results.map((row: any) => ({
|
||||
article: this.toArticleEntityFromRaw(row),
|
||||
similarity: parseFloat(row.similarity),
|
||||
}));
|
||||
}
|
||||
|
||||
async updateArticle(article: KnowledgeArticleEntity): Promise<void> {
|
||||
const orm = this.toArticleORM(article);
|
||||
await this.articleRepo.save(orm);
|
||||
}
|
||||
|
||||
async deleteArticle(id: string): Promise<void> {
|
||||
await this.articleRepo.delete(id);
|
||||
}
|
||||
|
||||
async countArticles(options?: { category?: string; publishedOnly?: boolean }): Promise<number> {
|
||||
const query = this.articleRepo.createQueryBuilder('article');
|
||||
|
||||
if (options?.category) {
|
||||
query.andWhere('article.category = :category', { category: options.category });
|
||||
}
|
||||
|
||||
if (options?.publishedOnly) {
|
||||
query.andWhere('article.isPublished = true');
|
||||
}
|
||||
|
||||
return query.getCount();
|
||||
}
|
||||
|
||||
// ========== 块操作 ==========
|
||||
|
||||
async saveChunk(chunk: KnowledgeChunkEntity): Promise<void> {
|
||||
const orm = this.toChunkORM(chunk);
|
||||
await this.chunkRepo.save(orm);
|
||||
}
|
||||
|
||||
async saveChunks(chunks: KnowledgeChunkEntity[]): Promise<void> {
|
||||
const orms = chunks.map(chunk => this.toChunkORM(chunk));
|
||||
await this.chunkRepo.save(orms);
|
||||
}
|
||||
|
||||
async findChunksByArticleId(articleId: string): Promise<KnowledgeChunkEntity[]> {
|
||||
const orms = await this.chunkRepo.find({
|
||||
where: { articleId },
|
||||
order: { chunkIndex: 'ASC' },
|
||||
});
|
||||
return orms.map(orm => this.toChunkEntity(orm));
|
||||
}
|
||||
|
||||
async searchChunksByVector(
|
||||
embedding: number[],
|
||||
options?: {
|
||||
category?: string;
|
||||
limit?: number;
|
||||
minSimilarity?: number;
|
||||
},
|
||||
): Promise<Array<{ chunk: KnowledgeChunkEntity; similarity: number }>> {
|
||||
const embeddingStr = `[${embedding.join(',')}]`;
|
||||
const limit = options?.limit || 5;
|
||||
const minSimilarity = options?.minSimilarity || 0.7;
|
||||
|
||||
let sql = `
|
||||
SELECT c.*,
|
||||
1 - (c.embedding <=> '${embeddingStr}'::vector) as similarity
|
||||
FROM knowledge_chunks c
|
||||
JOIN knowledge_articles a ON c.article_id = a.id
|
||||
WHERE c.embedding IS NOT NULL
|
||||
AND a.is_published = true
|
||||
`;
|
||||
|
||||
if (options?.category) {
|
||||
sql += ` AND a.category = '${options.category}'`;
|
||||
}
|
||||
|
||||
sql += `
|
||||
AND 1 - (c.embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity}
|
||||
ORDER BY similarity DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
const results = await this.chunkRepo.query(sql);
|
||||
|
||||
return results.map((row: any) => ({
|
||||
chunk: this.toChunkEntityFromRaw(row),
|
||||
similarity: parseFloat(row.similarity),
|
||||
}));
|
||||
}
|
||||
|
||||
async deleteChunksByArticleId(articleId: string): Promise<void> {
|
||||
await this.chunkRepo.delete({ articleId });
|
||||
}
|
||||
|
||||
// ========== 转换方法 ==========
|
||||
|
||||
private toArticleORM(entity: KnowledgeArticleEntity): KnowledgeArticleORM {
|
||||
const orm = new KnowledgeArticleORM();
|
||||
orm.id = entity.id;
|
||||
orm.title = entity.title;
|
||||
orm.content = entity.content;
|
||||
orm.summary = entity.summary;
|
||||
orm.category = entity.category;
|
||||
orm.tags = entity.tags;
|
||||
orm.source = entity.source;
|
||||
orm.sourceUrl = entity.sourceUrl;
|
||||
orm.embedding = entity.embedding;
|
||||
orm.isPublished = entity.isPublished;
|
||||
orm.citationCount = entity.citationCount;
|
||||
orm.helpfulCount = entity.helpfulCount;
|
||||
orm.unhelpfulCount = entity.unhelpfulCount;
|
||||
orm.qualityScore = entity.qualityScore;
|
||||
orm.createdBy = entity.createdBy;
|
||||
orm.updatedBy = entity.updatedBy;
|
||||
orm.createdAt = entity.createdAt;
|
||||
orm.updatedAt = entity.updatedAt;
|
||||
return orm;
|
||||
}
|
||||
|
||||
private toArticleEntity(orm: KnowledgeArticleORM): KnowledgeArticleEntity {
|
||||
return KnowledgeArticleEntity.fromPersistence({
|
||||
id: orm.id,
|
||||
title: orm.title,
|
||||
content: orm.content,
|
||||
summary: orm.summary,
|
||||
category: orm.category,
|
||||
tags: orm.tags,
|
||||
source: orm.source as KnowledgeSource,
|
||||
sourceUrl: orm.sourceUrl,
|
||||
embedding: orm.embedding,
|
||||
isPublished: orm.isPublished,
|
||||
citationCount: orm.citationCount,
|
||||
helpfulCount: orm.helpfulCount,
|
||||
unhelpfulCount: orm.unhelpfulCount,
|
||||
qualityScore: orm.qualityScore,
|
||||
createdBy: orm.createdBy,
|
||||
updatedBy: orm.updatedBy,
|
||||
createdAt: orm.createdAt,
|
||||
updatedAt: orm.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private toArticleEntityFromRaw(row: any): KnowledgeArticleEntity {
|
||||
return KnowledgeArticleEntity.fromPersistence({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
summary: row.summary,
|
||||
category: row.category,
|
||||
tags: row.tags,
|
||||
source: row.source as KnowledgeSource,
|
||||
sourceUrl: row.source_url,
|
||||
embedding: row.embedding,
|
||||
isPublished: row.is_published,
|
||||
citationCount: row.citation_count,
|
||||
helpfulCount: row.helpful_count,
|
||||
unhelpfulCount: row.unhelpful_count,
|
||||
qualityScore: row.quality_score,
|
||||
createdBy: row.created_by,
|
||||
updatedBy: row.updated_by,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
});
|
||||
}
|
||||
|
||||
private toChunkORM(entity: KnowledgeChunkEntity): KnowledgeChunkORM {
|
||||
const orm = new KnowledgeChunkORM();
|
||||
orm.id = entity.id;
|
||||
orm.articleId = entity.articleId;
|
||||
orm.content = entity.content;
|
||||
orm.chunkIndex = entity.chunkIndex;
|
||||
orm.chunkType = entity.chunkType;
|
||||
orm.embedding = entity.embedding;
|
||||
orm.metadata = entity.metadata as Record<string, unknown>;
|
||||
orm.tokenCount = entity.tokenCount;
|
||||
orm.createdAt = entity.createdAt;
|
||||
return orm;
|
||||
}
|
||||
|
||||
private toChunkEntity(orm: KnowledgeChunkORM): KnowledgeChunkEntity {
|
||||
return KnowledgeChunkEntity.fromPersistence({
|
||||
id: orm.id,
|
||||
articleId: orm.articleId,
|
||||
content: orm.content,
|
||||
chunkIndex: orm.chunkIndex,
|
||||
chunkType: orm.chunkType as ChunkType,
|
||||
embedding: orm.embedding,
|
||||
metadata: orm.metadata,
|
||||
tokenCount: orm.tokenCount,
|
||||
createdAt: orm.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
private toChunkEntityFromRaw(row: any): KnowledgeChunkEntity {
|
||||
return KnowledgeChunkEntity.fromPersistence({
|
||||
id: row.id,
|
||||
articleId: row.article_id,
|
||||
content: row.content,
|
||||
chunkIndex: row.chunk_index,
|
||||
chunkType: row.chunk_type as ChunkType,
|
||||
embedding: row.embedding,
|
||||
metadata: row.metadata,
|
||||
tokenCount: row.token_count,
|
||||
createdAt: new Date(row.created_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,426 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan, MoreThan } from 'typeorm';
|
||||
import {
|
||||
IUserMemoryRepository,
|
||||
ISystemExperienceRepository,
|
||||
} from '../../../domain/repositories/memory.repository.interface';
|
||||
import { UserMemoryEntity, MemoryType } from '../../../domain/entities/user-memory.entity';
|
||||
import {
|
||||
SystemExperienceEntity,
|
||||
ExperienceType,
|
||||
VerificationStatus,
|
||||
} from '../../../domain/entities/system-experience.entity';
|
||||
import { UserMemoryORM } from './entities/user-memory.orm';
|
||||
import { SystemExperienceORM } from './entities/system-experience.orm';
|
||||
|
||||
@Injectable()
|
||||
export class UserMemoryPostgresRepository implements IUserMemoryRepository {
|
||||
constructor(
|
||||
@InjectRepository(UserMemoryORM)
|
||||
private memoryRepo: Repository<UserMemoryORM>,
|
||||
) {}
|
||||
|
||||
async save(memory: UserMemoryEntity): Promise<void> {
|
||||
const orm = this.toORM(memory);
|
||||
await this.memoryRepo.save(orm);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<UserMemoryEntity | null> {
|
||||
const orm = await this.memoryRepo.findOne({ where: { id } });
|
||||
return orm ? this.toEntity(orm) : null;
|
||||
}
|
||||
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
options?: { memoryType?: MemoryType; includeExpired?: boolean; limit?: number },
|
||||
): Promise<UserMemoryEntity[]> {
|
||||
const query = this.memoryRepo.createQueryBuilder('memory')
|
||||
.where('memory.userId = :userId', { userId });
|
||||
|
||||
if (options?.memoryType) {
|
||||
query.andWhere('memory.memoryType = :type', { type: options.memoryType });
|
||||
}
|
||||
|
||||
if (!options?.includeExpired) {
|
||||
query.andWhere('memory.isExpired = false');
|
||||
}
|
||||
|
||||
query.orderBy('memory.importance', 'DESC')
|
||||
.addOrderBy('memory.createdAt', 'DESC');
|
||||
|
||||
if (options?.limit) {
|
||||
query.take(options.limit);
|
||||
}
|
||||
|
||||
const orms = await query.getMany();
|
||||
return orms.map(orm => this.toEntity(orm));
|
||||
}
|
||||
|
||||
async searchByVector(
|
||||
userId: string,
|
||||
embedding: number[],
|
||||
options?: { memoryType?: MemoryType; limit?: number; minSimilarity?: number },
|
||||
): Promise<Array<{ memory: UserMemoryEntity; similarity: number }>> {
|
||||
const embeddingStr = `[${embedding.join(',')}]`;
|
||||
const limit = options?.limit || 5;
|
||||
const minSimilarity = options?.minSimilarity || 0.7;
|
||||
|
||||
let sql = `
|
||||
SELECT *,
|
||||
1 - (embedding <=> '${embeddingStr}'::vector) as similarity
|
||||
FROM user_memories
|
||||
WHERE user_id = '${userId}'
|
||||
AND embedding IS NOT NULL
|
||||
AND is_expired = false
|
||||
`;
|
||||
|
||||
if (options?.memoryType) {
|
||||
sql += ` AND memory_type = '${options.memoryType}'`;
|
||||
}
|
||||
|
||||
sql += `
|
||||
AND 1 - (embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity}
|
||||
ORDER BY similarity DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
const results = await this.memoryRepo.query(sql);
|
||||
|
||||
return results.map((row: any) => ({
|
||||
memory: this.toEntityFromRaw(row),
|
||||
similarity: parseFloat(row.similarity),
|
||||
}));
|
||||
}
|
||||
|
||||
async findTopMemories(userId: string, limit: number): Promise<UserMemoryEntity[]> {
|
||||
const orms = await this.memoryRepo.find({
|
||||
where: { userId, isExpired: false },
|
||||
order: { importance: 'DESC', accessCount: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
return orms.map(orm => this.toEntity(orm));
|
||||
}
|
||||
|
||||
async update(memory: UserMemoryEntity): Promise<void> {
|
||||
const orm = this.toORM(memory);
|
||||
await this.memoryRepo.save(orm);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.memoryRepo.delete(id);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<void> {
|
||||
await this.memoryRepo.delete({ userId });
|
||||
}
|
||||
|
||||
async markExpiredMemories(userId: string, olderThanDays: number): Promise<number> {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
||||
|
||||
const result = await this.memoryRepo.update(
|
||||
{
|
||||
userId,
|
||||
isExpired: false,
|
||||
updatedAt: LessThan(cutoffDate),
|
||||
},
|
||||
{ isExpired: true },
|
||||
);
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
private toORM(entity: UserMemoryEntity): UserMemoryORM {
|
||||
const orm = new UserMemoryORM();
|
||||
orm.id = entity.id;
|
||||
orm.userId = entity.userId;
|
||||
orm.memoryType = entity.memoryType;
|
||||
orm.content = entity.content;
|
||||
orm.importance = entity.importance;
|
||||
orm.sourceConversationId = entity.sourceConversationId;
|
||||
orm.relatedCategory = entity.relatedCategory;
|
||||
orm.embedding = entity.embedding;
|
||||
orm.accessCount = entity.accessCount;
|
||||
orm.lastAccessedAt = entity.lastAccessedAt;
|
||||
orm.isExpired = entity.isExpired;
|
||||
orm.createdAt = entity.createdAt;
|
||||
orm.updatedAt = entity.updatedAt;
|
||||
return orm;
|
||||
}
|
||||
|
||||
private toEntity(orm: UserMemoryORM): UserMemoryEntity {
|
||||
return UserMemoryEntity.fromPersistence({
|
||||
id: orm.id,
|
||||
userId: orm.userId,
|
||||
memoryType: orm.memoryType as MemoryType,
|
||||
content: orm.content,
|
||||
importance: orm.importance,
|
||||
sourceConversationId: orm.sourceConversationId,
|
||||
relatedCategory: orm.relatedCategory,
|
||||
embedding: orm.embedding,
|
||||
accessCount: orm.accessCount,
|
||||
lastAccessedAt: orm.lastAccessedAt,
|
||||
isExpired: orm.isExpired,
|
||||
createdAt: orm.createdAt,
|
||||
updatedAt: orm.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private toEntityFromRaw(row: any): UserMemoryEntity {
|
||||
return UserMemoryEntity.fromPersistence({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
memoryType: row.memory_type as MemoryType,
|
||||
content: row.content,
|
||||
importance: row.importance,
|
||||
sourceConversationId: row.source_conversation_id,
|
||||
relatedCategory: row.related_category,
|
||||
embedding: row.embedding,
|
||||
accessCount: row.access_count,
|
||||
lastAccessedAt: row.last_accessed_at ? new Date(row.last_accessed_at) : undefined,
|
||||
isExpired: row.is_expired,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemExperiencePostgresRepository implements ISystemExperienceRepository {
|
||||
constructor(
|
||||
@InjectRepository(SystemExperienceORM)
|
||||
private experienceRepo: Repository<SystemExperienceORM>,
|
||||
) {}
|
||||
|
||||
async save(experience: SystemExperienceEntity): Promise<void> {
|
||||
const orm = this.toORM(experience);
|
||||
await this.experienceRepo.save(orm);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SystemExperienceEntity | null> {
|
||||
const orm = await this.experienceRepo.findOne({ where: { id } });
|
||||
return orm ? this.toEntity(orm) : null;
|
||||
}
|
||||
|
||||
async findPendingExperiences(options?: {
|
||||
experienceType?: ExperienceType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<SystemExperienceEntity[]> {
|
||||
const query = this.experienceRepo.createQueryBuilder('exp')
|
||||
.where('exp.verificationStatus = :status', { status: VerificationStatus.PENDING });
|
||||
|
||||
if (options?.experienceType) {
|
||||
query.andWhere('exp.experienceType = :type', { type: options.experienceType });
|
||||
}
|
||||
|
||||
query.orderBy('exp.confidence', 'DESC')
|
||||
.addOrderBy('exp.createdAt', 'DESC');
|
||||
|
||||
if (options?.limit) query.take(options.limit);
|
||||
if (options?.offset) query.skip(options.offset);
|
||||
|
||||
const orms = await query.getMany();
|
||||
return orms.map(orm => this.toEntity(orm));
|
||||
}
|
||||
|
||||
async findActiveExperiences(options?: {
|
||||
experienceType?: ExperienceType;
|
||||
category?: string;
|
||||
minConfidence?: number;
|
||||
limit?: number;
|
||||
}): Promise<SystemExperienceEntity[]> {
|
||||
const query = this.experienceRepo.createQueryBuilder('exp')
|
||||
.where('exp.isActive = true');
|
||||
|
||||
if (options?.experienceType) {
|
||||
query.andWhere('exp.experienceType = :type', { type: options.experienceType });
|
||||
}
|
||||
|
||||
if (options?.category) {
|
||||
query.andWhere('exp.relatedCategory = :category', { category: options.category });
|
||||
}
|
||||
|
||||
if (options?.minConfidence) {
|
||||
query.andWhere('exp.confidence >= :minConf', { minConf: options.minConfidence });
|
||||
}
|
||||
|
||||
query.orderBy('exp.confidence', 'DESC');
|
||||
|
||||
if (options?.limit) query.take(options.limit);
|
||||
|
||||
const orms = await query.getMany();
|
||||
return orms.map(orm => this.toEntity(orm));
|
||||
}
|
||||
|
||||
async searchByVector(
|
||||
embedding: number[],
|
||||
options?: {
|
||||
experienceType?: ExperienceType;
|
||||
activeOnly?: boolean;
|
||||
limit?: number;
|
||||
minSimilarity?: number;
|
||||
},
|
||||
): Promise<Array<{ experience: SystemExperienceEntity; similarity: number }>> {
|
||||
const embeddingStr = `[${embedding.join(',')}]`;
|
||||
const limit = options?.limit || 5;
|
||||
const minSimilarity = options?.minSimilarity || 0.7;
|
||||
|
||||
let sql = `
|
||||
SELECT *,
|
||||
1 - (embedding <=> '${embeddingStr}'::vector) as similarity
|
||||
FROM system_experiences
|
||||
WHERE embedding IS NOT NULL
|
||||
`;
|
||||
|
||||
if (options?.activeOnly !== false) {
|
||||
sql += ` AND is_active = true`;
|
||||
}
|
||||
|
||||
if (options?.experienceType) {
|
||||
sql += ` AND experience_type = '${options.experienceType}'`;
|
||||
}
|
||||
|
||||
sql += `
|
||||
AND 1 - (embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity}
|
||||
ORDER BY similarity DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
const results = await this.experienceRepo.query(sql);
|
||||
|
||||
return results.map((row: any) => ({
|
||||
experience: this.toEntityFromRaw(row),
|
||||
similarity: parseFloat(row.similarity),
|
||||
}));
|
||||
}
|
||||
|
||||
async findSimilarExperiences(
|
||||
embedding: number[],
|
||||
threshold: number,
|
||||
): Promise<SystemExperienceEntity[]> {
|
||||
const embeddingStr = `[${embedding.join(',')}]`;
|
||||
|
||||
const sql = `
|
||||
SELECT *
|
||||
FROM system_experiences
|
||||
WHERE embedding IS NOT NULL
|
||||
AND 1 - (embedding <=> '${embeddingStr}'::vector) >= ${threshold}
|
||||
ORDER BY 1 - (embedding <=> '${embeddingStr}'::vector) DESC
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
const results = await this.experienceRepo.query(sql);
|
||||
return results.map((row: any) => this.toEntityFromRaw(row));
|
||||
}
|
||||
|
||||
async update(experience: SystemExperienceEntity): Promise<void> {
|
||||
const orm = this.toORM(experience);
|
||||
await this.experienceRepo.save(orm);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.experienceRepo.delete(id);
|
||||
}
|
||||
|
||||
async getStatistics(): Promise<{
|
||||
total: number;
|
||||
byStatus: Record<VerificationStatus, number>;
|
||||
byType: Record<ExperienceType, number>;
|
||||
}> {
|
||||
const total = await this.experienceRepo.count();
|
||||
|
||||
const statusCounts = await this.experienceRepo
|
||||
.createQueryBuilder('exp')
|
||||
.select('exp.verificationStatus', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('exp.verificationStatus')
|
||||
.getRawMany();
|
||||
|
||||
const typeCounts = await this.experienceRepo
|
||||
.createQueryBuilder('exp')
|
||||
.select('exp.experienceType', 'type')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('exp.experienceType')
|
||||
.getRawMany();
|
||||
|
||||
const byStatus = {} as Record<VerificationStatus, number>;
|
||||
statusCounts.forEach((item: any) => {
|
||||
byStatus[item.status as VerificationStatus] = parseInt(item.count);
|
||||
});
|
||||
|
||||
const byType = {} as Record<ExperienceType, number>;
|
||||
typeCounts.forEach((item: any) => {
|
||||
byType[item.type as ExperienceType] = parseInt(item.count);
|
||||
});
|
||||
|
||||
return { total, byStatus, byType };
|
||||
}
|
||||
|
||||
private toORM(entity: SystemExperienceEntity): SystemExperienceORM {
|
||||
const orm = new SystemExperienceORM();
|
||||
orm.id = entity.id;
|
||||
orm.experienceType = entity.experienceType;
|
||||
orm.content = entity.content;
|
||||
orm.confidence = entity.confidence;
|
||||
orm.scenario = entity.scenario;
|
||||
orm.relatedCategory = entity.relatedCategory;
|
||||
orm.sourceConversationIds = entity.sourceConversationIds;
|
||||
orm.verificationStatus = entity.verificationStatus;
|
||||
orm.verifiedBy = entity.verifiedBy;
|
||||
orm.verifiedAt = entity.verifiedAt;
|
||||
orm.usageCount = entity.usageCount;
|
||||
orm.positiveCount = entity.positiveCount;
|
||||
orm.negativeCount = entity.negativeCount;
|
||||
orm.embedding = entity.embedding;
|
||||
orm.isActive = entity.isActive;
|
||||
orm.createdAt = entity.createdAt;
|
||||
orm.updatedAt = entity.updatedAt;
|
||||
return orm;
|
||||
}
|
||||
|
||||
private toEntity(orm: SystemExperienceORM): SystemExperienceEntity {
|
||||
return SystemExperienceEntity.fromPersistence({
|
||||
id: orm.id,
|
||||
experienceType: orm.experienceType as ExperienceType,
|
||||
content: orm.content,
|
||||
confidence: orm.confidence,
|
||||
scenario: orm.scenario,
|
||||
relatedCategory: orm.relatedCategory,
|
||||
sourceConversationIds: orm.sourceConversationIds,
|
||||
verificationStatus: orm.verificationStatus as VerificationStatus,
|
||||
verifiedBy: orm.verifiedBy,
|
||||
verifiedAt: orm.verifiedAt,
|
||||
usageCount: orm.usageCount,
|
||||
positiveCount: orm.positiveCount,
|
||||
negativeCount: orm.negativeCount,
|
||||
embedding: orm.embedding,
|
||||
isActive: orm.isActive,
|
||||
createdAt: orm.createdAt,
|
||||
updatedAt: orm.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private toEntityFromRaw(row: any): SystemExperienceEntity {
|
||||
return SystemExperienceEntity.fromPersistence({
|
||||
id: row.id,
|
||||
experienceType: row.experience_type as ExperienceType,
|
||||
content: row.content,
|
||||
confidence: row.confidence,
|
||||
scenario: row.scenario,
|
||||
relatedCategory: row.related_category,
|
||||
sourceConversationIds: row.source_conversation_ids,
|
||||
verificationStatus: row.verification_status as VerificationStatus,
|
||||
verifiedBy: row.verified_by,
|
||||
verifiedAt: row.verified_at ? new Date(row.verified_at) : undefined,
|
||||
usageCount: row.usage_count,
|
||||
positiveCount: row.positive_count,
|
||||
negativeCount: row.negative_count,
|
||||
embedding: row.embedding,
|
||||
isActive: row.is_active,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
/**
|
||||
* 向量嵌入服务 - 使用OpenAI的text-embedding-3-small模型
|
||||
* 用于将文本转换为向量,支持语义搜索
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmbeddingService implements OnModuleInit {
|
||||
private openai: OpenAI;
|
||||
private readonly modelName = 'text-embedding-3-small';
|
||||
private readonly dimensions = 1536; // text-embedding-3-small 默认维度
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('[EmbeddingService] OPENAI_API_KEY not set, using mock embeddings');
|
||||
return;
|
||||
}
|
||||
|
||||
this.openai = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: this.configService.get<string>('OPENAI_BASE_URL'), // 支持自定义endpoint
|
||||
});
|
||||
|
||||
console.log('[EmbeddingService] Initialized with OpenAI embedding model');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个文本的向量
|
||||
*/
|
||||
async getEmbedding(text: string): Promise<number[]> {
|
||||
if (!this.openai) {
|
||||
return this.getMockEmbedding(text);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.modelName,
|
||||
input: this.preprocessText(text),
|
||||
});
|
||||
|
||||
return response.data[0].embedding;
|
||||
} catch (error) {
|
||||
console.error('[EmbeddingService] Failed to get embedding:', error);
|
||||
// 降级到mock embedding
|
||||
return this.getMockEmbedding(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取向量
|
||||
*/
|
||||
async getEmbeddings(texts: string[]): Promise<number[][]> {
|
||||
if (!this.openai) {
|
||||
return texts.map(text => this.getMockEmbedding(text));
|
||||
}
|
||||
|
||||
try {
|
||||
const processedTexts = texts.map(text => this.preprocessText(text));
|
||||
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: this.modelName,
|
||||
input: processedTexts,
|
||||
});
|
||||
|
||||
// 按原始顺序返回
|
||||
return response.data
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map(item => item.embedding);
|
||||
} catch (error) {
|
||||
console.error('[EmbeddingService] Failed to get batch embeddings:', error);
|
||||
return texts.map(text => this.getMockEmbedding(text));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个向量的余弦相似度
|
||||
*/
|
||||
cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length !== b.length) {
|
||||
throw new Error('Vectors must have the same length');
|
||||
}
|
||||
|
||||
let dotProduct = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
if (normA === 0 || normB === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取向量维度
|
||||
*/
|
||||
getDimensions(): number {
|
||||
return this.dimensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理文本
|
||||
*/
|
||||
private preprocessText(text: string): string {
|
||||
return text
|
||||
.replace(/\s+/g, ' ') // 合并多个空白
|
||||
.trim()
|
||||
.substring(0, 8000); // OpenAI限制
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成Mock向量(用于开发测试)
|
||||
* 基于文本hash生成确定性的伪随机向量
|
||||
*/
|
||||
private getMockEmbedding(text: string): number[] {
|
||||
const embedding: number[] = [];
|
||||
let hash = 0;
|
||||
|
||||
// 简单hash
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
|
||||
// 基于hash生成伪随机向量
|
||||
const random = (seed: number) => {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
for (let i = 0; i < this.dimensions; i++) {
|
||||
const value = random(hash + i) * 2 - 1; // -1 to 1
|
||||
embedding.push(value);
|
||||
}
|
||||
|
||||
// 归一化
|
||||
const norm = Math.sqrt(embedding.reduce((sum, v) => sum + v * v, 0));
|
||||
return embedding.map(v => v / norm);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { KnowledgeService } from './knowledge.service';
|
||||
import { RAGService } from '../application/services/rag.service';
|
||||
import { KnowledgeSource } from '../domain/entities/knowledge-article.entity';
|
||||
|
||||
// ========== DTOs ==========
|
||||
|
||||
class CreateArticleDto {
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
sourceUrl?: string;
|
||||
autoPublish?: boolean;
|
||||
}
|
||||
|
||||
class UpdateArticleDto {
|
||||
title?: string;
|
||||
content?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
class SearchArticlesDto {
|
||||
query: string;
|
||||
category?: string;
|
||||
useVector?: boolean;
|
||||
}
|
||||
|
||||
class RetrieveKnowledgeDto {
|
||||
query: string;
|
||||
userId?: string;
|
||||
category?: string;
|
||||
includeMemories?: boolean;
|
||||
includeExperiences?: boolean;
|
||||
}
|
||||
|
||||
class ImportArticlesDto {
|
||||
articles: Array<{
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
class FeedbackDto {
|
||||
helpful: boolean;
|
||||
}
|
||||
|
||||
// ========== Controller ==========
|
||||
|
||||
@Controller('knowledge')
|
||||
export class KnowledgeController {
|
||||
constructor(
|
||||
private knowledgeService: KnowledgeService,
|
||||
private ragService: RAGService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建文章
|
||||
*/
|
||||
@Post('articles')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createArticle(@Body() dto: CreateArticleDto) {
|
||||
const article = await this.knowledgeService.createArticle({
|
||||
...dto,
|
||||
source: KnowledgeSource.MANUAL,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: article,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章详情
|
||||
*/
|
||||
@Get('articles/:id')
|
||||
async getArticle(@Param('id') id: string) {
|
||||
const article = await this.knowledgeService.getArticle(id);
|
||||
if (!article) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Article not found',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: article,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章列表
|
||||
*/
|
||||
@Get('articles')
|
||||
async listArticles(
|
||||
@Query('category') category?: string,
|
||||
@Query('publishedOnly') publishedOnly?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const result = await this.knowledgeService.listArticles({
|
||||
category,
|
||||
publishedOnly: publishedOnly === 'true',
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文章
|
||||
*/
|
||||
@Put('articles/:id')
|
||||
async updateArticle(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateArticleDto,
|
||||
) {
|
||||
const article = await this.knowledgeService.updateArticle(id, dto);
|
||||
return {
|
||||
success: true,
|
||||
data: article,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文章
|
||||
*/
|
||||
@Delete('articles/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteArticle(@Param('id') id: string) {
|
||||
await this.knowledgeService.deleteArticle(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布文章
|
||||
*/
|
||||
@Post('articles/:id/publish')
|
||||
async publishArticle(@Param('id') id: string) {
|
||||
await this.knowledgeService.publishArticle(id);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Article published',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消发布
|
||||
*/
|
||||
@Post('articles/:id/unpublish')
|
||||
async unpublishArticle(@Param('id') id: string) {
|
||||
await this.knowledgeService.unpublishArticle(id);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Article unpublished',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索文章
|
||||
*/
|
||||
@Post('articles/search')
|
||||
async searchArticles(@Body() dto: SearchArticlesDto) {
|
||||
const results = await this.knowledgeService.searchArticles(dto);
|
||||
return {
|
||||
success: true,
|
||||
data: results,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录反馈
|
||||
*/
|
||||
@Post('articles/:id/feedback')
|
||||
async recordFeedback(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: FeedbackDto,
|
||||
) {
|
||||
await this.knowledgeService.recordFeedback(id, dto.helpful);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Feedback recorded',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入
|
||||
*/
|
||||
@Post('articles/import')
|
||||
async importArticles(@Body() dto: ImportArticlesDto) {
|
||||
const result = await this.knowledgeService.importArticles(dto.articles);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
*/
|
||||
@Get('statistics')
|
||||
async getStatistics() {
|
||||
const stats = await this.knowledgeService.getStatistics();
|
||||
return {
|
||||
success: true,
|
||||
data: stats,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== RAG检索接口 ==========
|
||||
|
||||
/**
|
||||
* RAG知识检索(供对话服务调用)
|
||||
*/
|
||||
@Post('retrieve')
|
||||
async retrieveKnowledge(@Body() dto: RetrieveKnowledgeDto) {
|
||||
const result = await this.ragService.retrieve(dto);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索并格式化为提示词
|
||||
*/
|
||||
@Post('retrieve/prompt')
|
||||
async retrieveForPrompt(@Body() dto: RetrieveKnowledgeDto) {
|
||||
const context = await this.ragService.retrieveForPrompt({
|
||||
query: dto.query,
|
||||
userId: dto.userId,
|
||||
category: dto.category,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: { context },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否离题
|
||||
*/
|
||||
@Post('check-off-topic')
|
||||
async checkOffTopic(@Body() body: { query: string }) {
|
||||
const result = await this.ragService.checkOffTopic(body.query);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { KnowledgeController } from './knowledge.controller';
|
||||
import { KnowledgeService } from './knowledge.service';
|
||||
import { RAGService } from '../application/services/rag.service';
|
||||
import { ChunkingService } from '../application/services/chunking.service';
|
||||
import { EmbeddingService } from '../infrastructure/embedding/embedding.service';
|
||||
import { KnowledgePostgresRepository } from '../infrastructure/database/postgres/knowledge-postgres.repository';
|
||||
import {
|
||||
UserMemoryPostgresRepository,
|
||||
SystemExperiencePostgresRepository,
|
||||
} from '../infrastructure/database/postgres/memory-postgres.repository';
|
||||
import { KnowledgeArticleORM } from '../infrastructure/database/postgres/entities/knowledge-article.orm';
|
||||
import { KnowledgeChunkORM } from '../infrastructure/database/postgres/entities/knowledge-chunk.orm';
|
||||
import { UserMemoryORM } from '../infrastructure/database/postgres/entities/user-memory.orm';
|
||||
import { SystemExperienceORM } from '../infrastructure/database/postgres/entities/system-experience.orm';
|
||||
import { KNOWLEDGE_REPOSITORY } from '../domain/repositories/knowledge.repository.interface';
|
||||
import {
|
||||
USER_MEMORY_REPOSITORY,
|
||||
SYSTEM_EXPERIENCE_REPOSITORY,
|
||||
} from '../domain/repositories/memory.repository.interface';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
KnowledgeArticleORM,
|
||||
KnowledgeChunkORM,
|
||||
UserMemoryORM,
|
||||
SystemExperienceORM,
|
||||
]),
|
||||
],
|
||||
controllers: [KnowledgeController],
|
||||
providers: [
|
||||
KnowledgeService,
|
||||
RAGService,
|
||||
ChunkingService,
|
||||
EmbeddingService,
|
||||
{
|
||||
provide: KNOWLEDGE_REPOSITORY,
|
||||
useClass: KnowledgePostgresRepository,
|
||||
},
|
||||
{
|
||||
provide: USER_MEMORY_REPOSITORY,
|
||||
useClass: UserMemoryPostgresRepository,
|
||||
},
|
||||
{
|
||||
provide: SYSTEM_EXPERIENCE_REPOSITORY,
|
||||
useClass: SystemExperiencePostgresRepository,
|
||||
},
|
||||
],
|
||||
exports: [KnowledgeService, RAGService, EmbeddingService],
|
||||
})
|
||||
export class KnowledgeModule {}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { EmbeddingService } from '../infrastructure/embedding/embedding.service';
|
||||
import { ChunkingService } from '../application/services/chunking.service';
|
||||
import {
|
||||
IKnowledgeRepository,
|
||||
KNOWLEDGE_REPOSITORY,
|
||||
} from '../domain/repositories/knowledge.repository.interface';
|
||||
import {
|
||||
KnowledgeArticleEntity,
|
||||
KnowledgeSource,
|
||||
} from '../domain/entities/knowledge-article.entity';
|
||||
|
||||
/**
|
||||
* 知识管理服务
|
||||
* 提供知识库的CRUD操作和处理
|
||||
*/
|
||||
@Injectable()
|
||||
export class KnowledgeService {
|
||||
constructor(
|
||||
private embeddingService: EmbeddingService,
|
||||
private chunkingService: ChunkingService,
|
||||
@Inject(KNOWLEDGE_REPOSITORY)
|
||||
private knowledgeRepo: IKnowledgeRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建知识文章
|
||||
*/
|
||||
async createArticle(params: {
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
source?: KnowledgeSource;
|
||||
sourceUrl?: string;
|
||||
createdBy?: string;
|
||||
autoPublish?: boolean;
|
||||
}): Promise<KnowledgeArticleEntity> {
|
||||
// 1. 创建文章实体
|
||||
const article = KnowledgeArticleEntity.create({
|
||||
title: params.title,
|
||||
content: params.content,
|
||||
category: params.category,
|
||||
tags: params.tags,
|
||||
source: params.source || KnowledgeSource.MANUAL,
|
||||
sourceUrl: params.sourceUrl,
|
||||
createdBy: params.createdBy,
|
||||
});
|
||||
|
||||
// 2. 生成文章向量
|
||||
const embedding = await this.embeddingService.getEmbedding(
|
||||
`${article.title}\n${article.summary}`,
|
||||
);
|
||||
article.setEmbedding(embedding);
|
||||
|
||||
// 3. 自动发布(可选)
|
||||
if (params.autoPublish) {
|
||||
article.publish();
|
||||
}
|
||||
|
||||
// 4. 保存文章
|
||||
await this.knowledgeRepo.saveArticle(article);
|
||||
|
||||
// 5. 分块并保存
|
||||
await this.processArticleChunks(article);
|
||||
|
||||
console.log(`[KnowledgeService] Created article: ${article.id} - ${article.title}`);
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文章分块
|
||||
*/
|
||||
private async processArticleChunks(article: KnowledgeArticleEntity): Promise<void> {
|
||||
// 1. 删除旧的块
|
||||
await this.knowledgeRepo.deleteChunksByArticleId(article.id);
|
||||
|
||||
// 2. 分块
|
||||
const chunks = this.chunkingService.chunkArticle(article);
|
||||
|
||||
// 3. 批量生成向量
|
||||
const embeddings = await this.embeddingService.getEmbeddings(
|
||||
chunks.map(c => c.content),
|
||||
);
|
||||
|
||||
// 4. 设置向量
|
||||
chunks.forEach((chunk, index) => {
|
||||
chunk.setEmbedding(embeddings[index]);
|
||||
});
|
||||
|
||||
// 5. 保存块
|
||||
await this.knowledgeRepo.saveChunks(chunks);
|
||||
|
||||
console.log(`[KnowledgeService] Processed ${chunks.length} chunks for article ${article.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文章
|
||||
*/
|
||||
async updateArticle(
|
||||
articleId: string,
|
||||
params: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
updatedBy?: string;
|
||||
},
|
||||
): Promise<KnowledgeArticleEntity> {
|
||||
const article = await this.knowledgeRepo.findArticleById(articleId);
|
||||
if (!article) {
|
||||
throw new Error(`Article not found: ${articleId}`);
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if (params.title || params.content) {
|
||||
article.updateContent(
|
||||
params.title || article.title,
|
||||
params.content || article.content,
|
||||
params.updatedBy,
|
||||
);
|
||||
|
||||
// 重新生成向量
|
||||
const embedding = await this.embeddingService.getEmbedding(
|
||||
`${article.title}\n${article.summary}`,
|
||||
);
|
||||
article.setEmbedding(embedding);
|
||||
|
||||
// 重新分块
|
||||
await this.processArticleChunks(article);
|
||||
}
|
||||
|
||||
if (params.category) {
|
||||
article.category = params.category;
|
||||
}
|
||||
|
||||
if (params.tags) {
|
||||
article.tags = params.tags;
|
||||
}
|
||||
|
||||
await this.knowledgeRepo.updateArticle(article);
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章详情
|
||||
*/
|
||||
async getArticle(articleId: string): Promise<KnowledgeArticleEntity | null> {
|
||||
return this.knowledgeRepo.findArticleById(articleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章列表
|
||||
*/
|
||||
async listArticles(params: {
|
||||
category?: string;
|
||||
publishedOnly?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{
|
||||
items: KnowledgeArticleEntity[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}> {
|
||||
const page = params.page || 1;
|
||||
const pageSize = params.pageSize || 20;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.knowledgeRepo.findArticlesByCategory(params.category || '', {
|
||||
publishedOnly: params.publishedOnly,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
}),
|
||||
this.knowledgeRepo.countArticles({
|
||||
category: params.category,
|
||||
publishedOnly: params.publishedOnly,
|
||||
}),
|
||||
]);
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索文章
|
||||
*/
|
||||
async searchArticles(params: {
|
||||
query: string;
|
||||
category?: string;
|
||||
useVector?: boolean;
|
||||
}): Promise<Array<{
|
||||
article: KnowledgeArticleEntity;
|
||||
similarity?: number;
|
||||
}>> {
|
||||
if (params.useVector) {
|
||||
// 向量搜索
|
||||
const embedding = await this.embeddingService.getEmbedding(params.query);
|
||||
const results = await this.knowledgeRepo.searchArticlesByVector(embedding, {
|
||||
category: params.category,
|
||||
publishedOnly: true,
|
||||
limit: 10,
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
// 关键词搜索
|
||||
const articles = await this.knowledgeRepo.searchArticles(params.query, {
|
||||
category: params.category,
|
||||
publishedOnly: true,
|
||||
limit: 10,
|
||||
});
|
||||
return articles.map(article => ({ article }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布文章
|
||||
*/
|
||||
async publishArticle(articleId: string): Promise<void> {
|
||||
const article = await this.knowledgeRepo.findArticleById(articleId);
|
||||
if (!article) {
|
||||
throw new Error(`Article not found: ${articleId}`);
|
||||
}
|
||||
|
||||
article.publish();
|
||||
await this.knowledgeRepo.updateArticle(article);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消发布
|
||||
*/
|
||||
async unpublishArticle(articleId: string): Promise<void> {
|
||||
const article = await this.knowledgeRepo.findArticleById(articleId);
|
||||
if (!article) {
|
||||
throw new Error(`Article not found: ${articleId}`);
|
||||
}
|
||||
|
||||
article.unpublish();
|
||||
await this.knowledgeRepo.updateArticle(article);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文章
|
||||
*/
|
||||
async deleteArticle(articleId: string): Promise<void> {
|
||||
// 先删除分块
|
||||
await this.knowledgeRepo.deleteChunksByArticleId(articleId);
|
||||
// 再删除文章
|
||||
await this.knowledgeRepo.deleteArticle(articleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户反馈
|
||||
*/
|
||||
async recordFeedback(articleId: string, helpful: boolean): Promise<void> {
|
||||
const article = await this.knowledgeRepo.findArticleById(articleId);
|
||||
if (!article) {
|
||||
throw new Error(`Article not found: ${articleId}`);
|
||||
}
|
||||
|
||||
article.recordFeedback(helpful);
|
||||
await this.knowledgeRepo.updateArticle(article);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入文章
|
||||
*/
|
||||
async importArticles(
|
||||
articles: Array<{
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
}>,
|
||||
createdBy?: string,
|
||||
): Promise<{ success: number; failed: number; errors: string[] }> {
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const articleData of articles) {
|
||||
try {
|
||||
await this.createArticle({
|
||||
...articleData,
|
||||
source: KnowledgeSource.IMPORT,
|
||||
createdBy,
|
||||
autoPublish: false, // 导入后需要审核
|
||||
});
|
||||
success++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
errors.push(`Failed to import "${articleData.title}": ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { success, failed, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库统计
|
||||
*/
|
||||
async getStatistics(): Promise<{
|
||||
totalArticles: number;
|
||||
publishedArticles: number;
|
||||
byCategory: Record<string, number>;
|
||||
recentArticles: KnowledgeArticleEntity[];
|
||||
}> {
|
||||
const categories = ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TechTAS', 'GENERAL'];
|
||||
|
||||
const [total, published, byCategoryResults, recent] = await Promise.all([
|
||||
this.knowledgeRepo.countArticles(),
|
||||
this.knowledgeRepo.countArticles({ publishedOnly: true }),
|
||||
Promise.all(
|
||||
categories.map(async cat => ({
|
||||
category: cat,
|
||||
count: await this.knowledgeRepo.countArticles({ category: cat }),
|
||||
})),
|
||||
),
|
||||
this.knowledgeRepo.findArticlesByCategory('', { limit: 5 }),
|
||||
]);
|
||||
|
||||
const byCategory: Record<string, number> = {};
|
||||
byCategoryResults.forEach(r => {
|
||||
byCategory[r.category] = r.count;
|
||||
});
|
||||
|
||||
return {
|
||||
totalArticles: total,
|
||||
publishedArticles: published,
|
||||
byCategory,
|
||||
recentArticles: recent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 设置全局前缀
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
// 启用CORS
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3004;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ 🧠 iConsulting Knowledge Service ║
|
||||
║ ║
|
||||
║ Server running at: http://localhost:${port} ║
|
||||
║ API prefix: /api/v1 ║
|
||||
║ ║
|
||||
║ Endpoints: ║
|
||||
║ - POST /api/v1/knowledge/articles Create article ║
|
||||
║ - GET /api/v1/knowledge/articles List articles ║
|
||||
║ - POST /api/v1/knowledge/retrieve RAG retrieval ║
|
||||
║ - POST /api/v1/memory/user Save memory ║
|
||||
║ - POST /api/v1/memory/experience Save experience ║
|
||||
║ - GET /api/v1/memory/experience/pending Pending exp. ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { MemoryService } from './memory.service';
|
||||
import { MemoryType } from '../domain/entities/user-memory.entity';
|
||||
import { ExperienceType } from '../domain/entities/system-experience.entity';
|
||||
|
||||
// ========== DTOs ==========
|
||||
|
||||
class SaveMemoryDto {
|
||||
userId: string;
|
||||
memoryType: MemoryType;
|
||||
content: string;
|
||||
importance?: number;
|
||||
sourceConversationId?: string;
|
||||
relatedCategory?: string;
|
||||
}
|
||||
|
||||
class SearchMemoryDto {
|
||||
userId: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
class ExtractExperienceDto {
|
||||
experienceType: ExperienceType;
|
||||
content: string;
|
||||
scenario: string;
|
||||
relatedCategory?: string;
|
||||
sourceConversationId: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
class FeedbackDto {
|
||||
positive: boolean;
|
||||
}
|
||||
|
||||
// ========== Controller ==========
|
||||
|
||||
@Controller('memory')
|
||||
export class MemoryController {
|
||||
constructor(private memoryService: MemoryService) {}
|
||||
|
||||
// ========== 用户记忆 ==========
|
||||
|
||||
/**
|
||||
* 保存用户记忆
|
||||
*/
|
||||
@Post('user')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async saveUserMemory(@Body() dto: SaveMemoryDto) {
|
||||
const memory = await this.memoryService.saveUserMemory(dto);
|
||||
return {
|
||||
success: true,
|
||||
data: memory,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索用户相关记忆
|
||||
*/
|
||||
@Post('user/search')
|
||||
async searchUserMemories(@Body() dto: SearchMemoryDto) {
|
||||
const memories = await this.memoryService.getUserRelevantMemories(
|
||||
dto.userId,
|
||||
dto.query,
|
||||
{ limit: dto.limit },
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data: memories,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户最重要的记忆
|
||||
*/
|
||||
@Get('user/:userId/top')
|
||||
async getUserTopMemories(
|
||||
@Param('userId') userId: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
const memories = await this.memoryService.getUserTopMemories(
|
||||
userId,
|
||||
limit ? parseInt(limit) : 5,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data: memories,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所有记忆
|
||||
*/
|
||||
@Get('user/:userId')
|
||||
async getUserMemories(
|
||||
@Param('userId') userId: string,
|
||||
@Query('type') memoryType?: MemoryType,
|
||||
@Query('includeExpired') includeExpired?: string,
|
||||
) {
|
||||
const memories = await this.memoryService.getUserMemories(userId, {
|
||||
memoryType,
|
||||
includeExpired: includeExpired === 'true',
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: memories,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新记忆重要性
|
||||
*/
|
||||
@Put('user/memory/:id/importance')
|
||||
async updateMemoryImportance(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { importance: number },
|
||||
) {
|
||||
await this.memoryService.updateMemoryImportance(id, body.importance);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Importance updated',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记记忆为过期
|
||||
*/
|
||||
@Post('user/memory/:id/expire')
|
||||
async expireMemory(@Param('id') id: string) {
|
||||
await this.memoryService.expireMemory(id);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Memory expired',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户所有记忆
|
||||
*/
|
||||
@Delete('user/:userId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteUserMemories(@Param('userId') userId: string) {
|
||||
await this.memoryService.deleteUserMemories(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户时间线
|
||||
*/
|
||||
@Get('user/:userId/timeline')
|
||||
async getUserTimeline(
|
||||
@Param('userId') userId: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
const timeline = await this.memoryService.getUserTimeline(userId, {
|
||||
limit: limit ? parseInt(limit) : 20,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: timeline,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== 系统经验 ==========
|
||||
|
||||
/**
|
||||
* 提取并保存经验
|
||||
*/
|
||||
@Post('experience')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async extractExperience(@Body() dto: ExtractExperienceDto) {
|
||||
const experience = await this.memoryService.extractAndSaveExperience(dto);
|
||||
return {
|
||||
success: true,
|
||||
data: experience,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索相关经验
|
||||
*/
|
||||
@Post('experience/search')
|
||||
async searchExperiences(@Body() body: {
|
||||
query: string;
|
||||
experienceType?: ExperienceType;
|
||||
category?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const experiences = await this.memoryService.getRelevantExperiences(body.query, {
|
||||
experienceType: body.experienceType,
|
||||
category: body.category,
|
||||
limit: body.limit,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: experiences,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待验证的经验
|
||||
*/
|
||||
@Get('experience/pending')
|
||||
async getPendingExperiences(
|
||||
@Query('type') experienceType?: ExperienceType,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const result = await this.memoryService.getPendingExperiences({
|
||||
experienceType,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批经验
|
||||
*/
|
||||
@Post('experience/:id/approve')
|
||||
async approveExperience(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { adminId: string },
|
||||
) {
|
||||
await this.memoryService.approveExperience(id, body.adminId);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Experience approved',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝经验
|
||||
*/
|
||||
@Post('experience/:id/reject')
|
||||
async rejectExperience(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { adminId: string },
|
||||
) {
|
||||
await this.memoryService.rejectExperience(id, body.adminId);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Experience rejected',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录经验反馈
|
||||
*/
|
||||
@Post('experience/:id/feedback')
|
||||
async recordExperienceFeedback(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: FeedbackDto,
|
||||
) {
|
||||
await this.memoryService.recordExperienceFeedback(id, dto.positive);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Feedback recorded',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取经验统计
|
||||
*/
|
||||
@Get('experience/statistics')
|
||||
async getExperienceStatistics() {
|
||||
const stats = await this.memoryService.getExperienceStatistics();
|
||||
return {
|
||||
success: true,
|
||||
data: stats,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MemoryController } from './memory.controller';
|
||||
import { MemoryService } from './memory.service';
|
||||
import { EmbeddingService } from '../infrastructure/embedding/embedding.service';
|
||||
import { Neo4jService } from '../infrastructure/database/neo4j/neo4j.service';
|
||||
import {
|
||||
UserMemoryPostgresRepository,
|
||||
SystemExperiencePostgresRepository,
|
||||
} from '../infrastructure/database/postgres/memory-postgres.repository';
|
||||
import { UserMemoryORM } from '../infrastructure/database/postgres/entities/user-memory.orm';
|
||||
import { SystemExperienceORM } from '../infrastructure/database/postgres/entities/system-experience.orm';
|
||||
import {
|
||||
USER_MEMORY_REPOSITORY,
|
||||
SYSTEM_EXPERIENCE_REPOSITORY,
|
||||
} from '../domain/repositories/memory.repository.interface';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
UserMemoryORM,
|
||||
SystemExperienceORM,
|
||||
]),
|
||||
],
|
||||
controllers: [MemoryController],
|
||||
providers: [
|
||||
MemoryService,
|
||||
EmbeddingService,
|
||||
Neo4jService,
|
||||
{
|
||||
provide: USER_MEMORY_REPOSITORY,
|
||||
useClass: UserMemoryPostgresRepository,
|
||||
},
|
||||
{
|
||||
provide: SYSTEM_EXPERIENCE_REPOSITORY,
|
||||
useClass: SystemExperiencePostgresRepository,
|
||||
},
|
||||
],
|
||||
exports: [MemoryService, Neo4jService],
|
||||
})
|
||||
export class MemoryModule {}
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { EmbeddingService } from '../infrastructure/embedding/embedding.service';
|
||||
import { Neo4jService } from '../infrastructure/database/neo4j/neo4j.service';
|
||||
import {
|
||||
IUserMemoryRepository,
|
||||
ISystemExperienceRepository,
|
||||
USER_MEMORY_REPOSITORY,
|
||||
SYSTEM_EXPERIENCE_REPOSITORY,
|
||||
} from '../domain/repositories/memory.repository.interface';
|
||||
import { UserMemoryEntity, MemoryType } from '../domain/entities/user-memory.entity';
|
||||
import {
|
||||
SystemExperienceEntity,
|
||||
ExperienceType,
|
||||
VerificationStatus,
|
||||
} from '../domain/entities/system-experience.entity';
|
||||
|
||||
/**
|
||||
* 记忆管理服务
|
||||
* 管理用户长期记忆和系统经验
|
||||
*/
|
||||
@Injectable()
|
||||
export class MemoryService {
|
||||
constructor(
|
||||
private embeddingService: EmbeddingService,
|
||||
private neo4jService: Neo4jService,
|
||||
@Inject(USER_MEMORY_REPOSITORY)
|
||||
private memoryRepo: IUserMemoryRepository,
|
||||
@Inject(SYSTEM_EXPERIENCE_REPOSITORY)
|
||||
private experienceRepo: ISystemExperienceRepository,
|
||||
) {}
|
||||
|
||||
// ========== 用户记忆 ==========
|
||||
|
||||
/**
|
||||
* 保存用户记忆
|
||||
*/
|
||||
async saveUserMemory(params: {
|
||||
userId: string;
|
||||
memoryType: MemoryType;
|
||||
content: string;
|
||||
importance?: number;
|
||||
sourceConversationId?: string;
|
||||
relatedCategory?: string;
|
||||
}): Promise<UserMemoryEntity> {
|
||||
const memory = UserMemoryEntity.create(params);
|
||||
|
||||
// 生成向量
|
||||
const embedding = await this.embeddingService.getEmbedding(params.content);
|
||||
memory.setEmbedding(embedding);
|
||||
|
||||
// 保存到PostgreSQL
|
||||
await this.memoryRepo.save(memory);
|
||||
|
||||
// 同时记录到Neo4j时间线
|
||||
await this.neo4jService.recordUserEvent({
|
||||
userId: params.userId,
|
||||
eventId: memory.id,
|
||||
eventType: `MEMORY_${params.memoryType}`,
|
||||
content: params.content,
|
||||
metadata: {
|
||||
importance: params.importance,
|
||||
relatedCategory: params.relatedCategory,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[MemoryService] Saved memory for user ${params.userId}: ${params.memoryType}`);
|
||||
|
||||
return memory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的相关记忆
|
||||
*/
|
||||
async getUserRelevantMemories(
|
||||
userId: string,
|
||||
query: string,
|
||||
options?: { limit?: number; memoryTypes?: MemoryType[] },
|
||||
): Promise<UserMemoryEntity[]> {
|
||||
const embedding = await this.embeddingService.getEmbedding(query);
|
||||
|
||||
const results = await this.memoryRepo.searchByVector(userId, embedding, {
|
||||
limit: options?.limit || 5,
|
||||
minSimilarity: 0.6,
|
||||
});
|
||||
|
||||
// 记录访问
|
||||
for (const { memory } of results) {
|
||||
memory.recordAccess();
|
||||
await this.memoryRepo.update(memory);
|
||||
}
|
||||
|
||||
return results.map(r => r.memory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户最重要的记忆(用于对话上下文)
|
||||
*/
|
||||
async getUserTopMemories(userId: string, limit = 5): Promise<UserMemoryEntity[]> {
|
||||
return this.memoryRepo.findTopMemories(userId, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所有记忆
|
||||
*/
|
||||
async getUserMemories(
|
||||
userId: string,
|
||||
options?: { memoryType?: MemoryType; includeExpired?: boolean },
|
||||
): Promise<UserMemoryEntity[]> {
|
||||
return this.memoryRepo.findByUserId(userId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新记忆重要性
|
||||
*/
|
||||
async updateMemoryImportance(memoryId: string, importance: number): Promise<void> {
|
||||
const memory = await this.memoryRepo.findById(memoryId);
|
||||
if (memory) {
|
||||
memory.updateImportance(importance);
|
||||
await this.memoryRepo.update(memory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记记忆为过期
|
||||
*/
|
||||
async expireMemory(memoryId: string): Promise<void> {
|
||||
const memory = await this.memoryRepo.findById(memoryId);
|
||||
if (memory) {
|
||||
memory.markAsExpired();
|
||||
await this.memoryRepo.update(memory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期记忆
|
||||
*/
|
||||
async cleanupExpiredMemories(userId: string, olderThanDays = 180): Promise<number> {
|
||||
return this.memoryRepo.markExpiredMemories(userId, olderThanDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户所有记忆(GDPR合规)
|
||||
*/
|
||||
async deleteUserMemories(userId: string): Promise<void> {
|
||||
await this.memoryRepo.deleteByUserId(userId);
|
||||
console.log(`[MemoryService] Deleted all memories for user ${userId}`);
|
||||
}
|
||||
|
||||
// ========== 系统经验 ==========
|
||||
|
||||
/**
|
||||
* 提取并保存系统经验
|
||||
*/
|
||||
async extractAndSaveExperience(params: {
|
||||
experienceType: ExperienceType;
|
||||
content: string;
|
||||
scenario: string;
|
||||
relatedCategory?: string;
|
||||
sourceConversationId: string;
|
||||
confidence?: number;
|
||||
}): Promise<SystemExperienceEntity> {
|
||||
// 生成向量
|
||||
const embedding = await this.embeddingService.getEmbedding(
|
||||
`${params.scenario}\n${params.content}`,
|
||||
);
|
||||
|
||||
// 查找相似经验(用于合并)
|
||||
const similarExperiences = await this.experienceRepo.findSimilarExperiences(
|
||||
embedding,
|
||||
0.9, // 高阈值,只合并非常相似的
|
||||
);
|
||||
|
||||
if (similarExperiences.length > 0) {
|
||||
// 合并到现有经验
|
||||
const existingExperience = similarExperiences[0];
|
||||
existingExperience.addSourceConversation(params.sourceConversationId);
|
||||
existingExperience.setEmbedding(embedding); // 可以选择更新向量
|
||||
await this.experienceRepo.update(existingExperience);
|
||||
|
||||
console.log(`[MemoryService] Merged experience into ${existingExperience.id}`);
|
||||
return existingExperience;
|
||||
}
|
||||
|
||||
// 创建新经验
|
||||
const experience = SystemExperienceEntity.create(params);
|
||||
experience.setEmbedding(embedding);
|
||||
await this.experienceRepo.save(experience);
|
||||
|
||||
console.log(`[MemoryService] Created new experience: ${experience.id}`);
|
||||
return experience;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取相关系统经验
|
||||
*/
|
||||
async getRelevantExperiences(
|
||||
query: string,
|
||||
options?: {
|
||||
experienceType?: ExperienceType;
|
||||
category?: string;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<SystemExperienceEntity[]> {
|
||||
const embedding = await this.embeddingService.getEmbedding(query);
|
||||
|
||||
const results = await this.experienceRepo.searchByVector(embedding, {
|
||||
experienceType: options?.experienceType,
|
||||
activeOnly: true,
|
||||
limit: options?.limit || 5,
|
||||
minSimilarity: 0.7,
|
||||
});
|
||||
|
||||
// 记录使用
|
||||
for (const { experience } of results) {
|
||||
experience.recordUsage();
|
||||
await this.experienceRepo.update(experience);
|
||||
}
|
||||
|
||||
return results.map(r => r.experience);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待验证的经验
|
||||
*/
|
||||
async getPendingExperiences(options?: {
|
||||
experienceType?: ExperienceType;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{
|
||||
items: SystemExperienceEntity[];
|
||||
total: number;
|
||||
}> {
|
||||
const page = options?.page || 1;
|
||||
const pageSize = options?.pageSize || 20;
|
||||
|
||||
const items = await this.experienceRepo.findPendingExperiences({
|
||||
experienceType: options?.experienceType,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
});
|
||||
|
||||
// 简单计数(实际应该有专门的count方法)
|
||||
const stats = await this.experienceRepo.getStatistics();
|
||||
const total = stats.byStatus[VerificationStatus.PENDING] || 0;
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批经验
|
||||
*/
|
||||
async approveExperience(experienceId: string, adminId: string): Promise<void> {
|
||||
const experience = await this.experienceRepo.findById(experienceId);
|
||||
if (!experience) {
|
||||
throw new Error(`Experience not found: ${experienceId}`);
|
||||
}
|
||||
|
||||
experience.approve(adminId);
|
||||
await this.experienceRepo.update(experience);
|
||||
|
||||
console.log(`[MemoryService] Experience ${experienceId} approved by ${adminId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝经验
|
||||
*/
|
||||
async rejectExperience(experienceId: string, adminId: string): Promise<void> {
|
||||
const experience = await this.experienceRepo.findById(experienceId);
|
||||
if (!experience) {
|
||||
throw new Error(`Experience not found: ${experienceId}`);
|
||||
}
|
||||
|
||||
experience.reject(adminId);
|
||||
await this.experienceRepo.update(experience);
|
||||
|
||||
console.log(`[MemoryService] Experience ${experienceId} rejected by ${adminId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录经验反馈
|
||||
*/
|
||||
async recordExperienceFeedback(experienceId: string, positive: boolean): Promise<void> {
|
||||
const experience = await this.experienceRepo.findById(experienceId);
|
||||
if (experience) {
|
||||
experience.recordFeedback(positive);
|
||||
await this.experienceRepo.update(experience);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取经验统计
|
||||
*/
|
||||
async getExperienceStatistics(): Promise<{
|
||||
total: number;
|
||||
byStatus: Record<VerificationStatus, number>;
|
||||
byType: Record<ExperienceType, number>;
|
||||
}> {
|
||||
return this.experienceRepo.getStatistics();
|
||||
}
|
||||
|
||||
// ========== 用户时间线(Neo4j) ==========
|
||||
|
||||
/**
|
||||
* 获取用户时间线
|
||||
*/
|
||||
async getUserTimeline(
|
||||
userId: string,
|
||||
options?: {
|
||||
limit?: number;
|
||||
beforeDate?: Date;
|
||||
eventTypes?: string[];
|
||||
},
|
||||
) {
|
||||
return this.neo4jService.getUserTimeline(userId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化用户节点
|
||||
*/
|
||||
async initializeUserNode(userId: string, properties?: Record<string, unknown>): Promise<void> {
|
||||
await this.neo4jService.createUserNode(userId, properties);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# ===========================================
|
||||
# iConsulting Payment Service Dockerfile
|
||||
# ===========================================
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/services/payment-service/package.json ./packages/services/payment-service/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY packages/services/payment-service ./packages/services/payment-service
|
||||
COPY tsconfig.base.json ./
|
||||
|
||||
RUN pnpm --filter @iconsulting/shared build
|
||||
RUN pnpm --filter @iconsulting/payment-service build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nestjs
|
||||
|
||||
COPY --from=builder /app/packages/services/payment-service/dist ./dist
|
||||
COPY --from=builder /app/packages/services/payment-service/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3002
|
||||
|
||||
USER nestjs
|
||||
EXPOSE 3002
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/health || exit 1
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "@iconsulting/payment-service",
|
||||
"version": "1.0.0",
|
||||
"description": "Payment service for handling Alipay, WeChat Pay, and Stripe",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"typeorm": "^0.3.19",
|
||||
"pg": "^8.11.0",
|
||||
"alipay-sdk": "^3.6.0",
|
||||
"stripe": "^14.0.0",
|
||||
"qrcode": "^1.5.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"class-validator": "^0.14.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"uuid": "^9.0.0",
|
||||
"@iconsulting/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/qrcode": "^1.5.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PaymentModule } from './payment/payment.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: configService.get('POSTGRES_HOST', 'localhost'),
|
||||
port: configService.get<number>('POSTGRES_PORT', 5432),
|
||||
username: configService.get('POSTGRES_USER', 'iconsulting'),
|
||||
password: configService.get('POSTGRES_PASSWORD'),
|
||||
database: configService.get('POSTGRES_DB', 'iconsulting'),
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
synchronize: configService.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
}),
|
||||
|
||||
OrderModule,
|
||||
PaymentModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { PaymentEntity } from './payment.entity';
|
||||
|
||||
export enum OrderStatus {
|
||||
CREATED = 'CREATED',
|
||||
PENDING_PAYMENT = 'PENDING_PAYMENT',
|
||||
PAID = 'PAID',
|
||||
PROCESSING = 'PROCESSING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
REFUNDED = 'REFUNDED',
|
||||
}
|
||||
|
||||
export enum ServiceType {
|
||||
ASSESSMENT = 'ASSESSMENT',
|
||||
CONSULTATION = 'CONSULTATION',
|
||||
DOCUMENT_REVIEW = 'DOCUMENT_REVIEW',
|
||||
}
|
||||
|
||||
@Entity('orders')
|
||||
export class OrderEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({ name: 'conversation_id', type: 'uuid', nullable: true })
|
||||
conversationId: string;
|
||||
|
||||
@Column({
|
||||
name: 'service_type',
|
||||
type: 'enum',
|
||||
enum: ServiceType,
|
||||
})
|
||||
serviceType: ServiceType;
|
||||
|
||||
@Column({ name: 'service_category', nullable: true })
|
||||
serviceCategory: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||
amount: number;
|
||||
|
||||
@Column({ default: 'CNY' })
|
||||
currency: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: OrderStatus,
|
||||
default: OrderStatus.CREATED,
|
||||
})
|
||||
status: OrderStatus;
|
||||
|
||||
@Column({ name: 'payment_method', nullable: true })
|
||||
paymentMethod: string;
|
||||
|
||||
@Column({ name: 'payment_id', type: 'uuid', nullable: true })
|
||||
paymentId: string;
|
||||
|
||||
@Column({ name: 'paid_at', nullable: true })
|
||||
paidAt: Date;
|
||||
|
||||
@Column({ name: 'completed_at', nullable: true })
|
||||
completedAt: Date;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => PaymentEntity, (payment) => payment.order)
|
||||
payments: PaymentEntity[];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue