feat: add topic tagging, semantic duplicate detection, and per-provider vision toggle
This commit is contained in:
+12
-15
@@ -2,6 +2,18 @@
|
||||
|
||||
👤 <b>ویرایشکننده:</b> <code>mamad</code>
|
||||
|
||||
🔹 <b>تشخیص هوشمند محتوای تکراری و برچسبگذاری موضوعی (AI Semantic Dedup & Tagging):</b>
|
||||
• استخراج خودکار برچسبهای موضوعی (Tags) و عنوان موضوع (Subject) برای هر پست دریافتی.
|
||||
• جایگزینی هش ساده با مقایسه مفهومی هوش مصنوعی: جمعآوری پستهای مرتبط بر اساس برچسبها و تحلیل تشابه معنایی توسط هوش مصنوعی.
|
||||
• ثبت وضعیت تکراری، شناسه پست اصلی و دلیل تشابه در پایگاهداده.
|
||||
📁 <i>فایلهای تغییریافته:</i> <code>services/ai_processor.py</code>, <code>services/collector.py</code>, <code>db/repository.py</code>, <code>tests/test_semantic_dedup_and_vision.py</code>
|
||||
|
||||
🔹 <b>پیکربندی تفکیکی پردازش تصویر در ارائهدهندگان (Provider Vision Toggle):</b>
|
||||
• امکان فعال یا غیرفعال کردن دریافت تصویر برای هر ارائهدهنده به صورت جداگانه (با حالت پیشفرض فقط متن).
|
||||
• دکمه تغییر وضعیت پردازش تصویر در منوی تنظیمات اختصاصی هر سرویسدهنده در ربات تلگرام.
|
||||
• ارسال انحصاری تصویر به مدلهایی که پشتیبانی از تصویر در آنها روشن است.
|
||||
📁 <i>فایلهای تغییریافته:</i> <code>core/llm.py</code>, <code>db/database.py</code>, <code>db/models.py</code>, <code>services/admin_bot.py</code>
|
||||
|
||||
🔹 <b>پایش و نمودار فضای ذخیرهسازی دیسک سرور:</b>
|
||||
• متمرکزسازی روی یک نمودار کامل ۲۴ ستونه برای نمایش روند زمانی فضای آزاد درایوها.
|
||||
• فیلتر کردن متریکهای قدیمی و حذف ردیفهای نامعتبر در نمودار گرافانا.
|
||||
@@ -13,18 +25,3 @@
|
||||
• منوی تنظیم دستی ارائهدهنده پشتیبان برای هر سرویس هوش مصنوعی به صورت زنجیرهای مستقیم.
|
||||
• دستورات <code>/changes</code> و <code>/notes</code> جهت مشاهده گزارش تغییرات.
|
||||
📁 <i>فایلهای تغییریافته:</i> <code>services/admin_bot.py</code>, <code>tests/test_fallback_and_stop.py</code>
|
||||
|
||||
🔹 <b>پشتیبانی از تصاویر و بازنویسی مالتیمدال (Vision):</b>
|
||||
• ارسال تصاویر پستهای ورودی به ارائهدهندگان هوش مصنوعی و درک محتوای بصری عکسها و نمودارها.
|
||||
• تنظیم عمق تفکر و استدلال (Reasoning Effort) برای مدلها.
|
||||
📁 <i>فایلهای تغییریافته:</i> <code>core/llm.py</code>, <code>services/ai_processor.py</code>
|
||||
|
||||
🔹 <b>بهینهسازی خط لوله جمعآوری، انتشار و صفبندی:</b>
|
||||
• ارسال مستقیم پستهای خام به کانال بررسی ادمین با دکمههای اقدام سریع بازنویسی.
|
||||
• صف انتشار زمانبندیشده و منظم در ردیس (Paced Queue).
|
||||
📁 <i>فایلهای تغییریافته:</i> <code>services/collector.py</code>, <code>services/publisher.py</code>, <code>services/metrics_reporter.py</code>
|
||||
|
||||
🔹 <b>مدیریت پایگاهداده و دستهبندی کانالها:</b>
|
||||
• ایجاد جداول دستهبندی کانالها (Channel Categories) و ارائهدهندگان هوش مصنوعی با پشتیبانی از ارائهدهنده پشتیبان.
|
||||
• پشتیبانی از لحن (Personality)، فوتر اختصاصی و پرامپت سفارشی برای هر کانال مقصد.
|
||||
📁 <i>فایلهای تغییریافته:</i> <code>db/database.py</code>, <code>db/models.py</code>, <code>db/repository.py</code>
|
||||
|
||||
+7
-4
@@ -144,6 +144,9 @@ class LLMClient:
|
||||
p_key = profile.api_key.strip()
|
||||
p_effort = profile.reasoning_effort.strip().lower()
|
||||
|
||||
# Only pass image if this provider profile explicitly enables vision/image processing
|
||||
active_image = image_path if (profile.supports_vision and image_path and os.path.isfile(image_path)) else None
|
||||
|
||||
for attempt in range(self.max_retries_per_model + 1):
|
||||
try:
|
||||
if p_type == "gemini" and "orcarouter" not in p_base_url:
|
||||
@@ -153,7 +156,7 @@ class LLMClient:
|
||||
model=p_model,
|
||||
api_key=p_key or self.api_key,
|
||||
reasoning_effort=p_effort or self.reasoning_effort,
|
||||
image_path=image_path
|
||||
image_path=active_image
|
||||
)
|
||||
elif p_type == "agy":
|
||||
if not os.path.exists("/.dockerenv") and (shutil.which("agy") or os.path.exists("/home/mamad/.local/bin/agy")):
|
||||
@@ -161,7 +164,7 @@ class LLMClient:
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
effort=p_effort or self.reasoning_effort,
|
||||
image_path=image_path
|
||||
image_path=active_image
|
||||
)
|
||||
else:
|
||||
result = await self._call_openai(
|
||||
@@ -172,7 +175,7 @@ class LLMClient:
|
||||
api_key=p_key,
|
||||
reasoning_effort=p_effort or self.reasoning_effort,
|
||||
is_agy=True,
|
||||
image_path=image_path
|
||||
image_path=active_image
|
||||
)
|
||||
else:
|
||||
result = await self._call_openai(
|
||||
@@ -183,7 +186,7 @@ class LLMClient:
|
||||
api_key=p_key or self.api_key,
|
||||
reasoning_effort=p_effort or self.reasoning_effort,
|
||||
is_agy=False,
|
||||
image_path=image_path
|
||||
image_path=active_image
|
||||
)
|
||||
|
||||
status = "success"
|
||||
|
||||
@@ -117,6 +117,7 @@ CREATE TABLE IF NOT EXISTS ai_providers (
|
||||
reasoning_effort VARCHAR(32) DEFAULT '',
|
||||
is_active BOOLEAN DEFAULT FALSE,
|
||||
fallback_provider_id BIGINT REFERENCES ai_providers(id) ON DELETE SET NULL,
|
||||
supports_vision BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -132,6 +133,7 @@ CREATE INDEX IF NOT EXISTS idx_ai_providers_active ON ai_providers(is_active);
|
||||
|
||||
-- Migration safety for existing tables
|
||||
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS fallback_provider_id BIGINT REFERENCES ai_providers(id) ON DELETE SET NULL;
|
||||
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS supports_vision BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE sources ADD COLUMN IF NOT EXISTS category_id INT REFERENCES channel_categories(id) ON DELETE SET NULL;
|
||||
ALTER TABLE targets ADD COLUMN IF NOT EXISTS category_id INT REFERENCES channel_categories(id) ON DELETE SET NULL;
|
||||
ALTER TABLE sources ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE;
|
||||
|
||||
@@ -99,6 +99,7 @@ class AIProviderProfile:
|
||||
is_active: bool = False
|
||||
fallback_provider_id: Optional[int] = None
|
||||
fallback_provider_name: Optional[str] = None
|
||||
supports_vision: bool = False
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
+46
-9
@@ -231,6 +231,8 @@ class Repository:
|
||||
media_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
content_hash: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
subject: Optional[str] = None,
|
||||
is_duplicate: bool = False,
|
||||
duplicate_of_id: Optional[int] = None,
|
||||
similarity_reason: Optional[str] = None,
|
||||
@@ -242,9 +244,9 @@ class Repository:
|
||||
"""
|
||||
INSERT INTO posts (
|
||||
source_channel_id, source_message_id, raw_text, media_path,
|
||||
media_type, content_hash, is_duplicate, duplicate_of_id, similarity_reason, status
|
||||
media_type, content_hash, tags, subject, is_duplicate, duplicate_of_id, similarity_reason, status
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending_review')
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'pending_review')
|
||||
RETURNING id;
|
||||
""",
|
||||
source_channel_id,
|
||||
@@ -253,6 +255,8 @@ class Repository:
|
||||
media_path,
|
||||
media_type,
|
||||
content_hash,
|
||||
tags or [],
|
||||
subject,
|
||||
is_duplicate,
|
||||
duplicate_of_id,
|
||||
similarity_reason,
|
||||
@@ -267,6 +271,30 @@ class Repository:
|
||||
row = await conn.fetchrow("SELECT * FROM posts WHERE id = $1;", post_id)
|
||||
return _parse_post_row(row) if row else None
|
||||
|
||||
async def find_candidate_posts_by_tags(
|
||||
self,
|
||||
tags: List[str],
|
||||
exclude_post_id: Optional[int] = None,
|
||||
limit: int = 10
|
||||
) -> List[Post]:
|
||||
"""Find recent posts that share at least one tag."""
|
||||
if not tags:
|
||||
return []
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM posts
|
||||
WHERE tags && $1::text[]
|
||||
AND is_deleted = FALSE
|
||||
AND ($2::bigint IS NULL OR id <> $2)
|
||||
ORDER BY id DESC
|
||||
LIMIT $3;
|
||||
""",
|
||||
tags, exclude_post_id, limit
|
||||
)
|
||||
return [_parse_post_row(r) for r in rows]
|
||||
|
||||
async def find_duplicate_post(self, content_hash: Optional[str], exclude_post_id: Optional[int] = None) -> Optional[Post]:
|
||||
"""Return the earliest post already carrying this content hash, if any."""
|
||||
if not content_hash:
|
||||
@@ -575,7 +603,8 @@ class Repository:
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
reasoning_effort: str = "",
|
||||
is_active: bool = False
|
||||
is_active: bool = False,
|
||||
supports_vision: bool = False
|
||||
) -> int:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
@@ -583,11 +612,11 @@ class Repository:
|
||||
await conn.execute("UPDATE ai_providers SET is_active = FALSE;")
|
||||
return await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO ai_providers (name, provider_type, model, base_url, api_key, reasoning_effort, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO ai_providers (name, provider_type, model, base_url, api_key, reasoning_effort, is_active, supports_vision)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id;
|
||||
""",
|
||||
name, provider_type, model, base_url, api_key, reasoning_effort, is_active
|
||||
name, provider_type, model, base_url, api_key, reasoning_effort, is_active, supports_vision
|
||||
)
|
||||
|
||||
async def get_provider_profiles(self) -> List[AIProviderProfile]:
|
||||
@@ -596,7 +625,7 @@ class Repository:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT p.id, p.name, p.provider_type, p.model, p.base_url, p.api_key, p.reasoning_effort, p.is_active,
|
||||
p.fallback_provider_id,
|
||||
p.fallback_provider_id, p.supports_vision,
|
||||
fb.name as fallback_provider_name,
|
||||
to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
|
||||
FROM ai_providers p
|
||||
@@ -612,7 +641,7 @@ class Repository:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT p.id, p.name, p.provider_type, p.model, p.base_url, p.api_key, p.reasoning_effort, p.is_active,
|
||||
p.fallback_provider_id,
|
||||
p.fallback_provider_id, p.supports_vision,
|
||||
fb.name as fallback_provider_name,
|
||||
to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
|
||||
FROM ai_providers p
|
||||
@@ -629,7 +658,7 @@ class Repository:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT p.id, p.name, p.provider_type, p.model, p.base_url, p.api_key, p.reasoning_effort, p.is_active,
|
||||
p.fallback_provider_id,
|
||||
p.fallback_provider_id, p.supports_vision,
|
||||
fb.name as fallback_provider_name,
|
||||
to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
|
||||
FROM ai_providers p
|
||||
@@ -656,6 +685,14 @@ class Repository:
|
||||
fallback_provider_id, profile_id
|
||||
)
|
||||
|
||||
async def update_provider_vision(self, profile_id: int, supports_vision: bool) -> None:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE ai_providers SET supports_vision = $1 WHERE id = $2;",
|
||||
supports_vision, profile_id
|
||||
)
|
||||
|
||||
async def update_provider_profile(
|
||||
self,
|
||||
profile_id: int,
|
||||
|
||||
@@ -51,7 +51,7 @@ async def main():
|
||||
)
|
||||
ai_processor = AIProcessor(repo=repo, llm=llm)
|
||||
admin_bot.set_ai_processor(ai_processor)
|
||||
collector = CollectorService(repo=repo, on_post_received=admin_bot.handle_collected_post)
|
||||
collector = CollectorService(repo=repo, on_post_received=admin_bot.handle_collected_post, ai_processor=ai_processor)
|
||||
admin_bot.set_collector(collector)
|
||||
|
||||
# Publisher handles per-target delivery queues, intervals, and sleep windows.
|
||||
|
||||
+20
-1
@@ -388,6 +388,7 @@ class AdminBotService:
|
||||
masked_key = f"{p.api_key[:6]}...{p.api_key[-4:]}" if len(p.api_key) > 10 else ("تنظیم نشده" if not p.api_key else "******")
|
||||
url_label = p.base_url if p.base_url else "پیشفرض (لوکال)"
|
||||
fallback_label = f"🔄 {p.fallback_provider_name}" if p.fallback_provider_name else "❌ بدون پشتیبان (پایان زنجیره)"
|
||||
vision_label = "🟢 فعال" if getattr(p, "supports_vision", False) else "⚪️ غیرفعال (پیشفرض)"
|
||||
|
||||
text = (
|
||||
f"🔌 <b>اطلاعات سرویسدهنده: {p.name}</b>\n\n"
|
||||
@@ -396,6 +397,7 @@ class AdminBotService:
|
||||
f"• 🌐 <b>Base URL:</b> <code>{url_label}</code>\n"
|
||||
f"• 🔑 <b>API Key:</b> <code>{masked_key}</code>\n"
|
||||
f"• 🧠 <b>تفکر (Reasoning):</b> <code>{p.reasoning_effort or 'پیشفرض'}</code>\n"
|
||||
f"• 🖼 <b>پردازش تصویر (Vision):</b> <b>{vision_label}</b>\n"
|
||||
f"• 🔄 <b>پشتیبان دستی (Fallback):</b> <b>{fallback_label}</b>\n"
|
||||
f"• ⚡️ <b>وضعیت:</b> {st_text}"
|
||||
)
|
||||
@@ -409,11 +411,14 @@ class AdminBotService:
|
||||
Button.inline("🔄 تنظیم ارائهدهنده پشتیبان", data=f"ai_pv_fb_menu:{p.id}"),
|
||||
])
|
||||
buttons.append([
|
||||
Button.inline("🖼 تغییر وضعیت پردازش تصویر", data=f"ai_pv_vision:{p.id}"),
|
||||
Button.inline("🧠 حالت تفکر و استدلال", data=f"ai_pv_effort:{p.id}"),
|
||||
Button.inline("🏷 تغییر مدل", data=f"ai_pv_model:{p.id}"),
|
||||
])
|
||||
buttons.append([
|
||||
Button.inline("🏷 تغییر مدل", data=f"ai_pv_model:{p.id}"),
|
||||
Button.inline("🌐 تغییر Base URL", data=f"ai_pv_url:{p.id}"),
|
||||
])
|
||||
buttons.append([
|
||||
Button.inline("🔑 تغییر API Key", data=f"ai_pv_key:{p.id}"),
|
||||
])
|
||||
if not p.is_active:
|
||||
@@ -1914,6 +1919,20 @@ class AdminBotService:
|
||||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||||
await event.answer("✅ ارائهدهنده پشتیبان دستی ذخیره شد.")
|
||||
|
||||
elif data.startswith("ai_pv_vision:"):
|
||||
prof_id = int(data.split(":")[1])
|
||||
p = await self.repo.get_provider_profile_by_id(prof_id)
|
||||
if p:
|
||||
new_vision = not getattr(p, "supports_vision", False)
|
||||
await self.repo.update_provider_vision(prof_id, new_vision)
|
||||
if self.ai_processor and self.ai_processor.llm:
|
||||
await self.ai_processor.llm.sync_config_from_repo()
|
||||
text, buttons = await self._render_ai_provider_detail(prof_id)
|
||||
await event.edit(text, parse_mode="html", buttons=buttons)
|
||||
await event.answer(f"✅ پردازش تصویر {'فعال' if new_vision else 'غیرفعال'} شد!")
|
||||
else:
|
||||
await event.answer("❌ سرویسدهنده یافت نشد.", alert=True)
|
||||
|
||||
elif data.startswith("ai_pv_effort:"):
|
||||
prof_id = int(data.split(":")[1])
|
||||
text, buttons = await self._render_ai_provider_effort_menu(prof_id)
|
||||
|
||||
@@ -307,3 +307,111 @@ class AIProcessor:
|
||||
fallback = f"{fallback}\n\n{footer_text}"
|
||||
return RewriteResult(decision="accept", rejection_reason="", rewritten_text=fallback)
|
||||
|
||||
async def extract_tags_and_subject(
|
||||
self,
|
||||
raw_text: str,
|
||||
image_path: Optional[str] = None
|
||||
) -> tuple[List[str], str]:
|
||||
"""Extract 3-6 topic tags and a short subject from incoming post text and/or image."""
|
||||
if not raw_text and not image_path:
|
||||
return [], ""
|
||||
|
||||
sys_prompt = (
|
||||
"You are a Telegram post classifier and tagger. "
|
||||
"Analyze the given post content (and optional image) and extract:\n"
|
||||
"1. 'subject': A concise, descriptive subject or headline (in Persian, max 10 words).\n"
|
||||
"2. 'tags': A list of 3 to 6 relevant topical keywords/tags (lowercase, normalized, without '#' prefix).\n\n"
|
||||
"Respond ONLY in valid JSON format:\n"
|
||||
"{\n"
|
||||
' "subject": "...",\n'
|
||||
' "tags": ["tag1", "tag2", "tag3"]\n'
|
||||
"}"
|
||||
)
|
||||
|
||||
user_prompt = f"متن پست برای دستهبندی و برچسبگذاری:\n\n{raw_text}" if raw_text else "لطفاً با توجه به تصویر پیوست، موضوع و برچسبهای آن را استخراج کنید."
|
||||
|
||||
try:
|
||||
res = await self.llm.generate_json(
|
||||
prompt=user_prompt,
|
||||
system_prompt=sys_prompt,
|
||||
action_name="extract_tags_and_subject",
|
||||
image_path=image_path
|
||||
)
|
||||
subject = str(res.get("subject", "")).strip()
|
||||
raw_tags = res.get("tags") or []
|
||||
tags: List[str] = []
|
||||
if isinstance(raw_tags, list):
|
||||
for t in raw_tags:
|
||||
clean_t = str(t).strip().lstrip("#").lower()
|
||||
if clean_t and clean_t not in tags:
|
||||
tags.append(clean_t)
|
||||
return tags[:8], subject
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract tags and subject via AI: {e}")
|
||||
return [], ""
|
||||
|
||||
async def check_semantic_duplicate(
|
||||
self,
|
||||
new_text: str,
|
||||
candidate_posts: List[Post],
|
||||
image_path: Optional[str] = None
|
||||
) -> tuple[bool, Optional[int], Optional[str]]:
|
||||
"""Compare a new post against candidate posts sharing overlapping tags using AI semantic similarity."""
|
||||
if not candidate_posts or (not new_text and not image_path):
|
||||
return False, None, None
|
||||
|
||||
candidates_formatted = []
|
||||
for p in candidate_posts[:10]:
|
||||
p_text = (p.raw_text or "").strip()
|
||||
snippet = p_text[:250] + ("..." if len(p_text) > 250 else "")
|
||||
candidates_formatted.append(
|
||||
f"- [پست شناسه #{p.id}] (موضوع: {p.subject or 'نامشخص'} | برچسبها: {', '.join(p.tags or [])}):\n «{snippet}»"
|
||||
)
|
||||
|
||||
candidates_block = "\n\n".join(candidates_formatted)
|
||||
|
||||
sys_prompt = (
|
||||
"You are an expert news and content duplicate detection engine.\n"
|
||||
"Compare the NEW POST against the list of CANDIDATE POSTS.\n"
|
||||
"Determine if the NEW POST covers the EXACT SAME news event, identical story, announcement, or duplicate information.\n\n"
|
||||
"Respond ONLY in valid JSON format:\n"
|
||||
"{\n"
|
||||
' "is_duplicate": true,\n'
|
||||
' "duplicate_of_id": 123,\n'
|
||||
' "reason": "توضیح کوتاه به زبان فارسی در مورد علت تکراری بودن"\n'
|
||||
"}\n"
|
||||
"If it is NOT a duplicate of any candidate post:\n"
|
||||
"{\n"
|
||||
' "is_duplicate": false,\n'
|
||||
' "duplicate_of_id": null,\n'
|
||||
' "reason": ""\n'
|
||||
"}"
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
f"پست جدید برای بررسی:\n{new_text}\n\n"
|
||||
f"لیست پستهای مشابه قبلی برای مقایسه:\n{candidates_block}"
|
||||
)
|
||||
|
||||
try:
|
||||
res = await self.llm.generate_json(
|
||||
prompt=user_prompt,
|
||||
system_prompt=sys_prompt,
|
||||
action_name="check_semantic_duplicate",
|
||||
image_path=image_path
|
||||
)
|
||||
is_dup = bool(res.get("is_duplicate", False))
|
||||
dup_id = res.get("duplicate_of_id")
|
||||
reason = str(res.get("reason", "")).strip()
|
||||
|
||||
if is_dup and dup_id:
|
||||
try:
|
||||
dup_id_int = int(dup_id)
|
||||
return True, dup_id_int, reason or f"تشخیص هوش مصنوعی: مشابه پست #{dup_id_int}"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return False, None, None
|
||||
except Exception as e:
|
||||
logger.warning(f"AI semantic duplicate check failed: {e}")
|
||||
return False, None, None
|
||||
|
||||
|
||||
+91
-16
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Callable, Awaitable
|
||||
from typing import Optional, Callable, Awaitable, Any, List
|
||||
from telethon import TelegramClient, events
|
||||
from telethon.errors import SessionPasswordNeededError
|
||||
from telethon.tl.types import MessageMediaPhoto, MessageMediaDocument
|
||||
@@ -57,6 +57,7 @@ class CollectorService:
|
||||
repo: Repository,
|
||||
on_post_received: Optional[Callable[[int], Awaitable[None]]] = None,
|
||||
queue: Optional[RedisQueue] = None,
|
||||
ai_processor: Optional[Any] = None,
|
||||
api_id: Optional[int] = None,
|
||||
api_hash: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
@@ -65,6 +66,7 @@ class CollectorService:
|
||||
self.repo = repo
|
||||
self.on_post_received = on_post_received
|
||||
self.queue = queue
|
||||
self.ai_processor = ai_processor
|
||||
self.api_id = api_id or int(os.getenv("API_ID", "0"))
|
||||
self.api_hash = api_hash or os.getenv("API_HASH", "")
|
||||
self.phone = phone or os.getenv("PHONE")
|
||||
@@ -75,6 +77,9 @@ class CollectorService:
|
||||
self.phone_code_hash: Optional[str] = None
|
||||
self._handlers_registered = False
|
||||
|
||||
def set_ai_processor(self, ai_processor: Any) -> None:
|
||||
self.ai_processor = ai_processor
|
||||
|
||||
async def start(self, notify_fn: Optional[Callable[[str], Awaitable[None]]] = None):
|
||||
logger.info("Initializing Collector Userbot client...")
|
||||
try:
|
||||
@@ -201,7 +206,43 @@ class CollectorService:
|
||||
media_hash = compute_file_hash(downloaded_file)
|
||||
|
||||
content_hash = compute_content_hash(raw_text, media_hash)
|
||||
duplicate_of = await self.repo.find_duplicate_post(content_hash)
|
||||
|
||||
# 1. Extract subject and topic tags via AI
|
||||
tags, subject = [], ""
|
||||
if self.ai_processor:
|
||||
try:
|
||||
tags, subject = await self.ai_processor.extract_tags_and_subject(raw_text, media_path)
|
||||
except Exception as e:
|
||||
logger.debug(f"Tag extraction error: {e}")
|
||||
|
||||
# 2. Check duplicate via AI semantic similarity on candidate posts sharing tags
|
||||
is_duplicate = False
|
||||
duplicate_of_id = None
|
||||
similarity_reason = None
|
||||
|
||||
if tags and self.ai_processor:
|
||||
try:
|
||||
candidates = await self.repo.find_candidate_posts_by_tags(tags, limit=10)
|
||||
if candidates:
|
||||
is_dup, dup_id, reason = await self.ai_processor.check_semantic_duplicate(
|
||||
raw_text, candidates, media_path
|
||||
)
|
||||
if is_dup:
|
||||
is_duplicate = True
|
||||
duplicate_of_id = dup_id
|
||||
similarity_reason = reason
|
||||
DUPLICATES_DETECTED_TOTAL.labels(method="ai_semantic").inc()
|
||||
except Exception as e:
|
||||
logger.debug(f"Semantic duplicate check error: {e}")
|
||||
|
||||
# 3. Fallback hash duplicate check if not already caught by AI
|
||||
if not is_duplicate and content_hash:
|
||||
duplicate_of = await self.repo.find_duplicate_post(content_hash)
|
||||
if duplicate_of:
|
||||
is_duplicate = True
|
||||
duplicate_of_id = duplicate_of.id
|
||||
similarity_reason = f"content hash matches post #{duplicate_of.id}"
|
||||
DUPLICATES_DETECTED_TOTAL.labels(method="content_hash").inc()
|
||||
|
||||
post_id = await self.repo.create_raw_post(
|
||||
source_channel_id=chat_id,
|
||||
@@ -210,17 +251,16 @@ class CollectorService:
|
||||
media_path=media_path,
|
||||
media_type=media_type,
|
||||
content_hash=content_hash,
|
||||
is_duplicate=duplicate_of is not None,
|
||||
duplicate_of_id=duplicate_of.id if duplicate_of else None,
|
||||
similarity_reason=f"content hash matches post #{duplicate_of.id}" if duplicate_of else None,
|
||||
tags=tags,
|
||||
subject=subject,
|
||||
is_duplicate=is_duplicate,
|
||||
duplicate_of_id=duplicate_of_id,
|
||||
similarity_reason=similarity_reason,
|
||||
)
|
||||
|
||||
if post_id and duplicate_of:
|
||||
DUPLICATES_DETECTED_TOTAL.labels(method="content_hash").inc()
|
||||
|
||||
if post_id:
|
||||
SOURCE_ACTIVITY_TOTAL.labels(channel_id=str(chat_id), title=source.title or 'Unknown').inc()
|
||||
logger.info(f"Collected raw post ID {post_id} from source channel {chat_id}")
|
||||
logger.info(f"Collected raw post ID {post_id} from source channel {chat_id} (subject={subject}, tags={tags}, duplicate={is_duplicate})")
|
||||
|
||||
if self.on_post_received:
|
||||
await self.on_post_received(post_id)
|
||||
@@ -281,10 +321,43 @@ class CollectorService:
|
||||
media_hash = compute_file_hash(downloaded_file)
|
||||
|
||||
content_hash = compute_content_hash(raw_text, media_hash)
|
||||
duplicate_of = await self.repo.find_duplicate_post(content_hash)
|
||||
if duplicate_of:
|
||||
result.duplicates += 1
|
||||
DUPLICATES_DETECTED_TOTAL.labels(method="content_hash").inc()
|
||||
|
||||
# Extract tags and check semantic duplication
|
||||
tags, subject = [], ""
|
||||
if self.ai_processor:
|
||||
try:
|
||||
tags, subject = await self.ai_processor.extract_tags_and_subject(raw_text, media_path)
|
||||
except Exception as e:
|
||||
logger.debug(f"Tag extraction error: {e}")
|
||||
|
||||
is_duplicate = False
|
||||
duplicate_of_id = None
|
||||
similarity_reason = None
|
||||
|
||||
if tags and self.ai_processor:
|
||||
try:
|
||||
candidates = await self.repo.find_candidate_posts_by_tags(tags, limit=10)
|
||||
if candidates:
|
||||
is_dup, dup_id, reason = await self.ai_processor.check_semantic_duplicate(
|
||||
raw_text, candidates, media_path
|
||||
)
|
||||
if is_dup:
|
||||
is_duplicate = True
|
||||
duplicate_of_id = dup_id
|
||||
similarity_reason = reason
|
||||
result.duplicates += 1
|
||||
DUPLICATES_DETECTED_TOTAL.labels(method="ai_semantic").inc()
|
||||
except Exception as e:
|
||||
logger.debug(f"Semantic duplicate check error: {e}")
|
||||
|
||||
if not is_duplicate and content_hash:
|
||||
duplicate_of = await self.repo.find_duplicate_post(content_hash)
|
||||
if duplicate_of:
|
||||
is_duplicate = True
|
||||
duplicate_of_id = duplicate_of.id
|
||||
similarity_reason = f"content hash matches post #{duplicate_of.id}"
|
||||
result.duplicates += 1
|
||||
DUPLICATES_DETECTED_TOTAL.labels(method="content_hash").inc()
|
||||
|
||||
post_id = await self.repo.create_raw_post(
|
||||
source_channel_id=channel_id,
|
||||
@@ -293,9 +366,11 @@ class CollectorService:
|
||||
media_path=media_path,
|
||||
media_type=media_type,
|
||||
content_hash=content_hash,
|
||||
is_duplicate=duplicate_of is not None,
|
||||
duplicate_of_id=duplicate_of.id if duplicate_of else None,
|
||||
similarity_reason=f"content hash matches post #{duplicate_of.id}" if duplicate_of else None,
|
||||
tags=tags,
|
||||
subject=subject,
|
||||
is_duplicate=is_duplicate,
|
||||
duplicate_of_id=duplicate_of_id,
|
||||
similarity_reason=similarity_reason,
|
||||
)
|
||||
|
||||
if post_id:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import os
|
||||
import asyncio
|
||||
from db.database import init_db, close_db_pool
|
||||
from db.repository import Repository
|
||||
from db.models import Post, TargetChannel, SourceChannel
|
||||
from core.llm import LLMClient
|
||||
from services.ai_processor import AIProcessor
|
||||
from services.collector import CollectorService
|
||||
|
||||
|
||||
async def test_provider_vision_toggle():
|
||||
await init_db()
|
||||
repo = Repository()
|
||||
|
||||
prov_id = await repo.add_provider_profile(
|
||||
name="Test Vision Model",
|
||||
provider_type="openai",
|
||||
model="gpt-4o",
|
||||
supports_vision=False
|
||||
)
|
||||
assert prov_id is not None
|
||||
|
||||
p = await repo.get_provider_profile_by_id(prov_id)
|
||||
assert p.supports_vision is False
|
||||
|
||||
await repo.update_provider_vision(prov_id, True)
|
||||
p_updated = await repo.get_provider_profile_by_id(prov_id)
|
||||
assert p_updated.supports_vision is True
|
||||
|
||||
await repo.update_provider_vision(prov_id, False)
|
||||
p_reverted = await repo.get_provider_profile_by_id(prov_id)
|
||||
assert p_reverted.supports_vision is False
|
||||
|
||||
await repo.delete_provider_profile(prov_id)
|
||||
|
||||
|
||||
async def test_find_candidate_posts_by_tags():
|
||||
await init_db()
|
||||
repo = Repository()
|
||||
|
||||
import time
|
||||
msg_id = int(time.time() * 1000) % 100000000
|
||||
post1_id = await repo.create_raw_post(
|
||||
source_channel_id=-100111222,
|
||||
source_message_id=msg_id,
|
||||
raw_text="قیمت بیتکوین به ۷۰ هزار دلار رسید و رکورد جدیدی ثبت کرد.",
|
||||
tags=["بیت_کوین", "ارز_دیجیتال", "اقتصاد"],
|
||||
subject="رکوردشکنی بیتکوین",
|
||||
)
|
||||
assert post1_id is not None
|
||||
|
||||
candidates = await repo.find_candidate_posts_by_tags(["بیت_کوین", "رمزارز"])
|
||||
assert len(candidates) >= 1
|
||||
found_ids = [c.id for c in candidates]
|
||||
assert post1_id in found_ids
|
||||
|
||||
no_match = await repo.find_candidate_posts_by_tags(["ورزش", "فوتبال"])
|
||||
assert post1_id not in [c.id for c in no_match]
|
||||
|
||||
|
||||
async def test_semantic_duplicate_detection():
|
||||
await init_db()
|
||||
repo = Repository()
|
||||
|
||||
class FakeDuplicateLLM:
|
||||
def __init__(self):
|
||||
self.supports_vision = False
|
||||
self.last_used_model = "fake"
|
||||
self.last_used_provider = "fake"
|
||||
|
||||
async def generate_json(self, prompt, system_prompt=None, action_name=None, image_path=None):
|
||||
if action_name == "extract_tags_and_subject":
|
||||
return {
|
||||
"subject": "رشد بیتکوین",
|
||||
"tags": ["بیت_کوین", "کریپتو", "قیمت"]
|
||||
}
|
||||
elif action_name == "check_semantic_duplicate":
|
||||
return {
|
||||
"is_duplicate": True,
|
||||
"duplicate_of_id": 55,
|
||||
"reason": "پوشش یکسان خبر افزایش نرخ بیتکوین"
|
||||
}
|
||||
return {}
|
||||
|
||||
fake_llm = FakeDuplicateLLM()
|
||||
processor = AIProcessor(repo=repo, llm=fake_llm)
|
||||
|
||||
tags, subject = await processor.extract_tags_and_subject("بیتکوین ۷۰ هزار دلار شد")
|
||||
assert tags == ["بیت_کوین", "کریپتو", "قیمت"]
|
||||
assert subject == "رشد بیتکوین"
|
||||
|
||||
candidates = [
|
||||
Post(
|
||||
id=55,
|
||||
source_channel_id=-100123,
|
||||
source_message_id=10,
|
||||
raw_text="بیت کوین به ۷۰۰۰۰ دلار رسید",
|
||||
tags=["بیت_کوین"],
|
||||
subject="افزایش قیمت بیتکوین"
|
||||
)
|
||||
]
|
||||
|
||||
is_dup, dup_id, reason = await processor.check_semantic_duplicate(
|
||||
"بیتکوین به مرز هفتاد هزار دلار دست یافت",
|
||||
candidates
|
||||
)
|
||||
assert is_dup is True
|
||||
assert dup_id == 55
|
||||
assert "پوشش یکسان" in reason
|
||||
|
||||
|
||||
async def main():
|
||||
await test_provider_vision_toggle()
|
||||
await test_find_candidate_posts_by_tags()
|
||||
await test_semantic_duplicate_detection()
|
||||
print("All semantic deduplication and vision configuration tests passed successfully!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -71,6 +71,7 @@ async def test_multimodal_vision_image_payload():
|
||||
name="OpenAI Vision",
|
||||
provider_type="openai",
|
||||
model="gpt-4o",
|
||||
supports_vision=True,
|
||||
is_active=True
|
||||
)
|
||||
repo_mock = AsyncMock()
|
||||
|
||||
Reference in New Issue
Block a user