Compare commits

..

3 Commits

2 changed files with 68 additions and 24 deletions

View File

@@ -86,7 +86,9 @@ def expand_promo_detail(ai_cfg: AIConfig, sender: str, subject: str, body: str)
return result.get("details", []) return result.get("details", [])
MORE_REPLIES_PROMPT = """你是一个邮件回复助手。根据以下邮件内容生成3个完全不同的建议回复。 MORE_REPLIES_PROMPT_SHORT = """你是一个邮件回复助手。根据以下邮件内容生成3个完全不同的简洁回复。
简洁模式:直接说要点,不需要称呼、落款、邮件格式。像微信/短信一样简短。
以下是一些已经生成过的回复,请生成全新的回复,不要与已有回复重复。 以下是一些已经生成过的回复,请生成全新的回复,不要与已有回复重复。
@@ -101,22 +103,41 @@ MORE_REPLIES_PROMPT = """你是一个邮件回复助手。根据以下邮件内
每条回复控制在 50 字以内。 每条回复控制在 50 字以内。
只返回 JSON不要包含任何其他文字。""" 只返回 JSON不要包含任何其他文字。"""
MORE_REPLIES_PROMPT_FULL = """你是一个邮件回复助手。根据以下邮件内容生成3个不同的正式邮件回复。
完整模式:生成完整的邮件格式,包括称呼、正文、落款。语气正式专业,适合商务邮件。
以下是一些已经生成过的回复,请生成全新的回复,不要与已有回复重复。
返回 JSON:
{
"suggestions": [
{"text": "回复内容"},
{"text": "回复内容"},
{"text": "回复内容"}
]
}
每条回复控制在 300 字以内。
只返回 JSON不要包含任何其他文字。"""
@retry() @retry()
def generate_more_replies(ai_cfg: AIConfig, sender: str, subject: str, body: str, def generate_more_replies(ai_cfg: AIConfig, sender: str, subject: str, body: str,
previous_suggestions: list[dict], user_hint: str = "") -> list[dict]: previous_suggestions: list[dict], user_hint: str = "",
logger.info("AI 换批请求: sender=%s subject=%s user_hint=%s prev=%d", style: str = "short") -> list[dict]:
sender, subject, user_hint or "(无)", len(previous_suggestions)) logger.info("AI 换批请求: sender=%s subject=%s user_hint=%s prev=%d 条 style=%s",
sender, subject, user_hint or "(无)", len(previous_suggestions), style)
content = f"发件人: {sender}\n主题: {subject}\n正文:\n{body}\n\n已生成过的回复:\n" content = f"发件人: {sender}\n主题: {subject}\n正文:\n{body}\n\n已生成过的回复:\n"
for i, s in enumerate(previous_suggestions, 1): for i, s in enumerate(previous_suggestions, 1):
content += f"{i}. {s['text']}\n" content += f"{i}. {s['text']}\n"
if user_hint: if user_hint:
content += f"\n用户要求: {user_hint}" content += f"\n用户要求: {user_hint}"
prompt = MORE_REPLIES_PROMPT_FULL if style == "full" else MORE_REPLIES_PROMPT_SHORT
payload = { payload = {
"model": ai_cfg.model, "model": ai_cfg.model,
"messages": [ "messages": [
{"role": "system", "content": MORE_REPLIES_PROMPT}, {"role": "system", "content": prompt},
{"role": "user", "content": content}, {"role": "user", "content": content},
], ],
"response_format": {"type": "json_object"}, "response_format": {"type": "json_object"},

View File

@@ -169,7 +169,8 @@ def _escape(text: str) -> str:
CALLBACK_VIEW_ORIG = "v" CALLBACK_VIEW_ORIG = "v"
CALLBACK_VIEW_SUMM = "s" CALLBACK_VIEW_SUMM = "s"
CALLBACK_REPLY = "r" CALLBACK_REPLY = "r"
CALLBACK_AI_REPLY = "a" CALLBACK_AI_REPLY_FULL = "af"
CALLBACK_AI_REPLY_SHORT = "as"
CALLBACK_SELECT_REPLY = "sr" CALLBACK_SELECT_REPLY = "sr"
CALLBACK_REGEN = "rg" CALLBACK_REGEN = "rg"
CALLBACK_HINT = "h" CALLBACK_HINT = "h"
@@ -190,7 +191,10 @@ def _summary_keyboard(can_reply: bool = True, is_promotion: bool = False, links:
], ],
] ]
if can_reply: if can_reply:
kb.append([{"text": "AI回复", "callback_data": CALLBACK_AI_REPLY}]) kb.append([
{"text": "AI完整回复", "callback_data": CALLBACK_AI_REPLY_FULL},
{"text": "AI简洁回复", "callback_data": CALLBACK_AI_REPLY_SHORT},
])
for link in (links or []): for link in (links or []):
url = link.get("url", "") url = link.get("url", "")
text = link.get("text", "打开链接")[:30] text = link.get("text", "打开链接")[:30]
@@ -208,7 +212,10 @@ def _orig_keyboard(can_reply: bool = True) -> dict:
], ],
] ]
if can_reply: if can_reply:
kb.append([{"text": "AI回复", "callback_data": CALLBACK_AI_REPLY}]) kb.append([
{"text": "AI完整回复", "callback_data": CALLBACK_AI_REPLY_FULL},
{"text": "AI简洁回复", "callback_data": CALLBACK_AI_REPLY_SHORT},
])
return {"inline_keyboard": kb} return {"inline_keyboard": kb}
@@ -252,10 +259,11 @@ def _tg_req(tg_cfg: TelegramConfig, method: str, payload: dict) -> dict:
def _do_regen(cfg: Config, chat_id: int, msg_id: int, ctx: dict, conv: dict): def _do_regen(cfg: Config, chat_id: int, msg_id: int, ctx: dict, conv: dict):
"""在 AI 线程池中执行换一批,不阻塞按钮轮询""" """在 AI 线程池中执行换一批,不阻塞按钮轮询"""
style = conv.get("reply_style", "short")
try: try:
new_suggestions = generate_more_replies( new_suggestions = generate_more_replies(
cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"], cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"],
conv.get("all_suggestions", []), conv.get("all_suggestions", []), style=style,
) )
except Exception as e: except Exception as e:
send_text(cfg.telegram, str(chat_id), f"生成失败: {e}") send_text(cfg.telegram, str(chat_id), f"生成失败: {e}")
@@ -264,16 +272,17 @@ def _do_regen(cfg: Config, chat_id: int, msg_id: int, ctx: dict, conv: dict):
conv["ai_suggestions"] = new_suggestions conv["ai_suggestions"] = new_suggestions
conv["all_suggestions"].extend(new_suggestions) conv["all_suggestions"].extend(new_suggestions)
save_conversation(chat_id, conv) save_conversation(chat_id, conv)
logger.info("[ai_regen] 新生成 %d 条回复", len(new_suggestions)) logger.info("[ai_regen] 新生成 %d 条回复 (style=%s)", len(new_suggestions), style)
_show_ai_suggestions(cfg.telegram, chat_id, msg_id, new_suggestions) _show_ai_suggestions(cfg.telegram, chat_id, msg_id, new_suggestions)
def _do_ai_hint(cfg: Config, chat_id: int, conv: dict, ctx: dict, hint: str): def _do_ai_hint(cfg: Config, chat_id: int, conv: dict, ctx: dict, hint: str):
"""在 AI 线程池中根据用户提示生成回复""" """在 AI 线程池中根据用户提示生成回复"""
style = conv.get("reply_style", "short")
try: try:
new_suggestions = generate_more_replies( new_suggestions = generate_more_replies(
cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"], cfg.ai, ctx["sender"], ctx["subject"], ctx["original_body"],
conv.get("all_suggestions", []), user_hint=hint, conv.get("all_suggestions", []), user_hint=hint, style=style,
) )
except Exception as e: except Exception as e:
send_text(cfg.telegram, str(chat_id), f"生成失败: {e}") send_text(cfg.telegram, str(chat_id), f"生成失败: {e}")
@@ -326,10 +335,12 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
elif action == CALLBACK_VIEW_SUMM and ctx: elif action == CALLBACK_VIEW_SUMM and ctx:
can_reply = ctx.get("can_reply", True) can_reply = ctx.get("can_reply", True)
is_promotion = ctx.get("summary_data", {}).get("category") == "promotion"
links = ctx.get("summary_data", {}).get("links", [])
_tg_req(tg_cfg, "editMessageText", { _tg_req(tg_cfg, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id, "chat_id": chat_id, "message_id": msg_id,
"text": ctx["summary_text"], "parse_mode": "MarkdownV2", "text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(can_reply), "reply_markup": _summary_keyboard(can_reply, is_promotion, links),
}) })
logger.info(" 切换回摘要视图") logger.info(" 切换回摘要视图")
@@ -344,8 +355,9 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
}) })
logger.info(" 进入回复流程, prompt_msg_id=%d", prompt_msg_id) logger.info(" 进入回复流程, prompt_msg_id=%d", prompt_msg_id)
elif action == CALLBACK_AI_REPLY and ctx: elif action in (CALLBACK_AI_REPLY_FULL, CALLBACK_AI_REPLY_SHORT) and ctx:
suggestions = ctx["summary_data"].get("reply_suggestions", []) suggestions = ctx["summary_data"].get("reply_suggestions", [])
style = "full" if action == CALLBACK_AI_REPLY_FULL else "short"
if not suggestions: if not suggestions:
send_text(tg_cfg, str(chat_id), "此邮件无需回复。") send_text(tg_cfg, str(chat_id), "此邮件无需回复。")
logger.info(" AI 判定无需回复,隐藏") logger.info(" AI 判定无需回复,隐藏")
@@ -356,8 +368,9 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
"summary_msg_id": msg_id, "summary_msg_id": msg_id,
"ai_suggestions": suggestions, "ai_suggestions": suggestions,
"all_suggestions": list(suggestions), "all_suggestions": list(suggestions),
"reply_style": style,
}) })
logger.info(" 显示 AI 回复建议 (%d 条)", len(suggestions)) logger.info(" 显示 AI 回复建议 (%d, style=%s)", len(suggestions), style)
_show_ai_suggestions(tg_cfg, chat_id, msg_id, suggestions) _show_ai_suggestions(tg_cfg, chat_id, msg_id, suggestions)
elif action == CALLBACK_SELECT_REPLY and ctx: elif action == CALLBACK_SELECT_REPLY and ctx:
@@ -369,16 +382,26 @@ def _handle_callback(tg_cfg: TelegramConfig, cfg: Config, cb: dict):
if idx >= len(suggestions): if idx >= len(suggestions):
return return
sel = suggestions[idx] sel = suggestions[idx]
logger.info(" 选择回复 #%d: %s", idx, sel["text"][:60]) sel_text = sel["text"]
_do_send_reply(tg_cfg, cfg, chat_id, msg_id, ctx, sel["text"]) logger.info(" 选择回复 #%d: %s", idx, sel_text[:60])
send_text(tg_cfg, str(chat_id), f"✅ 已发送: {sel['text']}")
delete_conversation(chat_id) # 用代码块显示,点击可复制
can_reply = ctx.get("can_reply", True) escaped = sel_text.replace("\\", "\\\\").replace("`", "\\`")
_tg_req(tg_cfg, "editMessageText", { copy_msg = send_text(tg_cfg, str(chat_id),
"chat_id": chat_id, "message_id": msg_id, f"*建议回复(点击代码块复制):*\n`{escaped}`")
"text": ctx["summary_text"], "parse_mode": "MarkdownV2",
"reply_markup": _summary_keyboard(can_reply), # 进入 awaiting_reply 状态,用户可编辑后发送
}) prompt_msg_id = send_text(
tg_cfg, str(chat_id),
"请编辑后发送(或直接回复附件):",
reply_markup=_cancel_keyboard(),
)
conv["state"] = "awaiting_reply"
conv["summary_msg_id"] = msg_id
conv["prompt_msg_id"] = prompt_msg_id
conv["draft_text"] = sel_text
save_conversation(chat_id, conv)
logger.info(" 建议已展示,等待用户编辑发送")
elif action == CALLBACK_REGEN and ctx: elif action == CALLBACK_REGEN and ctx:
conv = load_conversation(chat_id) conv = load_conversation(chat_id)