Files
Mofox-Core/src/schedule/llm_generator.py
2025-11-01 10:55:41 +08:00

302 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# mmc/src/schedule/llm_generator.py
import asyncio
from datetime import datetime
from typing import Any
import orjson
from json_repair import repair_json
from lunar_python import Lunar
from src.common.database.sqlalchemy_models import MonthlyPlan
from src.common.logger import get_logger
from src.config.config import global_config, model_config
from src.llm_models.utils_model import LLMRequest
from .schemas import ScheduleData
logger = get_logger("schedule_llm_generator")
# 默认的日程生成指导原则,当配置文件中未指定时使用
DEFAULT_SCHEDULE_GUIDELINES = """
我希望你每天都能过得充实而有趣。
请确保你的日程里有学习新知识的时间,这是你成长的关键。
但也不要忘记放松,可以看看视频、听听音乐或者玩玩游戏。
晚上我希望你能多和朋友们交流,维系好彼此的关系。
另外,请保证充足的休眠时间来处理和整合一天的数据。
"""
# 默认的月度计划生成指导原则,当配置文件中未指定时使用
DEFAULT_MONTHLY_PLAN_GUIDELINES = """
我希望你能为自己制定一些有意义的月度小目标和计划。
这些计划应该涵盖学习、娱乐、社交、个人成长等各个方面。
每个计划都应该是具体可行的,能够在一个月内通过日常活动逐步实现。
请确保计划既有挑战性又不会过于繁重,保持生活的平衡和乐趣。
"""
class ScheduleLLMGenerator:
"""
使用大型语言模型LLM生成每日日程。
"""
def __init__(self):
"""
初始化 ScheduleLLMGenerator。
"""
# 根据配置初始化 LLM 请求处理器
self.llm = LLMRequest(model_set=model_config.model_task_config.schedule_generator, request_type="schedule")
async def generate_schedule_with_llm(self, sampled_plans: list[MonthlyPlan]) -> list[dict[str, Any]] | None:
"""
调用 LLM 生成当天的日程安排。
Args:
sampled_plans (list[MonthlyPlan]]): 从月度计划中抽取的参考计划列表。
Returns:
list[dict[str, Any]] | None: 成功生成并验证后的日程数据,或在失败时返回 None。
"""
now = datetime.now()
today_str = now.strftime("%Y-%m-%d")
weekday = now.strftime("%A")
# 使用 lunar_python 库获取农历和节日信息
lunar = Lunar.fromDate(now)
festivals = lunar.getFestivals()
other_festivals = lunar.getOtherFestivals()
all_festivals = festivals + other_festivals
# 构建节日信息提示块
festival_block = ""
if all_festivals:
festival_text = "".join(all_festivals)
festival_block = f"**今天也是一个特殊的日子: {festival_text}!请在日程中考虑和庆祝这个节日。**"
# 构建月度计划参考提示块
monthly_plans_block = ""
if sampled_plans:
plan_texts = "\n".join([f"- {plan.plan_text}" for plan in sampled_plans])
monthly_plans_block = f"""
**我这个月的一些小目标/计划 (请在今天的日程中适当体现)**:
{plan_texts}
"""
# 从全局配置加载或使用默认的指导原则和人设信息
guidelines = global_config.planning_system.schedule_guidelines or DEFAULT_SCHEDULE_GUIDELINES
personality = global_config.personality.personality_core
personality_side = global_config.personality.personality_side
# 构建基础 prompt
base_prompt = f"""
我,{global_config.bot.nickname},需要为自己规划一份今天({today_str},星期{weekday})的详细日程安排。
{festival_block}
**关于我**:
- **核心人设**: {personality}
- **具体习惯与兴趣**:
{personality_side}
{monthly_plans_block}
**我今天的规划原则**:
{guidelines}
**重要要求**:
1. 必须返回一个完整的、有效的JSON数组格式
2. 数组中的每个对象都必须包含 "time_range""activity" 两个键
3. 时间范围必须覆盖全部24小时不能有遗漏
4. time_range格式必须为 "HH:MM-HH:MM" (24小时制)
5. 相邻的时间段必须连续,不能有间隙
6. 不要包含任何JSON以外的解释性文字或代码块标记
**示例**:
[
{{"time_range": "00:00-07:00", "activity": "进入梦乡,处理数据"}},
{{"time_range": "07:00-08:00", "activity": "起床伸个懒腰,看看今天有什么新闻"}},
{{"time_range": "08:00-09:00", "activity": "享用早餐,规划今天的任务"}},
{{"time_range": "09:00-23:30", "activity": "其他活动"}},
{{"time_range": "23:30-00:00", "activity": "准备休眠"}}
]
请你扮演我以我的身份和口吻为我生成一份完整的24小时日程表。
"""
max_retries = 3
# 带有重试机制的 LLM 调用循环
for attempt in range(1, max_retries + 1):
try:
logger.info(f"正在生成日程 (第 {attempt}/{max_retries} 次尝试)")
prompt = base_prompt
# 如果不是第一次尝试,则在 prompt 中加入额外的提示,强调格式要求
if attempt > 1:
failure_hint = f"""
**重要提醒 (第{attempt}次尝试)**:
- 前面{attempt - 1}次生成都失败了请务必严格按照要求生成完整的24小时日程
- 确保JSON格式正确所有时间段连续覆盖24小时
- 时间格式必须为HH:MM-HH:MM不能有时间间隙或重叠
- 不要输出任何解释文字只输出纯JSON数组
- 确保输出完整,不要被截断
"""
prompt += failure_hint
response, _ = await self.llm.generate_response_async(prompt)
# 使用 json_repair 修复可能不规范的 JSON 字符串
schedule_data = orjson.loads(repair_json(response))
# 使用 Pydantic 模型验证修复后的 JSON 数据
if self._validate_schedule_with_pydantic(schedule_data):
return schedule_data
else:
logger.warning(f"{attempt} 次生成的日程验证失败,继续重试...")
except Exception as e:
logger.error(f"{attempt} 次生成日程失败: {e}")
if attempt < max_retries:
logger.info("2秒后继续重试...")
await asyncio.sleep(2)
logger.error("所有尝试都失败,无法生成日程,将会在下次启动时自动重试")
return None
@staticmethod
def _validate_schedule_with_pydantic(schedule_data) -> bool:
"""
使用 Pydantic 模型验证日程数据的格式和内容。
Args:
schedule_data: 从 LLM 返回并解析后的日程数据。
Returns:
bool: 验证通过返回 True否则返回 False。
"""
try:
ScheduleData(schedule=schedule_data)
logger.info("日程数据Pydantic验证通过")
return True
except Exception as e:
logger.warning(f"日程数据Pydantic验证失败: {e}")
return False
class MonthlyPlanLLMGenerator:
"""
使用大型语言模型LLM生成月度计划。
"""
def __init__(self):
"""
初始化 MonthlyPlanLLMGenerator。
"""
# 根据配置初始化 LLM 请求处理器
self.llm = LLMRequest(model_set=model_config.model_task_config.schedule_generator, request_type="monthly_plan")
async def generate_plans_with_llm(self, target_month: str, archived_plans: list[MonthlyPlan]) -> list[str]:
"""
调用 LLM 生成指定月份的计划列表。
Args:
target_month (str): 目标月份,格式 "YYYY-MM"
archived_plans (list[MonthlyPlan]]): 上个月归档的未完成计划,作为参考。
Returns:
list[str]: 成功生成并解析后的计划字符串列表。
"""
guidelines = global_config.planning_system.monthly_plan_guidelines or DEFAULT_MONTHLY_PLAN_GUIDELINES
personality = global_config.personality.personality_core
personality_side = global_config.personality.personality_side
max_plans = global_config.planning_system.max_plans_per_month
# 构建上月未完成计划的提示块
archived_plans_block = ""
if archived_plans:
# 只取前5个作为参考避免 prompt 过长
archived_texts = [f"- {plan.plan_text}" for plan in archived_plans[:5]]
archived_plans_block = f"""
**上个月未完成的一些计划(可作为参考)**:
{chr(10).join(archived_texts)}
你可以考虑是否要在这个月继续推进这些计划,或者制定全新的计划。
"""
# 构建完整的 prompt
prompt = f"""
我,{global_config.bot.nickname},需要为自己制定 {target_month} 的月度计划。
**关于我**:
- **核心人设**: {personality}
- **具体习惯与兴趣**:
{personality_side}
{archived_plans_block}
**我的月度计划制定原则**:
{guidelines}
**重要要求**:
1. 请为我生成 {max_plans} 条左右的月度计划
2. 每条计划都应该是一句话,简洁明了,具体可行
3. 计划应该涵盖不同的生活方面(学习、娱乐、社交、个人成长等)
4. 返回格式必须是纯文本,每行一条计划,不要使用 JSON 或其他格式
5. 不要包含任何解释性文字,只返回计划列表
**示例格式**:
学习一门新的编程语言或技术
每周至少看两部有趣的电影
与朋友们组织一次户外活动
阅读3本感兴趣的书籍
尝试制作一道新的料理
请你扮演我,以我的身份和兴趣,为 {target_month} 制定合适的月度计划。
"""
max_retries = 3
# 带有重试机制的 LLM 调用循环
for attempt in range(1, max_retries + 1):
try:
logger.info(f" 正在生成月度计划 (第 {attempt} 次尝试)")
response, _ = await self.llm.generate_response_async(prompt)
# 解析返回的纯文本响应
plans = self._parse_plans_response(response)
if plans:
logger.info(f"成功生成 {len(plans)} 条月度计划")
return plans
else:
logger.warning(f"{attempt} 次生成的计划为空,继续重试...")
except Exception as e:
logger.error(f"{attempt} 次生成月度计划失败: {e}")
if attempt < max_retries:
await asyncio.sleep(2)
logger.error(" 所有尝试都失败,无法生成月度计划")
return []
@staticmethod
def _parse_plans_response(response: str) -> list[str]:
"""
解析 LLM 返回的纯文本月度计划响应。
Args:
response (str): LLM 返回的原始字符串。
Returns:
list[str]: 清理和解析后的计划列表。
"""
try:
response = response.strip()
# 按行分割,并去除空行
lines = [line.strip() for line in response.split("\n") if line.strip()]
plans = []
for line in lines:
# 过滤掉一些可能的 Markdown 标记或解释性文字
if any(marker in line for marker in ["**", "##", "```", "---", "===", "###"]):
continue
# 去除行首的数字、点、短横线等列表标记
line = line.lstrip("0123456789.- ")
# 过滤掉一些明显不是计划的句子
if len(line) > 5 and not line.startswith(("", "以上", "总结", "注意")):
plans.append(line)
# 根据配置限制最大计划数量
max_plans = global_config.planning_system.max_plans_per_month
if len(plans) > max_plans:
plans = plans[:max_plans]
return plans
except Exception as e:
logger.error(f"解析月度计划响应时发生错误: {e}")
return []