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:
hailin 2026-01-09 00:01:12 -08:00
commit a7add8ff90
170 changed files with 29281 additions and 0 deletions

View File

@ -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:*)"
]
}
}

72
.dockerignore Normal file
View File

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

151
.env.example Normal file
View File

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

97
.gitignore vendored Normal file
View File

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

1322
DEVELOPMENT_GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

176
README.md Normal file
View File

@ -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
## 许可证
私有项目,保留所有权利。

903
deploy.sh Normal file
View File

@ -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 "$@"

319
docker-compose.yml Normal file
View File

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

BIN
iconsulting部署架构.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

View File

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

130
nginx/conf.d/default.conf Normal file
View File

@ -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;
# }

51
nginx/nginx.conf Normal file
View File

@ -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;
}

2
nginx/ssl/.gitkeep Normal file
View File

@ -0,0 +1,2 @@
# 此目录用于存放 SSL 证书
# 请将 fullchain.pem 和 privkey.pem 放置于此

32
package.json Normal file
View File

@ -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"
}

View File

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

View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

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

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

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

View File

@ -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>
);
}

View File

@ -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}</>;
}

View File

@ -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,
}),
}
)
);

View File

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

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -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冲突
},
}

View File

@ -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" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -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,
},
});

View File

@ -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"]

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -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"
}
}

View File

@ -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 {}

View File

@ -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',
};
}
}

View File

@ -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}`);
}
}

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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 {}

View File

@ -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)
- ****:
- ****:
- ****:
- ****:
- ****: 126
### 2. (GEP - General Employment Policy)
- ****:
- ****:
- ****: 200
- ****:
- ****:
### 3. IANG (Immigration Arrangements for Non-local Graduates)
- ****:
- ****:
- ****:
- ****: 21
- ****:
### 4. (TTPS - Top Talent Pass Scheme)
- ****:
- ****:
- A类: 年薪250
- B类: 毕业于世界前100名大学53
- C类: 毕业于世界前100名大学5310,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 || '暂无'}
使
`;

View File

@ -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: '已记住您的信息',
};
}
}

View File

@ -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();

View File

@ -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"]
}

View File

@ -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"]

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -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"
}
}

View File

@ -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}`);
}
}
}

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -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 {}

View File

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

View File

@ -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 {}

View File

@ -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 };
}
}

View File

@ -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 [];
}
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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();

View File

@ -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"]
}

View File

@ -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"]

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -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"
}
}

View File

@ -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 {}

View File

@ -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,
},
}),
);
}
}

View File

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

View File

@ -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', // 批量导入
}

View File

@ -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.5token1.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;
}

View File

@ -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', // 已弃用
}

View File

@ -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', // 自定义记忆
}

View File

@ -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');

View File

@ -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');

View File

@ -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,
}));
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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),
});
}
}

View File

@ -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),
});
}
}

View File

@ -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);
}
}

View File

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

View File

@ -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 {}

View File

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

View File

@ -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();

View File

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

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -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"]
}

View File

@ -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"]

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -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"
}
}

View File

@ -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 {}

View File

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