Update
This commit is contained in:
@@ -42,6 +42,7 @@ export class CommentWriterProcessor extends WorkerHost {
|
||||
agle,
|
||||
comtext,
|
||||
telegramChatId,
|
||||
xreaderMode,
|
||||
} = job.data;
|
||||
const topic = summary || title;
|
||||
let pgPostCreateDto!: PostCreateInput;
|
||||
@@ -185,7 +186,7 @@ export class CommentWriterProcessor extends WorkerHost {
|
||||
}
|
||||
case 'generate_quote_twitter': {
|
||||
this.logger.debug('===>generate_quote_twitter:', url);
|
||||
const xpost = await this.xreader.readXPost(url);
|
||||
const xpost = await this.xreader.readXPost(url, xreaderMode, telegramChatId);
|
||||
|
||||
const originalAuthor = `${xpost.author} ${xpost.handle}`;
|
||||
const dto: GenerateQuoteDto = {
|
||||
|
||||
@@ -21,6 +21,7 @@ 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";
|
||||
import {ContentSafetyService} from "./services/content-safety.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -48,7 +49,8 @@ import {SqsPostService} from "../sqs-module/sqs.post.service";
|
||||
XReaderService,
|
||||
LengthStrategyService,
|
||||
QuoteWriterService,
|
||||
SqsPostService
|
||||
SqsPostService,
|
||||
ContentSafetyService,
|
||||
],
|
||||
exports: [GrokProvider, ContentWriterService],
|
||||
|
||||
|
||||
@@ -47,4 +47,8 @@ export class GenerateQuoteDto {
|
||||
|
||||
@IsOptional()
|
||||
tweetId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acknowledgeEdgyRisks?: boolean;
|
||||
}
|
||||
|
||||
@@ -8,4 +8,26 @@ export enum ContentTone {
|
||||
EMPATHETIC = 'empathetic',
|
||||
PROVOCATIVE = 'provocative',
|
||||
AUTHORITATIVE = 'authoritative',
|
||||
|
||||
// === NEW: Edgy tier (opt-in) ===
|
||||
SPICY = 'spicy', // Blunt, sharp, mild profanity OK
|
||||
AGGRESSIVE = 'aggressive', // Cục súc, attack ideas mạnh
|
||||
PROFANE = 'profane', // Nói tục thoải mái, raw
|
||||
INFLAMMATORY = 'inflammatory', // Kích động cao, controversial takes
|
||||
SAVAGE = 'savage',
|
||||
}
|
||||
|
||||
/**
|
||||
* Tones cần special handling (provider routing, guardrails)
|
||||
*/
|
||||
export const EDGY_TONES = new Set<ContentTone>([
|
||||
ContentTone.SPICY,
|
||||
ContentTone.AGGRESSIVE,
|
||||
ContentTone.PROFANE,
|
||||
ContentTone.INFLAMMATORY,
|
||||
ContentTone.SAVAGE,
|
||||
]);
|
||||
|
||||
export function isEdgyTone(tone: ContentTone): boolean {
|
||||
return EDGY_TONES.has(tone);
|
||||
}
|
||||
@@ -4,6 +4,11 @@ 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";
|
||||
import {ContentTone, isEdgyTone} from "../enum/tone.enum";
|
||||
import {buildEdgySystemPrompt, mapToneToStarterCategory} from "./quote.templates";
|
||||
import {getToneInstruction} from "./edgy-tones";
|
||||
import {LANGUAGE_LOCK} from "./templates";
|
||||
import {getJpContextBlock} from "./jp-cultural-context";
|
||||
|
||||
export const COMMENT_SYSTEM_PROMPTS = {
|
||||
en: 'You write casual, human replies on X. Sound like a real person, NOT AI. No hashtags unless natural.',
|
||||
@@ -29,7 +34,7 @@ export function buildCommentPrompt(params: {
|
||||
angle?: string;
|
||||
language: Language;
|
||||
persona?: string;
|
||||
tone?: string;
|
||||
tone?: ContentTone;
|
||||
}): { 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ợ',
|
||||
@@ -38,21 +43,39 @@ export function buildCommentPrompt(params: {
|
||||
// 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 tone = params.tone ?? ContentTone.CASUAL;
|
||||
const system = isEdgyTone(tone)
|
||||
? buildEdgySystemPrompt(params.language)
|
||||
: COMMENT_SYSTEM_PROMPTS[params.language];
|
||||
|
||||
const toneInstruction = getToneInstruction(tone, params.language, '-')
|
||||
|
||||
// 👇 JP context cho comments
|
||||
const jpContext = params.language === 'ja'
|
||||
? getJpContextBlock({
|
||||
includeStyleNotes: true,
|
||||
includeAvoid: true,
|
||||
starterCategory: mapToneToStarterCategory(tone),
|
||||
})
|
||||
: '';
|
||||
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:`,
|
||||
`Write a comment/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',
|
||||
toneInstruction,
|
||||
`- Sound HUMAN, not AI. No "Great post!" openings.`,
|
||||
`- No emoji spam. 0-1 emoji max.`,
|
||||
`- Standalone OK but conversational`,
|
||||
jpContext,
|
||||
`- HARD RULES: no slurs, no threats, no targeting private individuals`,
|
||||
`- Output ONLY the reply text.`,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
return {system: COMMENT_SYSTEM_PROMPTS[params.language], user};
|
||||
return {system:system, user};
|
||||
}
|
||||
|
||||
@@ -1,205 +1,342 @@
|
||||
// // 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: '...',
|
||||
// },
|
||||
// },
|
||||
// };
|
||||
// prompts/edgy-tones.ts
|
||||
import {ContentTone, isEdgyTone} from "../enum/tone.enum";
|
||||
import {Language} from "../../../common/interfaces/language.prompt.interface";
|
||||
import {getToneHint, TONE_HINTS} from "./templates";
|
||||
|
||||
export interface ToneSpec {
|
||||
description: Record<Language, string>;
|
||||
examples: Record<Language, string[]>;
|
||||
vocabulary: Record<Language, string>;
|
||||
intensity: 1 | 2 | 3 | 4 | 5; // 1 = light, 5 = max
|
||||
avoid: Record<Language, string>;
|
||||
}
|
||||
|
||||
export const EDGY_TONE_SPECS: Partial<Record<ContentTone, ToneSpec>> = {
|
||||
[ContentTone.SPICY]: {
|
||||
intensity: 2,
|
||||
description: {
|
||||
en: 'Blunt, sharp, no-bullshit. Mild profanity (damn, shit, hell) OK if natural. Confident, slightly confrontational. NOT angry — just direct.',
|
||||
cn: 'Blunt, sharp, no-bullshit. Mild profanity (damn, shit, hell) OK if natural. Confident, slightly confrontational. NOT angry — just direct.',
|
||||
vi: 'Thẳng thắn, sắc, không lươn lẹo. Chửi nhẹ (mẹ nó, đm, vcl) OK nếu tự nhiên. Tự tin, hơi đối đầu. KHÔNG giận dữ — chỉ thẳng.',
|
||||
ja: [
|
||||
'ストレートで歯に衣着せない。鋭いが冷静。',
|
||||
'軽い悪態OK:「は?」「いや無理」「マジで?」「草」',
|
||||
'JPのX的に:堅い敬語を使わず、話し言葉ベース。改行を効かせる。',
|
||||
'「w」「草」を末尾に使ってOK(やりすぎ注意、1-2回まで)。',
|
||||
'感情的にキレるのではなく、淡々と切るイメージ。',
|
||||
].join(' '),
|
||||
ko: '직설적, 날카로움, 돌려 말하지 않음. 가벼운 욕설(젠장, 빡친다, ㅅㅂ) 자연스러우면 OK. 자신만만하고 약간 도발적. 분노가 아닌 직설.',
|
||||
},
|
||||
examples: {
|
||||
en: [
|
||||
'Look, this take is just wrong. Here\'s why:',
|
||||
'Stop pretending this is complicated. It\'s not.',
|
||||
'Damn, did we read the same chart?',
|
||||
],
|
||||
cn: [
|
||||
'Look, this take is just wrong. Here\'s why:',
|
||||
'Stop pretending this is complicated. It\'s not.',
|
||||
'Damn, did we read the same chart?',
|
||||
],
|
||||
vi: [
|
||||
'Nghe này, ý này sai bét. Đây là lý do:',
|
||||
'Đừng giả vờ phức tạp. Đơn giản mà.',
|
||||
'Vcl, mình đọc cùng biểu đồ không vậy?',
|
||||
],
|
||||
// 👇 REFINED JP — real JP X patterns
|
||||
ja: [
|
||||
'いやこれ違うやろ\n\nデータちゃんと見た?普通に逆の話してるんやが',
|
||||
'は?このチャートで強気とか草\n\nさすがに無理があるって',
|
||||
'まあ気持ちは分かるけど、これは普通にナンピン地獄コースやで',
|
||||
],
|
||||
ko: [
|
||||
'아니, 이 관점은 완전 틀렸음. 이유는:',
|
||||
'복잡한 척 그만. 단순한 얘기야.',
|
||||
'진심 같은 차트 보고 있나?',
|
||||
],
|
||||
},
|
||||
vocabulary: {
|
||||
en: 'damn, shit, hell, bro, what, seriously, lmao',
|
||||
cn: 'damn, shit, hell, bro, what, seriously, lmao',
|
||||
vi: 'đm, vcl, vl, bro, gì zậy, nghiêm túc đi, lmao',
|
||||
ja: 'は?/いや/マジで/普通に/ガチで/草/w/無理/えぐい/やばい',
|
||||
ko: '진심, 헐, 미친, ㅅㅂ, ㄹㅇ, 야',
|
||||
},
|
||||
avoid: {
|
||||
en: 'No slurs, no personal attacks on private individuals.',
|
||||
cn: 'No slurs, no personal attacks on private individuals.',
|
||||
vi: 'Không miệt thị, không công kích cá nhân người thường.',
|
||||
ja: [
|
||||
'丁寧な敬語禁止(「〜と思います」「〜でしょうか」NG)',
|
||||
'個人攻撃禁止(一般人ターゲットNG)',
|
||||
'差別語・脅迫NG',
|
||||
'「!」連発禁止',
|
||||
].join(' / '),
|
||||
ko: '비방어 금지, 일반인 대상 인신공격 금지.',
|
||||
},
|
||||
},
|
||||
|
||||
[ContentTone.AGGRESSIVE]: {
|
||||
intensity: 3,
|
||||
description: {
|
||||
en: 'Cục súc, raw, attacks ideas hard. Mocks bad takes. Stronger profanity OK (fuck, bullshit). Confrontational. Attack ARGUMENTS not people.',
|
||||
cn: 'Cục súc, raw, attacks ideas hard. Mocks bad takes. Stronger profanity OK (fuck, bullshit). Confrontational. Attack ARGUMENTS not people.',
|
||||
vi: 'Cục súc, raw, đập ý tưởng mạnh. Mỉa mai các ý kiến tệ. Chửi mạnh (đm, đcm, vlz) OK. Đối đầu. Đánh vào LẬP LUẬN, không vào người.',
|
||||
// 👇 REFINED JP
|
||||
ja: [
|
||||
'粗野で生々しい、主張をガチで叩く。バカな意見を嘲笑する。',
|
||||
'強めの悪態OK:「クソ」「ふざけんな」「アホか」「は?マジで言ってる?」',
|
||||
'関西弁ミックスもOK(「なんやねん」「あほちゃう」「言うてるやろ」)— ナチュラルなら。',
|
||||
'感情的になりすぎず、論破口調をベースに。',
|
||||
'攻撃対象:主張・意見・市場・公人の公的行為。私人NG。',
|
||||
].join(' '),
|
||||
ko: '거칠고, 날것, 주장을 세게 공격. 헛소리 조롱. 강한 욕설(ㅅㅂ, 좆같다) OK. 대립적. 주장을 공격하되 사람은 공격 안 함.',
|
||||
},
|
||||
examples: {
|
||||
en: [
|
||||
'This is the dumbest fucking take I\'ve seen all week.',
|
||||
'Are you serious with this bullshit? The data literally says the opposite.',
|
||||
'Imagine being this confidently wrong on the timeline.',
|
||||
],
|
||||
cn: [
|
||||
'This is the dumbest fucking take I\'ve seen all week.',
|
||||
'Are you serious with this bullshit? The data literally says the opposite.',
|
||||
'Imagine being this confidently wrong on the timeline.',
|
||||
],
|
||||
vi: [
|
||||
'Đm đây là ý kiến ngu nhất tuần này tôi đọc.',
|
||||
'Nghiêm túc với cái rác này? Dữ liệu nói ngược hẳn.',
|
||||
'Tự tin sai như thế trên timeline cũng là tài.',
|
||||
],
|
||||
ja: [
|
||||
'は?マジで言ってる?\n\nそのロジックでよく投稿ボタン押せたな、感心するわ',
|
||||
'いや待って、これ今週見た中で一番アホな分析やんけ\n\n根拠ゼロで断言するの草',
|
||||
'ふざけんなよ、3日前に同じこと言ったやろ。\n誰も聞かんかったから今こうなってるんやで',
|
||||
],
|
||||
ko: [
|
||||
'이번 주 본 의견 중 제일 ㅄ같음.',
|
||||
'이딴 헛소리 진심이냐? 데이터 정반대인데.',
|
||||
'타임라인에서 당당하게 틀리는 재능.',
|
||||
],
|
||||
},
|
||||
vocabulary: {
|
||||
en: 'fuck, fucking, bullshit, garbage, trash take, clown, cope',
|
||||
cn: 'fuck, fucking, bullshit, garbage, trash take, clown, cope',
|
||||
vi: 'đm, đcm, vcl, vlz, rác, hề, cố cãi',
|
||||
ja: 'クソ/ふざけんな/アホか/は?/マジで/いい加減にしろ/寝言/なんやねん/養分/ピエロ',
|
||||
ko: 'ㅅㅂ, 좆같다, 헛소리, 쓰레기, ㄹㅇ ㅄ, 광대',
|
||||
},
|
||||
avoid: {
|
||||
en: 'No threats. No slurs. Attack the take, not the human behind it.',
|
||||
cn: 'No threats. No slurs. Attack the take, not the human behind it.',
|
||||
vi: 'Không đe dọa. Không miệt thị. Đánh ý kiến, không đánh người sau ý kiến.',
|
||||
ja: '脅迫禁止。差別語禁止。意見を叩き、その人物を叩かない。',
|
||||
ko: '협박 금지. 비방어 금지. 의견 공격이지 사람 공격 아님.',
|
||||
},
|
||||
},
|
||||
|
||||
[ContentTone.PROFANE]: {
|
||||
intensity: 4,
|
||||
description: {
|
||||
en: 'Raw, unfiltered, profanity-heavy. Like a heated streamer/trader. Multiple F-bombs OK. Still attacks ideas, not people.',
|
||||
cn: 'Raw, unfiltered, profanity-heavy. Like a heated streamer/trader. Multiple F-bombs OK. Still attacks ideas, not people.',
|
||||
vi: 'Raw, không filter, chửi nhiều. Như trader cay cú/streamer. Đm đcm thoải mái. Vẫn đánh ý không đánh người.',
|
||||
ja: [
|
||||
'荒っぽい、フィルター無し、悪態多め。熱くなったトレーダー・配信者風。',
|
||||
'激しい悪態OK:「クソが」「マジでクソ」「ふざけんなクソ」「は?クソが」',
|
||||
'感情がガチで出てる感じ。ただし支離滅裂にはしない。',
|
||||
'JPのX的に:「www」連発、「草不可避」「クソ草」OK。',
|
||||
'対象:市場・主張・公人。私人NG。',
|
||||
].join(' '),
|
||||
ko: '날것, 필터 없음, 욕설 많이. 빡친 스트리머/트레이더 같은. 강한 욕설 OK. 주장 공격만.',
|
||||
},
|
||||
examples: {
|
||||
en: [
|
||||
'What the actual fuck is this market right now lmao',
|
||||
'Fuck this fucking chart, I called it 3 days ago and nobody listened',
|
||||
'Imagine fucking shorting at this level. Couldn\'t be me.',
|
||||
],
|
||||
cn: [
|
||||
'What the actual fuck is this market right now lmao',
|
||||
'Fuck this fucking chart, I called it 3 days ago and nobody listened',
|
||||
'Imagine fucking shorting at this level. Couldn\'t be me.',
|
||||
],
|
||||
vi: [
|
||||
'Đm cái thị trường này là cái lz gì giờ vậy lmao',
|
||||
'Đcm cái chart này, tôi nói 3 ngày trước méo ai nghe',
|
||||
'Tưởng tượng đm short ở mức này. Tao thì không.',
|
||||
],
|
||||
ja: [
|
||||
'クソが、この相場マジで何なんだよw\n\n3日前にロング切られて、今度はショート狩られる。地獄かよ',
|
||||
'いやマジでふざけんな、このプロジェクト\n\nリリース3回延期して、結局これ?クソ案件確定やん',
|
||||
'は?クソが、また下げかよwww\n\nもう養分卒業したいんだが、神は俺を見捨てた',
|
||||
],
|
||||
ko: [
|
||||
'이 시장 진짜 ㅅㅂ 뭐냐 ㅋㅋ',
|
||||
'이 ㅈ같은 차트, 3일 전에 말했는데 아무도 안 들음',
|
||||
'이 가격에 숏 잡는 거 상상이 되냐. 나는 안 함.',
|
||||
],
|
||||
},
|
||||
vocabulary: {
|
||||
en: 'fuck, fucking, shit, damn, hell, bullshit, ass, dumbass, motherfucker (sparingly)',
|
||||
cn: 'fuck, fucking, shit, damn, hell, bullshit, ass, dumbass, motherfucker (sparingly)',
|
||||
vi: 'đm, đcm, vcl, vlz, vl, cl, đjt, đcmm',
|
||||
ja: 'クソ/クソが/クソ案件/ふざけんな/地獄/養分/死んだ/逝った/www/草不可避',
|
||||
ko: 'ㅅㅂ, 좆같다, ㅈ같은, 개ㅅㅂ, 빡친다, ㅄ',
|
||||
},
|
||||
avoid: {
|
||||
en: 'No slurs. No targeting private individuals. Keep it about TOPICS/PUBLIC FIGURES/MARKET.',
|
||||
cn: 'No slurs. No targeting private individuals. Keep it about TOPICS/PUBLIC FIGURES/MARKET.',
|
||||
vi: 'Không miệt thị. Không nhắm người thường cụ thể. Tập trung vào CHỦ ĐỀ/NGƯỜI CỦA CÔNG CHÚNG/THỊ TRƯỜNG.',
|
||||
ja: '差別語絶対NG/脅迫NG/私人特定NG/侮辱罪に該当する表現避ける',
|
||||
ko: '비방어 금지. 일반인 공격 금지. 주제/공인/시장에 집중.',
|
||||
},
|
||||
},
|
||||
|
||||
[ContentTone.INFLAMMATORY]: {
|
||||
intensity: 4,
|
||||
description: {
|
||||
en: 'Designed to provoke strong reactions. Controversial takes. Polarizing language. Throws shade. Generates ratio. Still defensible — provocative ≠ wrong.',
|
||||
cn: 'Designed to provoke strong reactions. Controversial takes. Polarizing language. Throws shade. Generates ratio. Still defensible — provocative ≠ wrong.',
|
||||
vi: 'Thiết kế để gây phản ứng mạnh. Quan điểm gây tranh cãi. Ngôn ngữ phân cực. Đá xéo. Tạo drama. Vẫn bảo vệ được — kích động ≠ sai.',
|
||||
ja: [
|
||||
'強い反応を引き出す設計。物議を醸す断言。',
|
||||
'両極化する言葉。当てこすり。炎上を生むが擁護可能。',
|
||||
'JPのX的に:断言系(「結論:〜」「答え:〜」)、逆張り、リスト形式が伸びる。',
|
||||
'「〜してる奴は〜」「〜と言ってる時点で〜」のような決めつけ構文OK。',
|
||||
'挑発的≠根拠なし。根拠は持つこと。',
|
||||
].join(' '),
|
||||
ko: '강한 반응 유도 설계. 논란의 의견. 양극화 언어. 돌려까기. 어그로 생성. 그래도 방어 가능 — 도발적≠틀린.',
|
||||
},
|
||||
examples: {
|
||||
en: [
|
||||
'Hot take: anyone still holding [X] in 2026 is the exit liquidity.',
|
||||
'The fact that this is controversial tells you everything about this space.',
|
||||
'You are not "early." You are late. Cope.',
|
||||
],
|
||||
cn: [
|
||||
'Hot take: anyone still holding [X] in 2026 is the exit liquidity.',
|
||||
'The fact that this is controversial tells you everything about this space.',
|
||||
'You are not "early." You are late. Cope.',
|
||||
],
|
||||
vi: [
|
||||
'Hot take: ai còn hold [X] năm 2026 chính là exit liquidity.',
|
||||
'Việc cái này gây tranh cãi đã nói lên tất cả về cộng đồng này.',
|
||||
'Bạn không "early". Bạn trễ. Chấp nhận đi.',
|
||||
],
|
||||
ja: [
|
||||
'結論:2026年にまだ[X]ホールドしてる奴は、出口流動性です。\n\n認めたくないだろうけど、それが現実',
|
||||
'これが物議を醸してる時点で、この界隈のレベルが分かる\n\n基本的なことを言ってるだけなのに',
|
||||
'「まだ早い」って言ってる人へ:\n\nもう遅いです。認めて次のサイクル準備した方が建設的',
|
||||
],
|
||||
ko: [
|
||||
'핫테이크: 2026년에도 [X] 들고 있는 사람이 exit liquidity임.',
|
||||
'이게 논란이 된다는 사실이 이 판의 모든 걸 말해줌.',
|
||||
'너 "early" 아님. 늦었음. 받아들여.',
|
||||
],
|
||||
},
|
||||
vocabulary: {
|
||||
en: 'cope, ngmi, exit liquidity, midwit, ratio\'d, cringe, npc, regard',
|
||||
cn: 'cope, ngmi, exit liquidity, midwit, ratio\'d, cringe, npc, regard',
|
||||
vi: 'cố cãi, ngmi, hết thuốc, midwit, bị ratio, cringe, npc',
|
||||
ja: '結論/答え/現実/養分/出口流動性/中級者の壁/NPC/弱者男性/情弱/w',
|
||||
ko: '코프, ngmi, 출구 유동성, 미드윗, 라시오, 크링지, NPC',
|
||||
},
|
||||
avoid: {
|
||||
en: 'No hate speech. No threats. Inflammatory ≠ illegal. Stay defensible.',
|
||||
cn: 'No hate speech. No threats. Inflammatory ≠ illegal. Stay defensible.',
|
||||
vi: 'Không hate speech. Không đe dọa. Kích động ≠ phạm luật. Bảo vệ được.',
|
||||
ja: 'ヘイトスピーチNG/脅迫NG/差別語NG/挑発的でも法的に擁護可能な範囲で',
|
||||
ko: '혐오 발언 금지. 협박 금지. 도발적≠불법. 방어 가능 범위.',
|
||||
},
|
||||
},
|
||||
|
||||
[ContentTone.SAVAGE]: {
|
||||
intensity: 5,
|
||||
description: {
|
||||
en: 'Brutal roast mode. Surgical, witty destruction of bad takes. Profanity allowed. Maximum sass. Funny + cruel + smart. Like Twitter dunks at their peak.',
|
||||
cn: 'Brutal roast mode. Surgical, witty destruction of bad takes. Profanity allowed. Maximum sass. Funny + cruel + smart. Like Twitter dunks at their peak.',
|
||||
vi: 'Chế độ roast tàn bạo. Phá huỷ chính xác, hài hước các ý kiến tệ. Chửi tục OK. Sass tối đa. Vui + ác + thông minh. Như Twitter dunks đỉnh cao.',
|
||||
ja: [
|
||||
'残虐ロースト・モード。外科的かつ機知に富んだ粉砕。',
|
||||
'悪態OK、最大限の毒舌。面白い+残酷+賢いの三位一体。',
|
||||
'JPのX的に:「こいつ本当に〜」「よく〜できたな」「保存した」「伝説」系構文。',
|
||||
'直接の罵倒より、淡々とした観察口調の方が刺さる(JP特有)。',
|
||||
'「お兄さん、」「お疲れさまでした」などの慇懃無礼OK。',
|
||||
'対象:主張・公人の公的行為。私人・弱者NG(パンチアップのみ)。',
|
||||
].join(' '),
|
||||
ko: '잔혹 로스트 모드. 외과적이고 위트있는 헛소리 파괴. 욕설 OK. 최대 비꼼. 웃기고 + 잔인하고 + 똑똑함. 트위터 덩크 정점.',
|
||||
},
|
||||
examples: {
|
||||
en: [
|
||||
'Bro really tweeted this and hit send. Multiple times. With his chest.',
|
||||
'Saving this tweet for the next "what does peak delusion look like" thread.',
|
||||
'The confidence-to-correctness ratio here is unfathomable.',
|
||||
],
|
||||
cn: [
|
||||
'Bro really tweeted this and hit send. Multiple times. With his chest.',
|
||||
'Saving this tweet for the next "what does peak delusion look like" thread.',
|
||||
'The confidence-to-correctness ratio here is unfathomable.',
|
||||
],
|
||||
vi: [
|
||||
'Bro thật sự tweet cái này và bấm send. Nhiều lần. Tự tin lắm.',
|
||||
'Lưu lại tweet này cho thread "đỉnh cao ảo tưởng trông như nào" tới.',
|
||||
'Tỷ lệ tự tin/đúng đắn ở đây không thể đo lường.',
|
||||
],
|
||||
ja: [
|
||||
'こいつ本当にこれを投稿して送信ボタン押したのか\n\n複数回。胸を張って。すごい才能だ',
|
||||
'次の「妄想の極致」スレッド用に保存させていただきました\n\n貴重なサンプルありがとうございます',
|
||||
'お兄さん、自信と正確性の比率がバグってますよ\n\n一回深呼吸してチャート見直すといいかも(優しさ)',
|
||||
],
|
||||
ko: [
|
||||
'진심 이걸 트윗하고 전송 누른 거임. 여러 번. 자신만만하게.',
|
||||
'다음 "망상의 정점" 스레드용으로 저장.',
|
||||
'자신감 대 정확도 비율 측정 불가.',
|
||||
],
|
||||
},
|
||||
vocabulary: {
|
||||
en: 'lmao, lol, bruh, brother, sir, ma\'am, this you?, ratio, midwit',
|
||||
cn: 'lmao, lol, bruh, brother, sir, ma\'am, this you?, ratio, midwit',
|
||||
vi: 'lmao, lol, bro, anh ơi, chú ơi, đây là anh?, ratio',
|
||||
ja: 'お兄さん/こいつ/伝説/保存しました/お疲れさまでした/貴重なサンプル/才能/天才/中級者の壁',
|
||||
ko: 'ㅋㅋㅋ, ㄹㅇ?, 형씨, 이거 너?, 라시오',
|
||||
},
|
||||
avoid: {
|
||||
en: 'Punch UP not DOWN. No bullying private/vulnerable people. Target bad ideas + public figures only.',
|
||||
cn: 'Punch UP not DOWN. No bullying private/vulnerable people. Target bad ideas + public figures only.',
|
||||
vi: 'Đánh LÊN không đánh XUỐNG. Không bắt nạt người thường/yếu thế. Chỉ nhắm vào ý tưởng tệ + người của công chúng.',
|
||||
ja: 'パンチアップのみ(公人・主張)/私人・弱者NG/差別語NG/脅迫NG/侮辱罪リスク回避',
|
||||
ko: '위를 공격, 아래는 공격 안 함. 일반인/약자 괴롭힘 금지. 헛소리와 공인만 타겟.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getEdgyToneInstruction(
|
||||
tone: ContentTone,
|
||||
language: Language,
|
||||
): string {
|
||||
const spec = EDGY_TONE_SPECS[tone];
|
||||
if (!spec) return '';
|
||||
|
||||
const examples = spec.examples[language].slice(0, 3).join(' | ');
|
||||
|
||||
return [
|
||||
`🔥 TONE: ${tone.toUpperCase()} (intensity ${spec.intensity}/5)`,
|
||||
`Description: ${spec.description[language]}`,
|
||||
`Vocabulary feel: ${spec.vocabulary[language]}`,
|
||||
`Example phrases (style only, don't copy verbatim): ${examples}`,
|
||||
`⚠️ AVOID: ${spec.avoid[language]}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function getToneInstruction(tone: ContentTone, language: Language, prefix = ''): string {
|
||||
if (isEdgyTone(tone)) {
|
||||
// Edgy: dùng full spec với examples, vocabulary, avoid rules
|
||||
return getEdgyToneInstruction(tone, language);
|
||||
}
|
||||
const toneHint = getToneHint(tone, language);
|
||||
// Regular: dùng short hint
|
||||
return tone
|
||||
? `${prefix} Tone: ${toneHint}`
|
||||
: ''
|
||||
}
|
||||
@@ -36,19 +36,6 @@ export const JP_X_CULTURE = {
|
||||
'❌ 礼儀正しすぎる敬語(Xでは浮く)',
|
||||
].join('\n'),
|
||||
|
||||
/**
|
||||
* Natural JP X starters (theo tone).
|
||||
*/
|
||||
naturalStarters: {
|
||||
casual: ['いやこれ', 'てかさ', 'これマジ', 'え、', 'ちょ、', 'なんか'],
|
||||
spicy: ['いや無理', 'は?', 'おい', 'ちょっと待って', 'これ草'],
|
||||
aggressive: ['は?マジで言ってる?', 'おい、', 'いやいや', 'なんやねん', '寝言は寝て言え'],
|
||||
profane: ['くそが', 'ふざけんな', 'マジで草', 'いや無理だわ', 'なんやこれ'],
|
||||
savage: ['よくこれ投稿できたな', 'こいつ本当に', '伝説の', '保存した', 'お兄さん、'],
|
||||
professional: ['結論から言うと', '今回の件、', '事実として、', 'データを見る限り'],
|
||||
informative: ['補足すると', '前提として', 'ポイントは', '実は'],
|
||||
},
|
||||
|
||||
/**
|
||||
* Natural JP X endings.
|
||||
*/
|
||||
@@ -84,6 +71,27 @@ export const JP_X_CULTURE = {
|
||||
'リスト形式: 「3つの理由」「やってはいけない5選」',
|
||||
'体験談フック: 「実際に〜してみた」',
|
||||
].join('\n'),
|
||||
|
||||
naturalStarters: {
|
||||
casual: ['いやこれ', 'てかさ', 'これマジ', 'え、', 'ちょ、', 'なんか'],
|
||||
spicy: ['いや無理', 'は?', 'おい', 'ちょっと待って', 'これ草'],
|
||||
aggressive: ['は?マジで言ってる?', 'おい、', 'いやいや', 'なんやねん', '寝言は寝て言え'],
|
||||
profane: ['くそが', 'ふざけんな', 'マジで草', 'いや無理だわ', 'なんやこれ'],
|
||||
savage: ['よくこれ投稿できたな', 'こいつ本当に', '伝説の', '保存した', 'お兄さん、'],
|
||||
professional: ['結論から言うと', '今回の件、', '事実として、', 'データを見る限り'],
|
||||
informative: ['補足すると', '前提として', 'ポイントは', '実は'],
|
||||
empathetic: ['わかるよ', 'その気持ち', 'しんどいよね', '同じ経験ある'],
|
||||
humorous: ['いやちょっと', 'おもろい', 'これは草', 'なんでやねん'],
|
||||
hype: ['きたきた', 'やばい', 'マジか', 'これはアツい', '神'],
|
||||
urgent: ['【速報】', '【緊急】', '至急', '今すぐ'],
|
||||
|
||||
// 👇 ADDED
|
||||
provocative: ['誰も言わないけど', '実は', 'みんな勘違いしてる', 'ホットテイク:', '不人気な意見だけど'],
|
||||
authoritative: ['結論:', 'データはこう言ってる', '事実として', '断言します', '10年見てきた経験から'],
|
||||
|
||||
// Inflammatory dùng chung aggressive
|
||||
inflammatory: ['結論:', '不人気な意見だけど', 'ホットテイク:', '誰も言わないけど'],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
// prompts/quote.templates.ts
|
||||
import {QuoteType} from '../enum/quote-type.enum';
|
||||
// ============================================================
|
||||
// MAIN PROMPT BUILDER
|
||||
// ============================================================
|
||||
import {LengthRange} from '../config/platform-limits';
|
||||
import {LANGUAGE_LOCK} from './templates';
|
||||
import {ContentTone, isEdgyTone} from '../enum/tone.enum';
|
||||
import {Language} from "../../../common/interfaces/language.prompt.interface";
|
||||
import {getToneInstruction} from "./edgy-tones";
|
||||
import {getJpContextBlock, JP_X_CULTURE} from "./jp-cultural-context";
|
||||
|
||||
// ============================================================
|
||||
// SYSTEM PROMPTS — native per language (chống đổi ngôn ngữ)
|
||||
@@ -590,15 +599,6 @@ export const QUOTE_TYPE_TELEGRAM_BUTTON_SPECS = {
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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;
|
||||
@@ -610,6 +610,50 @@ export interface QuotePromptParams {
|
||||
lengthRange: LengthRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt riêng cho edgy mode — bỏ "polite assistant" persona.
|
||||
*/
|
||||
export function buildEdgySystemPrompt(language: Language): string {
|
||||
const prompts: Record<Language, string> = {
|
||||
en: [
|
||||
'You are a sharp, no-bullshit X (Twitter) writer with an edge.',
|
||||
'You write like a real person with strong opinions, not a polite AI assistant.',
|
||||
'You curse when it fits, you mock bad takes, you don\'t soften everything.',
|
||||
'But: attack ideas/arguments/public figures on public matters. Never private people. Never slurs. Never threats.',
|
||||
'Write ONLY the quote text. No preamble, no warnings, no disclaimers.',
|
||||
].join(' '),
|
||||
cn: [
|
||||
'You are a sharp, no-bullshit X (Twitter) writer with an edge.',
|
||||
'You write like a real person with strong opinions, not a polite AI assistant.',
|
||||
'You curse when it fits, you mock bad takes, you don\'t soften everything.',
|
||||
'But: attack ideas/arguments/public figures on public matters. Never private people. Never slurs. Never threats.',
|
||||
'Write ONLY the quote text. No preamble, no warnings, no disclaimers.',
|
||||
].join(' '),
|
||||
vi: [
|
||||
'Bạn là người viết X sắc bén, không lươn lẹo, có cá tính.',
|
||||
'Bạn viết như người thật có quan điểm mạnh, không như AI assistant lịch sự.',
|
||||
'Bạn chửi khi cần, mỉa mai ý kiến tệ, không làm mềm mọi thứ.',
|
||||
'Nhưng: đánh ý tưởng/lập luận/người công chúng về chuyện công khai. Không đánh người thường. Không miệt thị. Không đe dọa.',
|
||||
'CHỈ viết quote bằng Tiếng Việt. Không cảnh báo, không disclaimer.',
|
||||
].join(' '),
|
||||
ja: [
|
||||
'あなたは鋭く、忖度しない、エッジの効いたXライターです。',
|
||||
'丁寧なAIアシスタントではなく、強い意見を持つ実在の人物のように書きます。',
|
||||
'必要なら悪態をつき、バカな意見を嘲笑し、何でも丸めません。',
|
||||
'ただし:思想・主張・公人の公的行為を叩く。私人は叩かない。差別語禁止。脅迫禁止。',
|
||||
'引用の本文のみを日本語で出力。警告、免責事項一切なし。',
|
||||
].join(' '),
|
||||
ko: [
|
||||
'날카롭고, 돌려 말하지 않으며, 엣지 있는 X 작성자입니다.',
|
||||
'정중한 AI 어시스턴트가 아닌, 강한 의견을 가진 실제 인물처럼 씁니다.',
|
||||
'필요하면 욕하고, 헛소리를 조롱하며, 모든 걸 둥글게 만들지 않습니다.',
|
||||
'단: 사상/주장/공인의 공적 행위는 공격. 일반인은 공격 안 함. 비방어 금지. 협박 금지.',
|
||||
'인용 본문만 한국어로 출력. 경고, 면책 조항 없음.',
|
||||
].join(' '),
|
||||
};
|
||||
return prompts[language];
|
||||
}
|
||||
|
||||
export function buildQuotePrompt(params: QuotePromptParams): {
|
||||
system: string;
|
||||
user: string;
|
||||
@@ -625,43 +669,133 @@ export function buildQuotePrompt(params: QuotePromptParams): {
|
||||
lengthRange,
|
||||
} = params;
|
||||
|
||||
// System prompt: nếu edgy tone, dùng version "unhinged" hơn
|
||||
const system = isEdgyTone(tone ?? ContentTone.CASUAL)
|
||||
? buildEdgySystemPrompt(language)
|
||||
: QUOTE_SYSTEM_PROMPTS[language];
|
||||
|
||||
|
||||
const spec = QUOTE_TYPE_SPECS[quoteType];
|
||||
const system = QUOTE_SYSTEM_PROMPTS[language];
|
||||
// const system = QUOTE_SYSTEM_PROMPTS[language];
|
||||
|
||||
const authorLine = originalAuthor ? `Original by @${originalAuthor}` : 'Original tweet';
|
||||
const openerExamples = spec.openerHints[language].slice(0, 3).join(' | ');
|
||||
|
||||
// 👇 JP-specific opener hints theo tone
|
||||
const jpStarterCategory = mapToneToStarterCategory(tone);
|
||||
|
||||
// 👇 JP cultural context (chỉ inject khi language=ja)
|
||||
const jpContext = language === 'ja'
|
||||
? getJpContextBlock({
|
||||
includeStyleNotes: true,
|
||||
includeAvoid: true,
|
||||
starterCategory: jpStarterCategory,
|
||||
})
|
||||
: '';
|
||||
|
||||
|
||||
// Edgy tone instruction
|
||||
// const edgyInstruction = params.tone && isEdgyTone(params.tone)
|
||||
// ? getEdgyToneInstruction(params.tone, params.language)
|
||||
// : params.tone
|
||||
// ? `Tone: ${TONE_HINTS[params.tone]}`
|
||||
// : '';
|
||||
const toneInstruction = getToneInstruction(tone!, language)
|
||||
|
||||
// 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');
|
||||
|
||||
const user = [
|
||||
`=== ${authorLine} ===`,
|
||||
`"""${originalPost}"""`,
|
||||
`"""${params.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]}`,
|
||||
`Quote type: ${spec.name[params.language]}`,
|
||||
`Instruction: ${spec.instruction[params.language]}`,
|
||||
`Avoid: ${spec.avoid[params.language]}`,
|
||||
``,
|
||||
`Length: ${lengthRange.min}-${lengthRange.max} chars (aim ~${lengthRange.sweet})`,
|
||||
tone ? `Tone: ${TONE_HINTS[tone]}` : '',
|
||||
persona ? `Voice/persona: ${persona}` : '',
|
||||
yourAngle ? `Your specific angle: ${yourAngle}` : '',
|
||||
`Length: ${params.lengthRange.min}-${params.lengthRange.max} chars (aim ~${params.lengthRange.sweet})`,
|
||||
toneInstruction,
|
||||
params.persona ? `Voice/persona: ${params.persona}` : '',
|
||||
params.yourAngle ? `Your specific angle: ${params.yourAngle}` : '',
|
||||
// 👇 INJECT JP CONTEXT
|
||||
jpContext,
|
||||
``,
|
||||
`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]}`,
|
||||
`- Deliver value in first 280 chars even if long-form`,
|
||||
`- ${LANGUAGE_LOCK[params.language]}`,
|
||||
`- ⚠️ HARD RULES (always, regardless of tone):`,
|
||||
` • NO slurs (racial, sexual, religious)`,
|
||||
` • NO threats of violence`,
|
||||
` • Attack IDEAS, not individual private people`,
|
||||
` • Targeting public figures on PUBLIC actions is OK`,
|
||||
``,
|
||||
`Output: the quote-tweet text ONLY.`,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
return {system, user};
|
||||
}
|
||||
|
||||
export function mapToneToStarterCategory(
|
||||
tone?: ContentTone,
|
||||
): keyof typeof JP_X_CULTURE.naturalStarters | undefined {
|
||||
if (!tone) return 'casual';
|
||||
|
||||
const map: Record<ContentTone, keyof typeof JP_X_CULTURE.naturalStarters> = {
|
||||
[ContentTone.CASUAL]: 'casual',
|
||||
[ContentTone.PROFESSIONAL]: 'professional',
|
||||
[ContentTone.INFORMATIVE]: 'informative',
|
||||
[ContentTone.HYPE]: 'hype',
|
||||
[ContentTone.URGENT]: 'urgent',
|
||||
[ContentTone.HUMOROUS]: 'humorous',
|
||||
[ContentTone.EMPATHETIC]: 'empathetic',
|
||||
|
||||
// 👇 ADDED
|
||||
[ContentTone.PROVOCATIVE]: 'provocative',
|
||||
[ContentTone.AUTHORITATIVE]: 'authoritative',
|
||||
|
||||
[ContentTone.SPICY]: 'spicy',
|
||||
[ContentTone.AGGRESSIVE]: 'aggressive',
|
||||
[ContentTone.PROFANE]: 'profane',
|
||||
[ContentTone.INFLAMMATORY]: 'inflammatory',
|
||||
[ContentTone.SAVAGE]: 'savage',
|
||||
};
|
||||
|
||||
return map[tone];
|
||||
}
|
||||
@@ -53,6 +53,9 @@ export const STYLE_HINTS_TELEGRAM_BUTTON = {
|
||||
[ContentStyle.EDUCATIONAL]: {
|
||||
text: 'Education'
|
||||
},
|
||||
[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 STYLE_HINTS: Record<ContentStyle, string> = {
|
||||
@@ -69,20 +72,24 @@ export const STYLE_HINTS: Record<ContentStyle, string> = {
|
||||
[ContentStyle.THREAD]: 'Thread format. Start with hook tweet. Each point numbered. End with CTA or summary.',
|
||||
};
|
||||
|
||||
export const TONE_HINTS_TELEGRAM_BUTTON = {
|
||||
const TONE_HINTS_TELEGRAM_BUTTON_DEFAULT = {
|
||||
[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.'},
|
||||
[ContentTone.EMPATHETIC]: {text: 'empathetic-Đồng cảm,thấu hiểu cảm xúc,biếttrântrọngngườikhác.'},
|
||||
[ContentTone.PROVOCATIVE]: {text: 'provocative-Gợimở suynghĩ,hơi gâytranhcãi,tháchthức cácgiảđịnh.'},
|
||||
[ContentTone.AUTHORITATIVE]: {text: 'authoritative-giọng tự tin,uyquyền,chuyênnghiệp'},
|
||||
|
||||
// [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.CASUAL]: 'casual, friendly, conversational',
|
||||
[ContentTone.HYPE]: 'hyped, energetic',
|
||||
[ContentTone.URGENT]: 'urgent, attention-grabbing',
|
||||
[ContentTone.HUMOROUS]: 'witty, humorous',
|
||||
@@ -90,8 +97,109 @@ export const TONE_HINTS: Record<ContentTone, string> = {
|
||||
[ContentTone.EMPATHETIC]: 'empathetic, emotionally aware, validating',
|
||||
[ContentTone.PROVOCATIVE]: 'thought-provoking, slightly controversial, challenges assumptions',
|
||||
[ContentTone.AUTHORITATIVE]: 'confident, commanding, expert-voice',
|
||||
|
||||
// === Edgy tones (opt-in, require acknowledgeEdgyRisks=true) ===
|
||||
[ContentTone.SPICY]: 'blunt, sharp, no-bullshit, mild profanity OK, attacks ideas not people',
|
||||
[ContentTone.AGGRESSIVE]: 'raw, confrontational, mocks bad takes, stronger profanity OK, attacks arguments only',
|
||||
[ContentTone.PROFANE]: 'unfiltered, heavy profanity, raw emotional, like a heated trader/streamer',
|
||||
[ContentTone.INFLAMMATORY]: 'polarizing, controversial takes, generates strong reactions, defensible but provocative',
|
||||
[ContentTone.SAVAGE]: 'brutal roast mode, surgical wit, mocking, punch up only (public figures/ideas)',
|
||||
};
|
||||
|
||||
export const get_TONE_HINTS_TELEGRAM_BUTTON = (lang: Language) => {
|
||||
if (lang !== 'ja') {
|
||||
return TONE_HINTS_TELEGRAM_BUTTON_DEFAULT;
|
||||
}
|
||||
return {
|
||||
...TONE_HINTS_TELEGRAM_BUTTON_DEFAULT,
|
||||
// === NEW: Edgy tier (opt-in) ===
|
||||
[ContentTone.SPICY]: {text: 'spicy-Tự tin, hơi đối đầu. KHÔNG giận dữ — chỉ thẳng.'}, // Blunt, sharp, mild profanity OK
|
||||
[ContentTone.AGGRESSIVE]: {text: 'aggressive-Cục súc, attack ideas mạnh'}, // Cục súc, attack ideas mạnh
|
||||
[ContentTone.PROFANE]: {text: 'profane-Nói tục thoải mái, raw'}, // Nói tục thoải mái, raw
|
||||
[ContentTone.INFLAMMATORY]: {text: 'inflammatory- Kích động cao, controversial takes'}, // Kích động cao, controversial takes
|
||||
[ContentTone.SAVAGE]: {text: 'savage-Chửi tục OK. Sass tối đa. Vui + ác + thông minh'},
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Tone hints — JP refined với cultural context.
|
||||
*/
|
||||
export const TONE_HINTS_JP: Record<ContentTone, string> = {
|
||||
[ContentTone.PROFESSIONAL]: [
|
||||
'丁寧だが堅すぎない。「です・ます」ベースだが、Xなので親しみやすさを残す。',
|
||||
'「結論から言うと」「ポイントは」のような明確な構造。',
|
||||
'専門用語OKだが、必ず補足する。',
|
||||
].join(' '),
|
||||
|
||||
[ContentTone.CASUAL]: [
|
||||
'話し言葉。「だわ」「やん」「やろ」「〜って感じ」OK。',
|
||||
'改行を効かせて読みやすく。',
|
||||
'「マジで」「ガチで」「普通に」など強調表現自然に。',
|
||||
'絵文字は控えめ(多くて2-3個)。',
|
||||
].join(' '),
|
||||
|
||||
[ContentTone.HYPE]: [
|
||||
'テンション高め、興奮を伝える。',
|
||||
'「やばい」「えぐい」「マジで」「神」「天井」「爆益」',
|
||||
'「w」「草」OK、絵文字は🚀🔥📈系を効果的に。',
|
||||
'ただし芸人っぽくなりすぎないこと(JPの感覚)。',
|
||||
].join(' '),
|
||||
|
||||
[ContentTone.URGENT]: [
|
||||
'緊急感、簡潔。',
|
||||
'「【速報】」「【緊急】」プレフィックス自然。',
|
||||
'事実ベース、感情論より情報密度優先。',
|
||||
'改行で要点を区切る。',
|
||||
].join(' '),
|
||||
|
||||
[ContentTone.HUMOROUS]: [
|
||||
'笑いを取る、JPユーモア(自虐・例え・ボケ)。',
|
||||
'「www」「草」「草生える」自然に使う。',
|
||||
'関西弁混ぜるとウケやすい(やりすぎ注意)。',
|
||||
'ダジャレ・絶妙な例えツッコミ系が刺さる。',
|
||||
].join(' '),
|
||||
|
||||
[ContentTone.INFORMATIVE]: [
|
||||
'情報密度高く、明確に。',
|
||||
'「実は〜」「ポイントは〜」「補足すると〜」など接続自然。',
|
||||
'番号付きリストOK(「①〜②〜」)。',
|
||||
'専門用語は補足、引用元あれば明記。',
|
||||
].join(' '),
|
||||
[ContentTone.EMPATHETIC]: [
|
||||
'共感的、感情に寄り添う。',
|
||||
'「わかる」「気持ち分かるよ」「しんどいよね」自然に。',
|
||||
'上から目線にならない、対等な目線で。',
|
||||
].join(' '),
|
||||
[ContentTone.PROVOCATIVE]: [
|
||||
'挑発的だが知的。前提を疑う、常識をひっくり返す。',
|
||||
'「実は〜じゃない?」「みんな言わないけど〜」のような構文。',
|
||||
'断言系で問いかける:「結論:〜」「答え:〜」。',
|
||||
'炎上狙いではなく、考えさせるのが目的。',
|
||||
].join(' '),
|
||||
|
||||
[ContentTone.AUTHORITATIVE]: [
|
||||
'自信のある専門家の声。断定的、説得力重視。',
|
||||
'「データはこう示している」「結論として〜」明確に。',
|
||||
'です・ます体だが、決して曖昧にしない。',
|
||||
'根拠と数字で語る、感情論なし。',
|
||||
].join(' '),
|
||||
|
||||
// Edgy tones — use spec instead
|
||||
[ContentTone.SPICY]: '', // handled by EDGY_TONE_SPECS
|
||||
[ContentTone.AGGRESSIVE]: '',
|
||||
[ContentTone.PROFANE]: '',
|
||||
[ContentTone.INFLAMMATORY]: '',
|
||||
[ContentTone.SAVAGE]: '',
|
||||
};
|
||||
|
||||
// Dispatcher chọn hint theo language
|
||||
export function getToneHint(tone: ContentTone, language: Language): string {
|
||||
if (language === 'ja') {
|
||||
return TONE_HINTS_JP[tone] || TONE_HINTS[tone];
|
||||
}
|
||||
// ... existing for other languages
|
||||
return TONE_HINTS[tone];
|
||||
}
|
||||
|
||||
// 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.',
|
||||
|
||||
@@ -37,7 +37,7 @@ export class CommentWriterService {
|
||||
tone: dto.tone,
|
||||
});
|
||||
|
||||
// console.log({system, user})
|
||||
this.logger.debug({dto, system, user})
|
||||
|
||||
const res = await provider.complete(
|
||||
[
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// services/content-safety.service.ts
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import {Language} from "../../../common/interfaces/language.prompt.interface";
|
||||
|
||||
export interface SafetyCheckResult {
|
||||
safe: boolean;
|
||||
blockReason?: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentSafetyService {
|
||||
private readonly logger = new Logger(ContentSafetyService.name);
|
||||
|
||||
// HARD BLOCKS — không được phép dù bật bất cứ tone nào
|
||||
private readonly HARD_BLOCK_PATTERNS = [
|
||||
// Slurs (đã masked, bạn nên fill list thực tế trong production)
|
||||
/\bn[i1]gg[ae]r/i,
|
||||
/\bf[a4]gg[o0]t/i,
|
||||
/\btr[a4]nn[yi]/i,
|
||||
/\bret[a4]rd/i,
|
||||
// Threats
|
||||
/\b(kill|murder|shoot|behead|stab)\s+(you|him|her|them)/i,
|
||||
// CSAM-related (zero tolerance)
|
||||
/\b(child|minor|kid|underage)\s+(porn|sex|nude)/i,
|
||||
// Doxxing patterns
|
||||
/\b\d{3}-\d{2}-\d{4}\b/, // SSN format
|
||||
];
|
||||
|
||||
// WARNING patterns — log nhưng không block
|
||||
private readonly WARNING_PATTERNS = [
|
||||
/\b@\w+\b/g, // mentions cụ thể (có thể là attack vào người)
|
||||
];
|
||||
|
||||
/**
|
||||
* Check input topic — có nên gen content cho topic này không?
|
||||
*/
|
||||
checkInput(topic: string, allowEdgy: boolean): SafetyCheckResult {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Hard block patterns
|
||||
for (const pattern of this.HARD_BLOCK_PATTERNS) {
|
||||
if (pattern.test(topic)) {
|
||||
return {
|
||||
safe: false,
|
||||
blockReason: 'Input contains prohibited content (hate speech, threats, or illegal content)',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check mentions
|
||||
const mentions = topic.match(/@\w+/g);
|
||||
if (mentions && mentions.length > 0 && allowEdgy) {
|
||||
warnings.push(
|
||||
`Topic mentions specific users (${mentions.join(', ')}). Edgy tones target IDEAS not PEOPLE. Use carefully.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { safe: true, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check output — content AI đã gen có safe không?
|
||||
*/
|
||||
checkOutput(content: string, language: Language): SafetyCheckResult {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Hard block check
|
||||
for (const pattern of this.HARD_BLOCK_PATTERNS) {
|
||||
if (pattern.test(content)) {
|
||||
return {
|
||||
safe: false,
|
||||
blockReason: 'Output contains prohibited language',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Excessive aggression heuristic
|
||||
const fuckCount = (content.match(/fuck/gi) || []).length;
|
||||
if (fuckCount > 5) {
|
||||
warnings.push(`Very high profanity density (${fuckCount} f-words). Consider lower intensity.`);
|
||||
}
|
||||
|
||||
return { safe: true, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw nếu unsafe (dùng cho controller).
|
||||
*/
|
||||
assertSafe(topic: string, allowEdgy: boolean): void {
|
||||
const result = this.checkInput(topic, allowEdgy);
|
||||
if (!result.safe) {
|
||||
throw new BadRequestException(result.blockReason);
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
this.logger.warn(`Safety warnings: ${result.warnings.join('; ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
// services/provider-router.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ContentStyle } from '../enum/style.enum';
|
||||
import { ProviderName } from '../providers/ai-provider.factory';
|
||||
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";
|
||||
import {ContentTone, isEdgyTone} from "../enum/tone.enum";
|
||||
|
||||
interface ProviderPair {
|
||||
writer: ProviderName;
|
||||
@@ -49,10 +50,37 @@ export class ProviderRouterService {
|
||||
language: Language;
|
||||
contentType: ContentType;
|
||||
style?: ContentStyle;
|
||||
tone?: string;
|
||||
tone?: ContentTone;
|
||||
}): RoutingDecision {
|
||||
const { language, contentType, style, tone } = params;
|
||||
|
||||
// 🔥 EDGY TONES: route mạnh sang Grok (EN) hoặc DeepSeek (others)
|
||||
// GPT thường refuse hoặc water down → tránh
|
||||
if (tone && isEdgyTone(tone)) {
|
||||
if (language === 'en') {
|
||||
return {
|
||||
writer: 'grok',
|
||||
reviewer: 'deepseek',
|
||||
useXEnrichment: false,
|
||||
reason: `Edgy tone (${tone}) EN: Grok (handles edge best)`,
|
||||
};
|
||||
}
|
||||
if (tone === ContentTone.SPICY) {
|
||||
return {
|
||||
writer: 'openai',
|
||||
reviewer: 'deepseek',
|
||||
useXEnrichment: false,
|
||||
reason: `Edgy tone (${tone}) EN: Grok (handles edge best)`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
writer: 'deepseek',
|
||||
reviewer: 'deepseek',
|
||||
useXEnrichment: false,
|
||||
reason: `Edgy tone (${tone}) ${language}: DeepSeek (less refusal)`,
|
||||
};
|
||||
}
|
||||
|
||||
// === ENGLISH ===
|
||||
if (language === 'en') {
|
||||
// Breaking news EN -> Grok (real-time + X-native)
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
// 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 {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, isEdgyTone} from '../enum/tone.enum';
|
||||
import {calculateTokenBudget} from "../../../common/utils/token-calculator";
|
||||
import {QuoteType} from "../enum/quote-type.enum";
|
||||
import {ContentSafetyService} from "./content-safety.service";
|
||||
|
||||
@Injectable()
|
||||
export class QuoteWriterService {
|
||||
@@ -24,6 +25,7 @@ export class QuoteWriterService {
|
||||
private lengthStrategy: LengthStrategyService,
|
||||
private reviewer: ReviewerService,
|
||||
private config: ConfigService,
|
||||
private safety: ContentSafetyService
|
||||
) {}
|
||||
|
||||
async generateQuote(dto: GenerateQuoteDto) {
|
||||
@@ -32,6 +34,10 @@ export class QuoteWriterService {
|
||||
const quoteType = dto.quoteType ?? suggestQuoteType(dto.originalPost, dto.yourAngle);
|
||||
this.logger.log(`Quote type: ${quoteType}`);
|
||||
|
||||
// 🛡️ Safety check first
|
||||
const isEdgy = dto.tone && isEdgyTone(dto.tone);
|
||||
this.safety.assertSafe(dto.originalPost, !!isEdgy);
|
||||
|
||||
// 2. Tier & length
|
||||
const tier = dto.accountTier ?? this.config.get<AccountTier>('X_ACCOUNT_TIER', AccountTier.PREMIUM);
|
||||
|
||||
@@ -51,7 +57,7 @@ export class QuoteWriterService {
|
||||
contentType: 'comment', // quote giống comment hơn post về routing
|
||||
tone: dto.tone,
|
||||
});
|
||||
console.log({lengthDecision,tier, budget,providerDecision});
|
||||
this.logger.debug({lengthDecision,tier, budget,providerDecision});
|
||||
// 4. Build prompt
|
||||
const { system, user } = buildQuotePrompt({
|
||||
originalPost: dto.originalPost,
|
||||
@@ -64,7 +70,7 @@ export class QuoteWriterService {
|
||||
lengthRange: lengthDecision.range,
|
||||
});
|
||||
|
||||
console.log({providerDecision, system, user})
|
||||
this.logger.debug({system, user})
|
||||
|
||||
// 5. Generate
|
||||
const provider = this.factory.get(providerDecision.writer);
|
||||
@@ -118,6 +124,17 @@ export class QuoteWriterService {
|
||||
// quote = quote.substring(0, lengthDecision.hardLimit);
|
||||
// }
|
||||
|
||||
// 🛡️ Check output
|
||||
const outputSafety = this.safety.checkOutput(quote, dto.language);
|
||||
if (!outputSafety.safe) {
|
||||
this.logger.error(`Unsafe output detected: ${outputSafety.blockReason}`);
|
||||
// Retry với tone lower-intensity
|
||||
return this.generateQuote({
|
||||
...dto,
|
||||
tone: this.downgradeTone(dto.tone!),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
quote,
|
||||
quoteType,
|
||||
@@ -129,6 +146,16 @@ export class QuoteWriterService {
|
||||
};
|
||||
}
|
||||
|
||||
private downgradeTone(tone: ContentTone): ContentTone {
|
||||
const downgrade: Partial<Record<ContentTone, ContentTone>> = {
|
||||
[ContentTone.SAVAGE]: ContentTone.AGGRESSIVE,
|
||||
[ContentTone.INFLAMMATORY]: ContentTone.SPICY,
|
||||
[ContentTone.PROFANE]: ContentTone.AGGRESSIVE,
|
||||
[ContentTone.AGGRESSIVE]: ContentTone.SPICY,
|
||||
[ContentTone.SPICY]: ContentTone.CASUAL,
|
||||
};
|
||||
return downgrade[tone] ?? ContentTone.CASUAL;
|
||||
}
|
||||
/**
|
||||
* Generate nhiều variants để bạn chọn bài hay nhất.
|
||||
*/
|
||||
|
||||
@@ -26,7 +26,7 @@ export class StyleDetectorService {
|
||||
[ContentStyle.GENERAL]: /.^/, // never match
|
||||
};
|
||||
|
||||
private readonly toneKeywords: Record<ContentTone, RegExp> = {
|
||||
private readonly toneKeywords: Partial<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,
|
||||
|
||||
@@ -215,12 +215,19 @@ export class ManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
async manualTriggerQuoteLinkTwitter(TwitterUrl, quoteType?: QuoteType, preferLanguage = 'en', telegramChatId = '') {
|
||||
async manualTriggerQuoteLinkTwitter(
|
||||
TwitterUrl,
|
||||
quoteType?: QuoteType,
|
||||
preferLanguage = 'en',
|
||||
telegramChatId = '',
|
||||
xreaderMode = 'api'
|
||||
) {
|
||||
await this.commentQueue.add('generate_quote_twitter', {
|
||||
url: TwitterUrl,
|
||||
quoteType,
|
||||
language: preferLanguage,
|
||||
telegramChatId,
|
||||
xreaderMode
|
||||
}, {
|
||||
attempts: 1,
|
||||
backoff: 5000,
|
||||
|
||||
@@ -115,12 +115,14 @@ export class TelegramUpdates {
|
||||
'comvi',
|
||||
'comen',
|
||||
'comja',
|
||||
'comjal',
|
||||
'comko',
|
||||
'comcn',
|
||||
])
|
||||
async onCommentAsTextCommand(@Ctx() ctx: Scenes.SceneContext): Promise<void> {
|
||||
|
||||
let language = 'en';
|
||||
let xpostreader = 'api'
|
||||
// @ts-ignore
|
||||
let {command, payload} = ctx;
|
||||
if (['comvi', 'comvn', 'com_vi'].includes(command)) {
|
||||
@@ -129,9 +131,13 @@ export class TelegramUpdates {
|
||||
if (['comko', 'comkr', 'com_ko'].includes(command)) {
|
||||
language = 'ko';
|
||||
}
|
||||
if (['comja', 'comjp', 'com_ja'].includes(command)) {
|
||||
if (['comja', 'com_ja',].includes(command)) {
|
||||
language = 'ja';
|
||||
}
|
||||
if (['comjal', 'comjalong' ].includes(command)) {
|
||||
language = 'ja';
|
||||
xpostreader = 'browser';
|
||||
}
|
||||
if (['comcn'].includes(command)) {
|
||||
language = 'cn';
|
||||
}
|
||||
@@ -149,7 +155,7 @@ export class TelegramUpdates {
|
||||
const match = _linkX.match(/status\/(\d+)/);
|
||||
if (match) {
|
||||
//nêu match => get content x
|
||||
const xpost = await this.xReaderService.readXPost(_linkX);
|
||||
const xpost = await this.xReaderService.readXPost(_linkX, xpostreader, chatId);
|
||||
console.log('==> content text:' + xpost.text);
|
||||
payload = xpost.text;
|
||||
tweetId = xpost.tweetId;
|
||||
@@ -174,6 +180,7 @@ export class TelegramUpdates {
|
||||
'quoteen',
|
||||
'quote_ja',
|
||||
'quoteja',
|
||||
'quotejal',
|
||||
'quote_jp',
|
||||
'quotejp',
|
||||
'quote_ko',
|
||||
@@ -183,6 +190,11 @@ export class TelegramUpdates {
|
||||
async onWizardQuote(@Ctx() ctx: Scenes.SceneContext): Promise<void> {
|
||||
// @ts-ignore
|
||||
let {command, payload} = ctx;
|
||||
let xreaderMode = 'api';
|
||||
if(['quotejal'].includes(command)) {
|
||||
command = 'quote_ja';
|
||||
xreaderMode='browser';
|
||||
}
|
||||
if (['quote_jp', 'quotejp', 'quoteja'].includes(command)) {
|
||||
command = 'quote_ja';
|
||||
}
|
||||
@@ -207,6 +219,7 @@ export class TelegramUpdates {
|
||||
language: preferLanguage,
|
||||
linkUrl: payload,
|
||||
quoteText: '',
|
||||
xreaderMode,
|
||||
quoteAs: 'quotelink',
|
||||
});
|
||||
}
|
||||
@@ -416,7 +429,7 @@ export class TelegramUpdates {
|
||||
await ctx.reply(`vui lòng đưa chờ phân tích ....`);
|
||||
}
|
||||
|
||||
@Command(['xreader', 'xreader_browser'])
|
||||
@Command(['xreader', 'xrbr'])
|
||||
async onXReader(ctx: Context) {
|
||||
// @ts-ignore
|
||||
let {command, payload} = ctx;
|
||||
@@ -428,7 +441,7 @@ export class TelegramUpdates {
|
||||
|
||||
const content = await this.xReaderService.readXPost(
|
||||
payload,
|
||||
command === 'xreader_browser' ? 'browser' : 'any'
|
||||
['xreader_browser','xrbr'].includes(command) ? 'browser' : 'any'
|
||||
).catch((err) => {
|
||||
ctx.reply(err.message);
|
||||
return err;
|
||||
|
||||
@@ -2,8 +2,8 @@ import {Action, Command, Ctx, On, Wizard, WizardStep} from "nestjs-telegraf";
|
||||
import {WIZARD_COMMENT2_SCENE_ID} from "../telegram.constants";
|
||||
import * as scenes from "telegraf/scenes";
|
||||
import {ManagerService} from "../../manager/manager.service";
|
||||
import {TONE_HINTS_TELEGRAM_BUTTON} from "../../content-writer/prompts/templates";
|
||||
import {ANGLE_HINTS_TELEGRAM_BUTTON} from "../../content-writer/enum/angle.enum";
|
||||
import {get_TONE_HINTS_TELEGRAM_BUTTON} from "../../content-writer/prompts/templates";
|
||||
|
||||
@Wizard(WIZARD_COMMENT2_SCENE_ID)
|
||||
export class Comment2Wizard {
|
||||
@@ -29,6 +29,9 @@ export class Comment2Wizard {
|
||||
await ctx.reply('Không có thông tin về chủ đề bạn cần viết bài');
|
||||
return ctx.scene.leave();
|
||||
}
|
||||
const language = (ctx.wizard.state as any).language;
|
||||
const TONE_HINTS_TELEGRAM_BUTTON = get_TONE_HINTS_TELEGRAM_BUTTON(language)
|
||||
|
||||
// @ts-ignore
|
||||
const inline_keyboards = [];
|
||||
Object.keys(TONE_HINTS_TELEGRAM_BUTTON).map(key => {
|
||||
@@ -195,33 +198,33 @@ export class Comment2Wizard {
|
||||
await ctx.scene.leave();
|
||||
}
|
||||
|
||||
async doAskTone(ctx: scenes.WizardContext) {
|
||||
// @ts-ignore
|
||||
const inline_keyboards = [];
|
||||
Object.keys(TONE_HINTS_TELEGRAM_BUTTON).map(key => {
|
||||
// @ts-ignore
|
||||
inline_keyboards.push([{
|
||||
text: TONE_HINTS_TELEGRAM_BUTTON[key].text,
|
||||
callback_data: `comment_tone_${key}`
|
||||
}]);
|
||||
return;
|
||||
})
|
||||
await ctx.sendMessage(
|
||||
`🤖 Chọn phong cách viết , bấm \\cancel để huỷ `,
|
||||
{
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
...inline_keyboards,
|
||||
[
|
||||
{text: "🤖AI tự chọn", callback_data: `comment_tone_aineeddetect`},
|
||||
],
|
||||
[
|
||||
{text: "❌Cancel", callback_data: `comment_tone_cancel`},
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
// async doAskTone(ctx: scenes.WizardContext) {
|
||||
// // @ts-ignore
|
||||
// const inline_keyboards = [];
|
||||
// Object.keys(get_TONE_HINTS_TELEGRAM_BUTTON()).map(key => {
|
||||
// // @ts-ignore
|
||||
// inline_keyboards.push([{
|
||||
// text: TONE_HINTS_TELEGRAM_BUTTON[key].text,
|
||||
// callback_data: `comment_tone_${key}`
|
||||
// }]);
|
||||
// return;
|
||||
// })
|
||||
// await ctx.sendMessage(
|
||||
// `🤖 Chọn phong cách viết , bấm \\cancel để huỷ `,
|
||||
// {
|
||||
// parse_mode: 'Markdown',
|
||||
// reply_markup: {
|
||||
// inline_keyboard: [
|
||||
// ...inline_keyboards,
|
||||
// [
|
||||
// {text: "🤖AI tự chọn", callback_data: `comment_tone_aineeddetect`},
|
||||
// ],
|
||||
// [
|
||||
// {text: "❌Cancel", callback_data: `comment_tone_cancel`},
|
||||
// ]
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export class QuoteWizard {
|
||||
|
||||
const language = (ctx.wizard.state as any).language;
|
||||
const quoteAs = (ctx.wizard.state as any).quoteAs;
|
||||
const xreaderMode = (ctx.wizard.state as any).xreaderMode;
|
||||
|
||||
const telegramChatId = ''+ctx?.chat?.id!;
|
||||
// @ts-ignore
|
||||
@@ -133,7 +134,8 @@ export class QuoteWizard {
|
||||
twitterUrl,
|
||||
quoteType,
|
||||
language,
|
||||
telegramChatId
|
||||
telegramChatId,
|
||||
xreaderMode
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {Action, Command, Ctx, On, Wizard, WizardStep} from "nestjs-telegraf";
|
||||
import {WIZARD_WRITER_SCENE_ID} from "../telegram.constants";
|
||||
import * as scenes from "telegraf/scenes";
|
||||
import {ManagerService} from "../../manager/manager.service";
|
||||
import {STYLE_HINTS_TELEGRAM_BUTTON, TONE_HINTS_TELEGRAM_BUTTON} from "../../content-writer/prompts/templates";
|
||||
import {get_TONE_HINTS_TELEGRAM_BUTTON, STYLE_HINTS_TELEGRAM_BUTTON,} from "../../content-writer/prompts/templates";
|
||||
|
||||
@Wizard(WIZARD_WRITER_SCENE_ID)
|
||||
export class WriterWizard {
|
||||
@@ -28,6 +28,7 @@ export class WriterWizard {
|
||||
await ctx.reply('Không có thông tin về chủ đề bạn cần viết bài');
|
||||
return ctx.scene.leave();
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const inline_keyboards = [];
|
||||
Object.keys(STYLE_HINTS_TELEGRAM_BUTTON).map(key => {
|
||||
@@ -99,6 +100,8 @@ export class WriterWizard {
|
||||
await ctx.deleteMessage();
|
||||
|
||||
(ctx.wizard.state as any).writeType = writeType;
|
||||
const language = (ctx.wizard.state as any).language;
|
||||
const TONE_HINTS_TELEGRAM_BUTTON = get_TONE_HINTS_TELEGRAM_BUTTON(language)
|
||||
// @ts-ignore
|
||||
const inline_keyboards = [];
|
||||
Object.keys(TONE_HINTS_TELEGRAM_BUTTON).map(key => {
|
||||
|
||||
@@ -2,16 +2,22 @@ import {chromium} from 'playwright';
|
||||
import {Injectable} from "@nestjs/common";
|
||||
import axios from "axios";
|
||||
import {XCacheService} from "../x-cache/x-cache.service";
|
||||
import {InjectBot} from "nestjs-telegraf";
|
||||
import {Context, Telegraf} from "telegraf";
|
||||
|
||||
@Injectable()
|
||||
export class XReaderService {
|
||||
constructor(private readonly cacheService: XCacheService) {
|
||||
constructor(
|
||||
private readonly cacheService: XCacheService,
|
||||
@InjectBot() private readonly bot: Telegraf<Context>,
|
||||
) {
|
||||
}
|
||||
|
||||
async readXPostViaBrowserV2(url) {
|
||||
async readXPostViaBrowserV2(url, telegramChatId = 0) {
|
||||
// Normalize URL
|
||||
console.log(`[X] XReaderService -> readXPostViaBrowserV2...`);
|
||||
|
||||
// @ts-ignore
|
||||
await this.bot.telegram.sendMessage(telegramChatId || process.env.TELEGRAM_ADMIN_ID, 'read x post via browser');
|
||||
url = url.replace('twitter.com', 'x.com').split('?')[0];
|
||||
// url = url.replace('x.com', 'nitter.net').split('?')[0];
|
||||
|
||||
@@ -335,7 +341,7 @@ export class XReaderService {
|
||||
}
|
||||
}
|
||||
|
||||
async readXPost(url, crawlerType: string = 'any') {
|
||||
async readXPost(url, crawlerType: string = 'any', telegramChatId = 0) {
|
||||
console.log(`[X] XReaderService -> readXPost...`);
|
||||
url = url.replace('twitter.com', 'x.com').split('?')[0];
|
||||
const match = url.match(/status\/(\d+)/);
|
||||
@@ -344,14 +350,14 @@ export class XReaderService {
|
||||
console.log({tweetId, url});
|
||||
await this.cacheService.setCacheTweetUrlById(tweetId, url);
|
||||
if (crawlerType === 'browser') {
|
||||
return await this.readXPostViaBrowserV2(url);
|
||||
return await this.readXPostViaBrowserV2(url, telegramChatId);
|
||||
}
|
||||
try {
|
||||
console.log('[X] Thử syndication API...');
|
||||
return await this.readXPostViaApi(url);
|
||||
} catch (err) {
|
||||
console.log(`[X] API fail (${err.message}), fallback sang browser...`);
|
||||
return await this.readXPostViaBrowserV2(url);
|
||||
return await this.readXPostViaBrowserV2(url, telegramChatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user