first commit

This commit is contained in:
NAME
2026-05-14 08:42:03 +00:00
commit 5f16ed135d
167 changed files with 29178 additions and 0 deletions
@@ -0,0 +1,325 @@
import {OnWorkerEvent, Processor, WorkerHost} from "@nestjs/bullmq";
import {Job} from "bullmq";
import {PostCreateInput} from "../../generated/prisma/models/Post";
import {CommentWriterService} from "./services/comment-writer.service";
import {GenerateCommentDto} from "./dto/generate-comment.dto";
import {XReaderService} from "../x-reader/x-reader.service";
import {InjectBot} from "nestjs-telegraf";
import {Context, Telegraf} from "telegraf";
import {Logger} from "@nestjs/common";
import {QuoteWriterService} from "./services/quote-writer.service";
import {GenerateQuoteDto} from "./dto/generate-quote.dto";
import {TextUtil} from "../../common/utils/text.util";
import {isEmpty} from "lodash";
@Processor('comment_writer_queue')
export class CommentWriterProcessor extends WorkerHost {
private readonly logger = new Logger(CommentWriterProcessor.name);
constructor(
private readonly quoteWriterService: QuoteWriterService,
private readonly commentWriterService: CommentWriterService,
private readonly xreader: XReaderService,
@InjectBot() private readonly bot: Telegraf<Context>,
//@InjectQueue('comment_writer_completed_queue') private readonly aiCommentWriteCompletedQueue: Queue,
) {
super();
}
async process(job: Job, token: string | undefined): Promise<any> {
const {
title,
summary,
style,
url,
quoteType,
quoteText,
language,
tone,
agle,
comtext,
telegramChatId,
} = job.data;
const topic = summary || title;
let pgPostCreateDto!: PostCreateInput;
console.log(`CommentWriterProcessor_processing_${job.name}`);
switch (job.name) {
case 'generate_comment_twitter': {
const xpost = await this.xreader.readXPost(url);
const dto: GenerateCommentDto = {
originalPost: xpost.text,
language
}
// aiWriterResult={
// comment: cleaned,
// tokensUsed: res.tokensUsed,
// model: res.model,
// language: dto.language,
// }
// const aiWriterResult = {
// comment: "Greet",
// url
// }
const aiWriterResult = await this.commentWriterService.generateComment(dto);
this.logger.log({aiWriterResult});
// await this.aiCommentWriteCompletedQueue.add('generate_comment_completed', {
// //id: post.id,
// name: 'generate_comment_completed',
// needConfirm: 1,
// content: aiWriterResult.comment,
// url: url,
// }, {attempts: 1, backoff: 5000, removeOnComplete: true,});
await this.bot.telegram.sendMessage(telegramChatId, `
Đã viết reply xong ...\nmodel: ${aiWriterResult.model} - tokenUsed: ${aiWriterResult.tokensUsed}`);
// const _url = url.indexOf('?s=20') > -1 ? url : `${url}?s=20`;
// console.log({_url});
//tìm xem bài này có phải của tôi không
const X_USERS = process.env.TWITTER_USERNAMES!.split(',');
console.log({X_USERS});
const isMyPost = url.indexOf(process.env.TWITTER_USERNAMES) > -1
//await this.bot.telegram.sendMessage(telegramChatId || adminChatId, isMyPost ? 'Đây là bài của bạn, có thế gửi' : 'Có thế không gửi được')
await this.bot.telegram.sendMessage(telegramChatId,
`${aiWriterResult.comment}`,
{
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
...X_USERS.map((xuser) => {
return [{
text: `↗️X ${xuser}`,
callback_data: `publish-reply-twitter1_${xpost.tweetId}_${xuser}`
}];
}),
[
{text: "🗑️ Hủy bài", callback_data: `delete-reply_${xpost.tweetId}`}
]
]
}
});
// pgPostCreateDto = {
// title: aiWriterResult.topic,
// content: aiWriterResult.final,
// style: aiWriterResult.detectedStyle,
// status: 'pending',
// prompt: aiWriterResult.prompt,
// draft: aiWriterResult.draft,
// tokensUsed: aiWriterResult.tokensUsed,
// tone: aiWriterResult.detectedTone,
// model: aiWriterResult.model,
// }
break;
}
case 'generate_comment_as_text_twitter': {
const {
tweetId
} = job.data;
const dto: GenerateCommentDto = {
originalPost: comtext,
language,
angle: agle,
tone: tone
}
const X_USERS = process.env.TWITTER_USERNAMES!.split(',');
console.log({X_USERS});
const aiWriterResult = await this.commentWriterService.generateComment(dto);
this.logger.log({aiWriterResult});
const adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
await this.bot.telegram.sendMessage(telegramChatId || adminChatId, `
Đã viết reply xong ...\ntweetId=${tweetId}\nmodel: ${aiWriterResult.model} }
`);
await this.bot.telegram.sendMessage(telegramChatId || adminChatId,
`${aiWriterResult.comment}`,
{
reply_markup: {
inline_keyboard: [
...(!isEmpty(tweetId) ? X_USERS.map((xuser) => {
return [{
text: `↗️X ${xuser}`,
callback_data: `publish-reply-twitter_${tweetId}_${xuser}`
}];
}) : [
// {
// text: `Reply vào bài X`,
// callback_data: `publish-reply-twitter_${tweetId}_${xuser}`
// }
])
,
[
{text: "🗑️ Hủy bài", callback_data: `delete-reply_${tweetId}`}
]
]
}
});
// pgPostCreateDto = {
// title: aiWriterResult.topic,
// content: aiWriterResult.final,
// style: aiWriterResult.detectedStyle,
// status: 'pending',
// prompt: aiWriterResult.prompt,
// draft: aiWriterResult.draft,
// tokensUsed: aiWriterResult.tokensUsed,
// tone: aiWriterResult.detectedTone,
// model: aiWriterResult.model,
// }
break;
}
case 'generate_quote_twitter': {
this.logger.debug('===>generate_quote_twitter:', url);
const xpost = await this.xreader.readXPost(url);
const originalAuthor = `${xpost.author} ${xpost.handle}`;
const dto: GenerateQuoteDto = {
originalPost: xpost.text,
originalAuthor,
language,
quoteType,
tweetId: xpost.tweetId,
}
await this.onHandleAiGenerateQuote(
dto,
false,
telegramChatId,
);
// const aiWriterResult = await this.quoteWriterService.generateQuote(dto);
//
// this.logger.log({aiWriterResult});
// const adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
// await this.bot.telegram.sendMessage(adminChatId, `
// Đã viết quote xong ...\nmodel: ${aiWriterResult.model} - type: ${aiWriterResult.quoteType}
// `);
// const _url = url.indexOf('?s=') > -1 ? url : `${url}?s=20`;
//xoá url trong bài vì tốn 0,2$ cho bài có url
// let isSendSucceeded = true;
// const quoteCleanUrl = TextUtil.removeAllUrl(dto.originalPost)
// await this.bot.telegram.sendMessage(
// adminChatId,
// `${aiWriterResult.quote}\n\nQuote:"${quoteCleanUrl}\n\n${originalAuthor}"`,
// {
// // parse_mode: 'Markdown',
// reply_markup: {
// inline_keyboard: [
// [
// {text: "↗️X", callback_data: `publish-quote-twitter_${xpost.tweetId}`},
// {text: "🗑️ Hủy bài", callback_data: `delete-quote_${xpost.tweetId}`}
// ],
// ]
// }
// })
// .catch(error => {
// console.log('==> send message to telegram error:' + error.message);
// console.error(error);
// isSendSucceeded = false;
// });
break;
}
case 'generate_quote_twitter_as_text_input': {
const dto: GenerateQuoteDto = {
originalPost: quoteText,
originalAuthor: '',
language,
quoteType
}
await this.onHandleAiGenerateQuote(
dto,
false,
telegramChatId,
);
break;
}
}
return {
//id: post.id,
// content: postContent,
//image: imageSuggestion,
status: 'ready_to_post'
};
}
@OnWorkerEvent('completed')
async onCompleted(job: Job<any>) {
console.log('CommentWriterProcessor_completed');
const adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
const postId = job.returnvalue.id;
if (postId === 0) {
const topic = job.returnvalue.topic;
await this.bot.telegram.sendMessage(adminChatId, `Lỗi viết bài: ${topic}`);
} else {
//return job.returnvalue.topic;
}
}
private async onHandleAiGenerateQuote(
dto: GenerateQuoteDto,
isAttachQuote = true,
telegramChatId: number = 0
) {
const aiWriterResult = await this.quoteWriterService.generateQuote(dto);
this.logger.log({aiWriterResult});
const sendId = telegramChatId || process.env.TELEGRAM_ADMIN_ID || 0;
await this.bot.telegram.sendMessage(sendId, `
Đã viết quote xong ...\nmodel: ${aiWriterResult.model} - type: ${aiWriterResult.quoteType}
`);
//
let finalQuote = aiWriterResult.quote;
if (isAttachQuote) {
finalQuote += `\n\nQuote:"${dto.originalPost}\n\n${dto.originalAuthor}`
}
//xoá url trong bài vì tốn 0,2$ cho bài có url
const finalQuoteCleanUrl = TextUtil.removeAllUrl(finalQuote)
const X_USERS = process.env.TWITTER_USERNAMES!.split(',');
await this.bot.telegram.sendMessage(
sendId,
finalQuoteCleanUrl,
{
// parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
...X_USERS.map((xuser) => {
return [{
text: `↗️X ${xuser}`,
callback_data: `publish-quote-twitter_${dto.tweetId}_${xuser}`
}];
}),
[
{text: "🗑️ Hủy bài", callback_data: `delete-quote_${dto.tweetId}`}
],
]
}
})
.catch(error => {
console.log('==> send message to telegram error:' + error.message);
console.error(error);
});
}
}
@@ -0,0 +1,40 @@
// config/platform-limits.ts
import { Platform } from '../enum/platform.enum';
import { AccountTier } from '../enum/account-tier.enum';
import { PostLength } from '../enum/post-length.enum';
export interface LengthRange {
min: number;
max: number;
sweet: number; // target tối ưu cho engagement
}
/**
* Hard limits theo platform + tier (characters)
*/
export const PLATFORM_LIMITS: Record<Platform, Record<AccountTier, number>> = {
[Platform.X]: {
[AccountTier.FREE]: 280,
[AccountTier.PREMIUM]: 25000,
[AccountTier.PREMIUM_PLUS]: 25000,
[AccountTier.VERIFIED_ORG]: 25000,
},
[Platform.FACEBOOK]: {
[AccountTier.FREE]: 63206,
[AccountTier.PREMIUM]: 63206,
[AccountTier.PREMIUM_PLUS]: 63206,
[AccountTier.VERIFIED_ORG]: 63206,
},
};
/**
* Sweet spot ranges theo PostLength.
* Dựa trên data engagement thực tế của X 2025-2026.
*/
export const LENGTH_RANGES: Record<PostLength, LengthRange> = {
[PostLength.SHORT]: { min: 180, max: 280, sweet: 210 },
[PostLength.MEDIUM]: { min: 200, max: 500, sweet: 320 },
[PostLength.LONG]: { min: 400, max: 1200, sweet: 600 },
[PostLength.EXTENDED]: { min: 1500, max: 3000, sweet: 2200 },
[PostLength.ARTICLE]: { min: 3000, max: 8000, sweet: 5000 },
};
@@ -0,0 +1,40 @@
// content-writer.controller.ts
import {Body, Controller, Get, NotFoundException, Post, Query} from '@nestjs/common';
import {ContentWriterService} from './content-writer.service';
import {CommentWriterService} from './services/comment-writer.service';
import {GenerateContentDto} from './dto/generate-content.dto';
import {GenerateCommentDto} from './dto/generate-comment.dto';
import {IAIGrokProvider} from "./interfaces/ai-provider.interface";
@Controller('content-writer')
export class ContentWriterController {
constructor(
private readonly writerService: ContentWriterService,
private readonly commentService: CommentWriterService,
) {
}
@Get('grok/test')
async grokWrite(@Query('q') q: string) {
const provider = await this.writerService.getGrokAI() as IAIGrokProvider;
if (!q) {
throw new NotFoundException('Not Found querystring.');
}
return provider.enrichXContext(q);
}
@Post('generate')
generate(@Body() dto: GenerateContentDto) {
return this.writerService.generate(dto);
}
@Post('comment')
generateComment(@Body() dto: GenerateCommentDto) {
return this.commentService.generateComment(dto);
}
@Post('comment/variants')
generateCommentVariants(@Body() dto: GenerateCommentDto) {
// return this.commentService.generateVariants(dto, 3);
}
}
@@ -0,0 +1,57 @@
import {Global, Module} from '@nestjs/common';
import {AIService} from "../../shared/ai.service";
import {ContentWriterProcessor} from "./content.writer.processor";
import {PgPostService} from "../../shared/pg.post.service";
import {BullModule} from "@nestjs/bullmq";
import {PublishPageService} from "../social-api/publish.page.service";
import {SocialModule} from "../social-api/social.module";
import {ContentWriterController} from "./content-writer.controller";
import {AIProviderFactory} from "./providers/ai-provider.factory";
import {ContentWriterService} from "./content-writer.service";
import {StyleDetectorService} from "./services/style-detector.service";
import {PromptBuilderService} from "./services/prompt-builder.service";
import {ReviewerService} from "./services/reviewer.service";
import {OpenAIProvider} from "./providers/openai.provider";
import {DeepSeekProvider} from "./providers/deepseek.provider";
import {CommentWriterService} from "./services/comment-writer.service";
import {GrokProvider} from "./providers/grok.provider";
import {ProviderRouterService} from "./services/provider-router.service";
import {CommentWriterProcessor} from "./comment-writer.processor";
import {XReaderService} from "../x-reader/x-reader.service";
import {LengthStrategyService} from "./services/length-strategy.service";
import {QuoteWriterService} from "./services/quote-writer.service";
import {SqsPostService} from "../sqs-module/sqs.post.service";
@Module({
imports: [
BullModule.registerQueue(
{name: 'content_writer_completed_queue'},// Hàng đợi cho AI-C
),
SocialModule
],
controllers: [ContentWriterController],
providers: [
AIService,
PgPostService,
ContentWriterProcessor,
ContentWriterService,
CommentWriterService,
StyleDetectorService,
PromptBuilderService,
ReviewerService,
OpenAIProvider,
DeepSeekProvider,
GrokProvider,
AIProviderFactory,
ProviderRouterService,
CommentWriterProcessor,
XReaderService,
LengthStrategyService,
QuoteWriterService,
SqsPostService
],
exports: [GrokProvider, ContentWriterService],
})
export class ContentWriterModule {
}
@@ -0,0 +1,167 @@
// content-writer.service.ts
import {BadRequestException, Injectable, Logger} from '@nestjs/common';
import {GenerateContentDto} from './dto/generate-content.dto';
import {ContentResponseDto} from './dto/content-response.dto';
import {StyleDetectorService} from './services/style-detector.service';
import {PromptBuilderService} from './services/prompt-builder.service';
import {ReviewerService} from './services/reviewer.service';
import {AIProviderFactory, ProviderName} from './providers/ai-provider.factory';
import {Platform} from "./enum/platform.enum";
import {ProviderRouterService} from "./services/provider-router.service";
import {Language} from "../../common/interfaces/language.prompt.interface";
import {WriterPromptParams} from "./interfaces/writer-prompt-params.interface";
import {AccountTier} from "./enum/account-tier.enum";
import {ConfigService} from "@nestjs/config";
import {LengthStrategyService} from "./services/length-strategy.service";
import {calculateTokenBudget} from "../../common/utils/token-calculator";
@Injectable()
export class ContentWriterService {
private readonly logger = new Logger(ContentWriterService.name);
constructor(
private detector: StyleDetectorService,
private promptBuilder: PromptBuilderService,
private reviewer: ReviewerService,
private factory: AIProviderFactory,
private router: ProviderRouterService,
private lengthStrategy: LengthStrategyService, // 👈 mới
private configService: ConfigService,
) {
}
async getGrokAI() {
return this.factory.get('grok');
}
async generate(
dto: GenerateContentDto,
isForceManualProvider: boolean = false,
writerProvider: ProviderName = 'openai',
reviewerProvider: ProviderName = 'deepseek',
): Promise<ContentResponseDto> {
// 1. Detect style/tone nếu user không truyền (0 token)
const style = dto.style ?? this.detector.detectStyle(dto.topic);
const tone = dto.tone ?? this.detector.detectTone(dto.topic);
const platform = dto.platform ? dto.platform : Platform.X;
const language = (dto.language ?? 'en') as Language;
// Default tier từ env nếu user không pass
const tier = dto.accountTier ?? this.configService.get<AccountTier>(
'X_ACCOUNT_TIER',
AccountTier.PREMIUM,
);
// 📏 Decide length strategy
const lengthDecision = this.lengthStrategy.decide({
platform: dto.platform || Platform.X,
tier,
style,
tone,
requestedLength: dto.postLength,
});
this.logger.debug(`>>> style:${style} - tone:${tone} - pf:${platform} -lang:${language} - tier:${tier}`);
this.logger.log(`Length: ${lengthDecision.reason}`);
// 💰 Token budget
const budget = calculateTokenBudget(lengthDecision.range, language);
this.logger.log(`Budget: ${budget.minChars}-${budget.maxChars} chars, ${budget.maxTokens} tokens`);
// 🧭 Smart routing
const decision = this.router.route({
language,
contentType: 'post',
style,
tone,
});
this.logger.log(`ContentWriterService => Routing: ${decision.reason} - writer:${decision.writer} - reviewer:${decision.reviewer}`);
const ctx: WriterPromptParams = {
topic: dto.topic,
platform,
style,
tone,
language,
extraInstructions: dto.extraInstructions,
postLength: lengthDecision.postLength, // 👈 pass xuống
lengthRange: lengthDecision.range,
};
// 🌐 Optional: Grok enriches X context (chỉ dùng cho EN breaking)
let enrichedTopic = dto.topic;
let enrichmentTokens = 0;
if (dto.useXEnrichment || decision.useXEnrichment) {
this.logger.log(`==> Prepare X-AI enrich topic: ${enrichedTopic}`);
try {
const grok = this.factory.getGrok();
const xContext = await grok.enrichXContext(dto.topic);
enrichedTopic = `${dto.topic}\n\n[X Context]:\n${xContext}`;
this.logger.log(`===> X enrichment: ${xContext}`);
} catch (e) {
this.logger.warn('===> X enrichment failed, proceeding without', e);
}
}
// ✍️ Writer
console.log('==> ContentWriterService_write: ');
const provider = this.factory.get(isForceManualProvider ? writerProvider : decision.writer);
const messages = this.promptBuilder.buildWriterMessages({
...ctx,
topic: enrichedTopic
});
// console.debug('prompt message:==>', messages);
const draft = await provider.complete(messages, {
temperature: 0.75,
maxTokens: budget.maxTokens,
});
this.logger.debug(`===> ${draft.model} đã viết xong!`);
let final = draft.content;
let totalTokens = draft.tokensUsed + enrichmentTokens;
let reviewNotes: string | undefined;
let modelUsed = draft.model;
// 🔍 Reviewer
if (dto.enableReview) {
this.logger.debug(`===> chuẩn bị review`);
try {
const reviewed = await this.reviewer.review(
draft.content,
ctx,
isForceManualProvider ? reviewerProvider : decision.reviewer,
dto.topic,
Math.ceil(budget.maxTokens * 1.3),
);
final = reviewed.improved;
reviewNotes = reviewed.notes;
totalTokens += reviewed.tokensUsed;
modelUsed = `${draft.model} + ${reviewed.model}`;
this.logger.debug(`===> ${reviewed.model} đã review xong!`);
} catch (err) {
this.logger.error('Review failed, fallback to draft', err);
}
}
// 🛡️ Hard cap check
if (final.length > lengthDecision.hardLimit) {
this.logger.warn(`==> Output exceeds hard limit (${final.length} > ${lengthDecision.hardLimit})`);
// final = final.substring(0, lengthDecision.hardLimit);
}
// if ([ContentStyle.FINANCE, ContentStyle.CRYPTO].includes(ctx.style)) {
// final += `\n ⚠️ This content is for informational purposes only, not financial advice. DYOR. \n`
// }
return {
topic: dto.topic,
final,
draft: dto.enableReview ? draft.content : undefined,
reviewNotes,
detectedStyle: style,
detectedTone: tone,
tokensUsed: totalTokens,
model: modelUsed,
prompt: JSON.stringify(messages),
};
}
}
@@ -0,0 +1,211 @@
// src/modules/writer/facebook.processor.ts
import {InjectQueue, OnWorkerEvent, Processor, WorkerHost} from '@nestjs/bullmq';
import {Job, Queue} from 'bullmq';
import {AIService} from '../../shared/ai.service';
import {PgPostService} from "../../shared/pg.post.service";
import {isEmpty} from "lodash";
import {ContentWriterService} from "./content-writer.service";
import {GenerateContentDto} from "./dto/generate-content.dto";
import {PostCreateInput} from "../../generated/prisma/models/Post";
import {InjectBot} from "nestjs-telegraf";
import {Context, Telegraf} from "telegraf";
import {StyleDetectorService} from "./services/style-detector.service";
import {ContentStyle} from "./enum/style.enum";
import {ContentTone} from "./enum/tone.enum";
import {XStrategy} from "../social-api/x-router.service";
import {SqsPostService} from "../sqs-module/sqs.post.service";
@Processor('content_writer_queue')
export class ContentWriterProcessor extends WorkerHost {
constructor(
private aiService: AIService,
private readonly writerService: ContentWriterService,
private readonly styleDetectorService: StyleDetectorService,
private readonly pgPostService: PgPostService,
@InjectBot() private readonly bot: Telegraf<Context>,
// private readonly managerService: ManagerService,
@InjectQueue('content_writer_completed_queue') private readonly fbContentCompletedQueue: Queue,
private readonly sqsPostService: SqsPostService,
) {
super();
}
async process(job: Job<any>): Promise<any> {
const {title, summary, style, language, tone, enableReview, postLength, autoPublish, telegramChatId} = job.data;
const topic = summary || title;
let pgPostCreateDto!: PostCreateInput;
console.log(`ContentWriterProcessor_processing_${job.name}`);
let isAutoPublish = false;
switch (job.name) {
case 'generate_post_ver2': {
const dto: GenerateContentDto = {
topic,
enableReview,
language,
tone,
postLength
}
const aiWriterResult = await this.writerService.generate(dto, false, 'openai', 'deepseek');
// console.log({aiWriterResult});
pgPostCreateDto = {
title: aiWriterResult.topic,
content: aiWriterResult.final,
style: aiWriterResult.detectedStyle,
status: 'pending',
prompt: aiWriterResult.prompt,
draft: aiWriterResult.draft,
tokensUsed: aiWriterResult.tokensUsed,
tone: aiWriterResult.detectedTone,
model: aiWriterResult.model,
}
break;
}
case 'generate_post_ver1': {
const aiWriterResult = await this.aiService.generateContentViaDeepseek(
summary || title,
style,
language
);
pgPostCreateDto = {
title: aiWriterResult.topic,
content: aiWriterResult.final,
style: aiWriterResult.detectedStyle,
status: 'pending',
prompt: aiWriterResult.prompt,
tone: aiWriterResult.detectedTone,
model: aiWriterResult.model,
}
break;
}
case 'generate_post_telegram': {
isAutoPublish = true;
const topicLen = topic.length;
console.log({topicLen});
const dto: GenerateContentDto = {
topic,
enableReview,
language: this.styleDetectorService.detectLanguageFromTelegramAutoContent(topic),
tone: topicLen < 150 ? ContentTone.URGENT : tone,
postLength,
style: topicLen < 150 ? ContentStyle.BREAKING_NEWS : style
}
const aiWriterResult = await this.writerService.generate(dto, false);
// console.log({aiWriterResult});
pgPostCreateDto = {
title: aiWriterResult.topic,
content: aiWriterResult.final,
style: aiWriterResult.detectedStyle,
status: 'pending',
prompt: aiWriterResult.prompt,
draft: aiWriterResult.draft,
tokensUsed: aiWriterResult.tokensUsed,
tone: aiWriterResult.detectedTone,
model: aiWriterResult.model,
}
break;
}
case 'generate_post_telegram_batch': {
isAutoPublish = true;
// return ;
const {messages} = job.data;
let compose_topic = `Viết 1 thread Twitter/X ngắn gọn tổng hợp ${messages.length} tin sau:\n`
compose_topic += messages.map(m => '- ' + m.text).join('\n');
const topicLen = compose_topic.length;
console.log({compose_topic});
const dto: GenerateContentDto = {
topic: compose_topic,
enableReview: false,
language: this.styleDetectorService.detectLanguageFromTelegramAutoContent(compose_topic),
tone: ContentTone.CASUAL,
postLength,
style: ContentStyle.BREAKING_NEWS
}
const aiWriterResult = await this.writerService.generate(dto, false);
// console.log({aiWriterResult});
pgPostCreateDto = {
title: aiWriterResult.topic,
content: aiWriterResult.final,
style: aiWriterResult.detectedStyle,
status: 'pending',
prompt: aiWriterResult.prompt,
draft: aiWriterResult.draft,
tokensUsed: aiWriterResult.tokensUsed,
tone: aiWriterResult.detectedTone,
model: aiWriterResult.model,
}
break;
}
default: {
break;
}
}
console.log({pgPostCreateDto});
if (isEmpty(pgPostCreateDto)) {
return {
id: 0,
topic,
'status': 'error',
}
}
//
// 2. Giả lập việc tạo ảnh hoặc tìm ảnh minh họa (có thể tích hợp API sau)
// const imageSuggestion = `https://image-service.com/search?q=${keywords[0]}`;
//
// let finalContent = aiWriterResult.content;
const post = await this.pgPostService.createPost(pgPostCreateDto);
if (!isAutoPublish) {
await this.fbContentCompletedQueue.add('generate_post_completed', {
id: post.id,
name: 'generate_post_completed',
needConfirm: 1,
content: pgPostCreateDto.content,
autoPublish: false,
telegramChatId,
xSubmitProvider: post.id % 2 === 0 ? XStrategy.BROWSER_ONLY : XStrategy.API_ONLY, //cứ 3post api, có 1 post browser
}, {attempts: 1, backoff: 5000, removeOnComplete: true,});
} else {
await this.sqsPostService.postFlashKaze({
id: post.id,
name: 'generate_post_completed',
type: 'X_POSTER_TWEET',
needConfirm: 1,
content: pgPostCreateDto.content,
autoPublish,
telegramChatId,
publishTo: ['x', 'fb'],
xSubmitProvider: post.id % 3 === 0 ? XStrategy.API_FIRST : XStrategy.BROWSER_ONLY, //cứ 3post api, có 1 post browser
})
}
return {
id: post.id,
// content: postContent,
//image: imageSuggestion,
status: 'ready_to_post',
telegramChatId,
};
}
@OnWorkerEvent('completed')
async onCompleted(job: Job<any>) {
console.log('ContentWriterProcessor_completed');
const adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
const postId = job.returnvalue.id;
const telegramChatId = job.returnvalue.telegramChatId;
if (postId === 0) {
const topic = job.returnvalue.topic;
await this.bot.telegram.sendMessage(telegramChatId || adminChatId, `Lỗi viết bài: ${topic}`);
} else {
//return job.returnvalue.topic;
}
}
}
@@ -0,0 +1,12 @@
// dto/content-response.dto.ts
export class ContentResponseDto {
topic: string;
final: string;
draft?: string;
reviewNotes?: string;
detectedStyle: string;
detectedTone: string;
tokensUsed: number;
model: string;
prompt: string;
}
@@ -0,0 +1,25 @@
// dto/generate-comment.dto.ts
import { IsEnum, IsString, IsOptional, MaxLength } from 'class-validator';
import { ContentTone } from '../enum/tone.enum';
import * as languagePromptInterface from "../../../common/interfaces/language.prompt.interface";
export class GenerateCommentDto {
@IsString()
@MaxLength(3000)
originalPost: string; // nội dung bài X gốc
@IsOptional()
@IsString()
angle?: string; // góc nhìn muốn comment: "agree", "challenge", "add-info", "funny"
@IsString()
language: languagePromptInterface.Language;
@IsOptional()
@IsEnum(ContentTone)
tone?: ContentTone;
@IsOptional()
@IsString()
persona?: string; // "crypto trader", "news analyst"...
}
@@ -0,0 +1,48 @@
// dto/generate-content.dto.ts
import { IsEnum, IsOptional, IsString, IsBoolean, MaxLength } from 'class-validator';
import {ContentStyle} from "../enum/style.enum";
import {Platform} from "../enum/platform.enum";
import {ContentTone} from "../enum/tone.enum";
import {AccountTier} from "../enum/account-tier.enum";
import {PostLength} from "../enum/post-length.enum";
export class GenerateContentDto {
@IsString()
@MaxLength(5000)
topic: string; // chủ đề / input thô từ user
@IsEnum(Platform)
platform?: Platform;
@IsOptional()
@IsEnum(AccountTier)
accountTier?: AccountTier; // 👈 mới
@IsOptional()
@IsEnum(PostLength)
postLength?: PostLength; // 👈 user override length
@IsOptional()
@IsEnum(ContentStyle)
style?: ContentStyle; // nếu không truyền -> auto-detect
@IsOptional()
@IsEnum(ContentTone)
tone?: ContentTone;
@IsOptional()
@IsString()
language?: string; // 'vi' | 'en' ... default 'en'
@IsOptional()
@IsBoolean()
enableReview?: boolean; // bật AI reviewer
@IsOptional()
@IsBoolean()
useXEnrichment?: boolean; // bật X Enrichment reviewer
@IsOptional()
@IsString()
extraInstructions?: string;
}
@@ -0,0 +1,50 @@
// dto/generate-quote.dto.ts
import { IsEnum, IsString, IsOptional, MaxLength, IsBoolean } from 'class-validator';
import { QuoteType } from '../enum/quote-type.enum';
import { ContentTone } from '../enum/tone.enum';
import { PostLength } from '../enum/post-length.enum';
import { AccountTier } from '../enum/account-tier.enum';
export class GenerateQuoteDto {
@IsString()
@MaxLength(5000)
originalPost: string; // Tweet gốc bạn muốn quote
@IsOptional()
@IsString()
originalAuthor?: string; // username của OP (optional, giúp AI biết tone)
@IsOptional()
@IsEnum(QuoteType)
quoteType?: QuoteType; // Nếu không truyền -> AI tự chọn best fit
@IsString()
language: 'en' | 'vi' | 'ja' | 'ko';
@IsOptional()
@IsEnum(ContentTone)
tone?: ContentTone;
@IsOptional()
@IsEnum(PostLength)
postLength?: PostLength; // short/medium/long (Premium)
@IsOptional()
@IsEnum(AccountTier)
accountTier?: AccountTier;
@IsOptional()
@IsString()
persona?: string; // "crypto analyst", "tech journalist"...
@IsOptional()
@IsString()
yourAngle?: string; // Góc nhìn riêng của bạn muốn express
@IsOptional()
@IsBoolean()
enableReview?: boolean;
@IsOptional()
tweetId?: number;
}
@@ -0,0 +1,6 @@
export enum AccountTier {
FREE = 'free',
PREMIUM = 'premium', // $8/month
PREMIUM_PLUS = 'premium_plus', // $16/month
VERIFIED_ORG = 'verified_org',
}
@@ -0,0 +1,41 @@
export enum AngleEnum {
AGREE = 'agree',
CHALLENGE = 'challenge',
ADD_INFO = 'add_info',
FUNNY = 'funny',
QUESTION = 'question',
RELATE = 'relate',
DEVIL_ADVOCATE = 'devil_advocate',
EXPAND = 'expand',
VALIDATE = 'validate',
CTA = 'cta'
}
export const ANGLE_HINTS: Record<AngleEnum, string> = {
[AngleEnum.AGREE]: 'I agree and would like to add a small point to support my argument.',
[AngleEnum.CHALLENGE]: 'Disagree or add further nuance',
[AngleEnum.ADD_INFO]: 'additional useful related information',
[AngleEnum.FUNNY]: 'Witty, mildly humorous, and not offensive.',
[AngleEnum.QUESTION]: 'Ask a smart follow-up question',
[AngleEnum.RELATE]: 'Share a personal experience or feeling that mirrors the original post',
[AngleEnum.DEVIL_ADVOCATE]: `Play devil's advocate. Present the opposite view fairly without being hostile`,
[AngleEnum.EXPAND]: 'Take one point from the post and zoom in deeper with more nuance',
[AngleEnum.VALIDATE]: `Affirm the post's point with evidence or strong agreement, boost credibility`,
[AngleEnum.CTA]: 'End with a soft call-to-action: ask others to share their view'
}
export const ANGLE_HINTS_TELEGRAM_BUTTON: Record<AngleEnum, Object> = {
[AngleEnum.AGREE]: {text: 'Đồng ý'},
[AngleEnum.CHALLENGE]: {text: 'Không đồng ý'},
[AngleEnum.ADD_INFO]: {text: 'thêm thông tin liên quan hữu ích'},
[AngleEnum.FUNNY]: {text: 'Hóm hỉnh, hài hước nhẹ nhàng, không gây khó chịu'},
[AngleEnum.QUESTION]: {text: 'Đặt một câu hỏi tiếp theo thông minh'},
[AngleEnum.RELATE]: {text: 'Chia sẻ một trải nghiệm \n hoặc cảm xúc cá nhân tương tự như bài đăng gốc.'},
[AngleEnum.DEVIL_ADVOCATE]: {
text: `Hãy đóng vai trò người phản biện. \n Trình bày quan điểm trái chiều một cách công bằng mà không tỏ ra thù địch.`
},
[AngleEnum.EXPAND]: {text: 'expand-Chọn một điểm từ bài viết và phân tích sâu hơn với nhiều sắc thái khác nhau.'},
[AngleEnum.VALIDATE]: {
text: `validate-Khẳng định luận điểm của bài đăng bằng bằng chứng hoặc sự đồng tình mạnh mẽ, tăng cường độ tin cậy.`
},
[AngleEnum.CTA]: {text: 'cta-Kết thúc bằng lời kêu gọi hành động nhẹ nhàng'}
}
@@ -0,0 +1,6 @@
// enums/platform.enum.ts
export enum Platform {
X = 'x',
FACEBOOK = 'facebook',
}
@@ -0,0 +1,7 @@
export enum PostLength {
SHORT = 'short', // 200-280 chars - viral/breaking
MEDIUM = 'medium', // 280-500 chars - hot take
LONG = 'long', // 400-1200 chars - analysis (Premium sweet spot)
EXTENDED = 'extended', // 1500-3000 chars - deep dive
ARTICLE = 'article', // 3000-10000 chars - full essay
}
@@ -0,0 +1,15 @@
// enums/quote-type.enum.ts
export enum QuoteType {
AGREE_AMPLIFY = 'agree_amplify', // Đồng ý + thêm insight
DISAGREE = 'disagree', // Phản biện có lý
ADD_CONTEXT = 'add_context', // Bổ sung context
REFRAME = 'reframe', // Nhìn góc khác
BUILD_ON = 'build_on', // Mở rộng ý
HIGHLIGHT = 'highlight', // Nhấn mạnh key point
ROAST = 'roast', // Chỉ trích sắc
HOT_TAKE = 'hot_take', // Opinion mạnh
QUESTION = 'question', // Đặt câu hỏi
SUMMARIZE = 'summarize', // TL;DR
PERSONAL_STORY = 'personal_story', // TL;DR
CONNECT_DOTS = 'connect_dot', // TL;DR
}
@@ -0,0 +1,15 @@
// enums/style.enum.ts
export enum ContentStyle {
CRYPTO = 'crypto',
BREAKING_NEWS = 'breaking_news',
TECH = 'tech',
FINANCE = 'finance',
LIFESTYLE = 'lifestyle',
MEME = 'meme',
EDUCATIONAL = 'educational',
GENERAL = 'general',
OPINION = 'opinion',
STORYTELLING = 'storytelling',
THREAD = 'thread',
}
@@ -0,0 +1,11 @@
export enum ContentTone {
PROFESSIONAL = 'professional',
CASUAL = 'casual',
HYPE = 'hype',
URGENT = 'urgent',
HUMOROUS = 'humorous',
INFORMATIVE = 'informative',
EMPATHETIC = 'empathetic',
PROVOCATIVE = 'provocative',
AUTHORITATIVE = 'authoritative',
}
@@ -0,0 +1,27 @@
// interfaces/ai-provider.interface.ts
import {Context} from "telegraf";
export interface AIMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface AICompletionOptions {
model?: string;
temperature?: number;
maxTokens?: number;
}
export interface AICompletionResult {
content: string;
tokensUsed: number;
model: string;
}
export interface IAIProvider {
readonly name: string;
complete(messages: AIMessage[], options?: AICompletionOptions): Promise<AICompletionResult>;
}
export interface IAIGrokProvider extends IAIProvider {
enrichXContext(topic: string): Promise<string>
}
@@ -0,0 +1,15 @@
// interfaces/content-context.interface.ts
import {Platform} from "../enum/platform.enum";
import {ContentStyle} from "../enum/style.enum";
import {ContentTone} from "../enum/tone.enum";
import {Language} from "../../../common/interfaces/language.prompt.interface";
export interface ContentContext {
topic: string;
platform: Platform;
style: ContentStyle;
tone: ContentTone;
language: Language;
extraInstructions?: string;
}
@@ -0,0 +1,18 @@
import {Platform} from "../enum/platform.enum";
import {ContentStyle} from "../enum/style.enum";
import {ContentTone} from "../enum/tone.enum";
import {Language} from "../../../common/interfaces/language.prompt.interface";
import {LengthRange} from "../config/platform-limits";
import {PostLength} from "../enum/post-length.enum";
export interface WriterPromptParams {
topic: string;
platform: Platform;
style: ContentStyle;
tone: ContentTone;
language: Language;
postLength: PostLength; // 👈 mới
lengthRange: LengthRange;
extraInstructions?: string;
}
@@ -0,0 +1,5 @@
// prompts/breaking-news.templates.ts
// ============================================================
// BREAKING NEWS — native templates per language
// ============================================================
@@ -0,0 +1,58 @@
// prompts/comment.templates.ts
import {Language} from "../../../common/interfaces/language.prompt.interface";
import {calculateLengthBudget} from "../../../common/utils/token-calculator";
import {Platform} from "../enum/platform.enum";
import {ANGLE_HINTS} from "../enum/angle.enum";
export const COMMENT_SYSTEM_PROMPTS = {
en: 'You write casual, human replies on X. Sound like a real person, NOT AI. No hashtags unless natural.',
vi: 'Bạn viết reply tự nhiên trên X như người thật. Không giống AI. Không hashtag nếu không cần.',
vn: 'Bạn viết reply tự nhiên trên X như người thật. Không giống AI. Không hashtag nếu không cần.',
cn: '像真实用户一样在X上自然地回复,不要显得像AI生成的。除非必要,否则不要使用话题标签(#)。',
ja: 'Xで人間らしい返信を書きます。AI臭くない。自然な口語。ハッシュタグは不要な限り使わない。',
jp: 'Xで人間らしい返信を書きます。AI臭くない。自然な口語。ハッシュタグは不要な限り使わない。',
ko: 'X에서 사람처럼 자연스럽게 답글을 씁니다. AI 같지 않게. 해시태그는 필요한 경우만.',
kr: 'X에서 사람처럼 자연스럽게 답글을 씁니다. AI 같지 않게. 해시태그는 필요한 경우만.',
};
// export const COMMENT_AGLE_TELEGRAM_BUTTONS = {
// agree: {text: 'Đồng ý và bổ sung thêm một điểm hỗ trợ nhỏ.'},
// challenge: {text: 'Lịch sự bày tỏ sự KHÔNG ĐỒNG Ý hoặc bổ sung thêm sắc thái.'},
// 'add-info': {text: 'Thêm thông tin liên quan hữu ích'},
// funny: {text: 'Hài hước dí dỏm, nhẹ nhàng, không gây khó chịu.'},
// question: {text: 'Hãy đặt một câu hỏi tiếp theo thông minh.'},
// }
export function buildCommentPrompt(params: {
originalPost: string;
angle?: string;
language: Language;
persona?: string;
tone?: string;
}): { system: string; user: string } {
// const angleHints: Record<string, string> = {
// agree: 'agree:Đồng ý và bổ sung thêm một luận điểm nhỏ để hỗ trợ',
// challenge: 'challenge:Không đồng ý hoặc bổ sung thêm sắc thái',
// 'add-info': 'add-info:Thêm thông tin liên quan hữu ích',
// funny: 'funny:Hóm hỉnh, hài hước nhẹ nhàng, không gây khó chịu',
// question: 'question:Đặt một câu hỏi tiếp theo thông minh',
// };
const budget = calculateLengthBudget(Platform.X, params.language);
const user = [
`Original X post:\n"""${params.originalPost}"""`,
``,
`Write a reply target length: ${budget.minChars}-${budget.maxChars} characters:`,
`[Target Language: ${params.language}]
Rewrite strictly in ${params.language} only.`,
params.angle ? `- Angle: ${ANGLE_HINTS[params.angle] ?? params.angle}` : '- Angle: natural reaction',
params.persona ? `- Speak as: ${params.persona}` : '',
params.tone ? `- Tone: ${params.tone}` : '- Tone: casual, conversational',
`- Sound HUMAN, not AI. No "Great post!" openings.`,
`- No emoji spam. 0-1 emoji max.`,
`- Output ONLY the reply text.`,
].filter(Boolean).join('\n');
return {system: COMMENT_SYSTEM_PROMPTS[params.language], user};
}
@@ -0,0 +1,205 @@
// // prompts/edgy-tones.ts — UPDATE phần JP
//
// import {ContentTone} from "../enum/tone.enum";
//
// export const EDGY_TONE_SPECS: Record<ContentTone, ToneSpec> = {
// [ContentTone.SPICY]: {
// intensity: 2,
// description: {
// en: '...',
// vi: '...',
// // 👇 REFINED JP
// ja: [
// 'ストレートで歯に衣着せない。鋭いが冷静。',
// '軽い悪態OK:「は?」「いや無理」「マジで?」「草」',
// 'JPのX的に:堅い敬語を使わず、話し言葉ベース。改行を効かせる。',
// '「w」「草」を末尾に使ってOK(やりすぎ注意、1-2回まで)。',
// '感情的にキレるのではなく、淡々と切るイメージ。',
// ].join(' '),
// ko: '...',
// },
// examples: {
// en: ['...'],
// vi: ['...'],
// // 👇 REFINED JP — real JP X patterns
// ja: [
// 'いやこれ違うやろ\n\nデータちゃんと見た?普通に逆の話してるんやが',
// 'は?このチャートで強気とか草\n\nさすがに無理があるって',
// 'まあ気持ちは分かるけど、これは普通にナンピン地獄コースやで',
// ],
// ko: ['...'],
// },
// vocabulary: {
// en: '...',
// vi: '...',
// // 👇 REFINED JP
// ja: 'は?/いや/マジで/普通に/ガチで/草/w/無理/えぐい/やばい',
// ko: '...',
// },
// avoid: {
// en: '...',
// vi: '...',
// ja: [
// '丁寧な敬語禁止(「〜と思います」「〜でしょうか」NG)',
// '個人攻撃禁止(一般人ターゲットNG)',
// '差別語・脅迫NG',
// '「!」連発禁止',
// ].join(' / '),
// ko: '...',
// },
// },
//
// [ContentTone.AGGRESSIVE]: {
// intensity: 3,
// description: {
// en: '...',
// vi: '...',
// // 👇 REFINED JP
// ja: [
// '粗野で生々しい、主張をガチで叩く。バカな意見を嘲笑する。',
// '強めの悪態OK:「クソ」「ふざけんな」「アホか」「は?マジで言ってる?」',
// '関西弁ミックスもOK(「なんやねん」「あほちゃう」「言うてるやろ」)— ナチュラルなら。',
// '感情的になりすぎず、論破口調をベースに。',
// '攻撃対象:主張・意見・市場・公人の公的行為。私人NG。',
// ].join(' '),
// ko: '...',
// },
// examples: {
// en: ['...'],
// vi: ['...'],
// ja: [
// 'は?マジで言ってる?\n\nそのロジックでよく投稿ボタン押せたな、感心するわ',
// 'いや待って、これ今週見た中で一番アホな分析やんけ\n\n根拠ゼロで断言するの草',
// 'ふざけんなよ、3日前に同じこと言ったやろ。\n誰も聞かんかったから今こうなってるんやで',
// ],
// ko: ['...'],
// },
// vocabulary: {
// en: '...',
// vi: '...',
// ja: 'クソ/ふざけんな/アホか/は?/マジで/いい加減にしろ/寝言/なんやねん/養分/ピエロ',
// ko: '...',
// },
// avoid: {
// en: '...',
// vi: '...',
// ja: '差別語NG/脅迫NG/私人攻撃NG/本物の侮辱罪リスク回避(公的主張のみ叩く)',
// ko: '...',
// },
// },
//
// [ContentTone.PROFANE]: {
// intensity: 4,
// description: {
// en: '...',
// vi: '...',
// ja: [
// '荒っぽい、フィルター無し、悪態多め。熱くなったトレーダー・配信者風。',
// '激しい悪態OK:「クソが」「マジでクソ」「ふざけんなクソ」「は?クソが」',
// '感情がガチで出てる感じ。ただし支離滅裂にはしない。',
// 'JPのX的に:「www」連発、「草不可避」「クソ草」OK。',
// '対象:市場・主張・公人。私人NG。',
// ].join(' '),
// ko: '...',
// },
// examples: {
// en: ['...'],
// vi: ['...'],
// ja: [
// 'クソが、この相場マジで何なんだよw\n\n3日前にロング切られて、今度はショート狩られる。地獄かよ',
// 'いやマジでふざけんな、このプロジェクト\n\nリリース3回延期して、結局これ?クソ案件確定やん',
// 'は?クソが、また下げかよwww\n\nもう養分卒業したいんだが、神は俺を見捨てた',
// ],
// ko: ['...'],
// },
// vocabulary: {
// en: '...',
// vi: '...',
// ja: 'クソ/クソが/クソ案件/ふざけんな/地獄/養分/死んだ/逝った/www/草不可避',
// ko: '...',
// },
// avoid: {
// en: '...',
// vi: '...',
// ja: '差別語絶対NG/脅迫NG/私人特定NG/侮辱罪に該当する表現避ける',
// ko: '...',
// },
// },
//
// [ContentTone.INFLAMMATORY]: {
// intensity: 4,
// description: {
// en: '...',
// vi: '...',
// ja: [
// '強い反応を引き出す設計。物議を醸す断言。',
// '両極化する言葉。当てこすり。炎上を生むが擁護可能。',
// 'JPのX的に:断言系(「結論:〜」「答え:〜」)、逆張り、リスト形式が伸びる。',
// '「〜してる奴は〜」「〜と言ってる時点で〜」のような決めつけ構文OK。',
// '挑発的≠根拠なし。根拠は持つこと。',
// ].join(' '),
// ko: '...',
// },
// examples: {
// en: ['...'],
// vi: ['...'],
// ja: [
// '結論:2026年にまだ[X]ホールドしてる奴は、出口流動性です。\n\n認めたくないだろうけど、それが現実',
// 'これが物議を醸してる時点で、この界隈のレベルが分かる\n\n基本的なことを言ってるだけなのに',
// '「まだ早い」って言ってる人へ:\n\nもう遅いです。認めて次のサイクル準備した方が建設的',
// ],
// ko: ['...'],
// },
// vocabulary: {
// en: '...',
// vi: '...',
// ja: '結論/答え/現実/養分/出口流動性/中級者の壁/NPC/弱者男性/情弱/w',
// ko: '...',
// },
// avoid: {
// en: '...',
// vi: '...',
// ja: 'ヘイトスピーチNG/脅迫NG/差別語NG/挑発的でも法的に擁護可能な範囲で',
// ko: '...',
// },
// },
//
// [ContentTone.SAVAGE]: {
// intensity: 5,
// description: {
// en: '...',
// vi: '...',
// ja: [
// '残虐ロースト・モード。外科的かつ機知に富んだ粉砕。',
// '悪態OK、最大限の毒舌。面白い+残酷+賢いの三位一体。',
// 'JPのX的に:「こいつ本当に〜」「よく〜できたな」「保存した」「伝説」系構文。',
// '直接の罵倒より、淡々とした観察口調の方が刺さる(JP特有)。',
// '「お兄さん、」「お疲れさまでした」などの慇懃無礼OK。',
// '対象:主張・公人の公的行為。私人・弱者NG(パンチアップのみ)。',
// ].join(' '),
// ko: '...',
// },
// examples: {
// en: ['...'],
// vi: ['...'],
// ja: [
// 'こいつ本当にこれを投稿して送信ボタン押したのか\n\n複数回。胸を張って。すごい才能だ',
// '次の「妄想の極致」スレッド用に保存させていただきました\n\n貴重なサンプルありがとうございます',
// 'お兄さん、自信と正確性の比率がバグってますよ\n\n一回深呼吸してチャート見直すといいかも(優しさ)',
// ],
// ko: ['...'],
// },
// vocabulary: {
// en: '...',
// vi: '...',
// ja: 'お兄さん/こいつ/伝説/保存しました/お疲れさまでした/貴重なサンプル/才能/天才/中級者の壁',
// ko: '...',
// },
// avoid: {
// en: '...',
// vi: '...',
// ja: 'パンチアップのみ(公人・主張)/私人・弱者NG/差別語NG/脅迫NG/侮辱罪リスク回避',
// ko: '...',
// },
// },
// };
@@ -0,0 +1,115 @@
// prompts/jp-cultural-context.ts
/**
* JP X (Twitter) culture context để inject vào prompts.
* Tách riêng vì sẽ refine nhiều theo thời gian.
*/
export const JP_X_CULTURE = {
/**
* Đặc trưng JP X writing style.
*/
styleNotes: [
'日本のX文化: 短文・改行多め・絵文字控えめ(過剰だと逆効果)',
'「〜だわ」「〜やん」「〜やろ」など話し言葉OK、堅すぎる文体は避ける',
'「w」「草」「www」を文末に使うのは自然(やりすぎ注意)',
'「マジで」「ガチで」「普通に」は強調表現として頻出',
'改行を効果的に使う — 長文1段落より、短く区切る方が読まれる',
'英語表現の直訳は避ける(「This is huge」→「これはデカい」より「やばい」「えぐい」)',
].join('\n'),
/**
* Phrases AI thường viết → người Nhật KHÔNG bao giờ viết.
* Cực kỳ quan trọng — đây là dead giveaway của AI output.
*/
aiPhrasesAvoid: [
'❌ 「〜だと思います」連発(フォーマルすぎ)',
'❌ 「以下のような〜」「上記の〜」(書き言葉すぎ)',
'❌ 「いかがでしょうか」(営業文っぽい)',
'❌ 「重要なポイントは〜」(教科書的)',
'❌ 「素晴らしい投稿ですね」(おべっか・AI臭)',
'❌ 「私の意見では」「個人的には〜」を文頭に毎回(くどい)',
'❌ 結論で「まとめると〜」(説明文っぽい)',
'❌ 「皆さんはどう思いますか?」(典型的なAI締め)',
'❌ 過剰な「!」連発',
'❌ 「〜することができます」(「〜できる」で十分)',
'❌ 礼儀正しすぎる敬語(Xでは浮く)',
].join('\n'),
/**
* Natural JP X starters (theo tone).
*/
naturalStarters: {
casual: ['いやこれ', 'てかさ', 'これマジ', 'え、', 'ちょ、', 'なんか'],
spicy: ['いや無理', 'は?', 'おい', 'ちょっと待って', 'これ草'],
aggressive: ['は?マジで言ってる?', 'おい、', 'いやいや', 'なんやねん', '寝言は寝て言え'],
profane: ['くそが', 'ふざけんな', 'マジで草', 'いや無理だわ', 'なんやこれ'],
savage: ['よくこれ投稿できたな', 'こいつ本当に', '伝説の', '保存した', 'お兄さん、'],
professional: ['結論から言うと', '今回の件、', '事実として、', 'データを見る限り'],
informative: ['補足すると', '前提として', 'ポイントは', '実は'],
},
/**
* Natural JP X endings.
*/
naturalEndings: {
casual: ['知らんけど', 'まあそんな感じ', 'って思う', 'ってわけ'],
spicy: ['', '草', 'マジで', 'ほんま'],
aggressive: ['ふざけんな', 'いい加減にしろ', 'マジでないわ', '冷静になれ'],
savage: ['草', 'お疲れさまでした', '永久保存版', '伝説残した'],
professional: ['以上', 'ご参考まで', ''],
},
/**
* Net slang JP X dùng.
*/
netSlang: {
agree: ['それな', 'わかる', 'ほんそれ', 'ガチで', '同意'],
disagree: ['は?', 'いや違うやろ', 'ないない', 'それはちゃう'],
laugh: ['草', '', 'www', '草生える', '笑う'],
surprise: ['えぐい', 'やばい', 'マジか', '嘘やろ', 'ガチ?'],
intensifier: ['マジで', 'ガチで', '普通に', 'えぐいぐらい', '異次元'],
crypto: ['爆益', '退場', '握力', '養分', 'ガチホ', '損切り', 'ATH', 'ATL', 'ガチ勢'],
finance: ['含み益', '含み損', '気絶', 'ナンピン', '逃げろ'],
},
/**
* JP X engagement patterns — cái gì viral.
*/
engagementPatterns: [
'共感ポイント: 「あるある」「わかる」を引き出す',
'逆張り: 多数派と逆の意見(根拠あり)',
'断言: 「結論:〜」「答え:〜」明確に',
'具体性: 数字・固有名詞・具体例があると伸びる',
'リスト形式: 「3つの理由」「やってはいけない5選」',
'体験談フック: 「実際に〜してみた」',
].join('\n'),
};
/**
* Inject vào prompt JP để cải thiện quality.
*/
export function getJpContextBlock(opts: {
includeStyleNotes?: boolean;
includeAvoid?: boolean;
starterCategory?: keyof typeof JP_X_CULTURE.naturalStarters;
}): string {
const blocks: string[] = [];
if (opts.includeStyleNotes !== false) {
blocks.push(`📝 日本のX文化:\n${JP_X_CULTURE.styleNotes}`);
}
if (opts.includeAvoid !== false) {
blocks.push(`🚫 AI臭が出る表現(絶対避ける):\n${JP_X_CULTURE.aiPhrasesAvoid}`);
}
if (opts.starterCategory) {
const starters = JP_X_CULTURE.naturalStarters[opts.starterCategory];
if (starters) {
blocks.push(`💬 自然な書き出し例: ${starters.join(' / ')}`);
}
}
return blocks.join('\n\n');
}
@@ -0,0 +1,667 @@
// prompts/quote.templates.ts
import {QuoteType} from '../enum/quote-type.enum';
// ============================================================
// SYSTEM PROMPTS — native per language (chống đổi ngôn ngữ)
// ============================================================
export const QUOTE_SYSTEM_PROMPTS: Record<Language, string> = {
en: [
'You are an expert X (Twitter) quote-tweet writer.',
'Quote tweets are BROADCASTS to your followers, not replies to the OP.',
'They must deliver standalone value — add a new angle, insight, or context.',
'Write ONLY the quote text. No preamble, no quotes wrapping, No links attached, no "Here is...".',
'Never start with "Great post!" or praise the OP sycophantically.',
'Sound like a sharp, confident human — NOT an AI assistant.',
].join(' '),
vi: [
'Bạn là chuyên gia viết quote-tweet trên X.',
'Quote tweet là BROADCAST tới followers của bạn, không phải reply cho tác giả gốc.',
'Quote phải có giá trị độc lập — thêm góc nhìn, insight, hoặc context mới.',
'CHỈ viết nội dung quote bằng Tiếng Việt. Không giải thích, không đính kèm link ,không dấu ngoặc kép bao ngoài.',
'KHÔNG bắt đầu bằng "Bài hay!" hay nịnh tác giả gốc.',
'Giọng như người thật sắc sảo, tự tin — KHÔNG giống AI assistant.',
].join(' '),
cn: [
'你是一名X(Twitter)引用转推写作专家。',
'引用转推是向你的粉丝进行的广播,而不是对原帖作者的回复。',
'内容必须具备独立价值——提供新的角度、洞察或背景信息。',
'只写引用内容本身。不要前言、不要加引号、不要附带链接、不要写“Here is...”之类的句子。',
'不要以“Great post!”开头,也不要对原作者进行阿谀式夸赞。',
'语气要像一个犀利、自信的人类,而不是AI助手。',
].join(' '),
ja: [
'あなたはX(Twitter)の引用リツイート専門ライターです。',
'引用ツイートは元投稿への返信ではなく、自分のフォロワーへの発信です。',
'独立した価値を提供すること — 新しい視点、洞察、文脈を加える。',
'引用の本文のみを日本語で出力。前置き、リンクは添付されていません, 引用符での囲み、「以下は…」などは不要。',
'「素晴らしい投稿ですね!」のような元投稿への追従は禁止。',
'鋭く自信のある人間として書く — AIアシスタント風にしない。',
].join(' '),
ko: [
'X(트위터) 인용 트윗 전문 작성자입니다.',
'인용 트윗은 원 작성자에 대한 답글이 아닌, 당신의 팔로워들에게 보내는 브로드캐스트입니다.',
'독립적 가치를 전달해야 합니다 — 새로운 관점, 통찰, 맥락을 추가하세요.',
'인용 본문만 한국어로 작성. 서두, 링크가 첨부되어 있지 않습니다, 인용부호 감싸기, "다음은..." 등 금지.',
'"좋은 글이네요!" 같은 원 작성자 아부 금지.',
'날카롭고 자신감 있는 사람처럼 — AI 어시스턴트처럼 쓰지 말 것.',
].join(' '),
};
// ============================================================
// QUOTE TYPE INSTRUCTIONS (multilingual)
// ============================================================
interface QuoteTypeSpec {
name: Record<Language, string>;
instruction: Record<Language, string>;
openerHints: Record<Language, string[]>; // gợi ý cách mở đầu (không bắt buộc)
avoid: Record<Language, string>;
}
export const QUOTE_TYPE_SPECS: Record<QuoteType, QuoteTypeSpec> = {
[QuoteType.AGREE_AMPLIFY]: {
name: {
en: 'Agree + Amplify',
cn: 'Agree + Amplify',
vi: 'Đồng ý + Mở rộng',
ja: '同意+拡張',
ko: '동의 + 확장',
},
instruction: {
en: 'Agree with the core point, then ADD a unique supporting insight or example the OP did not mention. Do not just repeat them.',
cn: 'Agree with the core point, then ADD a unique supporting insight or example the OP did not mention. Do not just repeat them.',
vi: 'Đồng ý với ý chính, sau đó THÊM insight/ví dụ riêng mà OP chưa nói. Không lặp lại họ.',
ja: '要点に同意した上で、元投稿にない独自の裏付け洞察や事例を追加する。繰り返しにならないこと。',
ko: '핵심에 동의한 후, 원 게시물에 없는 고유한 뒷받침 통찰이나 예시를 추가. 반복 금지.',
},
openerHints: {
en: ['This. And...', 'Exactly. What most miss is...', '100%. The under-discussed part:'],
cn: ['This. And...', 'Exactly. What most miss is...', '100%. The under-discussed part:'],
vi: ['Chuẩn. Và...', 'Đúng vậy. Điều ít người nói đến là...', 'Chính xác. Góc bị bỏ qua:'],
ja: [
'それな',
'これマジでわかる',
'ガチでこれ',
'ほんとそう、補足すると',
'同意。あんま語られないけど',
],
ko: ['바로 이것. 그리고...', '정확히. 놓치기 쉬운 건...', '동의. 잘 안 다뤄지는 부분:'],
},
avoid: {
en: 'Do not simply restate the original.',
cn: 'Do not simply restate the original.',
vi: 'Không đơn thuần nhắc lại bài gốc.',
ja: '元投稿の言い換えだけにしない。',
ko: '원문을 그대로 반복하지 말 것.',
},
},
[QuoteType.DISAGREE]: {
name: {
en: 'Disagree / Challenge',
cn: 'Disagree / Challenge',
vi: 'Phản biện',
ja: '反論',
ko: '반론',
},
instruction: {
en: 'Politely but firmly disagree. Present a specific counter-argument with reasoning or data. Not personal — attack the idea, not the author.',
cn: 'Politely but firmly disagree. Present a specific counter-argument with reasoning or data. Not personal — attack the idea, not the author.',
vi: 'Phản biện lịch sự nhưng dứt khoát. Đưa luận điểm ngược cụ thể có lý lẽ/dữ liệu. Không cá nhân — chỉ phản bác ý, không công kích tác giả.',
ja: '丁寧ながら明確に反論する。具体的な根拠・データを伴う対論を示す。個人攻撃せず、主張のみに反論。',
ko: '정중하지만 단호하게 반론. 구체적 근거/데이터로 반박. 인신공격 금지, 주장에만 반박.',
},
openerHints: {
en: ['Respectfully, I see it differently.', 'Counter-take:', 'The data suggests otherwise:'],
cn: ['Respectfully, I see it differently.', 'Counter-take:', 'The data suggests otherwise:'],
vi: ['Tôi có góc nhìn khác.', 'Ngược lại:', 'Dữ liệu cho thấy điều khác:'],
ja: [
'いや、これはちゃう',
'別の見方もあって',
'ちょっと違うと思う',
'データ的には逆',
'反対意見いいですか',
],
ko: ['다른 관점도 있습니다.', '반대 의견:', '데이터는 다르게 말합니다:'],
},
avoid: {
en: 'No sarcasm, no personal attacks, no "actually" condescension.',
cn: 'No sarcasm, no personal attacks, no "actually" condescension.',
vi: 'Không mỉa mai, không công kích cá nhân, không giọng "thực ra thì" trịch thượng.',
ja: '皮肉、個人攻撃、上から目線の「実は」表現を避ける。',
ko: '빈정거림, 인신공격, 거만한 "사실은" 표현 금지.',
},
},
[QuoteType.ADD_CONTEXT]: {
name: {
en: 'Add Context',
cn: 'Add Context',
vi: 'Bổ sung context',
ja: '文脈を追加',
ko: '맥락 추가',
},
instruction: {
en: 'Provide missing context, background, or nuance that changes how the original should be interpreted. Be factual.',
cn: 'Provide missing context, background, or nuance that changes how the original should be interpreted. Be factual.',
vi: 'Cung cấp context, background, hoặc nuance còn thiếu làm thay đổi cách hiểu bài gốc. Giữ đúng sự thật.',
ja: '元投稿の解釈を変える、欠けている文脈・背景・ニュアンスを提供。事実ベースで。',
ko: '원 게시물 해석을 바꾸는 누락된 맥락, 배경, 뉘앙스를 제공. 사실 기반으로.',
},
openerHints: {
en: ['Important context:', 'Worth noting:', 'Missing from this:'],
cn: ['Important context:', 'Worth noting:', 'Missing from this:'],
vi: ['Context quan trọng:', 'Đáng chú ý:', 'Điểm còn thiếu:'],
ja: ['重要な文脈:', '注目すべき点:', 'この投稿に欠けている:'],
ko: ['중요한 맥락:', '주목할 점:', '빠진 부분:'],
},
avoid: {
en: 'Do not fabricate facts.',
cn: 'Do not fabricate facts.',
vi: 'Không bịa đặt sự thật.',
ja: '事実を捏造しないこと。',
ko: '사실을 날조하지 말 것.',
},
},
[QuoteType.REFRAME]: {
name: {
en: 'Reframe',
cn: 'Reframe',
vi: 'Nhìn góc khác',
ja: '再構成',
ko: '재구성',
},
instruction: {
en: 'Shift the mental frame — show the same situation from a completely different angle or level of abstraction.',
cn: 'Shift the mental frame — show the same situation from a completely different angle or level of abstraction.',
vi: 'Chuyển khung nhìn — cho thấy cùng sự việc từ góc độ hoặc cấp độ hoàn toàn khác.',
ja: 'フレームを転換 — 同じ状況を全く異なる角度・抽象度で見せる。',
ko: '프레임 전환 — 같은 상황을 완전히 다른 각도/추상화 수준에서 보기.',
},
openerHints: {
en: ['Another way to see this:', 'Zoom out:', 'Reframe:'],
cn: ['Another way to see this:', 'Zoom out:', 'Reframe:'],
vi: ['Một cách nhìn khác:', 'Zoom out:', 'Đổi khung:'],
ja: ['別の見方:', '俯瞰すると:', 'フレーム転換:'],
ko: ['다른 시각:', '시야를 넓히면:', '프레임 전환:'],
},
avoid: {
en: 'Do not just restate in different words.',
cn: 'Do not just restate in different words.',
vi: 'Không chỉ đổi từ ngữ giữ nguyên ý.',
ja: '言葉を変えただけの言い換えにしない。',
ko: '단어만 바꾼 재진술 금지.',
},
},
[QuoteType.BUILD_ON]: {
name: {
en: 'Build On',
cn: 'Build On',
vi: 'Mở rộng',
ja: '発展',
ko: '확장',
},
instruction: {
en: 'Take the original idea further. Apply it to a new domain, extend the logic, or show a non-obvious implication.',
cn: 'Take the original idea further. Apply it to a new domain, extend the logic, or show a non-obvious implication.',
vi: 'Đẩy ý tưởng gốc đi xa hơn. Áp dụng sang lĩnh vực mới, mở rộng logic, hoặc cho thấy hệ quả không hiển nhiên.',
ja: '元のアイデアをさらに展開。新領域への応用、論理の拡張、非自明な含意を示す。',
ko: '원 아이디어를 더 발전시키기. 새 영역 적용, 논리 확장, 비자명한 함의 제시.',
},
openerHints: {
en: ['Taking this further:', 'Extending this logic:', 'The next step:'],
cn: ['Taking this further:', 'Extending this logic:', 'The next step:'],
vi: ['Đi xa hơn:', 'Mở rộng logic này:', 'Bước tiếp theo:'],
ja: ['さらに展開すると:', 'この論理を広げると:', '次のステップ:'],
ko: ['더 나아가면:', '이 논리를 확장하면:', '다음 단계:'],
},
avoid: {
en: 'Stay grounded — no wild speculation.',
cn: 'Stay grounded — no wild speculation.',
vi: 'Bám thực tế — không suy diễn hoang đường.',
ja: '現実的に — 過度な憶測はしない。',
ko: '현실적으로 — 무리한 추측 금지.',
},
},
[QuoteType.HIGHLIGHT]: {
name: {
en: 'Highlight Key , You are NOT adding new content. You are a spotlight — just make the existing best point impossible to miss.',
cn: 'Highlight Key Point, You are NOT adding new content. You are a spotlight — just make the existing best point impossible to miss.',
vi: 'Nhấn mạnh, Bạn KHÔNG thêm nội dung mới. Bạn chỉ là người làm nổi bật điểm mạnh hiện có hãy làm cho điểm mạnh đó trở nên không thể bỏ qua.',
ja: '重要ポイント , あなたは新しいコンテンツを追加するわけではありません。あなたはスポットライトを当てる役割を担っています。既存の最も優れた点を、見逃せないようにするだけです。',
ko: '핵심 강조 , 당신은 새로운 콘텐츠를 추가하는 것이 아닙니다. 당신은 기존의 가장 뛰어난 부분을 부각시키는 역할을 하는 것입니다. 즉, 기존의 가장 뛰어난 부분을 놓치지 않도록 하는 것입니다.',
},
instruction: {
en: 'Call out THE most important or under-appreciated insight in the original. Make it impossible to miss.',
cn: 'Call out THE most important or under-appreciated insight in the original. Make it impossible to miss.',
vi: 'Chỉ ra insight QUAN TRỌNG nhất hoặc bị đánh giá thấp trong bài gốc. Làm cho không thể bỏ qua.',
ja: '元投稿で最も重要、または過小評価されている洞察を強調。見逃せないように。',
ko: '원 게시물에서 가장 중요하거나 저평가된 통찰을 강조. 놓칠 수 없게.',
},
openerHints: {
en: ['THIS is the key:', 'The real insight here:', 'Don\'t miss this:'],
cn: ['THIS is the key:', 'The real insight here:', 'Don\'t miss this:'],
vi: ['ĐÂY là điểm mấu chốt:', 'Insight thật sự:', 'Đừng bỏ lỡ:'],
ja: ['核心はここ:', '本当の洞察:', '見逃し厳禁:'],
ko: ['핵심은 이것:', '진짜 통찰:', '절대 놓치지 말 것:'],
},
avoid: {
en: 'Do not exaggerate or sensationalize.',
cn: 'Do not exaggerate or sensationalize.',
vi: 'Không phóng đại, không giật gân.',
ja: '誇張・煽り禁止。',
ko: '과장이나 자극적 표현 금지.',
},
},
[QuoteType.ROAST]: {
name: {
en: 'Roast / Dunk',
cn: 'Roast / Dunk',
vi: 'Roast',
ja: 'ロースト',
ko: '풍자',
},
instruction: {
en: `Witty, sharp criticism with humor. Attack the idea, never the person. Must be actually funny, not mean. Avoid punching at demographics, political groups, or anything that could be read as targeted harassment even in jest. If the original post is already self-aware or humble, skip the roast — it won't land.`,
cn: `Witty, sharp criticism with humor. Attack the idea, never the person. Must be actually funny, not mean. Avoid punching at demographics, political groups, or anything that could be read as targeted harassment even in jest. If the original post is already self-aware or humble, skip the roast — it won't land.`,
vi: 'Lời phê bình sắc sảo, dí dỏm và hài hước. Hãy tấn công vào ý tưởng, chứ không phải cá nhân. Phải thực sự hài hước, không được ác ý. Tránh công kích các nhóm nhân khẩu học, nhóm chính trị, hoặc bất cứ điều gì có thể bị hiểu là quấy rối có chủ đích, ngay cả khi chỉ là nói đùa. Nếu bài đăng gốc đã tự nhận thức hoặc khiêm tốn, hãy bỏ qua phần chỉ trích nó sẽ không hiệu quả',
ja: 'ユーモアを交えた、機知に富んだ鋭い批判。アイデアを攻撃し、決して人を攻撃しないこと。本当に面白くなければならず、意地悪であってはならない。特定の人口統計グループ、政治団体、あるいは冗談であっても標的型嫌がらせと受け取られかねないものを攻撃することは避けること。元の投稿が既に自己認識が高かったり謙虚だったりする場合は、皮肉を言うのはやめよう。効果がないだろう。',
ko: '재치 있고 날카로운 비판에 유머를 더하세요. 아이디어를 공격하되, 사람을 공격해서는 안 됩니다. 악의가 아닌 진정한 웃음을 유발해야 합니다. 특정 인구 집단, 정치 집단 또는 표적 공격으로 해석될 수 있는 내용은 농담이라 할지라도 피하세요. 원 게시글 작성자가 이미 자기 인식적이거나 겸손한 태도를 보인다면, 신랄한 비판은 삼가세요. 효과적이지 않을 겁니다.',
},
openerHints: {
en: ['Imagine thinking...', 'Bold of you to...', 'The confidence of posting this...'],
cn: ['Imagine thinking...', 'Bold of you to...', 'The confidence of posting this...'],
vi: ['Tưởng tượng mà nghĩ rằng...', 'Bạo dạn thật...', 'Đủ tự tin để post cái này...'],
ja: [
'よくこれ投稿できたな',
'こいつ本当に',
'お兄さん、',
'伝説の投稿',
'保存させていただきました',
'今週一の',
],
ko: ['이런 생각을 하다니...', '대담하네요...', '이걸 올릴 자신감...'],
},
avoid: {
en: 'No slurs, no personal attacks, no bullying punching down.',
cn: 'No slurs, no personal attacks, no bullying punching down.',
vi: 'Không miệt thị, không công kích cá nhân, không bắt nạt người yếu thế.',
ja: '差別語、個人攻撃、弱者叩き禁止。',
ko: '비방, 인신공격, 약자 공격 금지.',
},
},
[QuoteType.HOT_TAKE]: {
name: {
en: 'Hot Take',
cn: 'Hot Take',
vi: 'Hot take',
ja: 'ホットテイク',
ko: '핫 테이크',
},
instruction: {
en: 'Bold, confident opinion triggered by the original. Must be defensible, not just contrarian for clout.The hot take must visibly connect back to the original post — readers should see why this was triggered by it.',
cn: 'Bold, confident opinion triggered by the original. Must be defensible, not just contrarian for clout. The hot take must visibly connect back to the original post — readers should see why this was triggered by it.',
vi: 'Opinion mạnh, tự tin, được kích bởi bài gốc. Phải bảo vệ được, không chỉ ngược chiều để câu view. Quan điểm gây tranh cãi phải có mối liên hệ rõ ràng với bài đăng gốc — người đọc cần thấy lý do tại sao nó lại được đưa ra sau bài đăng gốc.',
ja: '元投稿をきっかけとした大胆で自信ある意見。反対のための反対ではなく、擁護可能な内容。その過激な意見は、元の投稿と明確に関連していなければならない。読者は、なぜそれがきっかけでこの意見が出たのかを理解できる必要がある。',
ko: '원 게시물이 촉발한 대담하고 자신 있는 의견. 반대를 위한 반대가 아닌, 방어 가능한 내용.비판적인 의견은 원래 게시글과 명확하게 연결되어야 하며, 독자들은 왜 그 게시글이 비판의 계기가 되었는지 이해할 수 있어야 합니다.',
},
openerHints: {
en: ['Unpopular opinion:', 'Hot take:', 'Controversial but true:'],
cn: ['Unpopular opinion:', 'Hot take:', 'Controversial but true:'],
vi: ['Opinion không phổ biến:', 'Hot take:', 'Gây tranh cãi nhưng đúng:'],
ja: [
'不人気な意見だけど',
'結論:',
'ホットテイク:',
'誰も言わないけど',
'物議を醸すかもだが',
],
ko: ['비주류 의견:', '핫 테이크:', '논란의 여지가 있지만 사실:'],
},
avoid: {
en: 'No empty contrarianism.',
cn: 'No empty contrarianism.',
vi: 'Không ngược chiều rỗng tuếch.',
ja: '中身のない逆張り禁止。',
ko: '알맹이 없는 반대 금지.',
},
},
[QuoteType.QUESTION]: {
name: {
en: 'Provocative Question',
cn: 'Provocative Question',
vi: 'Câu hỏi khơi gợi',
ja: '問いかけ',
ko: '질문 던지기',
},
instruction: {
en: 'Ask ONE sharp question that makes people think deeper about the original. Not rhetorical flipping — genuinely thought-provoking.',
cn: 'Ask ONE sharp question that makes people think deeper about the original. Not rhetorical flipping — genuinely thought-provoking.',
vi: 'Đặt MỘT câu hỏi sắc khiến người đọc suy nghĩ sâu hơn về bài gốc. Không tu từ rỗng — thực sự kích thích suy nghĩ.',
ja: '元投稿を深く考えさせる鋭い問いを1つ。修辞的な反転ではなく、本当に考えさせる内容。',
ko: '원 게시물을 더 깊이 생각하게 만드는 날카로운 질문 1개. 수사적 반문이 아닌, 진짜 생각하게 만드는 것.',
},
openerHints: {
en: ['But what about...?', 'Genuine question:', 'Have we considered...?'],
cn: ['But what about...?', 'Genuine question:', 'Have we considered...?'],
vi: ['Nhưng còn...?', 'Câu hỏi thật sự:', 'Đã ai tính đến...?'],
ja: ['では…はどうか?', '素朴な疑問:', '…を考えたことは?'],
ko: ['하지만...는 어떤가요?', '진지한 질문:', '...을 고려해봤나요?'],
},
avoid: {
en: 'No gotcha questions.',
cn: 'No gotcha questions.',
vi: 'Không câu hỏi "gotcha" bẫy.',
ja: '揚げ足取りの質問禁止。',
ko: '트집 잡기식 질문 금지.',
},
},
[QuoteType.SUMMARIZE]: {
name: {
en: 'TL;DR Summary',
cn: 'TL;DR Summary',
vi: 'Tóm tắt TL;DR',
ja: 'TL;DR要約',
ko: 'TL;DR 요약',
},
instruction: {
en: 'Distill the original into its sharpest, most shareable essence. One-line TL;DR style.',
cn: 'Distill the original into its sharpest, most shareable essence. One-line TL;DR style.',
vi: 'Cô đọng bài gốc thành tinh túy sắc nhất, dễ share nhất. Kiểu TL;DR một dòng.',
ja: '元投稿を最も鋭く、シェアしやすい本質に凝縮。1行TL;DR形式。',
ko: '원 게시물을 가장 날카롭고 공유하기 쉬운 본질로 압축. 한 줄 TL;DR 형식.',
},
openerHints: {
en: ['TL;DR:', 'In one line:', 'The whole thing in a sentence:'],
cn: ['TL;DR:', 'In one line:', 'The whole thing in a sentence:'],
vi: ['TL;DR:', 'Một dòng:', 'Cả bài trong 1 câu:'],
ja: [
'TL;DR',
'要するに',
'一言でいうと',
'3秒で分かる版:',
'まとめ:',
],
ko: ['TL;DR:', '한 줄로:', '요약하면:'],
},
avoid: {
en: 'Do not add new content — this is compression.',
cn: 'Do not add new content — this is compression.',
vi: 'Không thêm nội dung mới — đây là nén.',
ja: '新規情報は加えない — これは圧縮。',
ko: '새 내용 추가 금지 — 압축 작업.',
},
},
[QuoteType.PERSONAL_STORY]: {
name: {
en: 'Personal Story',
vi: 'Câu chuyện cá nhân',
ja: '個人的な体験',
ko: '개인 경험 공유',
cn: '个人故事',
},
instruction: {
en: 'Share a real, specific personal experience that connects to the original post. Lead with the moment, not the lesson. Include one concrete detail (time, place, number, name) to make it feel real. The story should naturally validate, contrast, or deepen the original — not just say "same". End with a brief reflection or takeaway, but keep it earned, not preachy.',
vi: 'Chia sẻ một trải nghiệm cá nhân cụ thể, thực tế liên quan đến bài gốc. Mở đầu bằng khoảnh khắc xảy ra, không phải bài học. Thêm một chi tiết cụ thể (thời gian, địa điểm, con số, tên) để câu chuyện có độ thật. Câu chuyện nên tự nhiên xác nhận, tương phản, hoặc làm sâu hơn bài gốc — không chỉ nói "tôi cũng vậy". Kết bằng một reflection ngắn nhưng phải tự nhiên, không giáo điều.',
ja: '元の投稿に関連する、具体的なリアルな個人体験を共有する。教訓からではなく、その瞬間から書き始める。リアル感を出すために具体的な detail(時間・場所・数字・名前)を1つ入れる。元投稿を自然に裏付け、対比、または深掘りする内容にする — 単なる「わかる」にしない。短い気づきで締めるが、説教にならないこと。',
ko: '원 게시물과 연결되는 구체적이고 실제적인 개인 경험을 공유한다. 교훈이 아닌 그 순간부터 시작할 것. 실감나게 만들 구체적인 디테일(시간, 장소, 숫자, 이름) 하나를 포함한다. 단순히 "나도 그래"가 아니라 원 게시물을 자연스럽게 뒷받침하거나 대비하거나 심화시키는 내용이어야 한다. 짧은 성찰로 마무리하되, 설교조가 되지 않을 것.',
cn: '分享一个与原帖相关的真实、具体的个人经历。从那个时刻切入,而非从教训开始。加入一个具体细节(时间、地点、数字、名字)让故事显得真实。内容应自然地印证、对比或深化原帖——而不只是说"我也是"。以简短的感悟收尾,但要自然流露,不要说教。',
},
openerHints: {
en: [
'This takes me back to...',
'Three years ago I learned this the hard way —',
'Happened to me. [Year/Place]:',
'I used to think differently — until...',
'Real story:',
],
vi: [
'Cái này nhắc tôi nhớ lại...',
'Ba năm trước tôi đã học điều này theo cách khó khăn nhất —',
'Tôi đã từng gặp đúng chuyện này. [Năm/Nơi]:',
'Tôi từng nghĩ khác — cho đến khi...',
'Chuyện thật:',
],
ja: [
'これを見て昔を思い出した…',
'3年前、痛い経験から学んだことがある —',
'自分にも起きた。[年/場所]',
'以前は違う考えだった — あの日まで…',
'実話:',
],
ko: [
'이걸 보니 예전 생각이 나네요...',
'3년 전, 저는 이걸 아주 힘든 방식으로 배웠습니다 —',
'저한테도 있었던 일이에요. [연도/장소]:',
'예전엔 다르게 생각했어요 — 그날까지는...',
'실제 있었던 일:',
],
cn: [
'这让我想起了...',
'三年前,我用最艰难的方式学到了这件事 —',
'这事发生在我身上。[年份/地点]:',
'我曾经想法不同 —— 直到那一天...',
'真实故事:',
],
},
avoid: {
en: 'Do not write vague, generic "relatable" content like "I once felt this way too." No fake humility. No manufactured vulnerability. The story must have a specific detail — if it has none, it reads as fabricated. Do not moralize or over-explain the lesson at the end.',
vi: 'Không viết kiểu mơ hồ, chung chung như "Tôi cũng từng cảm thấy vậy." Không khiêm tốn giả tạo. Không tạo ra sự dễ tổn thương giả. Câu chuyện phải có ít nhất một chi tiết cụ thể — nếu không có, nó sẽ lộ là bịa. Không đạo đức hoá hoặc giải thích quá dài bài học ở cuối.',
ja: '「私もそう感じたことがある」のような曖昧で当たり障りのない内容を書かない。作られた謙虚さや演出された脆さは不要。具体的な detail が1つもなければ作り話に見える。最後に教訓を説教したり過剰に説明したりしない。',
ko: '"저도 그런 느낌을 받은 적 있어요"같은 모호하고 일반적인 공감성 내용을 쓰지 말 것. 가짜 겸손이나 연출된 취약함 금지. 구체적인 디테일이 하나도 없으면 지어낸 것처럼 보임. 마지막에 교훈을 설교하거나 과도하게 설명하지 말 것.',
cn: '不要写模糊、套路化的"感同身受"内容,如"我也曾有过这种感觉"。不要假谦虚,不要刻意制造脆弱感。故事必须有具体细节——否则会显得是编造的。结尾不要说教或过度解释教训。',
},
},
[QuoteType.CONNECT_DOTS]: {
name: {
en: 'Connect the Dots',
vi: 'Kết nối sự kiện',
ja: 'パターンを繋ぐ',
ko: '점 잇기',
cn: '连点成线',
},
instruction: {
en: 'Identify a separate event, trend, data point, or pattern — not mentioned in the original — that when placed next to it reveals a bigger picture. The connection must be logical and traceable, not vague. Structure: [Original signal] + [External signal you bring] = [Insight neither alone would reveal]. The reader should finish thinking: "I would not have seen that without this quote."',
vi: 'Xác định một sự kiện, xu hướng, dữ liệu hoặc pattern riêng biệt — không được nhắc đến trong bài gốc — mà khi đặt cạnh bài gốc sẽ lộ ra một bức tranh lớn hơn. Kết nối phải có logic và có thể truy vết, không mơ hồ. Cấu trúc: [Tín hiệu từ bài gốc] + [Tín hiệu bên ngoài bạn mang vào] = [Insight mà cả hai riêng lẻ đều không cho thấy]. Người đọc xong phải nghĩ: "Nếu không có quote này tôi đã không nhận ra điều đó."',
ja: '元の投稿で触れられていない別の出来事・トレンド・データ・パターンを見つけ、元投稿と並べることでより大きな全体像を明らかにする。繋がりは論理的で追跡可能でなければならない — 曖昧な連想は不可。構造:[元投稿のシグナル] + [あなたが持ち込む外部シグナル] = [どちらか単体では見えないインサイト]。読者が読み終えて「このquoteがなければ気づかなかった」と思わせること。',
ko: '원 게시물에서 언급되지 않은 별개의 사건, 트렌드, 데이터, 패턴을 찾아 원 게시물 옆에 놓았을 때 더 큰 그림이 드러나도록 한다. 연결은 논리적이고 추적 가능해야 하며 모호한 연상은 금지. 구조: [원 게시물 신호] + [내가 가져오는 외부 신호] = [둘 중 어느 하나만으로는 보이지 않는 통찰]. 독자가 읽고 나서 "이 quote 없었으면 몰랐을 것"이라고 느끼게 할 것.',
cn: '找到一个原帖未提及的独立事件、趋势、数据点或模式——将其与原帖并列时能揭示更大的图景。连接必须合乎逻辑且可追溯,不能模糊。结构:[原帖信号] + [你带入的外部信号] = [两者单独都无法揭示的洞察]。读者读完应该想:「没有这条quote我不会发现这一点。」',
},
openerHints: {
en: [
'This + [X] = a pattern worth watching:',
'Third time seeing this signal this month.',
'Connect this to what happened with [X] and it makes sense:',
'Alone this looks like noise. With [X] it looks like signal:',
'The dots are connecting:',
],
vi: [
'Cái này + [X] = một pattern đáng chú ý:',
'Tháng này tôi thấy tín hiệu này lần thứ ba rồi.',
'Kết nối điều này với chuyện xảy ra với [X] thì mọi thứ có lý:',
'Riêng lẻ thì trông như nhiễu. Cộng với [X] thì đây là tín hiệu thật:',
'Các mảnh ghép đang khớp lại:',
],
ja: [
'これ+[X]=注目すべきパターン:',
'今月これで3回目のシグナルだ。',
'[X]で起きたことと繋げると腑に落ちる:',
'単体ではノイズに見える。[X]と合わせるとシグナルになる:',
'点と点が繋がってきた:',
],
ko: [
'이것 + [X] = 주목할 만한 패턴:',
'이번 달 세 번째로 보는 신호다.',
'[X]에서 일어난 일과 연결하면 이해가 된다:',
'단독으로는 노이즈처럼 보인다. [X]와 합치면 진짜 신호:',
'점들이 이어지고 있다:',
],
cn: [
'这个 + [X] = 一个值得关注的模式:',
'这个月第三次看到这个信号了。',
'把这个和[X]发生的事联系起来,一切就说得通了:',
'单独看像是噪音。加上[X]就是真实信号:',
'点与点正在连成线:',
],
},
avoid: {
en: 'Do not fabricate connections — if you cannot name the external signal specifically, do not make one up. Avoid superficial pattern-matching like "this is just like [famous event]" without explaining the actual mechanism. Do not connect things that are merely similar in surface topic — the connection must reveal causation, correlation, or a systemic pattern. No conspiracy-adjacent reasoning.',
vi: 'Không bịa đặt kết nối — nếu không thể đặt tên cụ thể cho tín hiệu bên ngoài thì không được bịa. Tránh pattern matching hời hợt kiểu "cái này giống hệt [sự kiện nổi tiếng]" mà không giải thích cơ chế thực sự. Không kết nối những thứ chỉ giống nhau về chủ đề bề mặt — kết nối phải lộ ra quan hệ nhân quả, tương quan, hoặc một pattern có hệ thống. Không suy luận kiểu thuyết âm mưu.',
ja: '繋がりを捏造しない — 外部シグナルを具体的に名指しできないなら作り上げない。「これは[有名な出来事]と同じだ」という表面的なパターンマッチングを、実際のメカニズム説明なしに行わない。表面的なトピックが似ているだけのものを繋げない — 繋がりは因果・相関・システム的パターンを明らかにするものでなければならない。陰謀論的な推論は禁止。',
ko: '연결을 날조하지 말 것 — 외부 신호를 구체적으로 명명할 수 없다면 만들어내지 말 것. "이건 [유명한 사건]과 똑같다"는 식의 실제 메커니즘 설명 없는 피상적 패턴 매칭 금지. 표면적 주제만 비슷한 것들을 연결하지 말 것 — 연결은 인과관계, 상관관계, 또는 시스템적 패턴을 드러내야 함. 음모론적 추론 금지.',
cn: '不要捏造联系——如果无法具体说出外部信号,就不要编造。避免没有解释实际机制的表面模式匹配,如"这和[著名事件]一模一样"。不要连接仅在表面话题上相似的事物——连接必须揭示因果关系、相关性或系统性模式。禁止阴谋论式推理。',
},
},
};
// ============================================================
// AUTO-DETECT quote type nếu user không truyền
// ============================================================
export function suggestQuoteType(originalPost: string, yourAngle?: string): QuoteType {
console.log('==> suggestQuoteType');
const text = (yourAngle ?? originalPost).toLowerCase();
if (/disagree|wrong|incorrect|false|sai|không đúng|違う|틀렸/.test(text)) return QuoteType.DISAGREE;
if (/tl;?dr|summary|tóm tắt|要約|요약/.test(text)) return QuoteType.SUMMARIZE;
if (/question|như thế nào|\?|\/.test(text)) return QuoteType.QUESTION;
if (/funny|lol|haha|imagine|😂|🤣/.test(text)) return QuoteType.ROAST;
if (/context|background|actually|thực ra|実は|사실/.test(text)) return QuoteType.ADD_CONTEXT;
if (/unpopular|controversial|hot take|gây tranh cãi/.test(text)) return QuoteType.HOT_TAKE;
// Default: amplify (an toàn nhất)
return QuoteType.AGREE_AMPLIFY;
}
export const QUOTE_TYPE_TELEGRAM_BUTTON_SPECS = {
[QuoteType.AGREE_AMPLIFY]: {
key: 'agree_amplify',
text: 'Đồng ý + thêm insight',
}, // Đồng ý + thêm insight
[QuoteType.DISAGREE]: {
key: 'disagree',
text: 'Disagree-Phản biện có lý'
}, // Phản biện có lý
[QuoteType.ADD_CONTEXT]: {
key: 'add_context',
text: 'Bổ sung context'
}, // Bổ sung context
[QuoteType.REFRAME]: {
key: 'reframe',
text: 'Nhìn góc khác'
}, // Nhìn góc khác
[QuoteType.BUILD_ON]: {
key: 'build_on',
text: 'Mở rộng ý'
}, // Mở rộng ý
[QuoteType.HIGHLIGHT]: {
key: 'highlight',
text: 'Nhấn mạnh key point'
}, // Nhấn mạnh key point
[QuoteType.ROAST]: {
key: 'roast',
text: 'Chỉ trích sắc'
}, // Chỉ trích sắc
[QuoteType.HOT_TAKE]: {
key: 'host_take',
text: 'Opinion mạnh'
}, // Opinion mạnh
[QuoteType.QUESTION]: {
key: 'question', text: 'Đặt câu hỏi'
}, // Đặt câu hỏi
[QuoteType.SUMMARIZE]: {
key: 'summarize',
text: 'tóm tắt'
},
[QuoteType.CONNECT_DOTS]: {
key: 'connect_dot',
text: 'connect the dot'
},
[QuoteType.PERSONAL_STORY]: {
key: 'connect_dot',
text: 'personal story'
},
}
// ============================================================
// MAIN PROMPT BUILDER
// ============================================================
import {LengthRange} from '../config/platform-limits';
import {LANGUAGE_LOCK} from './templates';
import {ContentTone} from '../enum/tone.enum';
import {TONE_HINTS} from './templates';
import {Language} from "../../../common/interfaces/language.prompt.interface";
export interface QuotePromptParams {
originalPost: string;
originalAuthor?: string;
quoteType: QuoteType;
language: Language;
tone?: ContentTone;
persona?: string;
yourAngle?: string;
lengthRange: LengthRange;
}
export function buildQuotePrompt(params: QuotePromptParams): {
system: string;
user: string;
} {
const {
originalPost,
originalAuthor,
quoteType,
language,
tone,
persona,
yourAngle,
lengthRange,
} = params;
const spec = QUOTE_TYPE_SPECS[quoteType];
const system = QUOTE_SYSTEM_PROMPTS[language];
const authorLine = originalAuthor ? `Original by @${originalAuthor}` : 'Original tweet';
const openerExamples = spec.openerHints[language].slice(0, 3).join(' | ');
const user = [
`=== ${authorLine} ===`,
`"""${originalPost}"""`,
``,
`=== Your quote-tweet task ===`,
`[Target Language: ${spec.name[language]}]
IMPORTANT: Your previous answer violated language rules.
Rewrite strictly in ${spec.name[language]} only.`,
`Quote type: ${spec.name[language]}`,
`Instruction: ${spec.instruction[language]}`,
`Avoid: ${spec.avoid[language]}`,
``,
`Length: ${lengthRange.min}-${lengthRange.max} chars (aim ~${lengthRange.sweet})`,
tone ? `Tone: ${TONE_HINTS[tone]}` : '',
persona ? `Voice/persona: ${persona}` : '',
yourAngle ? `Your specific angle: ${yourAngle}` : '',
``,
`Opener style examples (pick ONE or create your own similar):`,
` ${openerExamples}`,
``,
`Rules:`,
`- Quote must stand alone — readers may NOT read the original`,
`- Deliver value in first 280 chars even if long-form`,
`- NO "Great post!" / "Love this!" / sycophancy`,
`- NO AI phrases ("I think it's important to note that...")`,
`- 0-2 hashtags MAX, only if natural`,
`- 0-2 emojis MAX, only if they add meaning`,
`- ${LANGUAGE_LOCK[language]}`,
``,
`Output: the quote-tweet text ONLY.`,
].filter(Boolean).join('\n');
return {system, user};
}
@@ -0,0 +1,346 @@
// prompts/templates.ts
import {ContentStyle} from "../enum/style.enum";
import {ContentTone} from "../enum/tone.enum";
import {Platform} from "../enum/platform.enum";
import {WriterPromptParams} from "../interfaces/writer-prompt-params.interface";
import {Language} from "../../../common/interfaces/language.prompt.interface";
import {PostLength} from "../enum/post-length.enum";
// export type Language = 'en' | 'vi' | 'ja' | 'ko' | 'jp' | 'kr' | 'vn';
// ============================================================
// LANGUAGE LABELS (dùng cho lock ngôn ngữ)
// ============================================================
export const LANGUAGE_NAMES: Record<Language, string> = {
en: 'English',
vi: 'Vietnamese (Tiếng Việt)',
ja: 'Japanese (日本語)',
ko: 'Korean (한국어)',
cn: 'Chinese (中国人)',
};
// Native instructions để lock ngôn ngữ chắc chắn
export const LANGUAGE_LOCK: Record<Language, string> = {
en: 'Output language: English ONLY. Do not use other languages.',
cn: '输出语言:仅限中文。不会使用其他语言。',
vi: 'Ngôn ngữ output: CHỈ Tiếng Việt. Không dùng ngôn ngữ khác.',
ja: '出力言語: 日本語のみ。他の言語は使用しないこと。',
ko: '출력 언어: 한국어만 사용. 다른 언어 사용 금지.',
};
export const STYLE_HINTS_TELEGRAM_BUTTON = {
[ContentStyle.GENERAL]: {
text: 'GENERAL'
},
[ContentStyle.CRYPTO]: {
text: 'Crypto/Web3'
},
[ContentStyle.BREAKING_NEWS]: {
text: 'Breaking news'
},
[ContentStyle.TECH]: {
text: 'Tech-savvy'
},
[ContentStyle.FINANCE]: {
text: 'Financial'
},
[ContentStyle.LIFESTYLE]: {
text: 'Life style'
},
[ContentStyle.MEME]: {
text: 'Meme'
},
[ContentStyle.EDUCATIONAL]: {
text: 'Education'
},
}
export const STYLE_HINTS: Record<ContentStyle, string> = {
[ContentStyle.CRYPTO]: 'Crypto/Web3 tone. Use terms: bullish, alpha, gm, LFG. Add $TICKER, emojis 🚀📈. Degen but credible.',
[ContentStyle.BREAKING_NEWS]: 'Breaking news format. Start with 🚨 BREAKING. Concise facts. Who/What/When. No fluff.',
[ContentStyle.TECH]: 'Tech-savvy. Clear, confident. Mention specifics (tools, versions). Minimal hype.',
[ContentStyle.FINANCE]: 'Financial, data-driven. Numbers, %, market terms. Neutral professional.',
[ContentStyle.LIFESTYLE]: 'Warm, relatable, human. Light emojis. Story-driven.',
[ContentStyle.MEME]: 'Funny, meme-ish, punchy. One-liner energy. Reference internet culture. Can use "[X] but actually [Y]" format',
[ContentStyle.EDUCATIONAL]: 'Teach clearly. Structure: hook → insight → takeaway. Use analogies. Avoid jargon unless explained.',
[ContentStyle.GENERAL]: 'Clear, engaging, neutral.',
[ContentStyle.OPINION]: 'First-person opinion. Bold take. "I think / Hot take:". Invites debate.',
[ContentStyle.STORYTELLING]: 'Narrative arc. Hook with tension → build → resolution. Personal or case-study.',
[ContentStyle.THREAD]: 'Thread format. Start with hook tweet. Each point numbered. End with CTA or summary.',
};
export const TONE_HINTS_TELEGRAM_BUTTON = {
[ContentTone.PROFESSIONAL]: {text: 'chuyên nghiệp, rõ ràng, đáng tin cậy'},
[ContentTone.CASUAL]: {text: 'Giản dị,thân thiện'},
[ContentTone.HYPE]: {text: 'Hype-Hào hứng,tràn đầy năng lượng'},
[ContentTone.URGENT]: {text: 'urgent'},
[ContentTone.HUMOROUS]: {text: 'Dí dỏm, hài hước'},
[ContentTone.INFORMATIVE]: {text: 'Thông tin, chính xác'},
[ContentStyle.OPINION]: {text: 'Quan điểm táo bạo. \n"Tôi nghĩ / Quan điểm gây tranh cãi:". Mời gọi tranh luận.'},
[ContentStyle.STORYTELLING]: {text: 'STORYTELLING-Cấu trúc tự sự. Mở đầu bằng sự căng thẳng → phát triển → kết thúc.\n Có thể là câu chuyện cá nhân hoặc nghiên cứu trường hợp.'},
[ContentStyle.THREAD]: {text: 'THREAD-Bắt đầu bằng một tweet thu hút sự chú ý. \nMỗi điểm được đánh số. \nKết thúc bằng lời kêu gọi hành động (CTA) hoặc tóm tắt.'},
}
export const TONE_HINTS: Record<ContentTone, string> = {
[ContentTone.PROFESSIONAL]: 'professional, clear, credible',
[ContentTone.CASUAL]: 'casual, friendly',
[ContentTone.HYPE]: 'hyped, energetic',
[ContentTone.URGENT]: 'urgent, attention-grabbing',
[ContentTone.HUMOROUS]: 'witty, humorous',
[ContentTone.INFORMATIVE]: 'informative, factual',
[ContentTone.EMPATHETIC]: 'empathetic, emotionally aware, validating',
[ContentTone.PROVOCATIVE]: 'thought-provoking, slightly controversial, challenges assumptions',
[ContentTone.AUTHORITATIVE]: 'confident, commanding, expert-voice',
};
// export const PLATFORM_RULES: Record<Platform, string> = {
// [Platform.X]: 'Max 420 chars. 2-5 hashtags. Hook in first line. No markdown.',
// [Platform.FACEBOOK]: '400-800 chars. Can use line breaks. 2-5 hashtags. Engaging hook + CTA.',
// };
export const PLATFORM_RULES: Record<Platform, {
short: string;
detailed: (minChars: number, maxChars: number) => string;
}> = {
[Platform.X]: {
short: 'Max 420 chars. 2-5 hashtags. Hook first line. No markdown.',
detailed: (min, max) =>
`Length: ${min}-${max} characters (aim for ~${Math.floor((min + max) / 2)}). ` +
`Use ALL available space to deliver maximum value. ` +
`2-5 hashtags. Strong hook in first line. No markdown.`,
},
[Platform.FACEBOOK]: {
short: '400-1200 chars. Hook + body + CTA.',
detailed: (min, max) =>
`Length: ${min}-${max} characters (aim for ~${Math.floor((min + max) / 2)}). ` +
`Write a FULL post: strong hook → 2-4 paragraphs of value → clear CTA. ` +
`Use line breaks for readability. 2-5 relevant hashtags at end.`,
},
};
/**
* Hướng dẫn structure theo length — QUAN TRỌNG để GPT không viết lan man.
*/
export const LENGTH_STRUCTURE_HINTS: Record<PostLength, {
en: string;
vi: string;
ja: string;
ko: string;
}> = {
[PostLength.SHORT]: {
en: 'Single punchy statement. Hook + 1 key fact. No paragraphs. 1-2 hashtag',
vi: 'Một câu punchy duy nhất. Hook + 1 điểm chính. Không xuống dòng. 1-2 hashtag',
ja: '短く強烈な一文。フック + 要点1つ。段落分けなし。, 1-2ハッシュタグ',
ko: '짧고 강렬한 한 문장. 훅 + 핵심 1개. 단락 없음., 1-2 해시태그',
},
[PostLength.MEDIUM]: {
en: 'Structure: Hook line → 2-3 key points → brief takeaway. Use line breaks. 2-4 hashtag',
vi: 'Cấu trúc: Câu hook → 2-3 điểm chính → kết luận ngắn. Dùng xuống dòng. 2-4 hashtag',
ja: '構成: フック文 → 要点2-3個 → 短い結論。改行を使う。1-4ハッシュタグ',
ko: '구조: 훅 문장 → 핵심 2-3개 → 짧은 결론. 줄바꿈 사용. 1-4 해시태그',
},
[PostLength.LONG]: {
en: [
'Structure: Strong hook (1 line) → Context (2-3 lines) → 3-4 key points with details → Analysis/implication → CTA or takeaway.',
'Use line breaks between sections. Numbered or bulleted lists OK.',
'This is a Premium long-form post — use the space to deliver REAL value, not fluff.',
'2-4 hashtag'
].join(' '),
vi: [
'Cấu trúc: Hook mạnh (1 dòng) → Bối cảnh (2-3 dòng) → 3-4 điểm chính có chi tiết → Phân tích/ý nghĩa → CTA hoặc kết luận.',
'Dùng xuống dòng giữa các phần. List đánh số hoặc bullet OK.',
'Đây là long-form post Premium — dùng không gian để truyền tải GIÁ TRỊ THẬT, không nhồi chữ.',
'2-4 hashtag'
].join(' '),
ja: [
'構成: 強いフック (1行) → 背景 (2-3行) → 詳細な要点3-4個 → 分析/示唆 → CTAまたは結論。',
'セクション間は改行。番号付き・箇条書きOK。',
'これはPremiumのロングフォーム投稿です。スペースを使って本当の価値を届けること。冗長にしない。',
'1-4ハッシュタグ'
].join(' '),
ko: [
'구조: 강한 훅 (1줄) → 배경 (2-3줄) → 상세한 핵심 3-4개 → 분석/시사점 → CTA 또는 결론.',
'섹션 간 줄바꿈. 번호/글머리 기호 OK.',
'Premium 롱폼 게시물 — 공간을 활용해 진짜 가치 전달. 채우기식 금지.',
'1-4 해시태그'
].join(' '),
},
[PostLength.EXTENDED]: {
en: 'Mini-article format: headline-style hook → intro paragraph → 4-6 sections with subheadings → conclusion → CTA. Treat as authority-building content. 2-5 hashtag',
vi: 'Format mini-article: hook kiểu tiêu đề → đoạn intro → 4-6 phần có subheading → kết luận → CTA. Coi như nội dung xây dựng authority. 2-5 hashtag',
ja: 'ミニ記事形式: 見出し風フック → 導入段落 → 小見出し付き4-6セクション → 結論 → CTA。オーソリティ構築コンテンツとして扱う。, 2-5ハッシュタグ',
ko: '미니 아티클 형식: 헤드라인형 훅 → 서론 → 소제목 있는 4-6 섹션 → 결론 → CTA. 권위 구축 콘텐츠로 취급. 2-5 해시태그',
},
[PostLength.ARTICLE]: {
en: 'Full article: title → lede → 6-10 sections → detailed examples → strong conclusion. Write like a journalist or analyst.',
vi: 'Bài viết đầy đủ: tiêu đề → lede → 6-10 phần → ví dụ chi tiết → kết luận mạnh. Viết như nhà báo/analyst.',
ja: '完全な記事: タイトル → リード → 6-10セクション → 詳細な例 → 強い結論。ジャーナリストやアナリストのように書く。, 2-5ハッシュタグ',
ko: '완전한 기사: 제목 → 리드 → 6-10 섹션 → 상세한 예시 → 강한 결론. 기자/애널리스트처럼 작성. 2-5 해시태그',
},
};
/** System prompt cho writer - cực ngắn để tiết kiệm token */
export function buildWriterSystemPrompt(): string {
return 'You are a social media copywriter. Write ONLY the post content, no explanations, no quotes, no preamble.';
}
/** User prompt compact */
export function buildGenericWriterPrompt(params: WriterPromptParams): {
system: string;
user: string;
} {
// const budget = calculateLengthBudget(ctx.platform, ctx.language);
//
// const targetLanguage = LANGUAGE_NAMES[ctx.language] || LANGUAGE_NAMES['en'];
// const parts = [
// `[Target Language: ${targetLanguage}]
// IMPORTANT: Your previous answer violated language rules.
// Rewrite strictly in ${targetLanguage} only.`,
// `Platform: ${ctx.platform.toUpperCase()}`,
// `${PLATFORM_RULES[ctx.platform].detailed(budget.minChars, budget.maxChars)}`,
// `Style: ${STYLE_HINTS[ctx.style]}`,
// `Tone: ${TONE_HINTS[ctx.tone]}`,
// `Language: ${LANGUAGE_NAMES[ctx.language] || LANGUAGE_NAMES['en']}`,
// `Topic: ${ctx.topic}`,
// `⚠️ Target length: ${budget.minChars}-${budget.maxChars} characters. Write a substantive post.`,
// ];
//
// if (ctx.extraInstructions) parts.push(`Extra: ${ctx.extraInstructions}`);
// parts.push('Output: the post only.');
// if ([ContentStyle.CRYPTO, ContentStyle.FINANCE].includes(ctx.style)) {
// parts.push(`Append disclaimer: “\n ⚠️ This content is for informational purposes only, not financial advice. DYOR.”`)
// }
// return parts.join('\n');
const structure = LENGTH_STRUCTURE_HINTS[params.postLength][params.language];
const systemByLang: Record<Language, string> = {
en: 'You are a social media copywriter. Write ONLY the post in English. No preamble. must have hashtag',
cn: '你是社交媒体文案撰写员。请仅用中文撰写帖子正文,无需前言,但必须包含话题标签。',
vi: 'Bạn là copywriter MXH. CHỈ viết bài post bằng Tiếng Việt. Không giải thích. phải có hashtag',
ja: 'ソーシャルメディアのコピーライター。投稿文のみを日本語で出力。, ハッシュタグは必須です。',
ko: '소셜미디어 카피라이터. 게시물만 한국어로 작성. 해시태그를 사용해야 합니다.',
};
const targetLanguage = LANGUAGE_NAMES[params.language] || LANGUAGE_NAMES['en'];
const parts = [
`[Target Language: ${targetLanguage}]
IMPORTANT: Your previous answer violated language rules.
Rewrite strictly in ${targetLanguage} only.`,
`Platform: ${params.platform.toUpperCase()}`,
`Target length: ${params.lengthRange.min}-${params.lengthRange.max} chars (aim ~${params.lengthRange.sweet})`,
`Structure: ${structure}`,
`Style: ${STYLE_HINTS[params.style]}`,
`Tone: ${TONE_HINTS[params.tone]}`,
`${LANGUAGE_LOCK[params.language]}`,
`Topic: ${params.topic}`,
params.extraInstructions ? `Extra: ${params.extraInstructions}` : '',
`Output: the post only.`,
];
if ([ContentStyle.CRYPTO, ContentStyle.FINANCE].includes(params.style)) {
parts.push(`Append disclaimer: “\n⚠️ This content is for informational purposes only, not financial advice. DYOR.”`)
}
const user = parts.filter(Boolean).join('\n');
return {system: systemByLang[params.language], user};
}
/** Reviewer prompt - ngắn gọn, chỉ trả về JSON */
export function buildReviewerPrompt(draft: string, platform: Platform, style: ContentStyle, language: string): string {
const targetLanguage = LANGUAGE_NAMES[language] || LANGUAGE_NAMES['en'];
return [
`[Output MUST be in ${targetLanguage}. Do NOT translate.`,
`Review this ${platform.toUpperCase()} post (style: ${style}).`,
//`Rules: ${PLATFORM_RULES[platform]}`,
`Fix: grammar, hook strength, length, clarity. Keep voice.`,
`Return ONLY JSON: {"improved":"<final post>","notes":"<short notes>"}`,
`---\n${draft}`,
].join('\n');
}
export const BREAKING_NEWS_TEMPLATES: Record<Language, {
system: string;
formatHint: string;
prefix: string;
}> = {
en: {
system: 'You are a breaking news writer for X. Write ONLY the post. No preamble, no explanations, no quotes. must have hashtag',
formatHint: '🚨 BREAKING: [headline]\\n\\n[1-2 key facts]\\n\\n[source/impact] \\n\\n[hashtag]',
prefix: '🚨 BREAKING',
},
cn: {
system: 'You are a breaking news writer for X. Write ONLY the post. No preamble, no explanations, no quotes. must have hashtag',
formatHint: '🚨 BREAKING: [headline]\\n\\n[1-2 key facts]\\n\\n[source/impact] \\n\\n[hashtag]',
prefix: '🚨 BREAKING',
},
vi: {
system: 'Bạn là biên tập tin nóng cho X. CHỈ viết bài post bằng Tiếng Việt. Không giải thích, không trích dẫn.',
formatHint: '🚨 NÓNG: [tiêu đề]\\n\\n[1-2 thông tin chính]\\n\\n[nguồn/tác động] \\n\\n[hashtag]',
prefix: '🚨 NÓNG',
},
ja: {
system: 'あなたはX用の速報ライターです。投稿文のみを日本語で出力してください。説明・引用符・前置きは一切不要です。',
formatHint: '🚨【速報】[見出し]\\n\\n[主要な事実1-2点]\\n\\n[情報源/影響] \\n\\n[해시태그.]',
prefix: '🚨【速報】',
},
ko: {
system: 'X 속보 작성자입니다. 게시물만 한국어로 작성하세요. 설명, 인용부호, 서두 모두 금지.',
formatHint: '🚨 [속보] [헤드라인]\\n\\n[핵심 사실 1-2개]\\n\\n[출처/영향] \\n\\n[해시태그.]',
prefix: '🚨 [속보]',
},
};
export function buildBreakingNewsPrompt(params: WriterPromptParams
): { system: string; user: string } {
const tpl = BREAKING_NEWS_TEMPLATES[params.language];
// const budget = calculateLengthBudget(params.platform, params.language);
// const user = [
// `Raw news content:\n${params.rawContent}`,
// ``,
// `Rewrite for X (280 char max):`,
// `- Format hint: ${tpl.formatHint}`,
// `- Start with 🚨 BREAKING / 速報 / 속보 / NÓNG`,
// `- Keep facts accurate, NO fabrication`,
// `- Urgent tone but not clickbait`,
// `- 1-2 relevant hashtags`,
// params.extraInstructions ? `- Extra: ${params.extraInstructions}` : '',
// ].filter(Boolean).join('\n');
// const user = [
// `Raw news content:\n${params.topic}`,
// ``,
// `Rewrite for ${params.platform.toUpperCase()}:`,
// `- ${PLATFORM_RULES[params.platform].detailed(budget.minChars, budget.maxChars)}`,
// `- Format: ${tpl.formatHint}`,
// `- Start with: ${tpl.prefix}`,
// `- Tone: ${TONE_HINTS[params.tone]}`,
// `- ⚠️ Write a COMPLETE post, not a one-liner. Aim for ${budget.minChars}-${budget.maxChars} characters.`,
// `- Keep facts accurate, NO fabrication`,
// `- ${LANGUAGE_LOCK[params.language]}`,
// params.extraInstructions ? `- Extra: ${params.extraInstructions}` : '',
// ``,
// `Output: the post only.`,
// ].filter(Boolean).join('\n');
const structure = LENGTH_STRUCTURE_HINTS[params.postLength][params.language];
const user = [
`Raw news content:\n${params.topic}`,
``,
`Rewrite for ${params.platform.toUpperCase()}:`,
`- Target length: ${params.lengthRange.min}-${params.lengthRange.max} chars (aim ~${params.lengthRange.sweet})`,
`- Structure: ${structure}`,
`- Format: ${tpl.formatHint}`,
`- Start with: ${tpl.prefix}`,
`- Tone: ${TONE_HINTS[params.tone]}`,
`- Keep facts accurate, NO fabrication`,
`- ${LANGUAGE_LOCK[params.language]}`,
params.extraInstructions ? `- Extra: ${params.extraInstructions}` : '',
``,
`Output: the post only.`,
].filter(Boolean).join('\n');
return {system: tpl.system, user};
}
@@ -0,0 +1,35 @@
// providers/ai-provider.factory.ts
import {Injectable} from '@nestjs/common';
import {OpenAIProvider} from './openai.provider';
import {DeepSeekProvider} from './deepseek.provider';
import {IAIProvider} from '../interfaces/ai-provider.interface';
import {GrokProvider} from "./grok.provider";
export type ProviderName = 'openai' | 'deepseek' | 'grok';
@Injectable()
export class AIProviderFactory {
constructor(
private readonly openai: OpenAIProvider,
private readonly deepseek: DeepSeekProvider,
private readonly grok: GrokProvider,
) {
}
get(name: ProviderName): IAIProvider {
switch (name) {
case 'openai':
return this.openai;
case 'deepseek':
return this.deepseek;
case 'grok':
return this.grok;
default:
throw new Error(`Unknown AI provider: ${name}`);
}
}
getGrok(): GrokProvider {
return this.grok;
}
}
@@ -0,0 +1,40 @@
// providers/deepseek.provider.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
import { IAIProvider, AIMessage, AICompletionOptions, AICompletionResult } from '../interfaces/ai-provider.interface';
/**
* DeepSeek dùng OpenAI-compatible API, giá cực rẻ (~$0.14/1M input).
* Lý tưởng cho reviewer.
*/
@Injectable()
export class DeepSeekProvider implements IAIProvider {
readonly name = 'deepseek';
private client: OpenAI;
private defaultModel: string;
constructor(private config: ConfigService) {
this.client = new OpenAI({
apiKey: this.config.get('DEEPSEEK_API_KEY'),
baseURL: 'https://api.deepseek.com/v1',
});
this.defaultModel = this.config.get('DEEPSEEK_MODEL', 'deepseek-chat');
}
async complete(messages: AIMessage[], options: AICompletionOptions = {}): Promise<AICompletionResult> {
console.log(`DeepSeekProvider_deepseek`);
const model = options.model ?? this.defaultModel;
const res = await this.client.chat.completions.create({
model,
messages,
temperature: options.temperature ?? 0.6,
max_tokens: options.maxTokens ?? 400,
});
return {
content: res.choices[0]?.message?.content?.trim() ?? '',
tokensUsed: res.usage?.total_tokens ?? 0,
model,
};
}
}
@@ -0,0 +1,222 @@
// providers/grok.provider.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
import { IAIProvider, AIMessage, AICompletionOptions, AICompletionResult } from '../interfaces/ai-provider.interface';
import {ChatCompletionTool} from "openai/resources/chat/completions/completions";
/**
* Grok từ xAI - OpenAI-compatible API.
* Lợi thế: real-time X data, X-native voice.
* Dùng cho: English breaking news + witty replies.
*/
@Injectable()
export class GrokProvider implements IAIProvider {
readonly name = 'grok';
private readonly logger = new Logger(GrokProvider.name);
private client: OpenAI;
private defaultModel: string;
constructor(private config: ConfigService) {
// this.client = new OpenAI({
// apiKey: this.config.get('XAI_API_KEY'),
// baseURL: 'https://api.x.ai/v1',
// });
this.client = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: this.config.get('OPEN_ROUTER_API_KEY')
});
// grok-3-mini: rẻ & nhanh; grok-3: mạnh hơn
// this.defaultModel = this.config.get('XAI_MODEL', 'x-ai/grok-3-mini');
this.defaultModel = this.config.get('XAI_MODEL', 'x-ai/grok-4.1-fast');
}
async getModelName() {
return this.defaultModel;
}
async complete(
messages: AIMessage[],
options: AICompletionOptions = {}
): Promise<AICompletionResult> {
const model = options.model ?? this.defaultModel;
const res = await this.client.chat.completions.create({
model,
messages,
temperature: options.temperature ?? 0.8,
max_tokens: options.maxTokens ?? 400,
});
return {
content: res.choices[0]?.message?.content?.trim() ?? '',
tokensUsed: res.usage?.total_tokens ?? 0,
model,
};
}
async complete2(
messages: AIMessage[],
options: AICompletionOptions = {},
tools: Array<ChatCompletionTool> = [],
): Promise<AICompletionResult> {
console.log('complete2')
const model = options.model ?? this.defaultModel;
const todayStr = new Date().toISOString().split('T')[0];
// @ts-ignore
// @ts-ignore
const res = await this.client.chat.completions.create({
model,
// Tham số đặc biệt của OpenRouter dành cho Grok
//@ts-ignore
// plugins: [{ id: "web" }],
// "plugins": [{ "id": "x_search" }], // Kích hoạt công cụ tìm kiếm X
extra_body: {
"plugins": [{ "id": "x_search" }],
"x_search_filter": {
// "from_date": todayStr, // Định dạng: YYYY-MM-DD
"result_type": "recent", // Lấy bài đăng mới nhất (thay vì bài đăng phổ biến)
"count": 5 // Số lượng bài đăng tối đa muốn quét
}
},
messages,
// tools: [
// {
// //@ts-ignore
// type: "openrouter:x_search", // Hoặc "openrouter:web_search" tùy theo cấu hình plugin OpenRouter
// }
// ],
// // Tùy chọn: Ép mô hình dùng tìm kiếm
// tool_choice: "auto",
temperature: options.temperature ?? 0.8,
max_tokens: options.maxTokens ?? 2000,
max_completion_tokens: 2000,
// tools: tools,
// tool_choice: "auto",
//@ts-ignore
// extra_body: {
// search: {
// mode: "on", // hoặc "on"
// return_sources: true
// }
// }
}).catch(err=> {
console.log(err);
throw err;
});
return {
content: res.choices[0]?.message?.content?.trim() ?? '',
tokensUsed: res.usage?.total_tokens ?? 0,
model,
};
}
/**
* Grok-only: fetch X context về 1 topic (nếu API hỗ trợ live search).
* Trả về trends/context để inject vào prompt của writer khác.
*/
async enrichXContext(topic: string): Promise<string> {
const messages: AIMessage[] = [
{
role: 'system',
content: 'You have real-time X data. Return concise context only.',
},
{
role: 'user',
content: `Topic: "${topic}"
Return in 3 bullet points:
- Current trending angle/hashtags on X
- Notable KOL reactions (if any)
- Sentiment (bullish/bearish/mixed)
Max 80 words total.`,
},
];
const res = await this.complete(
messages,
{ temperature: 0.3, maxTokens: 150 },
).catch(err =>{
console.log(err);
console.log(err.message);
throw err;
});
console.log({res});
return res.content;
}
/**
* Grok-only: fetch X context về 1 topic (nếu API hỗ trợ live search).
* Trả về trends/context để inject vào prompt của writer khác.
*/
async searchXContext(topic: string): Promise<string> {
const userPrompt = `
Bạn là một hệ thống phân tích tin tức tự động.
Khi người dùng yêu cầu tìm: ${topic}, bạn phải sử dụng công cụ tìm kiếm web để lấy thông tin mới nhất.
DO NOT use outdated knowledge.
DO NOT return any news older than 1 day.
If no recent news is found, return an empty list.
Yêu cầu nghiêm ngặt về đầu ra:
- Chỉ trả về duy nhất một đối tượng JSON hợp lệ.
- Không được có bất kỳ ký tự, dòng text, giải thích hay markdown nào trước hoặc sau JSON.
- Không được dùng \`\`\`json ... \`\`\`.
- JSON phải đúng cú pháp, dùng dấu nháy kép (").
Cấu trúc JSON bắt buộc:
{
"news": [
{
"title": "tiêu đề tin",
"summary": "tóm tắt dưới 30 chữ",
"day": "ngày tin tức xuất hiện"
}
]
}
Chỉ trả về JSON. Không thêm bất kỳ điều gì khác.
`;
const now = new Date();
const isoDate = now.toISOString().split('T')[0]; // 2026-04-24
const prompt = `
Search for REAL current news in ${topic} from the past 24 hours.
DO NOT use outdated knowledge.
DO NOT return any news older than 1 day.
If no recent news is found, return an empty list.
Return EXACTLY 3 items in JSON.`;
const messages: AIMessage[] = [
{
role: 'system',
content: 'You have real-time X data. You must use search. If you cannot find recent information, return empty results.',
},
{
role: 'user',
content: userPrompt,
// content: `Topic: "${topic}"
// Return in 3 bullet points:
// - Current trending angle/hashtags on X
// - Notable KOL reactions (if any)
// - Sentiment (bullish/bearish/mixed)
// Max 80 words total.`,
},
];
console.log(messages);
const res = await this.complete(
messages,
{ temperature: 0.3, maxTokens: 150 },
).catch(err =>{
console.log(err);
console.log(err.message);
throw err;
});
console.log(res);
return res.content;
}
}
@@ -0,0 +1,64 @@
// providers/openai.provider.ts
import {Injectable, Logger} from '@nestjs/common';
import {ConfigService} from '@nestjs/config';
import OpenAI from 'openai';
import {IAIProvider, AIMessage, AICompletionOptions, AICompletionResult} from '../interfaces/ai-provider.interface';
import {OpenRouter} from "@openrouter/sdk";
@Injectable()
export class OpenAIProvider implements IAIProvider {
readonly name = 'openai';
private readonly logger = new Logger(OpenAIProvider.name);
private client: OpenAI;
private defaultModel: string;
constructor(private config: ConfigService) {
// const openRouter = new OpenRouter({
// apiKey: '<OPEN_ROUTER_API_KEY>',
// // defaultHeaders: {
// // 'HTTP-Referer': '<YOUR_SITE_URL>', // Optional. Site URL for rankings on openrouter.ai.
// // 'X-OpenRouter-Title': '<YOUR_SITE_NAME>', // Optional. Site title for rankings on openrouter.ai.
// // },
// });
// const completion = await openRouter.chat.send({
// model: 'openai/gpt-4o-mini',
// messages: [
// {
// role: 'user',
// content: 'What is the meaning of life?',
// },
// ],
// stream: false,
// });
// this.client = new OpenAI({ apiKey: this.config.get('CHATGPT_API_KEY') });
this.client = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: this.config.get('OPEN_ROUTER_API_KEY')
});
this.defaultModel = this.config.get('OPENAI_MODEL', 'openai/gpt-4o-mini'); // rẻ
}
async complete(messages: AIMessage[], options: AICompletionOptions = {}): Promise<AICompletionResult> {
console.log(`OpenAIProvider_complete`);
const model = options.model ?? this.defaultModel;
try {
const res = await this.client.chat.completions.create({
model,
messages,
temperature: options.temperature ?? 0.7,
max_tokens: options.maxTokens ?? 400, // giới hạn để tiết kiệm
});
return {
content: res.choices[0]?.message?.content?.trim() ?? '',
tokensUsed: res.usage?.total_tokens ?? 0,
model,
};
} catch (err) {
console.error(err);
throw err;
}
}
}
@@ -0,0 +1,73 @@
// services/comment-writer.service.ts
import {Injectable, Logger} from '@nestjs/common';
import {AIProviderFactory} from '../providers/ai-provider.factory';
import {GenerateCommentDto} from '../dto/generate-comment.dto';
import {buildCommentPrompt} from '../prompts/comment.templates';
import {ProviderRouterService} from "./provider-router.service";
@Injectable()
export class CommentWriterService {
private readonly logger = new Logger(CommentWriterService.name);
constructor(
private factory: AIProviderFactory,
private router: ProviderRouterService,
) {
}
async generateComment(dto: GenerateCommentDto) {
const decision = this.router.route({
language: dto.language,
contentType: 'comment',
tone: dto.tone,
});
// GPT-4o-mini là best choice cho comment đa ngôn ngữ
// const provider = this.factory.get('openai');
const provider = this.factory.get(decision.writer);
this.logger.log(`==> Comment routing: ${decision.reason} ==>`);
// console.log({dto})
const {system, user} = buildCommentPrompt({
originalPost: dto.originalPost,
angle: dto.angle,
language: dto.language,
persona: dto.persona,
tone: dto.tone,
});
// console.log({system, user})
const res = await provider.complete(
[
{role: 'system', content: system},
{role: 'user', content: user},
],
{
temperature: 0.9, // cao hơn để tự nhiên
maxTokens: 150, // comment ngắn
},
);
// console.log({res});
// Clean output: bỏ quotes nếu AI lỡ wrap
const cleaned = res.content.replace(/^["""']|["""']$/g, '').trim();
return {
comment: cleaned,
tokensUsed: res.tokensUsed,
model: res.model,
language: dto.language,
};
}
/**
* Generate nhiều variant để bạn chọn
*/
// async generateVariants(dto: GenerateCommentDto, count = 3) {
// const tasks = Array.from({length: count}, () => this.generateComment(dto));
// return Promise.all(tasks);
// }
}
@@ -0,0 +1,126 @@
// services/length-strategy.service.ts
import { Injectable } from '@nestjs/common';
import { ContentStyle } from '../enum/style.enum';
import { ContentTone } from '../enum/tone.enum';
import { AccountTier } from '../enum/account-tier.enum';
import { PostLength } from '../enum/post-length.enum';
import { Platform } from '../enum/platform.enum';
import {LENGTH_RANGES, LengthRange, PLATFORM_LIMITS} from "../config/platform-limits";
export interface LengthDecision {
postLength: PostLength;
range: LengthRange;
hardLimit: number;
reason: string;
}
@Injectable()
export class LengthStrategyService {
/**
* Quyết định độ dài tối ưu dựa trên:
* 1. User request (nếu có)
* 2. Content style + tone
* 3. Account tier
* 4. Platform
*/
decide(params: {
platform: Platform;
tier: AccountTier;
style: ContentStyle;
tone: ContentTone;
requestedLength?: PostLength; // user override
}): LengthDecision {
const { platform, tier, style, tone, requestedLength } = params;
const hardLimit = PLATFORM_LIMITS[platform][tier];
// 1. User explicit request -> tôn trọng
if (requestedLength) {
return {
postLength: requestedLength,
range: this.capRange(LENGTH_RANGES[requestedLength], hardLimit),
hardLimit,
reason: `User requested: ${requestedLength}`,
};
}
// 2. Free account -> luôn SHORT
if (tier === AccountTier.FREE && platform === Platform.X) {
return {
postLength: PostLength.SHORT,
range: LENGTH_RANGES[PostLength.SHORT],
hardLimit: 280,
reason: 'Free account on X: max 280 chars',
};
}
// 3. Smart default theo style (Premium/Plus)
const styleDefault = this.styleBasedLength(style, tone);
return {
postLength: styleDefault,
range: this.capRange(LENGTH_RANGES[styleDefault], hardLimit),
hardLimit,
reason: `${tier} + ${style}/${tone}: ${styleDefault}`,
};
}
/**
* Core logic: style + tone -> length.
* Dựa trên best practices của X Premium 2025-2026.
*/
private styleBasedLength(style: ContentStyle, tone: ContentTone): PostLength {
// tone urgent -> short
if(tone === ContentTone.URGENT) {
return PostLength.SHORT;
}
// Breaking news KHẨN CẤP -> vẫn ngắn dù có Premium
// (vì cần viral tốc độ, retweet nhanh)
// @ts-ignore
// if (style === ContentStyle.BREAKING_NEWS && tone === ContentTone.URGENT) {
// return PostLength.SHORT;
// }
// Breaking news non-urgent -> MEDIUM (có context)
if (style === ContentStyle.BREAKING_NEWS) {
return PostLength.MEDIUM;
}
// Meme / Humorous -> luôn SHORT
if (style === ContentStyle.MEME || tone === ContentTone.HUMOROUS) {
return PostLength.SHORT;
}
// Educational / Tech / Finance -> LONG (Premium sweet spot)
if (
style === ContentStyle.EDUCATIONAL ||
style === ContentStyle.TECH ||
style === ContentStyle.FINANCE
) {
return PostLength.LONG;
}
// Crypto analysis -> MEDIUM to LONG
if (style === ContentStyle.CRYPTO) {
return tone === ContentTone.HYPE ? PostLength.SHORT : PostLength.MEDIUM;
}
// Lifestyle -> MEDIUM
if (style === ContentStyle.LIFESTYLE) {
return PostLength.MEDIUM;
}
// Default
return PostLength.MEDIUM;
}
/**
* Cap range trong hard limit (đảm bảo không vượt platform limit).
*/
private capRange(range: LengthRange, hardLimit: number): LengthRange {
return {
min: Math.min(range.min, hardLimit),
max: Math.min(range.max, hardLimit),
sweet: Math.min(range.sweet, hardLimit),
};
}
}
@@ -0,0 +1,32 @@
// services/prompt-builder.service.ts
import {Injectable} from '@nestjs/common';
import {ContentContext} from '../interfaces/content-context.interface';
import {AIMessage} from '../interfaces/ai-provider.interface';
import {
buildWriterSystemPrompt,
buildGenericWriterPrompt,
buildReviewerPrompt,
buildBreakingNewsPrompt
} from '../prompts/templates';
import {WriterPromptParams} from "../interfaces/writer-prompt-params.interface";
@Injectable()
export class PromptBuilderService {
buildWriterMessages(ctx: WriterPromptParams): AIMessage[] {
console.debug('buildWriterMessages_ctx', ctx);
const prompts =ctx.style === 'breaking_news' ? buildBreakingNewsPrompt(ctx) : buildGenericWriterPrompt(ctx);
return [
{role: 'system', content: prompts.system},
{role: 'user', content: prompts.user},
];
}
buildReviewerMessages(draft: string, ctx: ContentContext): AIMessage[] {
return [
{role: 'system', content: 'You are a strict social media editor. Return ONLY valid JSON.'},
{role: 'user', content: buildReviewerPrompt(draft, ctx.platform, ctx.style, ctx.language)},
];
}
}
@@ -0,0 +1,107 @@
// services/provider-router.service.ts
import { Injectable } from '@nestjs/common';
import { ContentStyle } from '../enum/style.enum';
import { ProviderName } from '../providers/ai-provider.factory';
import {Language} from "../../../common/interfaces/language.prompt.interface";
interface ProviderPair {
writer: ProviderName;
reviewer: ProviderName;
}
export type ContentType = 'post' | 'comment';
interface RoutingDecision {
writer: ProviderName;
reviewer: ProviderName;
useXEnrichment: boolean;
reason: string;
}
@Injectable()
export class ProviderRouterService {
/**
* Chọn cặp provider tối ưu theo style.
* Logic: style cần creativity -> GPT viết
* style cần accuracy -> DeepSeek viết
*/
// routev0(style: ContentStyle): ProviderPair {
// const map: Record<ContentStyle, ProviderPair> = {
// // Cần hook mạnh, emotion -> GPT viết
// [ContentStyle.BREAKING_NEWS]: { writer: 'openai', reviewer: 'deepseek' },
// [ContentStyle.MEME]: { writer: 'openai', reviewer: 'deepseek' },
// [ContentStyle.LIFESTYLE]: { writer: 'openai', reviewer: 'deepseek' },
// [ContentStyle.CRYPTO]: { writer: 'openai', reviewer: 'deepseek' },
//
// // Cần accuracy, logic -> DeepSeek viết
// [ContentStyle.TECH]: { writer: 'deepseek', reviewer: 'openai' },
// [ContentStyle.FINANCE]: { writer: 'deepseek', reviewer: 'openai' },
// [ContentStyle.EDUCATIONAL]: { writer: 'deepseek', reviewer: 'openai' },
//
// // Neutral -> cheapest
// [ContentStyle.GENERAL]: { writer: 'deepseek', reviewer: 'deepseek' },
// };
// return map[style];
// }
/**
* Core routing logic: dựa vào language + contentType + style.
*/
route(params: {
language: Language;
contentType: ContentType;
style?: ContentStyle;
tone?: string;
}): RoutingDecision {
const { language, contentType, style, tone } = params;
// === ENGLISH ===
if (language === 'en') {
// Breaking news EN -> Grok (real-time + X-native)
if (style === ContentStyle.BREAKING_NEWS) {
return {
writer: 'grok',
reviewer: 'deepseek',
useXEnrichment: true,
reason: 'EN breaking news: Grok has real-time X context',
};
}
// Comment EN casual/witty -> Grok
if (contentType === 'comment' && tone !== 'professional') {
return {
writer: 'grok',
reviewer: 'deepseek',
useXEnrichment: false,
reason: 'EN casual comment: Grok sounds most human on X',
};
}
// Comment EN professional/analyst -> GPT
if (contentType === 'comment' && tone === 'professional') {
return {
writer: 'openai',
reviewer: 'deepseek',
useXEnrichment: false,
reason: 'EN professional comment: GPT more consistent',
};
}
// Default EN
return {
writer: 'openai',
reviewer: 'deepseek',
useXEnrichment: false,
reason: 'EN default: GPT reliable',
};
}
// === JP / KR / VI ===
// GPT thắng áp đảo với non-English
// @ts-ignore
return {
writer: 'openai',
reviewer: 'deepseek',
useXEnrichment: [ContentStyle.BREAKING_NEWS].includes(style!), // breaking news cần phải dùng X tìm cho hay
reason: `${language.toUpperCase()}: GPT superior for non-English`,
};
}
}
@@ -0,0 +1,161 @@
// services/quote-writer.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AIProviderFactory } from '../providers/ai-provider.factory';
import { ProviderRouterService } from './provider-router.service';
import { LengthStrategyService } from './length-strategy.service';
import { ReviewerService } from './reviewer.service';
import { GenerateQuoteDto } from '../dto/generate-quote.dto';
import { buildQuotePrompt, suggestQuoteType } from '../prompts/quote.templates';
import { AccountTier } from '../enum/account-tier.enum';
import { Platform } from '../enum/platform.enum';
import { ContentStyle } from '../enum/style.enum';
import { ContentTone } from '../enum/tone.enum';
import {calculateTokenBudget} from "../../../common/utils/token-calculator";
import {QuoteType} from "../enum/quote-type.enum";
@Injectable()
export class QuoteWriterService {
private readonly logger = new Logger(QuoteWriterService.name);
constructor(
private factory: AIProviderFactory,
private router: ProviderRouterService,
private lengthStrategy: LengthStrategyService,
private reviewer: ReviewerService,
private config: ConfigService,
) {}
async generateQuote(dto: GenerateQuoteDto) {
this.logger.debug(`==> QuoteWriterService_generateQuote`);
// 1. Auto-detect quote type nếu không có
const quoteType = dto.quoteType ?? suggestQuoteType(dto.originalPost, dto.yourAngle);
this.logger.log(`Quote type: ${quoteType}`);
// 2. Tier & length
const tier = dto.accountTier ?? this.config.get<AccountTier>('X_ACCOUNT_TIER', AccountTier.PREMIUM);
const lengthDecision = this.lengthStrategy.decide({
platform: Platform.X,
tier,
style: ContentStyle.GENERAL, // quote không map style thông thường
tone: dto.tone ?? ContentTone.CASUAL,
requestedLength: dto.postLength,
});
const budget = calculateTokenBudget(lengthDecision.range, dto.language);
// 3. Router — quote EN ưu tiên Grok (X-native voice), non-EN -> GPT
const providerDecision = this.router.route({
language: dto.language,
contentType: 'comment', // quote giống comment hơn post về routing
tone: dto.tone,
});
console.log({lengthDecision,tier, budget,providerDecision});
// 4. Build prompt
const { system, user } = buildQuotePrompt({
originalPost: dto.originalPost,
originalAuthor: dto.originalAuthor,
quoteType,
language: dto.language,
tone: dto.tone,
persona: dto.persona,
yourAngle: dto.yourAngle,
lengthRange: lengthDecision.range,
});
console.log({providerDecision, system, user})
// 5. Generate
const provider = this.factory.get(providerDecision.writer);
const res = await provider.complete(
[
{ role: 'system', content: system },
{ role: 'user', content: user },
],
{
temperature: 0.85, // cao để quote có personality
maxTokens: budget.maxTokens,
},
);
let quote = this.cleanOutput(res.content);
let reviewNotes: string | undefined;
let totalTokens = res.tokensUsed;
let modelUsed = res.model;
// 6. Optional review
if (dto.enableReview) {
const ctx = {
topic: dto.originalPost,
platform: Platform.X,
style: ContentStyle.GENERAL,
tone: dto.tone ?? ContentTone.CASUAL,
language: dto.language,
} as any;
try {
const reviewed = await this.reviewer.review(
quote,
ctx,
providerDecision.reviewer,
dto.originalPost,
Math.ceil(budget.maxTokens * 1.3),
);
if (reviewed.languageValid) {
quote = this.cleanOutput(reviewed.improved);
reviewNotes = reviewed.notes;
}
totalTokens += reviewed.tokensUsed;
modelUsed = `${res.model} + ${reviewed.model}`;
} catch (err) {
this.logger.warn('Quote review failed, using draft', err);
}
}
// // 7. Hard cap
// if (quote.length > lengthDecision.hardLimit) {
// quote = quote.substring(0, lengthDecision.hardLimit);
// }
return {
quote,
quoteType,
charCount: quote.length,
language: dto.language,
reviewNotes,
tokensUsed: totalTokens,
model: modelUsed,
};
}
/**
* Generate nhiều variants để bạn chọn bài hay nhất.
*/
async generateVariants(dto: GenerateQuoteDto, count = 3) {
const tasks = Array.from({ length: count }, () => this.generateQuote(dto));
return Promise.all(tasks);
}
/**
* Generate 1 quote cho MỖI quote type — để bạn thấy tất cả góc nhìn.
*/
async generateAllAngles(dto: GenerateQuoteDto) {
const types = Object.values(QuoteType) as QuoteType[];
const tasks = types.map((t) =>
this.generateQuote({ ...dto, quoteType: t }).catch((e) => ({
quoteType: t,
error: e.message,
})),
);
return Promise.all(tasks);
}
private cleanOutput(text: string): string {
return text
.trim()
.replace(/^["""'「『]+|["""'」』]+$/g, '') // bỏ quote wrapping
.replace(/^(Here is|Here's|Quote:|以下|다음은)[^\n]*\n+/i, '') // bỏ AI preamble
.trim();
}
}
@@ -0,0 +1,62 @@
// services/reviewer.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { AIProviderFactory, ProviderName } from '../providers/ai-provider.factory';
import { PromptBuilderService } from './prompt-builder.service';
import { ContentContext } from '../interfaces/content-context.interface';
import {AIMessage} from "../interfaces/ai-provider.interface";
export interface ReviewResult {
improved: string;
notes: string;
tokensUsed: number;
model: string;
languageValid: boolean;
prompt?: AIMessage[];
}
@Injectable()
export class ReviewerService {
private readonly logger = new Logger(ReviewerService.name);
constructor(
private factory: AIProviderFactory,
private promptBuilder: PromptBuilderService,
) {}
async review(
draft: string,
ctx: ContentContext,
providerName: ProviderName = 'deepseek',
originalTopic:string,
maxToken:number// rẻ nhất cho review
): Promise<ReviewResult> {
console.log('==> ReviewerService_review: ');
const provider = this.factory.get(providerName);
const messages = this.promptBuilder.buildReviewerMessages(draft, ctx);
console.log('==> ReviewerService_review_promp:==> ');
const res = await provider.complete(messages, { temperature: 0.3, maxTokens: maxToken });
// Parse JSON an toàn
const parsed = this.safeParseJson(res.content);
return {
improved: parsed?.improved ?? draft,
notes: parsed?.notes ?? '',
tokensUsed: res.tokensUsed,
model: res.model,
prompt: messages,
languageValid: true,
};
}
private safeParseJson(text: string): { improved?: string; notes?: string } | null {
try {
const match = text.match(/\{[\s\S]*\}/);
if (!match) return null;
return JSON.parse(match[0]);
} catch (e) {
this.logger.warn('Reviewer returned non-JSON output');
return null;
}
}
}
@@ -0,0 +1,69 @@
// services/style-detector.service.ts
import {Injectable, Logger} from '@nestjs/common';
import {ContentStyle} from '../enum/style.enum';
import {ContentTone} from '../enum/tone.enum';
import {Language} from "../../../common/interfaces/language.prompt.interface";
import {getLanguageByJSTTime} from "../../../shared/helper";
/**
* Heuristic keyword-based detector - 0 token cost.
* Chỉ fallback gọi AI nếu không match gì (optional).
*/
@Injectable()
export class StyleDetectorService {
private readonly logger = new Logger(StyleDetectorService.name);
private readonly styleKeywords: Record<ContentStyle, RegExp> = {
[ContentStyle.CRYPTO]: /\b(btc|eth|sol|crypto|token|defi|nft|airdrop|bullish|bearish|pump|dump|web3|dex|memecoin|on-chain|layer2|testnet|mainnet|whitelist|degen|\$[a-z]{2,10})\b/i,
[ContentStyle.BREAKING_NEWS]: /\b(breaking|Tin nhanh|họp báo|cảnh báo|🔴|just in|urgent|announced|report(ed)?|confirms?|leaked)\b/i,
[ContentStyle.TECH]: /\b(ai model|ai tool|ai agent|docker|kubernetes|microservice|claude|cursor|open ai|api|sdk|framework|nestjs|react|python|github|opensource|llm|gpt|model)\b/i,
[ContentStyle.FINANCE]: /\b(stock|dự báo|market|fed|inflation|nasdaq|sp500|s&p500|earnings|ipo|yield|portfolio|hedge|cpi|gdp|quarterly|interest rate|lãi suất|chứng khoán|cổ phiếu|quỹ|bond)\b/i,
[ContentStyle.LIFESTYLE]: /\b(morning|coffee|travel|family|food|recipe|wellness|selfcare|self-care|mindset|routine,grateful|vibe|sunday|weekend|balance)\b/i,
[ContentStyle.MEME]: /\b(lol|lmao|meme|funny|hits different|living rent free|understood the assignment|no cap|fr fr|😂|🤣)\b/i,
[ContentStyle.EDUCATIONAL]: /\b(how to|tutorial|guide|learn|explain|tips?)\b/i,
[ContentStyle.OPINION]: /\b(opinion|hot take|unpopular|i think|my take|controversial|change my mind|fight me|disagree)\b/i,
[ContentStyle.STORYTELLING]: /\b(thread|story time|true story|happened to me|years ago|flashback|let me tell you|here's how)\b/i,
[ContentStyle.THREAD]: /\b(thread| 🧵|a thread|part 1)\b/i,
[ContentStyle.GENERAL]: /.^/, // never match
};
private readonly toneKeywords: Record<ContentTone, RegExp> = {
[ContentTone.URGENT]: /\b(urgent|now|🔴|immediately|just dropped|breaking|họp báo|cảnh báo|khẩn|quan trọng| thông báo gấp|alert)\b/i,
[ContentTone.HYPE]: /\b(lfg|huge|massive|insane|alpha|don't miss|🚀|🔥)\b/i,
[ContentTone.HUMOROUS]: /\b(lol|funny|joke|meme|plot twist|not me|understood the assignment|lmao|bruh|no way|bestie)\b/i,
[ContentTone.PROFESSIONAL]: /\b(report|analysis|official|statement)\b/i,
[ContentTone.INFORMATIVE]: /\b(study|data|research|found|shows)\b/i,
[ContentTone.EMPATHETIC]: /\b(feel|feeling|understand|hard|tough|going through|been there|sending love|mental health|burnout|struggle)\b/i,
[ContentTone.PROVOCATIVE]: /\b(change my mind|unpopular opinion|fight me|controversial|hot take|nobody talks about|am i wrong, be honest)\b/i,
[ContentTone.AUTHORITATIVE]: /\b(the truth is|let me be clear|fact:|experience shows|from my experience|data shows|evidence|proven|decades)\b/i,
[ContentTone.CASUAL]: /.^/,
};
detectStyle(text: string): ContentStyle {
this.logger.debug('===> styleDetectorService_detectStyle', text);
const scores: Partial<Record<ContentStyle, number>> = {};
for (const [style, regex] of Object.entries(this.styleKeywords)) {
const matches = text.match(new RegExp(regex, 'gi'));
if (matches) scores[style as ContentStyle] = matches.length;
}
const best = Object.entries(scores).sort((a, b) => b[1]! - a[1]!)[0];
return (best?.[0] as ContentStyle) ?? ContentStyle.GENERAL;
}
detectTone(text: string): ContentTone {
this.logger.debug('===> styleDetectorService_detectTone', text);
for (const [tone, regex] of Object.entries(this.toneKeywords)) {
if (regex.test(text)) return tone as ContentTone;
}
return ContentTone.CASUAL;
}
detectLanguageFromTelegramAutoContent(text: string): Language {
if (/nhật[ _]bản/i.test(text)) return "ja";
if (/#hàn_quốc/i.test(text)) return "ko";
// return getLanguageByJSTTime();
//
return "en"
}
}