feat(pua): 新增重复检测——LLM语义相似度判断,相似60%效果降级,相似80%强制翻车,额外扣HC惩罚
This commit is contained in:
@@ -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: 话太废了反而debuff(score 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_boost,speed_boost→attack_boost,etc.
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user