diff --git a/src/modules/content-writer/comment-writer.processor.ts b/src/modules/content-writer/comment-writer.processor.ts index ef46c4b..f00312b 100644 --- a/src/modules/content-writer/comment-writer.processor.ts +++ b/src/modules/content-writer/comment-writer.processor.ts @@ -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 = { diff --git a/src/modules/content-writer/content-writer.module.ts b/src/modules/content-writer/content-writer.module.ts index 4ae9357..d3ee94c 100644 --- a/src/modules/content-writer/content-writer.module.ts +++ b/src/modules/content-writer/content-writer.module.ts @@ -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], diff --git a/src/modules/content-writer/dto/generate-quote.dto.ts b/src/modules/content-writer/dto/generate-quote.dto.ts index 2cd91f7..264d6f0 100644 --- a/src/modules/content-writer/dto/generate-quote.dto.ts +++ b/src/modules/content-writer/dto/generate-quote.dto.ts @@ -47,4 +47,8 @@ export class GenerateQuoteDto { @IsOptional() tweetId?: number; + + @IsOptional() + @IsBoolean() + acknowledgeEdgyRisks?: boolean; } diff --git a/src/modules/content-writer/enum/tone.enum.ts b/src/modules/content-writer/enum/tone.enum.ts index ca36e48..d9d98d4 100644 --- a/src/modules/content-writer/enum/tone.enum.ts +++ b/src/modules/content-writer/enum/tone.enum.ts @@ -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.SPICY, + ContentTone.AGGRESSIVE, + ContentTone.PROFANE, + ContentTone.INFLAMMATORY, + ContentTone.SAVAGE, +]); + +export function isEdgyTone(tone: ContentTone): boolean { + return EDGY_TONES.has(tone); +} \ No newline at end of file diff --git a/src/modules/content-writer/prompts/comment.templates.ts b/src/modules/content-writer/prompts/comment.templates.ts index 36e4f9d..dfd6b1d 100644 --- a/src/modules/content-writer/prompts/comment.templates.ts +++ b/src/modules/content-writer/prompts/comment.templates.ts @@ -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 = { // 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}; } diff --git a/src/modules/content-writer/prompts/edgy-tones.ts b/src/modules/content-writer/prompts/edgy-tones.ts index 70d79c5..d5a894e 100644 --- a/src/modules/content-writer/prompts/edgy-tones.ts +++ b/src/modules/content-writer/prompts/edgy-tones.ts @@ -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.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: '...', -// }, -// }, -// }; \ No newline at end of file +// 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; + examples: Record; + vocabulary: Record; + intensity: 1 | 2 | 3 | 4 | 5; // 1 = light, 5 = max + avoid: Record; +} + +export const EDGY_TONE_SPECS: Partial> = { + [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}` + : '' +} \ No newline at end of file diff --git a/src/modules/content-writer/prompts/jp-cultural-context.ts b/src/modules/content-writer/prompts/jp-cultural-context.ts index 4e42828..463248b 100644 --- a/src/modules/content-writer/prompts/jp-cultural-context.ts +++ b/src/modules/content-writer/prompts/jp-cultural-context.ts @@ -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: ['結論:', '不人気な意見だけど', 'ホットテイク:', '誰も言わないけど'], + }, }; /** diff --git a/src/modules/content-writer/prompts/quote.templates.ts b/src/modules/content-writer/prompts/quote.templates.ts index 40c52d7..0eed1da 100644 --- a/src/modules/content-writer/prompts/quote.templates.ts +++ b/src/modules/content-writer/prompts/quote.templates.ts @@ -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 = { + 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.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]; +} \ No newline at end of file diff --git a/src/modules/content-writer/prompts/templates.ts b/src/modules/content-writer/prompts/templates.ts index 732e5ac..91e4190 100644 --- a/src/modules/content-writer/prompts/templates.ts +++ b/src/modules/content-writer/prompts/templates.ts @@ -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 = { @@ -69,20 +72,24 @@ export const STYLE_HINTS: Record = { [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.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.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.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.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.', diff --git a/src/modules/content-writer/services/comment-writer.service.ts b/src/modules/content-writer/services/comment-writer.service.ts index 00bc76d..e22a488 100644 --- a/src/modules/content-writer/services/comment-writer.service.ts +++ b/src/modules/content-writer/services/comment-writer.service.ts @@ -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( [ diff --git a/src/modules/content-writer/services/content-safety.service.ts b/src/modules/content-writer/services/content-safety.service.ts new file mode 100644 index 0000000..36b9440 --- /dev/null +++ b/src/modules/content-writer/services/content-safety.service.ts @@ -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('; ')}`); + } + } +} \ No newline at end of file diff --git a/src/modules/content-writer/services/provider-router.service.ts b/src/modules/content-writer/services/provider-router.service.ts index b2c1b28..3a0dc89 100644 --- a/src/modules/content-writer/services/provider-router.service.ts +++ b/src/modules/content-writer/services/provider-router.service.ts @@ -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) diff --git a/src/modules/content-writer/services/quote-writer.service.ts b/src/modules/content-writer/services/quote-writer.service.ts index 089afb9..e981f25 100644 --- a/src/modules/content-writer/services/quote-writer.service.ts +++ b/src/modules/content-writer/services/quote-writer.service.ts @@ -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('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> = { + [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. */ diff --git a/src/modules/content-writer/services/style-detector.service.ts b/src/modules/content-writer/services/style-detector.service.ts index c7e1968..a71aa0d 100644 --- a/src/modules/content-writer/services/style-detector.service.ts +++ b/src/modules/content-writer/services/style-detector.service.ts @@ -26,7 +26,7 @@ export class StyleDetectorService { [ContentStyle.GENERAL]: /.^/, // never match }; - private readonly toneKeywords: Record = { + private readonly toneKeywords: Partial> = { [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, diff --git a/src/modules/manager/manager.service.ts b/src/modules/manager/manager.service.ts index 2dcd7ea..825038c 100644 --- a/src/modules/manager/manager.service.ts +++ b/src/modules/manager/manager.service.ts @@ -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, diff --git a/src/modules/telegram/telegram.updates.ts b/src/modules/telegram/telegram.updates.ts index 9ed6a5a..39e773d 100644 --- a/src/modules/telegram/telegram.updates.ts +++ b/src/modules/telegram/telegram.updates.ts @@ -115,12 +115,14 @@ export class TelegramUpdates { 'comvi', 'comen', 'comja', + 'comjal', 'comko', 'comcn', ]) async onCommentAsTextCommand(@Ctx() ctx: Scenes.SceneContext): Promise { 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 { // @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; diff --git a/src/modules/telegram/wizard/comment2.wizard.ts b/src/modules/telegram/wizard/comment2.wizard.ts index 0861895..3e517c7 100644 --- a/src/modules/telegram/wizard/comment2.wizard.ts +++ b/src/modules/telegram/wizard/comment2.wizard.ts @@ -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`}, + // ] + // ] + // } + // } + // ); + // } } diff --git a/src/modules/telegram/wizard/quote.wizard.ts b/src/modules/telegram/wizard/quote.wizard.ts index bdc2e7c..5a316a4 100644 --- a/src/modules/telegram/wizard/quote.wizard.ts +++ b/src/modules/telegram/wizard/quote.wizard.ts @@ -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 ); } diff --git a/src/modules/telegram/wizard/writer.wizard.ts b/src/modules/telegram/wizard/writer.wizard.ts index a601093..be84677 100644 --- a/src/modules/telegram/wizard/writer.wizard.ts +++ b/src/modules/telegram/wizard/writer.wizard.ts @@ -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 => { diff --git a/src/modules/x-reader/x-reader.service.ts b/src/modules/x-reader/x-reader.service.ts index 2c14516..0682bc4 100644 --- a/src/modules/x-reader/x-reader.service.ts +++ b/src/modules/x-reader/x-reader.service.ts @@ -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, + ) { } - 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); } } }