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,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"
}
}