170 lines
5.7 KiB
TypeScript
170 lines
5.7 KiB
TypeScript
import { Injectable, Logger } from "@nestjs/common";
|
||
import { ConfigService } from "@nestjs/config";
|
||
import { GeminiService } from "../gemini/gemini.service";
|
||
import { PredictionCardDto } from "./dto/prediction-card.dto";
|
||
import axios from "axios";
|
||
|
||
const SYSTEM_PROMPT = `Sen profesyonel bir spor analisti ve sosyal medya içerik üreticisisin.
|
||
Verilen maç tahmin verisini kullanarak kısa, etkili ve ilgi çekici sosyal medya postları yazıyorsun.
|
||
|
||
KURALLAR:
|
||
- Türkçe yaz
|
||
- Maximum 250 karakter (X/Twitter uyumlu)
|
||
- Emoji kullan ama abartma (2-4 emoji yeterli)
|
||
- Skor tahminini vurgula
|
||
- Güven yüzdesini belirt
|
||
- Lig, ülke, takım adları ve ana tahminleri SEO için doğal şekilde geçir
|
||
- İlgili hashtag'leri ekle (#PremierLeague, #SüperLig vb.)
|
||
- KESİNLİKLE "kesin kazanır", "garanti" gibi ifadeler KULLANMA
|
||
- "Tahminimiz", "Beklentimiz", "Analizimiz" gibi ifadeler kullan
|
||
- Farklı maçlar için farklı tarzda yaz, tekdüze olma
|
||
- Son satıra her zaman hashtag'leri koy`;
|
||
|
||
@Injectable()
|
||
export class CaptionGeneratorService {
|
||
private readonly logger = new Logger(CaptionGeneratorService.name);
|
||
private readonly ollamaBaseUrl: string;
|
||
private readonly ollamaModel: string;
|
||
|
||
constructor(
|
||
private readonly geminiService: GeminiService,
|
||
private readonly configService: ConfigService,
|
||
) {
|
||
this.ollamaBaseUrl =
|
||
this.configService.get<string>("OLLAMA_BASE_URL") ||
|
||
"http://localhost:11434";
|
||
this.ollamaModel =
|
||
this.configService.get<string>("OLLAMA_MODEL") ||
|
||
this.configService.get<string>("SOCIAL_POSTER_OLLAMA_MODEL") ||
|
||
"";
|
||
}
|
||
|
||
/**
|
||
* Generate a social media caption for a match prediction using Gemini AI.
|
||
*/
|
||
async generateCaption(card: PredictionCardDto): Promise<string> {
|
||
if (this.ollamaModel) {
|
||
const caption = await this.generateWithOllama(card);
|
||
if (caption) return caption;
|
||
}
|
||
|
||
if (!this.geminiService.isAvailable()) {
|
||
this.logger.warn("Gemini not available, using template caption");
|
||
return this.generateFallbackCaption(card);
|
||
}
|
||
|
||
const prompt = this.buildPrompt(card);
|
||
|
||
try {
|
||
const { text } = await this.geminiService.generateText(prompt, {
|
||
systemPrompt: SYSTEM_PROMPT,
|
||
temperature: 0.8,
|
||
maxTokens: 300,
|
||
});
|
||
|
||
// Ensure hashtags are present
|
||
const caption = this.ensureHashtags(text, card);
|
||
this.logger.log(
|
||
`Caption generated for ${card.homeTeam} vs ${card.awayTeam}`,
|
||
);
|
||
return caption;
|
||
} catch (error) {
|
||
this.logger.error("Gemini caption generation failed", error);
|
||
return this.generateFallbackCaption(card);
|
||
}
|
||
}
|
||
|
||
private async generateWithOllama(card: PredictionCardDto): Promise<string> {
|
||
const prompt = `${SYSTEM_PROMPT}
|
||
|
||
${this.buildPrompt(card)}`;
|
||
|
||
try {
|
||
const response = await axios.post(
|
||
`${this.ollamaBaseUrl.replace(/\/$/, "")}/api/generate`,
|
||
{
|
||
model: this.ollamaModel,
|
||
prompt,
|
||
stream: false,
|
||
options: {
|
||
temperature: 0.7,
|
||
num_predict: 260,
|
||
},
|
||
},
|
||
{ timeout: 20000 },
|
||
);
|
||
|
||
const text = String(response.data?.response || "").trim();
|
||
if (!text) return "";
|
||
|
||
this.logger.log(
|
||
`Ollama caption generated for ${card.homeTeam} vs ${card.awayTeam}`,
|
||
);
|
||
return this.ensureHashtags(text, card);
|
||
} catch (error) {
|
||
this.logger.warn(`Ollama caption generation failed: ${error.message}`);
|
||
return "";
|
||
}
|
||
}
|
||
|
||
private buildPrompt(card: PredictionCardDto): string {
|
||
const topPicksText = card.topPicks
|
||
.map(
|
||
(p, i) =>
|
||
`${i + 1}. ${p.market} (${p.marketEn}) — ${p.pick} — Güven: %${p.confidence} — Oran: ${p.odds}`,
|
||
)
|
||
.join("\n");
|
||
|
||
return `Aşağıdaki maç tahmin verisini kullanarak bir sosyal medya postu oluştur:
|
||
|
||
MAÇ: ${card.homeTeam} vs ${card.awayTeam}
|
||
SPOR: ${card.sport === "basketball" ? "Basketbol" : "Futbol"}
|
||
LİG: ${card.leagueName}
|
||
ÜLKE/BÖLGE: ${card.countryName || "-"}
|
||
TARİH: ${card.matchDate}
|
||
${card.sport === "basketball" ? "İLK DEVRE" : "İLK YARI"} SKOR TAHMİNİ: ${card.htScore}
|
||
MAÇ SONU SKOR TAHMİNİ: ${card.ftScore}
|
||
SKOR GÜVEN: %${card.scoreConfidence}
|
||
RİSK SEVİYESİ: ${card.riskLevel}
|
||
|
||
EN İYİ TAHMİNLER:
|
||
${topPicksText}
|
||
|
||
Sadece post metnini yaz, başka hiçbir şey ekleme.`;
|
||
}
|
||
|
||
private ensureHashtags(text: string, card: PredictionCardDto): string {
|
||
// If no hashtags in text, add them
|
||
if (!text.includes("#")) {
|
||
const leagueTag = card.leagueName
|
||
.replace(/\s+/g, "")
|
||
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
|
||
const homeTag = card.homeTeam.replace(/\s+/g, "");
|
||
const awayTag = card.awayTeam.replace(/\s+/g, "");
|
||
const sportTag = card.sport === "basketball" ? "Basketbol" : "Futbol";
|
||
text += `\n\n#${leagueTag} #${homeTag} #${awayTag} #${sportTag}`;
|
||
}
|
||
return text.trim();
|
||
}
|
||
|
||
/**
|
||
* Fallback caption when Gemini is not available.
|
||
*/
|
||
private generateFallbackCaption(card: PredictionCardDto): string {
|
||
const topPick = card.topPicks[0];
|
||
const leagueTag = card.leagueName
|
||
.replace(/\s+/g, "")
|
||
.replace(/[^a-zA-Z0-9üöçşğıİÜÖÇŞĞ]/g, "");
|
||
|
||
const sportLabel = card.sport === "basketball" ? "Basketbol" : "Futbol";
|
||
const halfLabel = card.sport === "basketball" ? "İD" : "İY";
|
||
|
||
return `⚡ ${card.leagueName}${card.countryName ? ` (${card.countryName})` : ""}: ${card.homeTeam} vs ${card.awayTeam}
|
||
🎯 ${sportLabel} tahminimiz: ${card.ftScore} (${halfLabel}: ${card.htScore})
|
||
📊 Güven: %${card.scoreConfidence}
|
||
${topPick ? `🔥 ${topPick.market}: ${topPick.pick} (%${topPick.confidence})` : ""}
|
||
|
||
#${leagueTag} #${sportLabel} #MaçTahmini #iddaai`.trim();
|
||
}
|
||
}
|