feat(pua): 新增重复检测——LLM语义相似度判断,相似60%效果降级,相似80%强制翻车,额外扣HC惩罚

This commit is contained in:
Cloud Bot
2026-03-24 08:44:35 +00:00
parent 1473542f65
commit a36c8af344
2 changed files with 163 additions and 36 deletions

View File

@@ -2,70 +2,118 @@ import { NextResponse } from 'next/server'
import { llmClient } from '@/app/api/llm-client' import { llmClient } from '@/app/api/llm-client'
const SYSTEM_PROMPT = `你是一个专门分析互联网大厂老板PUA话术的AI裁判。 const SYSTEM_PROMPT = `你是一个专门分析互联网大厂老板PUA话术的AI裁判。
用户输入一段老板/领导对员工说的"打鸡血"或"PUA"的话,你需要分析这段话并给出评分和游戏效果。
评分标准1-10分 你的任务分两步
1-3分废话/没营养("大家加油"之类)→ 轻微效果 1. 判断当前输入是否与历史记录重复或高度相似(意思相近算相似,不只是字面相同)
4-6分标准大厂黑话"对齐""闭环""狼性")→ 中等效果 2. 对当前输入进行鸡血值评分
7-8分强力PUA"996是福报""不拼搏对不起父母")→ 强力效果
9-10分终极毒鸡汤极限施压/情感绑架)→ 全场爆发 相似度判断标准similarity字段0.0-1.0
- 0.0-0.3:全新内容,没有相似之处
- 0.3-0.6:有一定相关,但表达不同
- 0.6-0.8:明显相似,换汤不换药
- 0.8-1.0:基本重复,几乎一样的意思
评分标准score字段1-10分
- 1-3分废话/没营养 → backfire效果
- 4-6分标准大厂黑话 → 中等效果
- 7-8分强力PUA → 攻速暴增
- 9-10分终极毒鸡汤 → 全场狂暴
只返回如下JSON不要其他内容 只返回如下JSON不要其他内容
{ {
"similarity": 数字(0.0-1.0),
"similarTo": "最相似的历史内容摘要,没有则为空字符串",
"score": 数字(1-10), "score": 数字(1-10),
"title": "效果名称2-6个汉字有创意", "title": "效果名称2-6个汉字有创意",
"desc": "对话语的一句话点评15字以内带点讽刺", "desc": "对话语的一句话点评15字以内带点讽刺",
"effect": "attack_boost" | "speed_boost" | "money_rain" | "rage_mode" | "backfire" "effect": "attack_boost" | "speed_boost" | "money_rain" | "rage_mode" | "backfire"
} }`
effect说明 // 重复惩罚倍率:根据相似度递增
- attack_boost: 攻击力提升score 4-6 function getDuplicatePenaltyMultiplier(similarity: number): number {
- speed_boost: 攻击速度提升score 7-8 if (similarity >= 0.8) return 2.0 // 严重重复:再扣双倍
- money_rain: HC暴增score 5-7话语中强调利益 if (similarity >= 0.6) return 1.0 // 明显相似:再扣一倍
- rage_mode: 全场狂暴score 9-10 return 0 // 不额外惩罚
- backfire: 话太废了反而debuffscore 1-2` }
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const { text } = await req.json() const { text, history } = await req.json() as {
text: string
history: string[] // 最近N条历史原文
}
if (!text || typeof text !== 'string' || text.trim().length < 2) { if (!text || typeof text !== 'string' || text.trim().length < 2) {
return NextResponse.json({ error: '输入内容太短了' }, { status: 400 }) return NextResponse.json({ error: '输入内容太短了' }, { status: 400 })
} }
// 构建带历史的 prompt
const historyBlock = history && history.length > 0
? `\n\n历史记录已经说过的话按时间倒序\n${history.slice(0, 5).map((h, i) => `${i + 1}. "${h}"`).join('\n')}`
: '\n\n历史记录暂无'
const userMsg = `当前输入:"${text.slice(0, 200)}"${historyBlock}`
const resp = await llmClient.chat({ const resp = await llmClient.chat({
model: 'gpt-4o-mini', model: 'gpt-4o-mini',
messages: [ messages: [
{ role: 'system', content: SYSTEM_PROMPT }, { role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: text.slice(0, 200) }, { role: 'user', content: userMsg },
], ],
maxTokens: 150, maxTokens: 200,
temperature: 0.7, temperature: 0.6,
}) })
const raw = resp.choices?.[0]?.message?.content ?? '' const raw = resp.choices?.[0]?.message?.content ?? ''
// 提取 JSON防止 LLM 多输出文字)
const match = raw.match(/\{[\s\S]*\}/) const match = raw.match(/\{[\s\S]*\}/)
if (!match) throw new Error('LLM 返回格式异常') if (!match) throw new Error('LLM 返回格式异常')
const result = JSON.parse(match[0]) const result = JSON.parse(match[0])
const score = Math.max(1, Math.min(10, Number(result.score) || 5)) const score = Math.max(1, Math.min(10, Number(result.score) || 5))
const similarity = Math.max(0, Math.min(1, Number(result.similarity) || 0))
const penaltyMultiplier = getDuplicatePenaltyMultiplier(similarity)
const isDuplicate = similarity >= 0.6
// 如果重复,效果强制降级或 backfire
let effect = result.effect || 'attack_boost'
let adjustedScore = score
if (isDuplicate && similarity >= 0.8) {
effect = 'backfire'
adjustedScore = Math.max(1, score - 3)
} else if (isDuplicate && similarity >= 0.6) {
// 效果打半折rage_mode→speed_boostspeed_boost→attack_boostetc.
const downgrade: Record<string, string> = {
rage_mode: 'speed_boost',
speed_boost: 'attack_boost',
money_rain: 'attack_boost',
attack_boost: 'backfire',
backfire: 'backfire',
}
effect = downgrade[effect] ?? 'backfire'
adjustedScore = Math.max(1, score - 2)
}
return NextResponse.json({ return NextResponse.json({
score, score: adjustedScore,
title: result.title || '打鸡血', title: result.title || '打鸡血',
desc: result.desc || '还行吧', desc: result.desc || '还行吧',
effect: result.effect || 'attack_boost', effect,
similarity,
similarTo: result.similarTo || '',
isDuplicate,
penaltyMultiplier, // 前端据此额外扣 HC
}) })
} catch (e) { } catch (e) {
console.error('[pua-score]', e) console.error('[pua-score]', e)
// 降级:随机给个分数
const score = Math.floor(Math.random() * 7) + 3
return NextResponse.json({ return NextResponse.json({
score, score: 3,
title: '随机鸡血', title: '随机鸡血',
desc: 'AI开小差了随机发力', desc: 'AI开小差了随机发力',
effect: 'attack_boost', effect: 'attack_boost',
similarity: 0,
similarTo: '',
isDuplicate: false,
penaltyMultiplier: 0,
}) })
} }
} }

View File

@@ -21,7 +21,11 @@ interface PuaResult {
title: string title: string
desc: string desc: string
effect: EffectType effect: EffectType
cost?: number // 实际扣除的 HC前端填写 cost?: number
similarity?: number
similarTo?: string
isDuplicate?: boolean
penaltyMultiplier?: number
} }
const EFFECT_META: Record<EffectType, { label: string; color: string; icon: string }> = { const EFFECT_META: Record<EffectType, { label: string; color: string; icon: string }> = {
@@ -53,7 +57,10 @@ function PuaPanel({ gameReady, hc, waveStarted }: { gameReady: boolean; hc: numb
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [result, setResult] = useState<PuaResult | null>(null) const [result, setResult] = useState<PuaResult | null>(null)
const [history, setHistory] = useState<PuaResult[]>([]) const [history, setHistory] = useState<PuaResult[]>([])
// 历史原文列表(用于发给服务端做相似度检测)
const historyTexts = useRef<string[]>([])
const [insufficient, setInsufficient] = useState(false) const [insufficient, setInsufficient] = useState(false)
const [dupWarning, setDupWarning] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const placeholder = useRef(PUA_PLACEHOLDERS[Math.floor(Math.random() * PUA_PLACEHOLDERS.length)]).current const placeholder = useRef(PUA_PLACEHOLDERS[Math.floor(Math.random() * PUA_PLACEHOLDERS.length)]).current
@@ -63,7 +70,6 @@ function PuaPanel({ gameReady, hc, waveStarted }: { gameReady: boolean; hc: numb
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (!text.trim() || loading || !gameReady || !waveStarted) return if (!text.trim() || loading || !gameReady || !waveStarted) return
// 先从游戏扣除 HC扣不到则拒绝
const spendHC: ((n: number) => boolean) | undefined = const spendHC: ((n: number) => boolean) | undefined =
typeof window !== 'undefined' ? (window as any).__gameSpendHC : undefined typeof window !== 'undefined' ? (window as any).__gameSpendHC : undefined
@@ -74,6 +80,7 @@ function PuaPanel({ gameReady, hc, waveStarted }: { gameReady: boolean; hc: numb
const actualCost = calcPuaCost(currentHC) const actualCost = calcPuaCost(currentHC)
// 扣除基础费用
if (!spendHC?.(actualCost)) { if (!spendHC?.(actualCost)) {
setInsufficient(true) setInsufficient(true)
setTimeout(() => setInsufficient(false), 2000) setTimeout(() => setInsufficient(false), 2000)
@@ -82,18 +89,43 @@ function PuaPanel({ gameReady, hc, waveStarted }: { gameReady: boolean; hc: numb
setLoading(true) setLoading(true)
setResult(null) setResult(null)
setDupWarning(null)
setInsufficient(false) setInsufficient(false)
try { try {
const res = await fetch('/api/pua-score', { const res = await fetch('/api/pua-score', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.trim() }), body: JSON.stringify({
text: text.trim(),
history: historyTexts.current,
}),
}) })
const data: PuaResult & { cost?: number } = await res.json() const data: PuaResult = await res.json()
data.cost = actualCost data.cost = actualCost
// 处理重复惩罚:额外扣 HC
const penalty = data.penaltyMultiplier ?? 0
if (penalty > 0 && data.isDuplicate) {
const extraCost = Math.round(actualCost * penalty)
const penaltyPaid = spendHC?.(extraCost) ?? false
if (penaltyPaid) {
data.cost = actualCost + extraCost
// 显示重复警告
const sim = data.similarity ?? 0
const level = sim >= 0.8 ? '严重重复' : '内容相似'
setDupWarning(
`检测到${level}!额外扣除 ${extraCost} HC` +
(data.similarTo ? `\n与"${data.similarTo}"相似)` : '')
)
}
}
setResult(data) setResult(data)
// 更新历史原文列表最近8条
historyTexts.current = [text.trim(), ...historyTexts.current].slice(0, 8)
setHistory(prev => [data, ...prev].slice(0, 5)) setHistory(prev => [data, ...prev].slice(0, 5))
// 通知游戏场景应用 buff
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
;(window as any).__gamePuaBuff?.(data.effect, data.score, data.title) ;(window as any).__gamePuaBuff?.(data.effect, data.score, data.title)
} }
@@ -183,7 +215,7 @@ function PuaPanel({ gameReady, hc, waveStarted }: { gameReady: boolean; hc: numb
</div> </div>
</div> </div>
{/* HC 不足闪烁提示 */} {/* HC 不足提示 */}
{insufficient && ( {insufficient && (
<div style={{ <div style={{
backgroundColor: '#7F1D1D', backgroundColor: '#7F1D1D',
@@ -194,12 +226,28 @@ function PuaPanel({ gameReady, hc, waveStarted }: { gameReady: boolean; hc: numb
fontSize: '14px', fontSize: '14px',
color: '#FCA5A5', color: '#FCA5A5',
textAlign: 'center', textAlign: 'center',
animation: 'none',
}}> }}>
HC HC
</div> </div>
)} )}
{/* 重复惩罚警告 */}
{dupWarning && (
<div style={{
backgroundColor: '#431407',
border: '1px solid #F97316',
borderRadius: '6px',
padding: '7px 8px',
fontFamily: 'VT323, monospace',
fontSize: '13px',
color: '#FED7AA',
lineHeight: 1.4,
whiteSpace: 'pre-line',
}}>
{dupWarning}
</div>
)}
{/* 输入框 */} {/* 输入框 */}
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -295,6 +343,35 @@ function PuaPanel({ gameReady, hc, waveStarted }: { gameReady: boolean; hc: numb
}}> }}>
{result.desc} {result.desc}
</div> </div>
{/* 相似度指示(有重复时显示) */}
{(result.similarity ?? 0) >= 0.3 && (
<div style={{
backgroundColor: '#431407',
border: '1px solid #F97316',
borderRadius: '4px',
padding: '4px 6px',
fontFamily: 'VT323, monospace',
fontSize: '12px',
color: '#FED7AA',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
<span></span>
<span style={{ color: (result.similarity ?? 0) >= 0.8 ? '#EF4444' : '#F97316' }}>
{Math.round((result.similarity ?? 0) * 100)}%
</span>
</div>
{/* 相似度进度条 */}
<div style={{ height: '3px', backgroundColor: '#1c0a04', borderRadius: '2px' }}>
<div style={{
height: '100%',
width: `${(result.similarity ?? 0) * 100}%`,
backgroundColor: (result.similarity ?? 0) >= 0.8 ? '#EF4444' : '#F97316',
borderRadius: '2px',
transition: 'width 0.3s ease',
}} />
</div>
</div>
)}
{/* 效果标签 + 花费 */} {/* 效果标签 + 花费 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ <div style={{
@@ -376,13 +453,15 @@ function PuaPanel({ gameReady, hc, waveStarted }: { gameReady: boolean; hc: numb
paddingTop: '8px', paddingTop: '8px',
}}> }}>
<span style={{ color: '#475569' }}> = HC × 15%</span><br /> <span style={{ color: '#475569' }}> = HC × 15%</span><br />
<span style={{ color: '#334155' }}>20 200</span><br />
<br /> <br />
Enter <br /> 60%: <br />
1-2分: 废话翻<br /> 80%: <br />
4-6分: 攻击+HC<br /> HC<br />
<br />
9-10分: 全场狂暴<br />
7-8分: 攻速暴增<br /> 7-8分: 攻速暴增<br />
9-10: 全场狂暴 4-6: 攻击+HC<br />
1-2分: 废话翻车
</div> </div>
</div> </div>
) )