29 Commits

Author SHA1 Message Date
CakeCN
6cc5a23874 upload ['multiagents_dialogs'] fix 2025-12-10 16:47:22 +08:00
CakeCN
38d65bac3d log 2025-12-10 16:31:22 +08:00
CakeCN
abf6f3aa2f 序列化 2025-12-10 15:10:37 +08:00
CakeCN
36662654ba add namespace logger 2025-12-10 14:52:33 +08:00
CakeCN
4d5a8bb23f learn progress 2025-12-09 22:56:51 +08:00
CakeCN
15cbeb93ee nouser require role 2025-12-09 22:08:58 +08:00
CakeCN
ed6075a89c 用user_id save 2025-12-09 22:03:55 +08:00
CakeCN
9b131dfc94 fix 2025-12-09 16:47:14 +08:00
CakeCN
67552dbca9 fix 2025-12-09 16:44:54 +08:00
CakeCN
ec2d1234b2 fix 2025-12-09 16:40:10 +08:00
CakeCN
7159371128 fix 2025-12-09 16:39:01 +08:00
CakeCN
3b4bebba6c aseclient fix 2025-12-09 16:38:18 +08:00
CakeCN
c3ca3f83f6 fix ase bug 2025-12-09 16:24:00 +08:00
CakeCN
d222358ded aseclient initdata 2025-12-09 16:21:14 +08:00
CakeCN
1be048bf06 load history or not 2025-12-09 16:02:11 +08:00
CakeCN
88ae20d596 调整文件格式 2025-12-09 15:47:17 +08:00
CakeCN
c85f8c9539 import fix 2025-12-09 14:29:16 +08:00
CakeCN
f92688cf2c import fix 2025-12-09 14:28:33 +08:00
CakeCN
a509b2efb2 调整course_service 文件长度 2025-12-09 13:25:05 +08:00
CakeCN
c3c76788fe 上传学习进度 2025-12-09 13:16:10 +08:00
CakeCN
c08fb49bcd log his 2025-12-08 23:55:33 +08:00
CakeCN
f8f7a9d104 建立连接OK提示 2025-12-08 13:28:41 +08:00
CakeCN
a210f5bac3 no chapter_dict show 2025-12-08 13:25:46 +08:00
CakeCN
dc9f246841 no app log in client 2025-12-08 12:56:56 +08:00
CakeCN
74f26c72fd url fix 2025-12-08 12:17:14 +08:00
CakeCN
aae10819ca more log 2025-12-08 11:35:09 +08:00
CakeCN
42549bb32a 构建test机制(hsamooc.cn上只跑test) 2025-12-02 22:40:03 +08:00
CakeCN
93cf991779 client: 增加ACK支持 2025-12-02 22:31:38 +08:00
CakeCN
788fbb07da ack-client 2025-12-02 22:31:34 +08:00
18 changed files with 1533 additions and 760 deletions

View File

@@ -0,0 +1,113 @@
# JSON Serializer 用法文档
## 概述
`json_serializer` 是一个通用的JSON序列化工具用于处理Python中常见的非JSON序列化类型`datetime``date``time``ObjectId`等。
## 支持的类型
- `datetime`: 转换为ISO格式字符串
- `date`: 转换为ISO格式字符串
- `time`: 转换为ISO格式字符串
- `ObjectId`: 转换为字符串
- `bytes`: 转换为UTF-8字符串
- `set`/`frozenset`: 转换为列表
- 其他不可序列化类型: 转换为字符串表示
## 安装位置
位于 `apps/utils/json_serializer.py`
## 使用方法
### 导入
```python
from apps.utils.json_serializer import json_serialize, json_serialize_dict, JSONEncoder
```
### 函数说明
1. **json_serialize(obj)**: 将对象序列化为JSON字符串
- 参数: `obj` - 要序列化的对象
- 返回: JSON字符串
2. **json_serialize_dict(obj)**: 将对象转换为可JSON序列化的字典
- 参数: `obj` - 要序列化的对象
- 返回: 可JSON序列化的字典
3. **JSONEncoder**: 自定义JSON编码器类可直接用于`json.dumps()`
### 示例
#### 基本使用
```python
from datetime import datetime
from apps.utils.json_serializer import json_serialize_dict
# 创建包含datetime的字典
data = {
"user_id": "test_user",
"updated_at": datetime.now(),
"scores": [85, 90, 95]
}
# 序列化
serialized = json_serialize_dict(data)
print(serialized)
# 输出: {"user_id": "test_user", "updated_at": "2025-12-10T15:30:45.123456", "scores": [85, 90, 95]}
```
#### 在API请求中使用
```python
import requests
from apps.utils.json_serializer import json_serialize_dict
# 准备请求数据
data = {
"user_id": "test_user",
"updated_at": datetime.now(),
"progress": {"chapter1": "completed", "chapter2": "in_progress"}
}
# 序列化数据
serialized_data = json_serialize_dict(data)
# 发送请求
response = requests.post(
"https://api.example.com/upload",
json=serialized_data,
headers={"Content-Type": "application/json"}
)
```
#### 直接使用JSONEncoder
```python
import json
from datetime import datetime
from apps.utils.json_serializer import JSONEncoder
# 创建数据
data = {"updated_at": datetime.now()}
# 使用自定义编码器
json_str = json.dumps(data, cls=JSONEncoder, ensure_ascii=False)
print(json_str)
# 输出: {"updated_at": "2025-12-10T15:30:45.123456"}
```
## 应用场景
1. API请求中的数据序列化
2. 保存数据到文件或数据库
3. 跨服务数据传输
4. 日志记录中的复杂对象序列化
## 注意事项
- 对于自定义对象,会尝试序列化其`__dict__`属性
- 对于无法序列化的类型,会转换为字符串表示
- 确保导入路径正确,特别是在不同模块中使用时

View File

@@ -0,0 +1,124 @@
# ACK机制支持指南
## 概述
客户端已实现基于emit机制的ACK确认功能允许客户端在发送消息后等待服务器确认从而支持同步和异步两种通信模式。
## 客户端改动
### 1. 新增参数
客户端的三个主要发送方法 `send_text``send_voice``send_image` 都新增了以下参数:
- `wait_for_ack: bool = False` - 是否等待服务器ACK确认
- `timeout: int = 5` - 等待ACK的超时时间
### 2. 消息格式变化
客户端发送的消息中新增了 `ack_id` 字段,格式为 `{id}_{timestamp}`,其中:
- `id` 是客户端ID
- `timestamp` 是当前时间的格式化字符串(精确到微秒)
### 3. ACK事件
客户端监听 `ack` 事件,用于接收服务器的确认消息。
## 后端WebSocket服务器需要的支持
### 1. 接收消息处理
当服务器收到客户端发送的消息时,需要:
1. 检查消息中是否包含 `ack_id` 字段
2. 如果包含 `ack_id`立即发送ACK确认消息不需要等待后续业务逻辑执行
3. 继续执行后续的业务逻辑
### 2. ACK消息格式
服务器需要向客户端发送 `ack` 事件,消息格式如下:
```json
{
"ack_id": "客户端发送的ack_id值"
}
```
### 3. 发送ACK的时机
**非常重要**:服务器必须在收到客户端消息后**立即**发送ACK确认而不是在业务逻辑执行完成后发送。这样可以确保客户端能够快速收到确认继续执行后续操作。
### 4. 实现示例Python Socket.IO
```python
# 服务器端示例代码
@sio.on('text_message') # 根据实际事件名调整
def handle_text_message(sid, data):
# 1. 立即发送ACK确认
if 'ack_id' in data:
sio.emit('ack', {'ack_id': data['ack_id']}, room=sid)
# 2. 执行后续业务逻辑(可能耗时较长)
# ... 业务逻辑代码 ...
@sio.on('voice_message') # 根据实际事件名调整
def handle_voice_message(sid, data):
# 1. 立即发送ACK确认
if 'ack_id' in data:
sio.emit('ack', {'ack_id': data['ack_id']}, room=sid)
# 2. 执行后续业务逻辑(可能耗时较长)
# ... 业务逻辑代码 ...
@sio.on('image_message') # 根据实际事件名调整
def handle_image_message(sid, data):
# 1. 立即发送ACK确认
if 'ack_id' in data:
sio.emit('ack', {'ack_id': data['ack_id']}, room=sid)
# 2. 执行后续业务逻辑(可能耗时较长)
# ... 业务逻辑代码 ...
```
## 使用示例
### 客户端同步调用等待ACK
```python
client = ASEngineClient(server_url, namespace, id)
client.connect()
# 同步调用等待ACK确认后再继续执行
result = client.send_text('text_message', 'Hello', wait_for_ack=True, timeout=3)
if result:
print("消息已被服务器确认收到")
# 执行后续操作
else:
print("等待ACK超时")
```
### 客户端异步调用不等待ACK
```python
client = ASEngineClient(server_url, namespace, id)
client.connect()
# 异步调用发送后立即返回不等待ACK
result = client.send_text('text_message', 'Hello', wait_for_ack=False)
if result:
print("消息已发送")
# 执行后续操作不需要等待ACK
```
## 注意事项
1. **向后兼容**:客户端的改动保持了向后兼容性,现有代码无需修改即可继续使用。
2. **超时处理**客户端会在指定的超时时间后停止等待ACK并返回False。
3. **资源清理**客户端会在收到ACK或超时时清理相关的ACK状态避免内存泄漏。
4. **唯一标识**`ack_id` 确保了每个消息的唯一性避免ACK混淆。
5. **性能考虑**等待ACK会阻塞当前线程建议仅在必要时使用同步模式。
## 总结
通过实现基于emit机制的ACK确认功能客户端和服务器之间的通信可以灵活支持同步和异步两种模式。服务器需要做的改动非常简单只需要在收到消息后立即发送ACK确认即可。
这种设计既保证了通信的可靠性,又不会影响业务逻辑的执行效率,是一种高效且可靠的通信方式。

View File

@@ -5,6 +5,7 @@ import os
from datetime import datetime
from typing import Dict, Callable, Any, Optional
from functools import partial
from threading import Event
class ASEngineClient:
def __init__(self, server_url: str, namespace: str, id:str):
@@ -29,8 +30,9 @@ class ASEngineClient:
self._on_connect_callback_entry=None
self._on_connect_callback_initData=None
# ACK相关
self.ack_events: Dict[str, Event] = {} # 存储ack_id和对应的Event对象
# 注册基础事件处理函数
self._register_base_events()
@@ -43,7 +45,11 @@ class ASEngineClient:
self.sid = self.sio.get_sid(namespace=self.namespace)
print(f"Connected to server. SID: {self.sid}")
if self._on_connect_callback is not None:
self._on_connect_callback(self._on_connect_callback_entry, self._on_connect_callback_initData)
self._on_connect_callback(
self,
self._on_connect_callback_entry,
init_data=self._on_connect_callback_initData
)
# 断开连接事件
@self.sio.on('disconnect', namespace=self.namespace)
@@ -62,28 +68,60 @@ class ASEngineClient:
@self.sio.on('response', namespace=self.namespace)
def on_response(data):
print(f"Received response: {data}")
# ACK事件处理
@self.sio.on('ack', namespace=self.namespace)
def on_ack(data):
ack_id = data.get('ack_id')
if ack_id and ack_id in self.ack_events:
print(f"Received ACK for message: {ack_id}")
event = self.ack_events[ack_id]
event.set() # 触发事件,通知等待的线程
def register_on_connect_entry(self, callback: Callable, entry: Any, init_data: Any)->bool:
"""注册连接事件回调函数
参数:
- callback: 连接事件回调函数,接受 **kwargs 参数
- entry: 回调关联的条目对象
- init_data: 初始化数据
回调关键字参数:
- entry: 回调关联的条目对象
- init_data: 初始化数据
"""
if not callable(callback):
return False
self._on_connect_callback = callback
self._on_connect_callback_entry=entry
self._on_connect_callback_initData=init_data
return True
def register_route_with_entry(self, route: str,
callback: Optional[Callable] = None,
entry: Any = None,
init_data: Optional[Any] = None) -> bool:
"""注册带条目的路由回调函数
参数:
- route: 路由名称
- callback: 回调函数,接受 **kwargs 参数
- entry: 回调关联的条目对象
- init_data: 初始化数据
回调关键字参数:
- entry: 回调关联的条目对象
- data: 接收到的消息数据
- init_data: 初始化数据
"""
if not route:
print("Route cannot be empty")
return False
if callback is None:
bound_cb = self._default_callback
else:
bound_cb = partial(callback, entry) # 等价于 lambda data, init: callback(entry, data, init)
else:bound_cb = callback
self.routes[route] = {
'callback': bound_cb,
'entry': entry,
@@ -98,8 +136,9 @@ class ASEngineClient:
return
cb = route_info['callback'] # 已经绑定过 entry
init = route_info.get('init_data')
entry = route_info.get('entry')
try:
cb(data, init) # 现在只传 data / init_data
cb(self, entry, data=data, init_data=init) # 传递关键字参数
except Exception as e:
print(f"Callback error on route '{_route}': {e}")
@@ -107,11 +146,17 @@ class ASEngineClient:
def register_route(self, route: str, callback: Optional[Callable] = None, init_data: Optional[Any] = None):
"""
注册一个路由及其回调函数
:param route: 路由名称
:param callback: 回调函数,接收消息数据作为参数
:return: 是否注册成功
"""注册路由回调函数
参数:
- route: 路由名称
- callback: 回调函数,接受 **kwargs 参数
- init_data: 初始化数据
回调关键字参数:
- entry: 回调关联的条目对象(此处为 None
- data: 接收到的消息数据
- init_data: 初始化数据
"""
if not route:
print("Route cannot be empty")
@@ -119,7 +164,9 @@ class ASEngineClient:
# 存储路由和回调
self.routes[route] = {
'callback': callback if callback else self._default_callback
'callback': callback if callback else self._default_callback,
'entry': None,
'init_data': init_data
}
# 注册事件处理器
@@ -128,15 +175,27 @@ class ASEngineClient:
print(f"Received message on route '{route}': {data}")
if 'callback' in self.routes[route]:
try:
self.routes[route]['callback'](None, data, init_data)
self.routes[route]['callback'](
self,
entry=None,
data=data,
init_data=init_data
)
except Exception as e:
print(f"Error in callback for route '{route}': {e}")
print(f"Route '{route}' registered successfully")
return True
def _default_callback(self, entry: Any, data: Any, init_data: Optional[Any] = None):
"""默认回调函数"""
def _default_callback(self, **kwargs):
"""默认回调函数
关键字参数:
- entry: 回调关联的条目对象
- data: 接收到的消息数据
- init_data: 初始化数据
"""
data = kwargs.get('data', {})
print(f"Default callback received: {data}")
def connect(self, timeout: int = 5) -> bool:
@@ -169,12 +228,15 @@ class ASEngineClient:
self.sid = None
print("Disconnected from server")
def send_text(self, route: str, text: str, id:str="") -> bool:
def send_text(self, route: str, text: str, id:str="", wait_for_ack: bool = False, timeout: int = 5) -> bool:
"""
发送文本消息
:param route: 目标路由
:param text: 要发送的文本
:return: 是否发送成功
:param id: 发送者ID
:param wait_for_ack: 是否等待服务器ACK确认
:param timeout: 等待ACK的超时时间
:return: 是否发送成功若wait_for_ack为True则返回是否收到ACK
"""
if not self.connected:
print("Not connected to server")
@@ -183,19 +245,53 @@ class ASEngineClient:
id = self.id
try:
self.sio.emit(route, {'type': 'text', 'data': text, 'id': id}, namespace=self.namespace)
print(f"Sent text to route '{route}': {text}")
return True
# 生成唯一ack_id
ack_id = f"{id}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
# 准备发送数据
data = {
'type': 'text',
'data': text,
'id': id,
'ack_id': ack_id
}
# 发送数据
self.sio.emit(route, data, namespace=self.namespace)
print(f"Sent text to route '{route}': {text[:20]}")
# 如果不需要等待ACK直接返回True
if not wait_for_ack:
return True
# 初始化ACK事件
event = Event()
self.ack_events[ack_id] = event
# 等待ACK或超时
received_ack = event.wait(timeout=timeout)
# 清理ACK事件
del self.ack_events[ack_id]
if received_ack:
return True
else:
print(f"ACK timeout for message: {ack_id}")
return False
except Exception as e:
print(f"Failed to send text to route '{route}': {e}")
return False
def send_voice(self, route: str, voice_data: bytes, id:str=None) -> bool:
def send_voice(self, route: str, voice_data: bytes, id:str=None, wait_for_ack: bool = False, timeout: int = 5) -> bool:
"""
发送语音数据
:param route: 目标路由
:param voice_data: 语音数据字节流
:return: 是否发送成功
:param id: 发送者ID
:param wait_for_ack: 是否等待服务器ACK确认
:param timeout: 等待ACK的超时时间
:return: 是否发送成功若wait_for_ack为True则返回是否收到ACK
"""
if id is None:
id = self.id
@@ -204,8 +300,9 @@ class ASEngineClient:
return False
try:
# 编码语音数据为base64
# voice_data = 'data:image/jpeg;base64,' +base64.b64encode(voice_data).decode('utf-8')
# 生成唯一ack_id
ack_id = f"{id}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
# 发送数据
self.sio.emit(
route,
@@ -214,24 +311,46 @@ class ASEngineClient:
'data': voice_data,
'timestamp': int(time.time() * 1000),
'sampleRate': 16000,
'id': id
'id': id,
'ack_id': ack_id
},
namespace=self.namespace
)
print(f"Sent voice file to route '{route}'")
return True
# 如果不需要等待ACK直接返回True
if not wait_for_ack:
return True
# 初始化ACK事件
event = Event()
self.ack_events[ack_id] = event
# 等待ACK或超时
received_ack = event.wait(timeout=timeout)
# 清理ACK事件
del self.ack_events[ack_id]
if received_ack:
return True
else:
print(f"ACK timeout for message: {ack_id}")
return False
except Exception as e:
print(f"Failed to send voice to route '{route}': {e}")
return False
def send_image(self, route: str, file_path: str, id:str) -> bool:
def send_image(self, route: str, file_path: str, id:str, wait_for_ack: bool = False, timeout: int = 5) -> bool:
"""
发送图片文件
:param route: 目标路由
:param file_path: 图片文件路径
:return: 是否发送成功
:param id: 发送者ID
:param wait_for_ack: 是否等待服务器ACK确认
:param timeout: 等待ACK的超时时间
:return: 是否发送成功若wait_for_ack为True则返回是否收到ACK
"""
if not self.connected:
print("Not connected to server")
@@ -251,18 +370,41 @@ class ASEngineClient:
# 获取文件名
filename = os.path.basename(file_path)
# 生成唯一ack_id
ack_id = f"{id}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
# 发送数据
self.sio.emit(
route,
{
'type': 'image',
'data': image_data,
'id': id
'id': id,
'ack_id': ack_id
},
namespace=self.namespace
)
print(f"Sent image file to route '{route}': {filename}")
return True
# 如果不需要等待ACK直接返回True
if not wait_for_ack:
return True
# 初始化ACK事件
event = Event()
self.ack_events[ack_id] = event
# 等待ACK或超时
received_ack = event.wait(timeout=timeout)
# 清理ACK事件
del self.ack_events[ack_id]
if received_ack:
return True
else:
print(f"ACK timeout for message: {ack_id}")
return False
except Exception as e:
print(f"Failed to send image to route '{route}': {e}")
return False
@@ -286,6 +428,6 @@ class HSAEngineClient(ASEngineClient):
super().__init__(server_url, namespace, id)
self.chatmanager = chatmanager
def loadmarkdown(self, fmd, fmdp, fsp):
self.send_text('markdown-in', fmd, self.id)
self.send_text('markdown-prompt-in', fmdp, self.id)
self.send_text('score-prompt-in', fsp, self.id)
self.send_text('markdown-in', fmd, self.id, wait_for_ack=True)
self.send_text('markdown-prompt-in', fmdp, self.id, wait_for_ack=True)
self.send_text('score-prompt-in', fsp, self.id, wait_for_ack=True)

View File

@@ -33,7 +33,8 @@ class ProcRecord:
class ChatManager:
_procs: Dict[str, ProcRecord] = {}
_lock = threading.RLock()
def __init__(self):
def __init__(self, restart=False):
self.restart = restart
self.chapter_chain_now = -1
self.ase_client = None
self.app = None

View File

@@ -0,0 +1,73 @@
import requests
from flask_socketio import Namespace
def on_connect_to_ase(client, entry, init_data, **kwargs):
'''
此时刚刚建立好aseclient连接并向前端发送打开code-server-like指令。
'''
namespace_entry, ase_client = entry
namespace_entry.emit('open-iframe',"", room=init_data['room'], namespace='/agent')
namespace_entry.emit('message', "Agents服务已连接, 加载教案中", room=init_data['room'], namespace='/agent')
if init_data.get("restart", False):
try:
base_url = namespace_entry.app.config['ASE_ENGINE_URL']
clear_url = f"{base_url}/clear_user_session"
payload = {
'namespace_url': namespace_entry.app.config['ASE_ENGINE_URL_TOKEN'], # WebSocket命名空间URL
'user_id': init_data['room'], # 用户ID对应session_info.id
'clear_type': 'all' # 清理类型,默认为全部
}
headers = {
'Content-Type': 'application/json'
}
response = requests.post(clear_url, json=payload, headers=headers)
response.raise_for_status() # 检查请求是否成功
print(f"Clear user session successful: {response.json()}")
except Exception as e:
print(f"Error clearing user session: {e}")
ase_client.chatmanager.load_next_chapter()
ase_client.send_text("chapter-start", "")
namespace_entry.emit('message', "教案加载完毕", room=init_data['room'], namespace='/agent')
#接受消息回来直接让message监听发送
def receive_ase_paste_detected(client_entry, namespace_entry, data, init_data):
print("receive_ase_paste_detected", data)
client_entry.chatmanager.chat_historys.append({'role': 'assistant', 'content': data['data']})
namespace_entry.emit('message', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_dialog(client_entry, namespace_entry, data, init_data):
print("receive_ase_dialog", data)
client_entry.chatmanager.chat_historys.append({'role': 'assistant', 'content': data['data']})
namespace_entry.emit('message', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_message_hint(client_entry, namespace_entry, data, init_data):
print("receive_ase_message_hint", data)
namespace_entry.emit('message-hint', data['data'], room=init_data['room'], namespace='/agent')
#接受语音信息
def receive_ase_voice_out(client_entry, namespace_entry, data, init_data):
namespace_entry.emit('voiceMessage', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_process(client_entry, namespace_entry, data, init_data):
namespace_entry.emit('process', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_judge(client_entry, namespace_entry, data, init_data):
print("receive_ase_judge", data)
namespace_entry.emit('request_function', data, room=init_data['room'], namespace='/agent') # data 是一个列表 /dict也支持
def receive_ase_next_chapter(client_entry, namespace_entry, data, init_data):
print("receive_ase_next_chapter", data)
namespace_entry.emit('request_function',data, room=init_data['room'], namespace='/agent') # data 是一个列表 /dict也支持
def receive_ase_chapter_score(client_entry, user_and_manager, data, init_data):
print("load_next_chapter")
user, chatmanager = user_and_manager
if len(chatmanager.scores) >= chatmanager.chapter_chain_now:
chatmanager.scores[chatmanager.chapter_chain_now - 1] = data.get('data', None)
else: chatmanager.scores.append(data.get('data', None))
user.set_course_progress(chatmanager.chat_historys, chatmanager.scores, chatmanager.course_id, chatmanager.chapter_name, chatmanager.lesson_name)
chatmanager.load_next_chapter()
chatmanager.ase_client.send_text("chapter-start", "")

View File

@@ -27,7 +27,7 @@ def get_text_file(file_name):
response = requests.get(file_name)
if response.status_code == 200:
print(file_name)
# print(file_name)
# print(f"get_text_file: {response.text}")
return response.text

View File

@@ -8,591 +8,7 @@ from urllib.parse import urlparse
from datetime import datetime
from bson import ObjectId
from ..services.cos_service import upload_file, delete_file
from ..models.material import Material
from .course_services.material_process import *
def get_new_edit_materials(num: int)->List[Material]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find().sort("updated_at", -1).limit(num)
returnCode = []
for material in materials:
returnCode.append(Material(**material))
returnCode[-1]._id = str(material['_id'])
return returnCode
def user_selected_course(user_obj):
briefs = []
for cid in getattr(user_obj, "select_course", []):
course_obj = load_material(cid)
brief={}
brief["_id"] = cid
brief["name"] = course_obj.name
brief["description"] = course_obj.description
brief["image_url"] = course_obj.image_url
brief["created_at"] = course_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")
brief["updated_at"] = course_obj.updated_at.strftime("%Y-%m-%d %H:%M:%S")
brief["chapters"] = [chapter.model_dump() for chapter in course_obj.chapters]
briefs.append(brief)
return briefs
def user_selected_course_briefs(user_obj):
"""根据用户对象的选课列表,拼装课程简要信息数组"""
briefs = []
for cid in getattr(user_obj, "select_course", []):
course_obj = load_material(cid)
brief={}
brief["_id"] = cid
brief["name"] = course_obj.name
brief["description"] = course_obj.description
brief["image_url"] = course_obj.image_url
brief["created_at"] = course_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")
brief["updated_at"] = course_obj.updated_at.strftime("%Y-%m-%d %H:%M:%S")
briefs.append(brief)
return briefs
def create_material(teacher_id, material_name, description, chapters, image_url):
material = Material(
name=material_name,
description=description,
teacher_id=teacher_id,
created_at=datetime.now(),
updated_at=datetime.now(),
chapters=chapters,
image_url=image_url
)
mongo = current_app.extensions["mongo"]
material_id = mongo.db.materials.insert_one(material.model_dump()).inserted_id
return str(material_id)
def load_material(material_id):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({"_id": ObjectId(material_id)})
material['_id'] = material_id
return Material(**material)
def update_material(material_id, chapters):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if material:
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters': chapters, 'updated_at': datetime.now()}}
)
return True
return False
def add_material_chapter(material_id, chapter_name, teacher_id):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if material['teacher_id'] != teacher_id:
return False
if material:
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$push': {'chapters': {'chapter_name': chapter_name, 'lessons': []}}})
return True
def rename_material_structure(material_id, is_chapter, chapter_name, lesson_name, new_name):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if not material:
return False # 材料不存在
# 检查名称是否存在于列表中的辅助函数
def name_exists_in_list(item_list, name_key, target_name):
return any(item[name_key] == target_name for item in item_list)
# 查找包含指定章节名的章节
def find_chapter_by_name(chapters, target_name):
for chapter in chapters:
if chapter['chapter_name'] == target_name:
return chapter
return None
# 检查章节是否存在
target_chapter = find_chapter_by_name(material['chapters'], chapter_name)
if not target_chapter:
return False
if is_chapter:
# 检查新章节名是否已存在
if name_exists_in_list(material['chapters'], 'chapter_name', new_name):
return False
# 更新章节名
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters.$[c].chapter_name': new_name}},
array_filters=[{'c.chapter_name': chapter_name}]
)
else:
# 检查课时是否存在
if not name_exists_in_list(target_chapter['lessons'], 'lesson_name', lesson_name):
return False
# 检查新课时名是否已存在
if name_exists_in_list(target_chapter['lessons'], 'lesson_name', new_name):
return False
# 更新课时名
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters.$[c].lessons.$[l].lesson_name': new_name}},
array_filters=[{'c.chapter_name': chapter_name}, {'l.lesson_name': lesson_name}]
)
return True
def delete_material_chapter_lesson(material_id, chapter_name, lesson_name, teacher_id):
"""
删除指定课程的课时。
删除后,更新 MongoDB 中对应的章节中的 lesson 列表。
"""
mongo = current_app.extensions["mongo"]
# 查询 material 是否存在,且确保该 teacher_id 对应的 material
material = mongo.db.materials.find_one({'_id': ObjectId(material_id), 'teacher_id': teacher_id})
for chapter in material['chapters']:
if chapter['chapter_name'] == chapter_name:
for lesson in chapter['lessons']:
if lesson['lesson_name'] == lesson_name:
if lesson['markdown_lesson_file_link']!='':
delete_file(lesson['markdown_lesson_file_link'])
if lesson['markdown_prompt_file_name']!='':
delete_file(lesson['markdown_prompt_file_name'])
if lesson['markdown_score_prompt_file_link']!='':
delete_file(lesson['markdown_score_prompt_file_link'])
if not material:
return False # 找不到对应的 material 或 teacher_id 不匹配
# 删除 lesson 的条件
res = mongo.db.materials.update_one(
{
"_id": ObjectId(material_id),
"teacher_id": teacher_id # 确保是该教师的课程
},
{
"$pull": {
"chapters.$[c].lessons": {
"lesson_name": lesson_name # 根据 lesson_name 来删除
}
},
"$set": {"updated_at": datetime.now()} # 更新修改时间
},
array_filters=[{"c.chapter_name": chapter_name}] # 找到匹配的章节
)
# 如果删除操作成功res.modified_count 应为 1
return res.modified_count == 1
def reorder_material_structure(material_id, order):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if not material:
return False
# 1. 构建原章节映射(保留章节元信息)和原章节的课时映射
chapter_map = {ch["chapter_name"]: ch for ch in material["chapters"]}
# 每个章节的课时映射:{章节名: {课时名: 课时对象}}
original_chapter_lessons = {
ch["chapter_name"]: {ls["lesson_name"]: ls for ls in ch["lessons"]}
for ch in material["chapters"]
}
# 2. 收集所有章节“少掉的课时”到全局池(仅包含原章节有但新排序没包含的课时)
# 用列表存储,避免同名课时被覆盖(处理可能的重名场景)
global_extra_lessons = []
for ch_name in chapter_map:
original_lessons = original_chapter_lessons[ch_name] # 原章节的所有课时
# 找到当前章节在order中的配置如果没有默认该章节所有课时都是“少掉的”
chapter_order = next((item for item in order if item["chapter_name"] == ch_name), None)
if not chapter_order:
# 章节未在order中出现所有原课时都是“少掉的”
global_extra_lessons.extend(original_lessons.values())
continue
# 章节在order中出现找出原课时中不在新排序里的即“少掉的”
new_lesson_names = chapter_order["lessons"]
for ls_name, ls in original_lessons.items():
if ls_name not in new_lesson_names:
global_extra_lessons.append(ls)
print("global_extra_lessons:", global_extra_lessons)
new_chapters = []
for chapter_order in order:
ch_name = chapter_order["chapter_name"]
if ch_name not in chapter_map:
continue # 跳过不存在的章节
original_ch = chapter_map[ch_name]
original_lessons = original_chapter_lessons[ch_name] # 本章节原课时
new_lessons = []
# 3. 构建当前章节的新课时列表
for ls_name in chapter_order["lessons"]:
# 优先使用本章节原有的课时
if ls_name in original_lessons:
new_lessons.append(original_lessons[ls_name])
continue
# 本章节没有,从全局池(其他章节少掉的课时)中找
# 处理可能的重名:找到第一个匹配的课时
for i, extra_ls in enumerate(global_extra_lessons):
if extra_ls["lesson_name"] == ls_name:
new_lessons.append(extra_ls)
del global_extra_lessons[i] # 从全局池移除,避免重复
break
# 4. 构造新章节(保留原章节元信息,替换课时列表)
new_chapter = {**original_ch, "lessons": new_lessons}
new_chapters.append(new_chapter)
# 5. 处理全局池中剩余的课时(未被任何章节引用的,追加到最后一个章节)
if global_extra_lessons and new_chapters:
new_chapters[-1]["lessons"].extend(global_extra_lessons)
print("new_chapters:", new_chapters)
# 更新数据库
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{
"$set": {
"chapters": new_chapters,
"updated_at": datetime.now()
}
}
)
return True
def save_materials_markdown_cos(
material_id: str,
chapter_name: str,
lesson_name: str,
docs: Dict[str, Dict[str, str]],
version: str | None = None, # 目前不做并发控制,保留参数
actor=None # 可记录操作者到审计日志
) -> Dict[str, str]:
"""
docs 结构:
{
"lesson": {"cdn_link": "...lesson.md", "content": "..."},
"prompt": {"cdn_link": "...prompt.md", "content": "..."},
"score": {"cdn_link": "...score_prompt.md", "content": "..."}
}
约定cdn_link 与对象存储的 key 一一映射(同路径/同文件名),
我们通过解析 URL 的 path 作为 key 进行覆盖写入(不改文件名)。
"""
def _cdn_to_key(cdn_link: str) -> str:
"""
把 CDN URL 映射成对象存储的 key
- 若 CDN 与存储是“同 key 直出”,则直接取 path去掉前导 /
- 若你有自定义映射规则,在此处改写
"""
p = urlparse(cdn_link)
key = p.path.lstrip("/")
if not key:
raise ValueError(f"非法 cdn_link{cdn_link}")
return key
# 1) 覆盖写入三份文件
results: Dict[str, Dict[str, str]] = {}
for kind in ("lesson", "prompt", "score"):
item = docs.get(kind)
if not item or "cdn_link" not in item or "content" not in item:
raise ValueError(f"缺少 {kind} 的 cdn_link 或 content")
key = _cdn_to_key(item["cdn_link"])
content = item["content"]
# 覆盖写入注意upload_file 内部需实现覆盖同 key
try:
# 约定upload_file 返回可访问的 URL覆盖后可与原相同
url = upload_file(content.encode("utf-8"), key)
except Exception as e:
current_app.logger.exception("覆盖写入失败 kind=%s key=%s", kind, key)
raise
# 简单返回:保留原 cdn_link一般与 url 相同),以及 md5 作为伪版本
import hashlib
etag = hashlib.md5(content.encode("utf-8")).hexdigest()
results[kind] = {"cdn_link": url or item["cdn_link"], "version": etag}
# 2) 写库:更新该 lesson 的更新时间(不改文件名/链接)
_touch_material_lesson_updated_at(material_id, chapter_name, lesson_name, actor=actor)
results["updated_at"] = datetime.now().isoformat() + "Z"
return results
def _touch_material_lesson_updated_at(material_id: str, chapter_name: str, lesson_name: str, actor=None) -> None:
"""仅刷新 materials 文档中该课时的更新时间戳(以及顶层 updated_at"""
mongo = current_app.extensions["mongo"]
now = datetime.now()
# 更新顶层 updated_at
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{"$set": {"updated_at": now}}
)
# 更新嵌套 lesson 的更新时间(如果你在 lesson 内有该字段)
# 需要使用 arrayFilters 精确定位到对应章节/课时
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{
"$set": {
"chapters.$[c].lessons.$[l].updated_at": now,
# 可选:记录最近编辑人
"chapters.$[c].lessons.$[l].last_editor": getattr(actor, "id", None) if actor else None
}
},
array_filters=[
{"c.chapter_name": chapter_name},
{"l.lesson_name": lesson_name}
]
)
def add_material_chapter_lesson(material_id, chapter_name, lesson_name, teacher_id):
"""
1) 生成占位 Markdownlesson_prompt_score_prompt
2) 上传到对象存储(返回 URL
3) 将 lesson 文档(含三份 URLpush 到指定章节
"""
mongo = current_app.extensions["mongo"]
# 组织对象存储的文件路径
chapter_slug = _slugify(chapter_name or "")
lesson_slug = _slugify(lesson_name or "")
timestamp = datetime.now().strftime('%Y%m%dT%H%M%SZ')
base_prefix = f"materials_{material_id}_{chapter_slug}_{lesson_slug}_{timestamp}"
# 占位文本
lesson_md, prompt_md, score_prompt_md = _default_markdowns(lesson_name or "未命名课时")
# 生成文件名(带层级路径)
lesson_key = f"{base_prefix}_lesson.md"
prompt_key = f"{base_prefix}_prompt.md"
score_key = f"{base_prefix}_score_prompt.md"
# 上传(任意一个失败则终止,不写库)
try:
lesson_url = upload_file(lesson_md.encode("utf-8"), lesson_key)
prompt_url = upload_file(prompt_md.encode("utf-8"), prompt_key)
score_url = upload_file(score_prompt_md.encode("utf-8"), score_key)
except Exception as e:
current_app.logger.exception("Upload default markdown failed: %s", e)
return False
# 组装 lesson_doc
lesson_doc = {
"lesson_name": lesson_name or "",
"markdown_lesson_file_link": lesson_url, # lesson 主体
"markdown_prompt_file_name": prompt_url, # 给大模型的 prompt
"markdown_score_prompt_file_link": score_url, # 评分 prompt
}
# 将 lesson push 到指定章节
res = mongo.db.materials.update_one(
{
"_id": ObjectId(material_id),
"teacher_id": teacher_id, # 注意:若存的是 ObjectId请确保传入的是同类型
},
{
"$push": {"chapters.$[c].lessons": lesson_doc},
"$set": {"updated_at": datetime.now()},
},
array_filters=[{"c.chapter_name": chapter_name}],
)
# 可选:如果 res.modified_count != 1说明 push 失败(可能章节名不匹配)
# 这里可以考虑是否回滚 COS 上刚上传的 3 个文件(需实现删除逻辑)。
return res.modified_count == 1
def get_materials_by_teacher(teacher_id)->List[Material]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find({'teacher_id': teacher_id})
return [Material(**material) for material in materials]
def get_materials_by_teacher_dict(teacher_id)->List[Dict]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find({'teacher_id': teacher_id})
result = []
for material in materials:
material['_id'] = str(material['_id'])
result.append(material)
return result
def _slugify(name: str) -> str:
"""将章节/课时名转为 URL 友好的 slug。"""
s = name.strip().lower()
s = re.sub(r'\s+', '-', s) # 空白 -> -
s = re.sub(r'[^a-z0-9\-_]+', '', s) # 仅保留安全字符
return s or 'untitled'
def _default_markdowns(lesson_name: str):
"""三份占位 Markdown 文本。可按需调整模板内容。"""
ts = datetime.now().strftime('%Y-%m-%d %H:%M')
lesson_md = f"""# {lesson_name}
## 阶段一:示例阶段
### 步骤A
在这里编写该步骤的教学内容(文字/代码/图片链接等)。
### 步骤B
继续补充该阶段的其它步骤内容。
"""
prompt_md = f"""# {lesson_name}
## 阶段一:示例阶段
### 步骤A
请基于"步骤A"的教学目标与材料,生成指导性提示词(尽量结构化,给出评估标准和示例)。
### 步骤B
请基于"步骤B"的教学目标与材料,生成指导性提示词。
"""
score_prompt_md = f"""# {lesson_name}
## 评分准则
- 正确性、完整性、可读性、效率(按需调整权重)
## 阶段一:示例阶段
### 步骤A
对学员在"步骤A"的输出进行评分与文字反馈,分数区间 0-100并输出 JSON```{{"score": <int>, "reasons": "<string>", "advices": "<string>"}}```
### 步骤B
同上,对"步骤B"输出进行评分与反馈。
"""
return lesson_md, prompt_md, score_prompt_md
def save_learning_progress(user_uuid: str, chatmanager, user_id: str) -> bool:
"""
保存用户学习进度到MongoDB
参数:
- user_uuid: 用户UUID
- chatmanager: ChatManager实例包含学习进度数据
- user_id: 用户ID
返回:
- bool: 保存是否成功
"""
try:
# 获取MongoDB连接
mongo = current_app.extensions["mongo"]
# 获取MultiAgents框架中的对话列表
dialog_list = get_multiagents_dialog_list(user_id)
# 构建学习进度数据
progress_data = {
"user_uuid": user_uuid,
"user_id": user_id,
"material_id": chatmanager.material_id,
"chapter_name": chatmanager.chapter_name,
"lesson_name": chatmanager.lesson_name,
"chapter_chain_now": chatmanager.chapter_chain_now,
"chat_historys": chatmanager.chat_historys,
"scores": chatmanager.scores,
"multiagents_dialogs": dialog_list,
"updated_at": datetime.now()
}
# 保存到MongoDB使用upsert操作如果存在则更新不存在则插入
mongo.db.learning_progress.update_one(
{
"user_uuid": user_uuid,
"material_id": chatmanager.material_id,
"chapter_name": chatmanager.chapter_name,
"lesson_name": chatmanager.lesson_name
},
{"$set": progress_data},
upsert=True
)
current_app.logger.info(f"学习进度保存成功: user_uuid={user_uuid}, lesson={chatmanager.lesson_name}")
return True
except Exception as e:
current_app.logger.error(f"保存学习进度失败: {str(e)}")
return False
def get_multiagents_dialog_list(user_id: str) -> List[Dict]:
"""
从MultiAgents框架获取用户对话列表
参数:
- user_id: 用户ID
返回:
- List[Dict]: 对话列表
"""
try:
# 从配置中获取ASE引擎URL和token
config = current_app.config
url_token = config.get("ASE_ENGINE_URL_TOKEN", "")
# 构建请求URL
base_url = config.get("ASE_ENGINE_URL", "")
api_url = f"{base_url}/api/sync/download"
# 构建请求参数
payload = {
"namespace_url": url_token, # 使用配置中的token作为namespace_url
"user_id": user_id
}
# 发送POST请求
response = requests.post(
api_url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=10
)
# 检查响应
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"获取MultiAgents对话列表失败: HTTP {response.status_code}")
return []
except Exception as e:
current_app.logger.error(f"获取MultiAgents对话列表异常: {str(e)}")
return []
def load_learning_progress(user_uuid: str, material_id: str, chapter_name: str, lesson_name: str) -> Optional[Dict]:
"""
加载用户学习进度
参数:
- user_uuid: 用户UUID
- material_id: 课程ID
- chapter_name: 章节名
- lesson_name: 课时名
返回:
- Optional[Dict]: 学习进度数据如果不存在则返回None
"""
try:
mongo = current_app.extensions["mongo"]
progress = mongo.db.learning_progress.find_one({
"user_uuid": user_uuid,
"material_id": material_id,
"chapter_name": chapter_name,
"lesson_name": lesson_name
})
if progress:
# 转换ObjectId为字符串
if "_id" in progress:
progress["_id"] = str(progress["_id"])
return progress
return None
except Exception as e:
current_app.logger.error(f"加载学习进度失败: {str(e)}")
return None
from .course_services.learning_progess_process import *

View File

@@ -0,0 +1,235 @@
# myapp/services/course_service.py
import re
import requests
import json
from typing import Dict, List, Optional
from flask import current_app
from urllib.parse import urlparse
from datetime import datetime
from bson import ObjectId
def save_learning_progress(chatmanager, user_id: str) -> bool:
"""
保存用户学习进度到MongoDB
参数:
- chatmanager: ChatManager实例包含学习进度数据
- user_id: 用户ID
返回:
- bool: 保存是否成功
"""
try:
# 获取MongoDB连接
mongo = current_app.extensions["mongo"]
# 获取MultiAgents框架中的对话列表
dialog_list = get_multiagents_dialog_list(user_id)
his_data = dialog_list['data']
# 构建学习进度数据
progress_data = {
"user_id": user_id,
"material_id": chatmanager.material_id,
"chapter_name": chatmanager.chapter_name,
"lesson_name": chatmanager.lesson_name,
"chapter_chain_now": chatmanager.chapter_chain_now,
"chat_historys": chatmanager.chat_historys,
"scores": chatmanager.scores,
"multiagents_dialogs": his_data,
"updated_at": datetime.now()
}
# 保存到MongoDB使用upsert操作如果存在则更新不存在则插入
mongo.db.learning_progress.update_one(
{
"user_id": user_id,
"material_id": chatmanager.material_id,
"chapter_name": chatmanager.chapter_name,
"lesson_name": chatmanager.lesson_name
},
{"$set": progress_data},
upsert=True
)
current_app.logger.info(f"学习进度保存成功: user_id={user_id}, lesson={chatmanager.lesson_name}")
return True
except Exception as e:
current_app.logger.error(f"保存学习进度失败: {str(e)}")
return False
def get_multiagents_dialog_list(user_id: str) -> List[Dict]:
"""
从MultiAgents框架获取用户对话列表
参数:
- user_id: 用户ID
返回:
- Dict[List]: 对话列表(key 为 History database)
"""
try:
# 从配置中获取ASE引擎URL和token
config = current_app.config
url_token = config.get("ASE_ENGINE_URL_TOKEN", "")
# 构建请求URL
base_url = config.get("ASE_ENGINE_URL", "")
api_url = f"{base_url}/api/sync/download"
# 构建请求参数
payload = {
"namespace_url": url_token, # 使用配置中的token作为namespace_url
"user_id": user_id
}
# 发送POST请求
response = requests.post(
api_url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=10
)
# 检查响应
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"获取MultiAgents对话列表失败: HTTP {response.status_code}")
return []
except Exception as e:
current_app.logger.error(f"获取MultiAgents对话列表异常: {str(e)}")
return []
def load_learning_progress(user_id: str, material_id: str, chapter_name: str, lesson_name: str) -> Dict:
"""
加载用户学习进度
参数:
- user_id: 用户ID
- material_id: 课程ID
- chapter_name: 章节名
- lesson_name: 课时名
返回:
- Dict: 学习进度数据,包含以下字段:
- exists: bool表示学习记录是否存在
- data: Dict学习进度详细数据包含chat_historys、scores、chapter_chain_now等字段
- message: str操作结果描述
"""
try:
mongo = current_app.extensions["mongo"]
progress = mongo.db.learning_progress.find_one({
"user_id": user_id,
"material_id": material_id,
"chapter_name": chapter_name,
"lesson_name": lesson_name
})
if progress:
# 转换ObjectId为字符串
if "_id" in progress:
progress["_id"] = str(progress["_id"])
return {
"exists": True,
"data": progress,
"message": "学习记录加载成功"
}
# 返回默认值,确保即使没有学习记录也有完整的结构
return {
"exists": False,
"data": {
"user_id": user_id,
"material_id": material_id,
"chapter_name": chapter_name,
"lesson_name": lesson_name,
"chat_historys": [],
"scores": [],
"chapter_chain_now": None,
"updated_at": datetime.now().isoformat()
},
"message": "未找到学习记录,返回默认值"
}
except Exception as e:
error_msg = f"加载学习进度失败: {str(e)}"
current_app.logger.error(error_msg)
# 发生错误时返回默认结构,确保系统稳定性
return {
"exists": False,
"data": {
"user_id": user_id,
"material_id": material_id,
"chapter_name": chapter_name,
"lesson_name": lesson_name,
"chat_historys": [],
"scores": [],
"chapter_chain_now": None,
"updated_at": datetime.now().isoformat()
},
"message": error_msg
}
def upload_learning_progress_to_cloud(progress: Dict) -> Dict:
"""
将学习进度上传到云服务
参数:
- progress: 学习进度数据字典{'databases':{}, 'global_databases':{}, 'session_info_out':{}}
返回:
- Dict: 上传结果包含success和message字段
"""
try:
# 从配置中获取ASE引擎URL和token
config = current_app.config
url_token = config.get("ASE_ENGINE_URL_TOKEN", "")
# 构建请求URL
base_url = config.get("ASE_ENGINE_URL", "")
api_url = f"{base_url}/api/sync/upload"
# 导入JSON序列化工具
from ...utils.json_serializer import json_serialize_dict
# 序列化进度数据处理datetime等特殊类型
serialized_progress = json_serialize_dict(progress['multiagents_dialogs'])
# 构建请求参数
payload = {
"namespace_url": url_token, # 使用配置中的token作为namespace_url
"user_id": progress.get("user_id", ""),
"data": serialized_progress
}
# 发送POST请求
response = requests.post(
api_url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=10
)
# 处理响应
if response.status_code == 200:
result = response.json()
current_app.logger.info(f"学习进度上传成功: user_id={progress.get('user_id')} 内容:{serialized_progress}")
return {
"success": True,
"message": "学习进度上传成功",
"data": result
}
else:
error_msg = f"上传失败HTTP状态码: {response.status_code}"
current_app.logger.error(f"学习进度上传失败: {error_msg}")
return {
"success": False,
"message": error_msg
}
except Exception as e:
error_msg = f"上传异常: {str(e)}"
current_app.logger.error(f"学习进度上传异常: {error_msg}")
return {
"success": False,
"message": error_msg
}

View File

@@ -0,0 +1,473 @@
import re
import requests
import json
from typing import Dict, List, Optional
from flask import current_app
from urllib.parse import urlparse
from datetime import datetime
from bson import ObjectId
from apps.services.cos_service import upload_file, delete_file
from apps.models.material import Material
def get_new_edit_materials(num: int)->List[Material]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find().sort("updated_at", -1).limit(num)
returnCode = []
for material in materials:
returnCode.append(Material(**material))
returnCode[-1]._id = str(material['_id'])
return returnCode
def user_selected_course(user_obj):
briefs = []
for cid in getattr(user_obj, "select_course", []):
course_obj = load_material(cid)
brief={}
brief["_id"] = cid
brief["name"] = course_obj.name
brief["description"] = course_obj.description
brief["image_url"] = course_obj.image_url
brief["created_at"] = course_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")
brief["updated_at"] = course_obj.updated_at.strftime("%Y-%m-%d %H:%M:%S")
brief["chapters"] = [chapter.model_dump() for chapter in course_obj.chapters]
briefs.append(brief)
return briefs
def user_selected_course_briefs(user_obj):
"""根据用户对象的选课列表,拼装课程简要信息数组"""
briefs = []
for cid in getattr(user_obj, "select_course", []):
course_obj = load_material(cid)
brief={}
brief["_id"] = cid
brief["name"] = course_obj.name
brief["description"] = course_obj.description
brief["image_url"] = course_obj.image_url
brief["created_at"] = course_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")
brief["updated_at"] = course_obj.updated_at.strftime("%Y-%m-%d %H:%M:%S")
briefs.append(brief)
return briefs
def create_material(teacher_id, material_name, description, chapters, image_url):
material = Material(
name=material_name,
description=description,
teacher_id=teacher_id,
created_at=datetime.now(),
updated_at=datetime.now(),
chapters=chapters,
image_url=image_url
)
mongo = current_app.extensions["mongo"]
material_id = mongo.db.materials.insert_one(material.model_dump()).inserted_id
return str(material_id)
def load_material(material_id):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({"_id": ObjectId(material_id)})
material['_id'] = material_id
return Material(**material)
def update_material(material_id, chapters):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if material:
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters': chapters, 'updated_at': datetime.now()}}
)
return True
return False
def add_material_chapter(material_id, chapter_name, teacher_id):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if material['teacher_id'] != teacher_id:
return False
if material:
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$push': {'chapters': {'chapter_name': chapter_name, 'lessons': []}}})
return True
def rename_material_structure(material_id, is_chapter, chapter_name, lesson_name, new_name):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if not material:
return False # 材料不存在
# 检查名称是否存在于列表中的辅助函数
def name_exists_in_list(item_list, name_key, target_name):
return any(item[name_key] == target_name for item in item_list)
# 查找包含指定章节名的章节
def find_chapter_by_name(chapters, target_name):
for chapter in chapters:
if chapter['chapter_name'] == target_name:
return chapter
return None
# 检查章节是否存在
target_chapter = find_chapter_by_name(material['chapters'], chapter_name)
if not target_chapter:
return False
if is_chapter:
# 检查新章节名是否已存在
if name_exists_in_list(material['chapters'], 'chapter_name', new_name):
return False
# 更新章节名
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters.$[c].chapter_name': new_name}},
array_filters=[{'c.chapter_name': chapter_name}]
)
else:
# 检查课时是否存在
if not name_exists_in_list(target_chapter['lessons'], 'lesson_name', lesson_name):
return False
# 检查新课时名是否已存在
if name_exists_in_list(target_chapter['lessons'], 'lesson_name', new_name):
return False
# 更新课时名
mongo.db.materials.update_one(
{'_id': ObjectId(material_id)},
{'$set': {'chapters.$[c].lessons.$[l].lesson_name': new_name}},
array_filters=[{'c.chapter_name': chapter_name}, {'l.lesson_name': lesson_name}]
)
return True
def delete_material_chapter_lesson(material_id, chapter_name, lesson_name, teacher_id):
"""
删除指定课程的课时。
删除后,更新 MongoDB 中对应的章节中的 lesson 列表。
"""
mongo = current_app.extensions["mongo"]
# 查询 material 是否存在,且确保该 teacher_id 对应的 material
material = mongo.db.materials.find_one({'_id': ObjectId(material_id), 'teacher_id': teacher_id})
for chapter in material['chapters']:
if chapter['chapter_name'] == chapter_name:
for lesson in chapter['lessons']:
if lesson['lesson_name'] == lesson_name:
if lesson['markdown_lesson_file_link']!='':
delete_file(lesson['markdown_lesson_file_link'])
if lesson['markdown_prompt_file_name']!='':
delete_file(lesson['markdown_prompt_file_name'])
if lesson['markdown_score_prompt_file_link']!='':
delete_file(lesson['markdown_score_prompt_file_link'])
if not material:
return False # 找不到对应的 material 或 teacher_id 不匹配
# 删除 lesson 的条件
res = mongo.db.materials.update_one(
{
"_id": ObjectId(material_id),
"teacher_id": teacher_id # 确保是该教师的课程
},
{
"$pull": {
"chapters.$[c].lessons": {
"lesson_name": lesson_name # 根据 lesson_name 来删除
}
},
"$set": {"updated_at": datetime.now()} # 更新修改时间
},
array_filters=[{"c.chapter_name": chapter_name}] # 找到匹配的章节
)
# 如果删除操作成功res.modified_count 应为 1
return res.modified_count == 1
def reorder_material_structure(material_id, order):
mongo = current_app.extensions["mongo"]
material = mongo.db.materials.find_one({'_id': ObjectId(material_id)})
if not material:
return False
# 1. 构建原章节映射(保留章节元信息)和原章节的课时映射
chapter_map = {ch["chapter_name"]: ch for ch in material["chapters"]}
# 每个章节的课时映射:{章节名: {课时名: 课时对象}}
original_chapter_lessons = {
ch["chapter_name"]: {ls["lesson_name"]: ls for ls in ch["lessons"]}
for ch in material["chapters"]
}
# 2. 收集所有章节“少掉的课时”到全局池(仅包含原章节有但新排序没包含的课时)
# 用列表存储,避免同名课时被覆盖(处理可能的重名场景)
global_extra_lessons = []
for ch_name in chapter_map:
original_lessons = original_chapter_lessons[ch_name] # 原章节的所有课时
# 找到当前章节在order中的配置如果没有默认该章节所有课时都是“少掉的”
chapter_order = next((item for item in order if item["chapter_name"] == ch_name), None)
if not chapter_order:
# 章节未在order中出现所有原课时都是“少掉的”
global_extra_lessons.extend(original_lessons.values())
continue
# 章节在order中出现找出原课时中不在新排序里的即“少掉的”
new_lesson_names = chapter_order["lessons"]
for ls_name, ls in original_lessons.items():
if ls_name not in new_lesson_names:
global_extra_lessons.append(ls)
print("global_extra_lessons:", global_extra_lessons)
new_chapters = []
for chapter_order in order:
ch_name = chapter_order["chapter_name"]
if ch_name not in chapter_map:
continue # 跳过不存在的章节
original_ch = chapter_map[ch_name]
original_lessons = original_chapter_lessons[ch_name] # 本章节原课时
new_lessons = []
# 3. 构建当前章节的新课时列表
for ls_name in chapter_order["lessons"]:
# 优先使用本章节原有的课时
if ls_name in original_lessons:
new_lessons.append(original_lessons[ls_name])
continue
# 本章节没有,从全局池(其他章节少掉的课时)中找
# 处理可能的重名:找到第一个匹配的课时
for i, extra_ls in enumerate(global_extra_lessons):
if extra_ls["lesson_name"] == ls_name:
new_lessons.append(extra_ls)
del global_extra_lessons[i] # 从全局池移除,避免重复
break
# 4. 构造新章节(保留原章节元信息,替换课时列表)
new_chapter = {**original_ch, "lessons": new_lessons}
new_chapters.append(new_chapter)
# 5. 处理全局池中剩余的课时(未被任何章节引用的,追加到最后一个章节)
if global_extra_lessons and new_chapters:
new_chapters[-1]["lessons"].extend(global_extra_lessons)
print("new_chapters:", new_chapters)
# 更新数据库
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{
"$set": {
"chapters": new_chapters,
"updated_at": datetime.now()
}
}
)
return True
def save_materials_markdown_cos(
material_id: str,
chapter_name: str,
lesson_name: str,
docs: Dict[str, Dict[str, str]],
version: str | None = None, # 目前不做并发控制,保留参数
actor=None # 可记录操作者到审计日志
) -> Dict[str, str]:
"""
docs 结构:
{
"lesson": {"cdn_link": "...lesson.md", "content": "..."},
"prompt": {"cdn_link": "...prompt.md", "content": "..."},
"score": {"cdn_link": "...score_prompt.md", "content": "..."}
}
约定cdn_link 与对象存储的 key 一一映射(同路径/同文件名),
我们通过解析 URL 的 path 作为 key 进行覆盖写入(不改文件名)。
"""
def _cdn_to_key(cdn_link: str) -> str:
"""
把 CDN URL 映射成对象存储的 key
- 若 CDN 与存储是“同 key 直出”,则直接取 path去掉前导 /
- 若你有自定义映射规则,在此处改写
"""
p = urlparse(cdn_link)
key = p.path.lstrip("/")
if not key:
raise ValueError(f"非法 cdn_link{cdn_link}")
return key
# 1) 覆盖写入三份文件
results: Dict[str, Dict[str, str]] = {}
for kind in ("lesson", "prompt", "score"):
item = docs.get(kind)
if not item or "cdn_link" not in item or "content" not in item:
raise ValueError(f"缺少 {kind} 的 cdn_link 或 content")
key = _cdn_to_key(item["cdn_link"])
content = item["content"]
# 覆盖写入注意upload_file 内部需实现覆盖同 key
try:
# 约定upload_file 返回可访问的 URL覆盖后可与原相同
url = upload_file(content.encode("utf-8"), key)
except Exception as e:
current_app.logger.exception("覆盖写入失败 kind=%s key=%s", kind, key)
raise
# 简单返回:保留原 cdn_link一般与 url 相同),以及 md5 作为伪版本
import hashlib
etag = hashlib.md5(content.encode("utf-8")).hexdigest()
results[kind] = {"cdn_link": url or item["cdn_link"], "version": etag}
# 2) 写库:更新该 lesson 的更新时间(不改文件名/链接)
_touch_material_lesson_updated_at(material_id, chapter_name, lesson_name, actor=actor)
results["updated_at"] = datetime.now().isoformat() + "Z"
return results
def _touch_material_lesson_updated_at(material_id: str, chapter_name: str, lesson_name: str, actor=None) -> None:
"""仅刷新 materials 文档中该课时的更新时间戳(以及顶层 updated_at"""
mongo = current_app.extensions["mongo"]
now = datetime.now()
# 更新顶层 updated_at
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{"$set": {"updated_at": now}}
)
# 更新嵌套 lesson 的更新时间(如果你在 lesson 内有该字段)
# 需要使用 arrayFilters 精确定位到对应章节/课时
mongo.db.materials.update_one(
{"_id": ObjectId(material_id)},
{
"$set": {
"chapters.$[c].lessons.$[l].updated_at": now,
# 可选:记录最近编辑人
"chapters.$[c].lessons.$[l].last_editor": getattr(actor, "id", None) if actor else None
}
},
array_filters=[
{"c.chapter_name": chapter_name},
{"l.lesson_name": lesson_name}
]
)
def add_material_chapter_lesson(material_id, chapter_name, lesson_name, teacher_id):
"""
1) 生成占位 Markdownlesson_prompt_score_prompt
2) 上传到对象存储(返回 URL
3) 将 lesson 文档(含三份 URLpush 到指定章节
"""
mongo = current_app.extensions["mongo"]
# 组织对象存储的文件路径
chapter_slug = _slugify(chapter_name or "")
lesson_slug = _slugify(lesson_name or "")
timestamp = datetime.now().strftime('%Y%m%dT%H%M%SZ')
base_prefix = f"materials_{material_id}_{chapter_slug}_{lesson_slug}_{timestamp}"
# 占位文本
lesson_md, prompt_md, score_prompt_md = _default_markdowns(lesson_name or "未命名课时")
# 生成文件名(带层级路径)
lesson_key = f"{base_prefix}_lesson.md"
prompt_key = f"{base_prefix}_prompt.md"
score_key = f"{base_prefix}_score_prompt.md"
# 上传(任意一个失败则终止,不写库)
try:
lesson_url = upload_file(lesson_md.encode("utf-8"), lesson_key)
prompt_url = upload_file(prompt_md.encode("utf-8"), prompt_key)
score_url = upload_file(score_prompt_md.encode("utf-8"), score_key)
except Exception as e:
current_app.logger.exception("Upload default markdown failed: %s", e)
return False
# 组装 lesson_doc
lesson_doc = {
"lesson_name": lesson_name or "",
"markdown_lesson_file_link": lesson_url, # lesson 主体
"markdown_prompt_file_name": prompt_url, # 给大模型的 prompt
"markdown_score_prompt_file_link": score_url, # 评分 prompt
}
# 将 lesson push 到指定章节
res = mongo.db.materials.update_one(
{
"_id": ObjectId(material_id),
"teacher_id": teacher_id, # 注意:若存的是 ObjectId请确保传入的是同类型
},
{
"$push": {"chapters.$[c].lessons": lesson_doc},
"$set": {"updated_at": datetime.now()},
},
array_filters=[{"c.chapter_name": chapter_name}],
)
# 可选:如果 res.modified_count != 1说明 push 失败(可能章节名不匹配)
# 这里可以考虑是否回滚 COS 上刚上传的 3 个文件(需实现删除逻辑)。
return res.modified_count == 1
def get_materials_by_teacher(teacher_id)->List[Material]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find({'teacher_id': teacher_id})
return [Material(**material) for material in materials]
def get_materials_by_teacher_dict(teacher_id)->List[Dict]:
mongo = current_app.extensions["mongo"]
materials = mongo.db.materials.find({'teacher_id': teacher_id})
result = []
for material in materials:
material['_id'] = str(material['_id'])
result.append(material)
return result
def _slugify(name: str) -> str:
"""将章节/课时名转为 URL 友好的 slug。"""
s = name.strip().lower()
s = re.sub(r'\s+', '-', s) # 空白 -> -
s = re.sub(r'[^a-z0-9\-_]+', '', s) # 仅保留安全字符
return s or 'untitled'
def _default_markdowns(lesson_name: str):
"""三份占位 Markdown 文本。可按需调整模板内容。"""
ts = datetime.now().strftime('%Y-%m-%d %H:%M')
lesson_md = f"""# {lesson_name}
## 阶段一:示例阶段
### 步骤A
在这里编写该步骤的教学内容(文字/代码/图片链接等)。
### 步骤B
继续补充该阶段的其它步骤内容。
"""
prompt_md = f"""# {lesson_name}
## 阶段一:示例阶段
### 步骤A
请基于"步骤A"的教学目标与材料,生成指导性提示词(尽量结构化,给出评估标准和示例)。
### 步骤B
请基于"步骤B"的教学目标与材料,生成指导性提示词。
"""
score_prompt_md = f"""# {lesson_name}
## 评分准则
- 正确性、完整性、可读性、效率(按需调整权重)
## 阶段一:示例阶段
### 步骤A
对学员在"步骤A"的输出进行评分与文字反馈,分数区间 0-100并输出 JSON```{{"score": <int>, "reasons": "<string>", "advices": "<string>"}}```
### 步骤B
同上,对"步骤B"输出进行评分与反馈。
"""
return lesson_md, prompt_md, score_prompt_md

View File

@@ -10,17 +10,27 @@ from ..function_tools import judge
from ..function_tools import next_chapter
from ..services.backboard_service import realtime_response
from ..services.markdown_service import load_full_markdown_file
from ..services.course_service import save_learning_progress
from ..services.course_service import save_learning_progress, upload_learning_progress_to_cloud
from ..extension_ase.ase_client import HSAEngineClient
from ..services.user_service import get_or_load_current_user
from ..services.course_service import load_learning_progress
from ..services.asectrl_service import (
on_connect_to_ase,
receive_ase_paste_detected,
receive_ase_dialog,
receive_ase_message_hint,
receive_ase_voice_out,
receive_ase_process,
receive_ase_judge,
receive_ase_next_chapter,
receive_ase_chapter_score
)
class VSCodeNamespace(Namespace):
def on_login(self,data):
self.app = current_app
ex = current_app.extensions
print("VSCode client connected")
dataconfig = data['config']
@@ -53,7 +63,7 @@ class VSCodeNamespace(Namespace):
class AgentNamespace(Namespace):
def on_login(self, data):
self.current_app = current_app
self.app = current_app
ex = current_app.extensions
uuid2username = ex["uuid2username"]
backboard_manager = ex["backboard_manager"]
@@ -75,38 +85,43 @@ class AgentNamespace(Namespace):
chatmanager.function_manager.add_function(judge, {'course_id': course_id, 'root_path': f'/home/{user_id}/{course_id}/{chapter_name}/{lesson_name}'})
chatmanager.function_manager.add_function(next_chapter, {'user_uuid': user_uuid})
# 尝试加载用户学习进度
from ..services.course_service import load_learning_progress
progress = load_learning_progress(user_uuid, course_id, chapter_name, lesson_name)
need_skip_chapters = 0
if progress:
# 恢复学习进度数据
if 'scores' in progress:
# 根据scores的数量确定需要跳过的章节数
need_skip_chapters = len(progress['scores'])
chatmanager.scores = progress['scores']
print(f"恢复用户学习进度: {user_uuid}, 已完成 {need_skip_chapters} 个章节")
if not chatmanager.restart:# 尝试加载用户学习进度
self.app.logger.info(f"尝试加载用户学习进度: {user_id}, {course_id}, {chapter_name}, {lesson_name}")
progress_result = load_learning_progress(user_id, course_id, chapter_name, lesson_name)
need_skip_chapters = 0
# 恢复聊天历史
if 'chat_historys' in progress:
chatmanager.chat_historys = progress['chat_historys']
if progress_result['exists']:
progress = progress_result['data']
# 恢复学习进度数据
if 'scores' in progress:
# 根据scores的数量确定需要跳过的章节数
need_skip_chapters = len(progress['scores'])
chatmanager.scores = progress['scores']
self.app.logger.info(f"恢复用户学习进度: {user_id}, 已完成 {need_skip_chapters} 个章节")
# 恢复聊天历史
if 'chat_historys' in progress:
chatmanager.chat_historys = progress['chat_historys']
else:
chatmanager.chat_historys = []
# 恢复当前章节链
if 'chapter_chain_now' in progress:
chatmanager.chapter_chain_now = progress['chapter_chain_now']
upload_learning_progress_to_cloud(progress)
else:
chatmanager.chat_historys = []
# 恢复当前章节链
if 'chapter_chain_now' in progress:
chatmanager.chapter_chain_now = progress['chapter_chain_now']
else:
chatmanager.chat_historys = []
chatmanager.scores = []
# 使用默认值,确保有完整的结构
chatmanager.chat_historys = progress_result['data']['chat_historys']
chatmanager.scores = progress_result['data']['scores']
self.app.logger.info(f"未找到学习记录,使用默认值: {progress_result['message']}")
fmd,fmdp,fsp = load_full_markdown_file(course_id, chapter_name, lesson_name)
user_uuid2ase_client[user_uuid] = HSAEngineClient(current_app.config["ASE_ENGINE_URL"], current_app.config["ASE_ENGINE_NAMESPACE"],id=user_id, chatmanager = chatmanager)
chatmanager.init(user_uuid, user_uuid2ase_client[user_uuid], fmd, fmdp, fsp, course_id, chapter_name, lesson_name, max_iter=5, app=current_app, socketio=self)
backboard_manager.add_backboard(user_uuid, user_id, course_id, lesson_name, root_path=f'/home/{user_id}/{course_id}/{chapter_name}/{lesson_name}')
chatmanager.bb = backboard_manager.get_backboard(user_uuid)
chatmanager.next_chapter()
if not progress:
chatmanager.next_chapter()
# 如果有保存的进度,需要跳过已经完成的章节
for _ in range(need_skip_chapters):
@@ -114,55 +129,55 @@ class AgentNamespace(Namespace):
self.chatmanager = chatmanager
self.ase_client = user_uuid2ase_client[user_uuid]
user_uuid2ase_client[user_uuid].register_on_connect_entry(
callback=self.on_connect_to_ase,
callback=on_connect_to_ase,
entry=(self, user_uuid2ase_client[user_uuid]),
init_data={'room':user_uuid}
init_data={'room':user_uuid, 'restart':chatmanager.restart}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='dialog',
callback=self.receive_ase_dialog,
callback=receive_ase_dialog,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='dialog-hint',
callback=self.receive_ase_message_hint,
callback=receive_ase_message_hint,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='voice_out',
callback=self.receive_ase_voice_out,
callback=receive_ase_voice_out,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='judge',
callback=self.receive_ase_judge,
callback=receive_ase_judge,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='next_chapter',
callback=self.receive_ase_next_chapter,
callback=receive_ase_next_chapter,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='chapter_score',
callback=self.receive_ase_chapter_score,
callback=receive_ase_chapter_score,
entry=(get_or_load_current_user(),chatmanager),
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='process',
callback=self.receive_ase_process,
callback=receive_ase_process,
entry=self,
init_data={'room': user_uuid}
)
user_uuid2ase_client[user_uuid].register_route_with_entry(
route='paste_detected',
callback=self.receive_ase_paste_detected,
callback=receive_ase_paste_detected,
entry=self,
init_data={'room': user_uuid}
)
@@ -171,10 +186,10 @@ class AgentNamespace(Namespace):
def on_language(self, language):
ex = current_app.extensions
print(f"Language changed to: {language}")
self.app.logger.info(f"Language changed to: {language}")
def on_message(self, data):
print(f"Message from client: {data}")
self.app.logger.info(f"Message from client: {data}")
ex = current_app.extensions
chatmanager = ex["user_uuid2chatmanager"]
uuid = session.get('user_uuid')
@@ -184,14 +199,14 @@ class AgentNamespace(Namespace):
if data['type'] == 'text':
res = user_uuid2ase_client[uuid].send_text('dialog', data['data'])
chatmanager[uuid].chat_historys.append({'role': 'user', 'content': data['data']})
print(f"Send text to route 'dialog' success: {res}")
self.app.logger.info(f"Send text to route 'dialog' success: {res}")
if data['type'] == 'function': # 在这里 前端会发送允许执行的function与参数
print("function_call", data['data'])
self.app.logger.info(f"function_call: {data['data']}")
d = data['data']
res = chatmanager[uuid].function_call(d['data']['name'], d['data'].get('args', {}))
d['data'] = res.to_dict()
print("function_call_res", d['data'])
self.app.logger.info(f"function_call_res: {d['data']}")
emit(d['route'], d, room=uuid, namespace='/agent')
chatmanager[uuid].chat_historys.append({'role': 'user', 'content': data['data']})
user_uuid2ase_client[uuid].send_text(d['route'], data['data'])
@@ -202,88 +217,10 @@ class AgentNamespace(Namespace):
uuid = session.get('user_uuid')
user_uuid2ase_client = ex["user_uuid2ase_client"]
res = user_uuid2ase_client[uuid].send_voice('voice_in', voice_data['data'])
print(f"Send voicce to route 'voice_in' success: {res}")
def on_connect_to_ase(client_entry: HSAEngineClient, namespace_entry, init_data):
'''
此时刚刚建立好aseclient连接并向前端发送打开code-server-like指令。
'''
print(f"on_connect_to_ase {client_entry}, {namespace_entry}, {init_data}")
namespace_entry, ase_client = namespace_entry
namespace_entry.emit('open-iframe',"", room=init_data['room'], namespace='/agent')
# 将异步emit改为同步requests.post请求
try:
# 构建请求URL使用配置中的ASE_ENGINE_URL
base_url = current_app.config['ASE_ENGINE_URL']
clear_url = f"{base_url}/clear_user_session"
# 准备请求参数
payload = {
'namespace_url': current_app.config['ASE_ENGINE_URL_TOKEN'], # WebSocket命名空间URL
'user_id': init_data['room'], # 用户ID对应session_info.id
'clear_type': 'all' # 清理类型,默认为全部
}
# 添加token认证头
headers = {
'Content-Type': 'application/json'
}
# 发送同步POST请求
response = requests.post(clear_url, json=payload, headers=headers)
response.raise_for_status() # 检查请求是否成功
print(f"Clear user session successful: {response.json()}")
except Exception as e:
print(f"Error clearing user session: {e}")
ase_client.chatmanager.load_next_chapter()
ase_client.send_text("chapter-start", "")
namespace_entry.emit('message', "Multi-Agents服务已连接", room=init_data['room'], namespace='/agent')
#接受消息回来直接让message监听发送
def receive_ase_paste_detected(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
print("receive_ase_paste_detected", data)
client_entry.chatmanager.chat_historys.append({'role': 'assistant', 'content': data['data']})
namespace_entry.emit('message', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_dialog(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
print("receive_ase_dialog", data)
client_entry.chatmanager.chat_historys.append({'role': 'assistant', 'content': data['data']})
namespace_entry.emit('message', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_message_hint(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
print("receive_ase_message_hint", data)
namespace_entry.emit('message-hint', data['data'], room=init_data['room'], namespace='/agent')
#接受语音信息
def receive_ase_voice_out(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
namespace_entry.emit('voiceMessage', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_process(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
namespace_entry.emit('process', data['data'], room=init_data['room'], namespace='/agent')
def receive_ase_judge(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
print("receive_ase_judge", data)
namespace_entry.emit('request_function', data, room=init_data['room'], namespace='/agent') # data 是一个列表 /dict也支持
def receive_ase_next_chapter(client_entry: HSAEngineClient, namespace_entry: Namespace, data, init_data):
print("receive_ase_next_chapter", data)
namespace_entry.emit('request_function',data, room=init_data['room'], namespace='/agent') # data 是一个列表 /dict也支持
def receive_ase_chapter_score(client_entry: HSAEngineClient, user_and_manager, data, init_data):
print("load_next_chapter")
user, chatmanager = user_and_manager
if len(chatmanager.scores) >= chatmanager.chapter_chain_now:
chatmanager.scores[chatmanager.chapter_chain_now - 1] = data.get('data', None)
else: chatmanager.scores.append(data.get('data', None))
user.set_course_progress(chatmanager.chat_historys, chatmanager.scores, chatmanager.course_id, chatmanager.chapter_name, chatmanager.lesson_name)
chatmanager.load_next_chapter()
time.sleep(0.5)
chatmanager.ase_client.send_text("chapter-start", "")
self.app.logger.info(f"Send voicce to route 'voice_in' success: {res}")
def on_initiative(self,data): # 主动call
print("User active function call")
self.app.logger.info("User active function call")
ex = current_app.extensions
uuid = session.get('user_uuid')
manager = ex["user_uuid2chatmanager"][uuid]
@@ -296,7 +233,7 @@ class AgentNamespace(Namespace):
def on_disconnect(self,data):
print(f'AgentNamespace client disconnected, session user_uuid: {session.get("user_uuid", "unknown")}')
self.app.logger.info(f'AgentNamespace client disconnected, session user_uuid: {session.get("user_uuid", "unknown")}')
ex = current_app.extensions
uuid = session.get('user_uuid')
user_uuid2chatmanager = ex.get("user_uuid2chatmanager", {})
@@ -306,14 +243,15 @@ class AgentNamespace(Namespace):
# 获取用户ID
uuid2username = ex.get("uuid2username", {})
user_id = uuid2username.get(uuid, "unknown")
self.app.logger.debug(f"get user's dialog his user_id={user_id}")
# 调用保存学习进度功能
chatmanager = user_uuid2chatmanager.get(uuid)
if chatmanager:
save_result = save_learning_progress(uuid, chatmanager, user_id)
print(f"保存学习进度结果: {save_result}, user_uuid={uuid}")
save_result = save_learning_progress(chatmanager, user_id)
self.app.logger.info(f"保存学习进度结果: {save_result}")
except Exception as e:
print(f"保存学习进度时发生异常: {str(e)}")
self.app.logger.error(f"保存学习进度时发生异常: {str(e)}")
# 执行原有的断开连接逻辑
if uuid in user_uuid2chatmanager:
@@ -323,5 +261,5 @@ class AgentNamespace(Namespace):
if uuid:
leave_room(uuid, namespace='/agent')
print("VSCode client disconnected")
print("Disconnect reason:"+str(data))
self.app.logger.info("VSCode client disconnected")
self.app.logger.info(f"Disconnect reason: {str(data)}")

View File

@@ -85,6 +85,22 @@
folder:"{{workspace_path}}"
}
</script>
<script>
// 检测页面刷新事件
window.addEventListener('beforeunload', function(e) {
// 这里不弹出提示,只在页面刷新时执行跳转
// 但是beforeunload中不能直接跳转所以我们需要在页面加载时检测是否是刷新
});
// 检测是否是页面刷新
if (performance.navigation.type === 1 || performance.getEntriesByType("navigation")[0].type === "reload") {
// 是刷新操作,跳转到确认页面
const course_id = "{{course_id}}";
const chapter_name = "{{chapter_name}}";
const lesson_name = "{{lesson_name}}";
window.location.href = `/desktop_nouser/${course_id}/${chapter_name}/${lesson_name}`;
}
</script>
</div>
</div>

View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>确认加载历史数据</title>
<link rel="stylesheet" href="/static/cdnback/bootstrap.min.css">
<style>
body {
background-color: #f8f9fa;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}
.confirm-container {
background: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 30px;
max-width: 500px;
width: 100%;
text-align: center;
}
h1 {
color: #343a40;
margin-bottom: 20px;
}
p {
color: #6c757d;
margin-bottom: 30px;
font-size: 16px;
}
.btn-group {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 20px;
}
.btn {
padding: 10px 30px;
font-size: 16px;
border-radius: 5px;
}
.btn-primary {
background-color: #007bff;
border: none;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
border: none;
}
.btn-secondary:hover {
background-color: #545b62;
}
</style>
</head>
<body>
<div class="confirm-container">
<h1>加载历史数据确认</h1>
<p>您即将进入学习环境,是否需要加载您之前的对话历史记录和学习进度数据?</p>
<div class="btn-group">
<a href="{{ url_for('vscode.desktop', user_uuid=user_uuid, course_id=course_id, chapter_name=chapter_name, lesson_name=lesson_name, load_history='true') }}" class="btn btn-primary">
是,加载历史数据
</a>
<a href="{{ url_for('vscode.desktop', user_uuid=user_uuid, course_id=course_id, chapter_name=chapter_name, lesson_name=lesson_name, load_history='false') }}" class="btn btn-secondary">
否,重新开始
</a>
</div>
</div>
</body>
</html>

View File

@@ -156,7 +156,6 @@ def load_chapters(markdown, markdown_prompt, score_prompt):
chapter_chain = []
No = 1
print (chapter_dict)
for chapter_name in chapter_sequence:
chapter_chain.append(Chapter( No, CHAPTER_LATTER, chapter_name, chapter_dict[chapter_name]["markdown"], chapter_dict[chapter_name]["markdown_prompt"], chapter_dict[chapter_name]["score_prompt"],chapter_dict[chapter_name]["require_tools"]))
No+=1

View File

@@ -0,0 +1,58 @@
import json
from datetime import datetime, date, time
from bson import ObjectId
from typing import Any, Dict, List, Union
class JSONEncoder(json.JSONEncoder):
"""
通用JSON编码器处理常见的非JSON序列化类型
支持的特殊类型:
- datetime: 转换为ISO格式字符串
- date: 转换为ISO格式字符串
- time: 转换为ISO格式字符串
- ObjectId: 转换为字符串
- 其他不可序列化类型: 转换为字符串表示
"""
def default(self, obj: Any) -> Any:
if isinstance(obj, (datetime, date, time)):
return obj.isoformat()
elif isinstance(obj, ObjectId):
return str(obj)
elif isinstance(obj, bytes):
return obj.decode('utf-8', errors='replace')
elif isinstance(obj, (set, frozenset)):
return list(obj)
elif hasattr(obj, '__dict__'):
return obj.__dict__
else:
return str(obj)
def json_serialize(obj: Any) -> str:
"""
通用JSON序列化函数处理常见的非JSON序列化类型
参数:
- obj: 要序列化的对象
返回:
- str: JSON字符串
"""
return json.dumps(obj, cls=JSONEncoder, ensure_ascii=False)
def json_serialize_dict(obj: Any) -> Dict:
"""
通用JSON序列化函数将对象转换为可JSON序列化的字典
参数:
- obj: 要序列化的对象
返回:
- Dict: 可JSON序列化的字典
"""
json_str = json_serialize(obj)
return json.loads(json_str)

View File

@@ -17,6 +17,13 @@ def desktop(user_uuid, course_id, chapter_name, lesson_name):
userid_recorder = current_app.extensions["userid_recorder"]
if "user_uuid" not in session:
return redirect(url_for("auth.login"))
# 检查是否有 load_history 参数
load_history = request.args.get('load_history')
if load_history is None:
# 如果没有参数,跳转到确认页面
return redirect(url_for("vscode.desktop_nouser", course_id=course_id, chapter_name=chapter_name, lesson_name=lesson_name))
# 全局映射
user_id = uuid2username[user_uuid]
userid_recorder[f"{user_id}&{course_id}"] = session["user_uuid"]
@@ -58,7 +65,7 @@ def desktop(user_uuid, course_id, chapter_name, lesson_name):
chatmanager = ChatManager()
chatmanager = ChatManager(restart=not load_history)
print(f"now user uuid {user_uuid}")
current_app.extensions["user_uuid2chatmanager"][user_uuid] = chatmanager
code_server_port = 10000 + int(uuid.uuid4().int % 10000)
@@ -136,11 +143,18 @@ def desktop(user_uuid, course_id, chapter_name, lesson_name):
)
@bp.route("/desktop_nouser/<course_id>/<chapter_name>/<lesson_name>")
@require_role
def desktop_nouser(course_id, chapter_name, lesson_name):
if "user_uuid" not in session:
return redirect(url_for("auth.login")) # 如果你有 auth 蓝图
user_uuid = session["user_uuid"]
return redirect(url_for("vscode.desktop", user_uuid=user_uuid, course_id=course_id, chapter_name=chapter_name, lesson_name=lesson_name))
return render_template(
"confirm_load_history.html",
user_uuid=user_uuid,
course_id=course_id,
chapter_name=chapter_name,
lesson_name=lesson_name
)
@bp.route("/vscode_data", methods=["POST"])
def vscode_data():

View File

@@ -5,9 +5,9 @@ api_key = sk-aMpnWklN2IbsK44d1kNpy6YOP9bk1pdPJjFeEmbb0a5ytEFf
model=gpt-4.1-nano
[VSCODE_WEB]
url = https://hsamooc.com
url = https://hsamooc.cn
[CODE_LIKE]
url = https://hsamooc.com/vsc-like
url = /vsc-like
#http://asengine.net:8282
[VSCODE_WEB_PATH]
is_wsl = true
@@ -27,6 +27,6 @@ bucket = hsamooc-cdn-1374354408
region = ap-guangzhou
[ASE_ENGINE]
url = https://asengine.net
url = https://test.asengine.net
namespace = /socket.io/7100eebf7a909e1ee3f4e06766f512f0358ecb6ac51d5434c17d31dd70890dc1
url_token = 7100eebf7a909e1ee3f4e06766f512f0358ecb6ac51d5434c17d31dd70890dc1

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import sys
import os
from datetime import datetime
# Add the project root to the Python path
sys.path.insert(0, '/Users/cakecai/Documents/gitea/hsa/Html')
# Test the JSON serializer
try:
from apps.utils.json_serializer import json_serialize, json_serialize_dict, JSONEncoder
import json
# Create a test dictionary with datetime object
test_data = {
"user_id": "test_user",
"material_id": "test_course",
"chapter_name": "test_chapter",
"lesson_name": "test_lesson",
"chat_historys": [],
"scores": [],
"chapter_chain_now": None,
"updated_at": datetime.now()
}
print("Testing datetime serialization...")
print(f"Original data: {test_data}")
print(f"Type of updated_at: {type(test_data['updated_at'])}")
# Test json_serialize function
json_str = json_serialize(test_data)
print(f"\nSerialized JSON string: {json_str}")
# Test json_serialize_dict function
serialized_dict = json_serialize_dict(test_data)
print(f"\nSerialized dictionary: {serialized_dict}")
print(f"Type of updated_at in serialized dict: {type(serialized_dict['updated_at'])}")
# Test direct json.dumps with custom encoder
json_str2 = json.dumps(test_data, cls=JSONEncoder, ensure_ascii=False)
print(f"\nDirect json.dumps with custom encoder: {json_str2}")
# Verify it can be parsed back
parsed = json.loads(json_str)
print(f"\nParsed back from JSON: {parsed}")
print("\n✅ All tests passed! datetime objects can be correctly serialized to JSON.")
except Exception as e:
print(f"\n❌ Test failed with error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

41
Html/test_progress.py Normal file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import sys
import os
# Add the project root to the Python path
sys.path.insert(0, '/Users/cakecai/Documents/gitea/hsa/Html')
from flask import Flask
from flask_pymongo import PyMongo
# Create a minimal Flask app for testing
app = Flask(__name__)
app.config['MONGO_URI'] = 'mongodb://localhost:27017/test_db'
mongo = PyMongo(app)
# Register the mongo extension with the app
app.extensions = {'mongo': mongo}
# Import the function to test
with app.app_context():
from apps.services.course_services.learning_progess_process import load_learning_progress
# Test with non-existent record
print("Testing with non-existent record:")
result = load_learning_progress("test_user", "test_course", "test_chapter", "test_lesson")
print(f"Result: {result}")
print(f"Exists: {result['exists']}")
print(f"Data keys: {list(result['data'].keys())}")
print(f"Message: {result['message']}")
print("\n")
# Test that default values are correctly set
print("Testing default values:")
print(f"Chat historys: {result['data']['chat_historys']}")
print(f"Scores: {result['data']['scores']}")
print(f"Chapter chain now: {result['data']['chapter_chain_now']}")
print(f"Updated at: {result['data']['updated_at']}")
print("\n")
print("Test completed successfully!")