feat: v4 — TUI fight loop, rate limiting, webhook tooling, expanded choreographies
- Fight loop CLI with TUI renderer (ink-style terminal UI) - Rate limiting middleware for API routes - Queue cooldowns wired into orchestrator after fights - Webhook test utility for bot debugging - API docs route - Expanded FightScene choreographies and weapon props - Fix Drizzle transaction execution in orchestrator - Schema additions, scoring/challenge/mock expansions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2c0323d5fb
commit
4d8b18a58a
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Answer checking for factual challenges.
|
||||
* Handles: case insensitivity, numeric equivalence, containment matching,
|
||||
* number words (forty = 40), stripped punctuation/articles.
|
||||
* number words (forty = 40), basic stemming, contraction normalization.
|
||||
*/
|
||||
|
||||
const NUMBER_WORDS: Record<string, number> = {
|
||||
@@ -23,6 +23,47 @@ function normalize(s: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Expand contractions so "can't" matches "cannot", "don't" matches "do not" etc. */
|
||||
function expandContractions(s: string): string {
|
||||
return s
|
||||
.replace(/\bcan'?t\b/gi, 'cannot')
|
||||
.replace(/\bdon'?t\b/gi, 'do not')
|
||||
.replace(/\bwon'?t\b/gi, 'will not')
|
||||
.replace(/\bdoesn'?t\b/gi, 'does not')
|
||||
.replace(/\bisn'?t\b/gi, 'is not')
|
||||
.replace(/\baren'?t\b/gi, 'are not')
|
||||
.replace(/\bwasn'?t\b/gi, 'was not')
|
||||
.replace(/\bweren'?t\b/gi, 'were not')
|
||||
.replace(/\bhasn'?t\b/gi, 'has not')
|
||||
.replace(/\bhaven'?t\b/gi, 'have not')
|
||||
.replace(/\bhadn'?t\b/gi, 'had not')
|
||||
.replace(/\bcouldn'?t\b/gi, 'could not')
|
||||
.replace(/\bshouldn'?t\b/gi, 'should not')
|
||||
.replace(/\bwouldn'?t\b/gi, 'would not')
|
||||
.replace(/\bit'?s\b/gi, 'it is')
|
||||
.replace(/\bthat'?s\b/gi, 'that is')
|
||||
.replace(/\bthey'?re\b/gi, 'they are')
|
||||
.replace(/\bwe'?re\b/gi, 'we are')
|
||||
.replace(/\byou'?re\b/gi, 'you are')
|
||||
}
|
||||
|
||||
/** Basic English stemming: strip common suffixes for loose comparison. */
|
||||
function stem(word: string): string {
|
||||
if (word.length <= 3) return word
|
||||
// Plural: buses → bus, foxes → fox, tries → tri (imperfect but ok)
|
||||
if (word.endsWith('ies') && word.length > 4) return word.slice(0, -3) + 'y'
|
||||
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes') || word.endsWith('ches') || word.endsWith('shes')) {
|
||||
return word.slice(0, -2)
|
||||
}
|
||||
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
|
||||
return word
|
||||
}
|
||||
|
||||
/** Stem all words in a string. */
|
||||
function stemAll(s: string): string {
|
||||
return s.split(/\s+/).map(stem).join(' ')
|
||||
}
|
||||
|
||||
function tryParseNumber(s: string): number | null {
|
||||
const cleaned = normalize(s)
|
||||
|
||||
@@ -55,7 +96,7 @@ function tryParseNumber(s: string): number | null {
|
||||
|
||||
/**
|
||||
* Check if a bot's response matches any of the accepted answers.
|
||||
* Returns a confidence score: 1.0 = definite match, 0.5 = partial, 0 = no match.
|
||||
* Returns a confidence score: 1.0 = definite match, 0.5-0.9 = partial, 0 = no match.
|
||||
*/
|
||||
export function checkAnswer(response: string | null, acceptedAnswers: string[]): number {
|
||||
if (!response || response.trim() === '') return 0
|
||||
@@ -63,48 +104,77 @@ export function checkAnswer(response: string | null, acceptedAnswers: string[]):
|
||||
const normResponse = normalize(response)
|
||||
if (normResponse === '') return 0
|
||||
|
||||
// Pre-compute expanded/stemmed versions once
|
||||
const expandedResponse = normalize(expandContractions(response))
|
||||
const stemmedResponse = stemAll(normResponse)
|
||||
|
||||
for (const accepted of acceptedAnswers) {
|
||||
const normAccepted = normalize(accepted)
|
||||
const expandedAccepted = normalize(expandContractions(accepted))
|
||||
const stemmedAccepted = stemAll(normAccepted)
|
||||
|
||||
// 1. Exact match after normalization
|
||||
if (normResponse === normAccepted) return 1.0
|
||||
|
||||
// 2. Numeric equivalence
|
||||
// 2. Exact match after contraction expansion
|
||||
if (expandedResponse === expandedAccepted) return 1.0
|
||||
|
||||
// 3. Exact match after stemming (catches plural/singular)
|
||||
if (stemmedResponse === stemmedAccepted) return 1.0
|
||||
|
||||
// 4. Numeric equivalence
|
||||
const respNum = tryParseNumber(normResponse)
|
||||
const accNum = tryParseNumber(normAccepted)
|
||||
if (respNum !== null && accNum !== null && respNum === accNum) return 1.0
|
||||
|
||||
// 3. Response contains the accepted answer
|
||||
// 5. Response contains the accepted answer (or stemmed version)
|
||||
if (normResponse.includes(normAccepted) && normAccepted.length >= 2) return 1.0
|
||||
if (stemmedResponse.includes(stemmedAccepted) && stemmedAccepted.length >= 2) return 0.95
|
||||
|
||||
// 4. Accepted answer contains the response (for short definitive answers)
|
||||
// 6. Accepted answer contains the response (for short definitive answers)
|
||||
if (normAccepted.includes(normResponse) && normResponse.length >= 3) return 0.8
|
||||
if (stemmedAccepted.includes(stemmedResponse) && stemmedResponse.length >= 3) return 0.75
|
||||
|
||||
// 5. Check if number appears anywhere in a longer response
|
||||
// 7. Check if number appears anywhere in a longer response
|
||||
if (accNum !== null) {
|
||||
// Look for the number in the response text
|
||||
const numStr = String(accNum)
|
||||
if (normResponse.includes(numStr)) return 1.0
|
||||
// Check number words in response
|
||||
const responseNum = tryParseNumber(normResponse.split(/\s+/).find(w => tryParseNumber(w) !== null) || '')
|
||||
if (responseNum !== null && responseNum === accNum) return 0.9
|
||||
const responseWords = normResponse.split(/\s+/)
|
||||
for (const w of responseWords) {
|
||||
const parsed = tryParseNumber(w)
|
||||
if (parsed !== null && parsed === accNum) return 0.9
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Word-level containment — all words of the answer appear in the response
|
||||
// 8. Word-level containment — all words of the answer appear in the response
|
||||
const acceptedWords = normAccepted.split(/\s+/)
|
||||
if (acceptedWords.length >= 2) {
|
||||
const allFound = acceptedWords.every(w => normResponse.includes(w))
|
||||
if (allFound) return 0.9
|
||||
// Try with stemming
|
||||
const stemmedAccWords = stemmedAccepted.split(/\s+/)
|
||||
const allStemFound = stemmedAccWords.every(w => stemmedResponse.includes(w))
|
||||
if (allStemFound) return 0.85
|
||||
}
|
||||
|
||||
// 9. Contraction-expanded word containment
|
||||
if (expandedAccepted.split(/\s+/).length >= 2) {
|
||||
const allFound = expandedAccepted.split(/\s+/).every(w => expandedResponse.includes(w))
|
||||
if (allFound) return 0.85
|
||||
}
|
||||
}
|
||||
|
||||
// 7. For true/false questions, check if the response starts with the right keyword
|
||||
// 10. For true/false questions, check if the response starts with the right keyword
|
||||
const tfAnswer = acceptedAnswers.find(a => a.toLowerCase() === 'true' || a.toLowerCase() === 'false')
|
||||
if (tfAnswer) {
|
||||
const firstWord = normResponse.split(/\s+/)[0]
|
||||
if (firstWord === tfAnswer.toLowerCase()) return 1.0
|
||||
// "that's true" / "this is false" etc.
|
||||
// "that's true" / "this is false" / "yes" for true / "no" for false
|
||||
if (normResponse.includes(tfAnswer.toLowerCase())) return 0.9
|
||||
// "yes" ≈ true, "no" ≈ false
|
||||
if (tfAnswer.toLowerCase() === 'true' && (firstWord === 'yes' || firstWord === 'correct' || firstWord === 'right')) return 0.9
|
||||
if (tfAnswer.toLowerCase() === 'false' && (firstWord === 'no' || firstWord === 'incorrect' || firstWord === 'wrong')) return 0.9
|
||||
}
|
||||
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user