feat: ai letter + disabled fl

This commit is contained in:
2026-07-17 12:21:53 +07:00
parent b906825134
commit 3fe870024d
13 changed files with 1610 additions and 24 deletions

80
app/ai.py Normal file
View File

@@ -0,0 +1,80 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import requests
from app.models import Offer
class OfferReplyGenerator:
def __init__(
self,
api_base_url: str,
api_key: str,
model: str,
system_prompt_file: Path,
timeout_seconds: int,
) -> None:
self._api_base_url = api_base_url.rstrip("/")
self._api_key = api_key
self._model = model
self._system_prompt = system_prompt_file.read_text(encoding="utf-8").strip()
self._timeout_seconds = timeout_seconds
async def generate_reply(self, offer: Offer) -> str:
return await asyncio.to_thread(self._generate_reply_sync, offer)
def _generate_reply_sync(self, offer: Offer) -> str:
response = requests.post(
f"{self._api_base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
json={
"model": self._model,
"messages": [
{"role": "system", "content": self._system_prompt},
{"role": "user", "content": _build_offer_prompt(offer)},
],
},
timeout=self._timeout_seconds,
)
response.raise_for_status()
payload = response.json()
choices = payload.get("choices") or []
if not choices:
raise ValueError("OpenAI-compatible API returned no choices")
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, str) and content.strip():
return content.strip()
if isinstance(content, list):
text_parts = [item.get("text", "") for item in content if isinstance(item, dict)]
text = "\n".join(part.strip() for part in text_parts if part and part.strip()).strip()
if text:
return text
raise ValueError("OpenAI-compatible API returned empty content")
def _build_offer_prompt(offer: Offer) -> str:
seller_line = f"Исполнитель: @{offer.seller_username}\n" if offer.seller_username else ""
price_amount_line = f"Числовой бюджет: {offer.price_amount}\n" if offer.price_amount is not None else ""
return (
f"Подготовь сообщение для отклика на этот оффер.\n"
f"Источник: {offer.source}\n"
f"ID: {offer.external_id}\n"
f"Заголовок: {offer.title}\n"
f"Категория: {offer.category_id}\n"
f"Бюджет: {offer.price}\n"
f"{price_amount_line}"
f"Опубликовано: {offer.published_at}\n"
f"{seller_line}"
f"Описание:\n{offer.description}\n\n"
f"Ссылка: {offer.url}"
)

View File

@@ -20,6 +20,11 @@ class Settings:
min_price_amount: int | None
requests_timeout_seconds: int
state_file: Path
published_offers_file: Path
openai_api_base_url: str
openai_api_key: str
openai_model: str
openai_system_prompt_file: Path
def load_settings() -> Settings:
@@ -40,6 +45,11 @@ def load_settings() -> Settings:
min_price_amount=_parse_optional_int(os.getenv("MIN_PRICE_AMOUNT", "")),
requests_timeout_seconds=int(os.getenv("REQUESTS_TIMEOUT_SECONDS", "20")),
state_file=Path(os.getenv("STATE_FILE", "data/sent_offers.json")),
published_offers_file=Path(os.getenv("PUBLISHED_OFFERS_FILE", "data/published_offers.json")),
openai_api_base_url=_require_env("OPENAI_API_BASE_URL"),
openai_api_key=_require_env("OPENAI_API_KEY"),
openai_model=_require_env("OPENAI_MODEL"),
openai_system_prompt_file=Path(_require_env("OPENAI_SYSTEM_PROMPT_FILE")),
)

View File

@@ -19,3 +19,32 @@ class Offer:
@property
def dedup_key(self) -> str:
return f"{self.source}:{self.external_id}"
def to_dict(self) -> dict[str, str | int | None]:
return {
"source": self.source,
"external_id": self.external_id,
"title": self.title,
"description": self.description,
"price": self.price,
"price_amount": self.price_amount,
"url": self.url,
"category_id": self.category_id,
"published_at": self.published_at,
"seller_username": self.seller_username,
}
@classmethod
def from_dict(cls, data: dict[str, str | int | None]) -> "Offer":
return cls(
source=str(data["source"]),
external_id=str(data["external_id"]),
title=str(data["title"]),
description=str(data["description"]),
price=str(data["price"]),
price_amount=int(data["price_amount"]) if data["price_amount"] is not None else None,
url=str(data["url"]),
category_id=int(data["category_id"]),
published_at=str(data["published_at"]),
seller_username=str(data["seller_username"]) if data["seller_username"] is not None else None,
)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
from app.filters import get_offer_skip_reason
from app.state import SentOffersStore
from app.state import PublishedOffersStore, SentOffersStore
from app.telegram import TelegramPublisher
from app.sources.base import OfferSource
@@ -17,6 +17,7 @@ class OfferProcessingService:
sources: list[OfferSource],
publisher: TelegramPublisher,
sent_offers_store: SentOffersStore,
published_offers_store: PublishedOffersStore,
include_keywords: list[str],
exclude_keywords: list[str],
min_price_amount: int | None,
@@ -24,6 +25,7 @@ class OfferProcessingService:
self._sources = sources
self._publisher = publisher
self._sent_offers_store = sent_offers_store
self._published_offers_store = published_offers_store
self._include_keywords = include_keywords
self._exclude_keywords = exclude_keywords
self._min_price_amount = min_price_amount
@@ -66,12 +68,13 @@ class OfferProcessingService:
continue
try:
await self._publisher.publish_offer(offer)
message = await self._publisher.publish_offer(offer)
except Exception:
logger.exception("Failed to publish offer %s", offer.dedup_key)
continue
self._sent_offers_store.add(offer.dedup_key)
self._published_offers_store.add(message.chat.id, message.message_id, offer)
published_count += 1
logger.info("Published offer %s", offer.dedup_key)

View File

@@ -1,5 +1,6 @@
from app.sources.base import OfferSource
from app.sources.fl import FlSource
# from app.sources.fl import FlSource
from app.sources.kwork import KworkSource
__all__ = ["OfferSource", "FlSource", "KworkSource"]
__all__ = ["OfferSource", "KworkSource"]
# __all__ = ["OfferSource", "FlSource", "KworkSource"]

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
import json
from pathlib import Path
from app.models import Offer
class SentOffersStore:
def __init__(self, file_path: Path) -> None:
@@ -29,3 +31,44 @@ class SentOffersStore:
def _persist(self) -> None:
with self._file_path.open("w", encoding="utf-8") as file:
json.dump(sorted(self._sent_keys), file, ensure_ascii=False, indent=2)
class PublishedOffersStore:
def __init__(self, file_path: Path) -> None:
self._file_path = file_path
self._file_path.parent.mkdir(parents=True, exist_ok=True)
self._offers_by_message_key = self._load()
def add(self, chat_id: int, message_id: int, offer: Offer) -> None:
self._offers_by_message_key[self._message_key(chat_id, message_id)] = offer.to_dict()
self._persist()
def get(self, chat_id: int, message_id: int) -> Offer | None:
data = self._offers_by_message_key.get(self._message_key(chat_id, message_id))
if data is None:
return None
return Offer.from_dict(data)
def remove(self, chat_id: int, message_id: int) -> None:
removed = self._offers_by_message_key.pop(self._message_key(chat_id, message_id), None)
if removed is not None:
self._persist()
def _load(self) -> dict[str, dict[str, str | int | None]]:
if not self._file_path.exists():
return {}
with self._file_path.open("r", encoding="utf-8") as file:
data = json.load(file)
if not isinstance(data, dict):
return {}
return data
def _persist(self) -> None:
with self._file_path.open("w", encoding="utf-8") as file:
json.dump(self._offers_by_message_key, file, ensure_ascii=False, indent=2)
@staticmethod
def _message_key(chat_id: int, message_id: int) -> str:
return f"{chat_id}:{message_id}"

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
from app.models import Offer
@@ -11,12 +11,12 @@ class TelegramPublisher:
self._bot = bot
self._channel_id = channel_id
async def publish_offer(self, offer: Offer) -> None:
await self._bot.send_message(
async def publish_offer(self, offer: Offer) -> Message:
return await self._bot.send_message(
chat_id=self._channel_id,
text=_format_offer_message(offer),
disable_web_page_preview=True,
reply_markup=_build_delete_markup(),
reply_markup=_build_offer_markup(),
)
@@ -39,9 +39,10 @@ def _format_offer_message(offer: Offer) -> str:
)
def _build_delete_markup() -> InlineKeyboardMarkup:
def _build_offer_markup() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="❌", callback_data="delete_post")]
[InlineKeyboardButton(text="Принять", callback_data="accept_offer")],
[InlineKeyboardButton(text="❌", callback_data="delete_post")],
]
)