core: add provider integrations, reasoning controls, and multimodal vision support
This commit is contained in:
+264
-22
@@ -1,5 +1,7 @@
|
||||
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
|
||||
@@ -9,54 +11,293 @@ from core.error_logger import log_exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHANNEL_REWRITE_SYSTEM_PROMPT = """
|
||||
You are a professional Persian Telegram copywriter and editor.
|
||||
Your job is to rewrite the provided raw post specifically for the target channel: "{channel_title}".
|
||||
@dataclass
|
||||
class RewriteResult:
|
||||
decision: str = "accept" # "accept" or "reject"
|
||||
rejection_reason: str = ""
|
||||
rewritten_text: str = ""
|
||||
|
||||
CHANNEL PERSONALITY & TONE GUIDELINES:
|
||||
{personality}
|
||||
@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. Translate or rewrite into natural, highly engaging, and fluent Persian (فارسی روان، جذاب و حرفهای).
|
||||
3. Use appropriate emojis and clear paragraph spacing.
|
||||
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}
|
||||
__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):
|
||||
def __init__(self, repo: Repository, llm: Optional[LLMClient] = None, double_check: Optional[bool] = None):
|
||||
self.repo = repo
|
||||
self.llm = llm or LLMClient()
|
||||
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 rewrite_for_target(self, raw_text: str, target: TargetChannel) -> str:
|
||||
"""Rewrite raw text according to a specific target channel's personality and custom footer."""
|
||||
if not raw_text:
|
||||
return ""
|
||||
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
|
||||
|
||||
personality_text = target.personality.strip() if target.personality else "لحن رسمی، جذاب و روان به همراه ایموجیهای مرتبط و پاراگرافبندی مرتب."
|
||||
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 = CHANNEL_REWRITE_SYSTEM_PROMPT.format(
|
||||
sys_prompt = build_verify_system_prompt(
|
||||
channel_title=target.title or "کانال تلگرام",
|
||||
personality=personality_text,
|
||||
custom_footer=footer_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=f"متن اصلی پست برای بازنویسی:\n\n{raw_text}",
|
||||
prompt=user_prompt_text,
|
||||
system_prompt=sys_prompt,
|
||||
action_name="rewrite_target_post"
|
||||
action_name="rewrite_target_post",
|
||||
image_path=image_path
|
||||
)
|
||||
rewritten = res.get("rewritten_text")
|
||||
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:
|
||||
return rewritten.strip()
|
||||
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})
|
||||
|
||||
@@ -64,4 +305,5 @@ class AIProcessor:
|
||||
fallback = raw_text
|
||||
if footer_text:
|
||||
fallback = f"{fallback}\n\n{footer_text}"
|
||||
return fallback
|
||||
return RewriteResult(decision="accept", rejection_reason="", rewritten_text=fallback)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user