diff --git a/Html/apps/services/course_service.py b/Html/apps/services/course_service.py index 2c36115..169c8d3 100644 --- a/Html/apps/services/course_service.py +++ b/Html/apps/services/course_service.py @@ -8,650 +8,10 @@ from urllib.parse import urlparse from datetime import datetime from bson import ObjectId -from ..services.cos_service import upload_file, delete_file +from services.cos_service import upload_file, delete_file -from ..models.material import Material +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) 生成占位 Markdown(lesson_prompt_score_prompt) - 2) 上传到对象存储(返回 URL) - 3) 将 lesson 文档(含三份 URL)push 到指定章节 - """ - 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": , "reasons": "", "advices": ""}}``` -### 步骤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) - his_data = dialog_list['data'] - # 构建学习进度数据 - 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": his_data, - "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 - - 返回: - - 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_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 - - -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" - - # 构建请求参数 - payload = { - "namespace_url": url_token, # 使用配置中的token作为namespace_url - "user_id": progress.get("user_id", ""), - "data": 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')}") - 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 - } +from .course_services.learning_progess_process import * diff --git a/Html/apps/services/course_services/learning_progess_process.py b/Html/apps/services/course_services/learning_progess_process.py new file mode 100644 index 0000000..b3899c3 --- /dev/null +++ b/Html/apps/services/course_services/learning_progess_process.py @@ -0,0 +1,197 @@ +# 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 + +from services.cos_service import upload_file, delete_file + +from models.material import Material +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) + his_data = dialog_list['data'] + # 构建学习进度数据 + 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": his_data, + "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 + + 返回: + - 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_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 + + +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" + + # 构建请求参数 + payload = { + "namespace_url": url_token, # 使用配置中的token作为namespace_url + "user_id": progress.get("user_id", ""), + "data": 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')}") + 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 + } diff --git a/Html/apps/services/course_services/material_process.py b/Html/apps/services/course_services/material_process.py new file mode 100644 index 0000000..0d02e82 --- /dev/null +++ b/Html/apps/services/course_services/material_process.py @@ -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 services.cos_service import upload_file, delete_file + +from 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) 生成占位 Markdown(lesson_prompt_score_prompt) + 2) 上传到对象存储(返回 URL) + 3) 将 lesson 文档(含三份 URL)push 到指定章节 + """ + 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": , "reasons": "", "advices": ""}}``` +### 步骤B +同上,对"步骤B"输出进行评分与反馈。 +""" + return lesson_md, prompt_md, score_prompt_md