feat: ai letter + disabled fl
This commit is contained in:
80
app/ai.py
Normal file
80
app/ai.py
Normal 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}"
|
||||
)
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
43
app/state.py
43
app/state.py
@@ -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}"
|
||||
|
||||
@@ -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")],
|
||||
]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user