feat(看板娘): 记得你常错在哪,题目页上会主动开口,互动都记下来
Deploy / deploy (push) Successful in 51s

- 按学生的行为挑话:最近 20 次提交里某类错特别多(运行出错细到 NameError、TypeError、
  ValueError、EOFError),再错这一类时点名说;今天做对第 3 道起偶尔数一数;
  第一次戳她有见面的话,戳得多了说「又是你」
- 题目页上主动开口:同一题错到第 5 次、连着 3 次没跑例子就交还错了、又犯老毛病、
  15 分钟没交没跑。比赛里不说,至少隔 3 分钟,同一件事一题一次,10 秒后收起
- 互动记进新表 mascot_event(0043):点开、点按钮、主动开口、摸头、点烦、被拖走;
  只记看板娘这边的事,每人每分钟最多 30 条
- GET /mascot/profile 给最近的错类、今天做对几道、点过几次;门槛按本机生产快照定
  (266 人里运行出错 ≥3 的 87 人、答案错误 ≥5 的 82 人)
- 编辑过程(粘贴)数据学生侧不用,免得冤枉人

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-03 16:22:58 +08:00
1 parent ef3c53c671
commit 81ef4dca6b
12 files changed
+4630 -29

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
import type { MascotEventInput, MascotProfile } from '@oj2/contract'
import api from 'utils/api'
import { useUserStore } from 'shared/store/user'
import type { MascotGuide } from './guide'
import type { MascotSituation } from './lines'
/**
* 看板娘按学生的行为挑话:常犯哪类错、今天做了几道、和她熟不熟。数从 GET /mascot/profile 来,
* 那边按库里的提交和互动记录算;互动本身经 logMascotEvent 记进 mascot_event。
*
* 只对学生。老师的话不跟着行为变。
*/
/** 记一条互动。只记登录了的,失败就算了:前台不等它、也不提示 */
export function logMascotEvent(event: Omit<MascotEventInput, 'path'>) {
if (!useUserStore().isAuthed) return
const body: MascotEventInput = { ...event, path: location.pathname.slice(0, 200) }
if (body.line) body.line = body.line.slice(0, 100)
api.post('mascot/events', body).catch(() => {})
}
export const mascotProfile = shallowRef<MascotProfile | null>(null)
let loadedAt = 0
let loading: Promise<void> | null = null
/** 进页面、交完一次都想要新的数,但别每次都打一遍 */
const STALE_MS = 30_000
export function refreshMascotProfile(force = false) {
const userStore = useUserStore()
if (!userStore.isAuthed || userStore.isTeacherOrAbove) {
mascotProfile.value = null
return null
}
if (loading) return loading
if (!force && mascotProfile.value && Date.now() - loadedAt < STALE_MS) return null
loading = api
.get<MascotProfile>('mascot/profile')
.then((profile) => {
mascotProfile.value = profile
loadedAt = Date.now()
})
.catch(() => {})
.finally(() => {
loading = null
})
return loading
}
/** 这一页会话里点开过几次:第一次见面那句只说一回,刚点过的也算熟了一点 */
let sessionPokes = 0
export function notePoke() {
sessionPokes++
}
// 门槛按本机生产快照定的(2026-11,最近 120 天交过 10 次以上的 266 个学生):最近 20 次里
// 运行出错 ≥3 的 87 人、答案错误 ≥5 的 82 人,各占三成;编译错误有提交前语法检查,≥3 的只有 7 人
const HABIT_MIN = { wrongAnswer: 5, compileError: 3, runtimeError: 3, timeout: 2 } as const
/** 有专门几句的运行错误类型,别的走 habit.runtimeError */
const NAMED_RUNTIME_ERRORS = new Set(['NameError', 'TypeError', 'ValueError', 'EOFError'])
/**
* 这一类错是不是这个学生的老毛病:是就给对应的那组话,不是返回 null。
* 题目页点她、交完错了她主动开口都走这里
*/
export function habitSituation(failure: MascotSituation): MascotSituation | null {
const recent = mascotProfile.value?.recent
if (!recent) return null
switch (failure) {
case 'problem.wrong':
return recent.wrongAnswer >= HABIT_MIN.wrongAnswer ? 'habit.wrongAnswer' : null
case 'problem.compileError':
return recent.compileError >= HABIT_MIN.compileError ? 'habit.compileError' : null
case 'problem.runtimeError': {
if (recent.runtimeError < HABIT_MIN.runtimeError) return null
const type = recent.runtimeErrorType
return type && NAMED_RUNTIME_ERRORS.has(type) ? (`habit.${type}` as MascotSituation) : 'habit.runtimeError'
}
case 'problem.timeout':
return recent.timeout >= HABIT_MIN.timeout ? 'habit.timeout' : null
default:
return null
}
}
const HABIT_CHANCE = 0.6
const TODAY_CHANCE = 0.4
const FAMILIAR_CHANCE = 0.2
/** 点开过这么多次算老熟人 */
const FAMILIAR_POKES = 50
/** 今天做到这么多道才拿出来说 */
const TODAY_MIN = 3
const GREETINGS = new Set<MascotSituation>(['greet', 'announcement', 'achievement', 'profile'])
const PRAISE = new Set<MascotSituation>(['problem.accepted', 'problem.firstTry', 'problem.finally', 'home.done', 'home.assigned', 'home.free'])
/** 点她的时候:按行为把页面给的情况换成更贴这个学生的那一组(按钮不变) */
export function personalize(guide: MascotGuide): MascotGuide {
const profile = mascotProfile.value
if (!profile) return guide
if (profile.pokes + sessionPokes === 0) return { ...guide, situation: 'first.meet', n: undefined }
const habit = habitSituation(guide.situation)
if (habit && Math.random() < HABIT_CHANCE) return { ...guide, situation: habit }
if (PRAISE.has(guide.situation) && profile.todaySolved >= TODAY_MIN && Math.random() < TODAY_CHANCE) {
return { ...guide, situation: 'today.count', n: profile.todaySolved }
}
if (GREETINGS.has(guide.situation) && profile.pokes >= FAMILIAR_POKES && Math.random() < FAMILIAR_CHANCE) {
return { ...guide, situation: 'familiar' }
}
return guide
}
// ---- 主动开口 ----
export interface MascotSpeech extends MascotGuide {
/** 同一个 key 一次页面会话里只说一次(比如「这道题卡住了」每道题只说一回) */
key: string
}
/** 页面请她主动说一句;说不说、什么时候说由 Mascot.vue 按频率上限定 */
export const speechRequest = shallowRef<MascotSpeech | null>(null)
export function requestSpeech(speech: MascotSpeech) {
speechRequest.value = speech
}