310 lines
14 KiB
Python
310 lines
14 KiB
Python
import os
|
||
import logging
|
||
import json
|
||
from dataclasses import dataclass
|
||
from typing import List, Optional, Dict, Any
|
||
from db.models import Post, TargetChannel
|
||
from db.repository import Repository
|
||
from core.llm import LLMClient
|
||
from core.metrics import DUPLICATES_DETECTED_TOTAL
|
||
from core.error_logger import log_exception
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
@dataclass
|
||
class RewriteResult:
|
||
decision: str = "accept" # "accept" or "reject"
|
||
rejection_reason: str = ""
|
||
rewritten_text: str = ""
|
||
|
||
@property
|
||
def is_rejected(self) -> bool:
|
||
return self.decision.lower() == "reject"
|
||
|
||
def __str__(self) -> str:
|
||
return self.rewritten_text
|
||
|
||
|
||
SUPPORTED_LANGUAGES = {
|
||
"fa": {
|
||
"name": "Persian / Farsi",
|
||
"instruction": "Translate or rewrite into natural, highly engaging, and fluent Persian (فارسی روان، جذاب و حرفهای).",
|
||
"default_personality": "لحن رسمی، جذاب و روان به همراه ایموجیهای مرتبط و پاراگرافبندی مرتب.",
|
||
"label": "فارسی 🇮🇷",
|
||
},
|
||
"en": {
|
||
"name": "English",
|
||
"instruction": "Translate or rewrite into natural, highly engaging, and fluent English.",
|
||
"default_personality": "Professional, engaging, and clear tone with appropriate emojis and clean paragraph formatting.",
|
||
"label": "English 🇬🇧",
|
||
},
|
||
"es": {
|
||
"name": "Spanish / Español",
|
||
"instruction": "Translate or rewrite into natural, highly engaging, and fluent Spanish (español natural, fluido y profesional).",
|
||
"default_personality": "Tono profesional, atractivo y fluido con emojis apropiados y párrafos claros.",
|
||
"label": "Español 🇪🇸",
|
||
},
|
||
"ar": {
|
||
"name": "Arabic / العربية",
|
||
"instruction": "Translate or rewrite into natural, highly engaging, and fluent Arabic (عربي فصيح وسلس وجذاب).",
|
||
"default_personality": "أسلوب مهني وجذاب وسلس مع إيموجي مناسبة وفقرات واضحة.",
|
||
"label": "العربية 🇸🇦",
|
||
},
|
||
"tr": {
|
||
"name": "Turkish / Türkçe",
|
||
"instruction": "Translate or rewrite into natural, highly engaging, and fluent Turkish (akıcı, doğal ve ilgi çekici Türkçe).",
|
||
"default_personality": "İlgili emojiler ve düzenli paragraflarla profesyonel, akıcı ve ilgi çekici bir ton.",
|
||
"label": "Türkçe 🇹🇷",
|
||
},
|
||
}
|
||
|
||
# The prompt embeds a literal JSON example, so the placeholders are substituted by name
|
||
# rather than through str.format(), which would try to read the braces as fields.
|
||
CHANNEL_REWRITE_SYSTEM_PROMPT = """
|
||
You are a professional __LANGUAGE_NAME__ Telegram copywriter and content adapter.
|
||
Your job is to transform and rewrite raw posts specifically to match the target channel's personality and tone: "__CHANNEL_TITLE__".
|
||
|
||
TARGET CHANNEL DESIRED OUTPUT PERSONALITY & TONE:
|
||
__PERSONALITY__
|
||
__CUSTOM_INSTRUCTIONS_BLOCK__
|
||
|
||
IMPORTANT RULES FOR PERSONALITY & TONE:
|
||
- You MUST actively, thoroughly rewrite and restyle the post into the requested personality and tone.
|
||
- If the personality requires satire, humor, intense sarcasm/slang, formal journalism, or energetic marketing, YOU MUST WRITE IN THAT EXACT VOICE.
|
||
- Do NOT output bland, neutral, or unstyled text.
|
||
- Do NOT reject incoming posts because their original style, length, or tone is different from this personality. Your task is to transform ANY valid content into this target voice.
|
||
|
||
CRITICAL RULES:
|
||
1. Completely REMOVE all original channel usernames (e.g. @source_channel), sponsor tags, author watermarks, and source links.
|
||
2. __LANGUAGE_INSTRUCTION__
|
||
3. Rewrite and format the post to fully embody the target personality with appropriate emojis and clear paragraph spacing.
|
||
4. If a custom footer/tag is provided below, append it cleanly at the very end of the post:
|
||
__CUSTOM_FOOTER__
|
||
5. TELEGRAM CHARACTER LIMIT CONSTRAINT:
|
||
__LENGTH_LIMIT_RULE__
|
||
6. POST EVALUATION & REJECTION CRITERIA:
|
||
- Default decision is "accept". You should adapt and rewrite normal posts even if their original tone or subject is diverse.
|
||
- You should ONLY reject a post if it is:
|
||
* Pure spam, unrelated scam/gambling/phishing ads
|
||
* Completely empty, corrupt, or meaningless text
|
||
* Explicitly forbidden by specific negative constraints in the custom commands above
|
||
- If REJECTING: set "decision": "reject" and specify the exact reason in "rejection_reason" (in Persian, e.g. "تبلیغات نامرتبط و اسپم").
|
||
- If ACCEPTING (Default): set "decision": "accept", set "rejection_reason": "", and provide the rewritten output in "rewritten_text".
|
||
|
||
Respond ONLY in valid JSON format:
|
||
{
|
||
"decision": "accept",
|
||
"rejection_reason": "",
|
||
"rewritten_text": "..."
|
||
}
|
||
"""
|
||
|
||
|
||
|
||
def build_rewrite_system_prompt(
|
||
channel_title: str,
|
||
personality: str,
|
||
custom_footer: str,
|
||
custom_prompt: str = "",
|
||
language: str = "fa",
|
||
has_media: bool = False,
|
||
) -> str:
|
||
lang_info = SUPPORTED_LANGUAGES.get(language, SUPPORTED_LANGUAGES["fa"])
|
||
if has_media:
|
||
length_rule = "This message contains media (photo/video/document). The total output (including footer) MUST NOT exceed 1000 characters to strictly fit Telegram's 1024-character caption limit."
|
||
else:
|
||
length_rule = "This is a text-only message. The total output (including footer) MUST NOT exceed 4000 characters to strictly fit Telegram's 4096-character message limit."
|
||
|
||
custom_block = ""
|
||
if custom_prompt and custom_prompt.strip():
|
||
custom_block = f"\nTARGET CHANNEL SPECIFIC COMMANDS & FORMATTING RULES:\n{custom_prompt.strip()}\n"
|
||
|
||
return (
|
||
CHANNEL_REWRITE_SYSTEM_PROMPT
|
||
.replace("__CHANNEL_TITLE__", channel_title)
|
||
.replace("__LANGUAGE_NAME__", lang_info["name"])
|
||
.replace("__LANGUAGE_INSTRUCTION__", lang_info["instruction"])
|
||
.replace("__PERSONALITY__", personality)
|
||
.replace("__CUSTOM_INSTRUCTIONS_BLOCK__", custom_block)
|
||
.replace("__CUSTOM_FOOTER__", custom_footer)
|
||
.replace("__LENGTH_LIMIT_RULE__", length_rule)
|
||
)
|
||
|
||
|
||
VERIFY_REWRITE_SYSTEM_PROMPT = """
|
||
You are a senior editor and quality-assurance reviewer for Telegram channels.
|
||
Your task is to double-check and clean up an AI-rewritten post for the target channel: "__CHANNEL_TITLE__".
|
||
|
||
TARGET CHANNEL PERSONALITY & TONE (MUST BE PRESERVED):
|
||
__PERSONALITY__
|
||
__CUSTOM_INSTRUCTIONS_BLOCK__
|
||
|
||
TARGET LANGUAGE REQUIREMENT:
|
||
The ENTIRE post MUST be written 100% in __LANGUAGE_NAME__ (__LANGUAGE_INSTRUCTION__).
|
||
NO mixed languages, NO foreign sentences/phrases accidentally left over from other languages, and NO unwanted translations.
|
||
|
||
QUALITY & SAFETY AUDIT CHECKLIST:
|
||
1. PRESERVE THE PERSONALITY & HUMOR: Do NOT sanitize, formalize, or flatten the draft! Keep the exact tone, humor, satire, slang, and stylistic persona of the draft intact.
|
||
2. Fix Language Leaks: If any foreign words or mixed language leaked into the draft, translate them into __LANGUAGE_NAME__ while strictly preserving the persona and tone.
|
||
3. Remove any surviving source channel tags, usernames (@...), sponsor links, or URLs from the original post.
|
||
4. Keep the custom footer intact if present:
|
||
__CUSTOM_FOOTER__
|
||
5. TELEGRAM CHARACTER LIMIT CONSTRAINT:
|
||
__LENGTH_LIMIT_RULE__
|
||
|
||
Respond ONLY in valid JSON format:
|
||
{
|
||
"final_text": "..."
|
||
}
|
||
"""
|
||
|
||
|
||
def build_verify_system_prompt(
|
||
channel_title: str,
|
||
personality: str,
|
||
custom_footer: str,
|
||
custom_prompt: str = "",
|
||
language: str = "fa",
|
||
has_media: bool = False,
|
||
) -> str:
|
||
lang_info = SUPPORTED_LANGUAGES.get(language, SUPPORTED_LANGUAGES["fa"])
|
||
if has_media:
|
||
length_rule = "This message contains media (photo/video/document). The total output (including footer) MUST NOT exceed 1000 characters to strictly fit Telegram's 1024-character caption limit."
|
||
else:
|
||
length_rule = "This is a text-only message. The total output (including footer) MUST NOT exceed 4000 characters to strictly fit Telegram's 4096-character message limit."
|
||
|
||
custom_block = ""
|
||
if custom_prompt and custom_prompt.strip():
|
||
custom_block = f"\nTARGET CHANNEL SPECIFIC COMMANDS & FORMATTING RULES:\n{custom_prompt.strip()}\n"
|
||
|
||
return (
|
||
VERIFY_REWRITE_SYSTEM_PROMPT
|
||
.replace("__CHANNEL_TITLE__", channel_title)
|
||
.replace("__LANGUAGE_NAME__", lang_info["name"])
|
||
.replace("__LANGUAGE_INSTRUCTION__", lang_info["instruction"])
|
||
.replace("__PERSONALITY__", personality)
|
||
.replace("__CUSTOM_INSTRUCTIONS_BLOCK__", custom_block)
|
||
.replace("__CUSTOM_FOOTER__", custom_footer)
|
||
.replace("__LENGTH_LIMIT_RULE__", length_rule)
|
||
)
|
||
|
||
|
||
class AIProcessor:
|
||
def __init__(self, repo: Repository, llm: Optional[LLMClient] = None, double_check: Optional[bool] = None):
|
||
self.repo = repo
|
||
self.llm = llm or LLMClient(repo=self.repo)
|
||
self.explicit_double_check = double_check
|
||
if double_check is not None:
|
||
self.double_check = double_check
|
||
else:
|
||
self.double_check = os.getenv("AI_DOUBLE_CHECK", "false").lower() in ("true", "1", "yes")
|
||
|
||
async def _is_double_check_enabled(self) -> bool:
|
||
if self.explicit_double_check is not None:
|
||
return self.explicit_double_check
|
||
if self.repo:
|
||
db_dc = await self.repo.get_setting("ai_double_check")
|
||
if db_dc is not None:
|
||
return db_dc.strip().lower() in ("true", "1", "yes")
|
||
return self.double_check
|
||
|
||
async def _verify_rewrite(self, raw_text: str, draft_text: str, target: TargetChannel, has_media: bool = False) -> str:
|
||
"""Second-pass quality check to fix language mixing, tags, and length constraints without losing personality."""
|
||
lang = getattr(target, "language", "fa") or "fa"
|
||
lang_info = SUPPORTED_LANGUAGES.get(lang, SUPPORTED_LANGUAGES["fa"])
|
||
personality_text = target.personality.strip() if target.personality else lang_info["default_personality"]
|
||
footer_text = target.custom_footer.strip() if target.custom_footer else (f"@{target.username}" if target.username else "")
|
||
custom_prompt_text = getattr(target, "custom_prompt", "") or ""
|
||
|
||
sys_prompt = build_verify_system_prompt(
|
||
channel_title=target.title or "کانال تلگرام",
|
||
personality=personality_text,
|
||
custom_footer=footer_text,
|
||
custom_prompt=custom_prompt_text,
|
||
language=lang,
|
||
has_media=has_media,
|
||
)
|
||
|
||
|
||
user_prompt = (
|
||
f"متن خام اولیه:\n{raw_text}\n\n"
|
||
f"پیشنویس اولیه بازنویسیشده برای بررسی و اصلاح زبان/فرمت:\n{draft_text}"
|
||
)
|
||
try:
|
||
res = await self.llm.generate_json(
|
||
prompt=user_prompt,
|
||
system_prompt=sys_prompt,
|
||
action_name="verify_target_post"
|
||
)
|
||
final_text = res.get("final_text")
|
||
if final_text and final_text.strip():
|
||
return final_text.strip()
|
||
except Exception as e:
|
||
logger.warning(f"Double-check verification failed, keeping initial draft: {e}")
|
||
return draft_text
|
||
|
||
async def rewrite_for_target(
|
||
self,
|
||
raw_text: str,
|
||
target: TargetChannel,
|
||
has_media: bool = False,
|
||
image_path: Optional[str] = None,
|
||
) -> RewriteResult:
|
||
"""Rewrite raw text according to target channel's language, personality, custom prompt commands, length limits, and optional image."""
|
||
if not raw_text and not image_path:
|
||
return RewriteResult(decision="reject", rejection_reason="متن پیام و تصویر هر دو خالی هستند", rewritten_text="")
|
||
|
||
lang = getattr(target, "language", "fa") or "fa"
|
||
lang_info = SUPPORTED_LANGUAGES.get(lang, SUPPORTED_LANGUAGES["fa"])
|
||
personality_text = target.personality.strip() if target.personality else lang_info["default_personality"]
|
||
footer_text = target.custom_footer.strip() if target.custom_footer else (f"@{target.username}" if target.username else "")
|
||
custom_prompt_text = getattr(target, "custom_prompt", "") or ""
|
||
|
||
sys_prompt = build_rewrite_system_prompt(
|
||
channel_title=target.title or "کانال تلگرام",
|
||
personality=personality_text,
|
||
custom_footer=footer_text,
|
||
custom_prompt=custom_prompt_text,
|
||
language=lang,
|
||
has_media=has_media,
|
||
)
|
||
|
||
user_prompt_text = f"متن اصلی پست برای بازنویسی و تبدیل به لحن و استایل کانال مقصد:\n\n{raw_text}" if raw_text else "لطفاً با توجه به تصویر پیوست، یک متن جذاب و مناسب برای کانال بنویسید."
|
||
|
||
try:
|
||
res = await self.llm.generate_json(
|
||
prompt=user_prompt_text,
|
||
system_prompt=sys_prompt,
|
||
action_name="rewrite_target_post",
|
||
image_path=image_path
|
||
)
|
||
decision = str(res.get("decision", "accept")).strip().lower()
|
||
rejection_reason = str(res.get("rejection_reason", "")).strip()
|
||
rewritten = res.get("rewritten_text") or res.get("text") or ""
|
||
|
||
if decision == "reject":
|
||
return RewriteResult(
|
||
decision="reject",
|
||
rejection_reason=rejection_reason or "رد شده طبق ارزیابی هوش مصنوعی",
|
||
rewritten_text=rewritten.strip() if rewritten else ""
|
||
)
|
||
|
||
if rewritten:
|
||
rewritten = rewritten.strip()
|
||
if await self._is_double_check_enabled():
|
||
rewritten = await self._verify_rewrite(raw_text, rewritten, target, has_media=has_media)
|
||
return RewriteResult(
|
||
decision="accept",
|
||
rejection_reason="",
|
||
rewritten_text=rewritten
|
||
)
|
||
except Exception as e:
|
||
await log_exception("ai_processor.rewrite", e, {"target_id": target.id, "target_title": target.title})
|
||
|
||
# Fallback if AI fails: clean basic @mentions and append footer
|
||
fallback = raw_text
|
||
if footer_text:
|
||
fallback = f"{fallback}\n\n{footer_text}"
|
||
return RewriteResult(decision="accept", rejection_reason="", rewritten_text=fallback)
|
||
|