Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 51 additions & 51 deletions AUTHORING.md

Large diffs are not rendered by default.

46 changes: 23 additions & 23 deletions README.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const TOPIC_LABEL = {
}

const GRADES = [
{ min: 90, name: 'ORIGIN SHIELD', line: '오리진까지 갈 일이 거의 없군요. 이 정도면 설계를 맡깁니다.' },
{ min: 90, name: 'ORIGIN SHIELD', line: '오리진까지 갈 일이 거의 없군요. 이 정도면 설계를 맡겨도 되겠습니다.' },
{ min: 75, name: 'CACHE HIT', line: '대부분 엣지에서 끝냅니다. 실무에서 바로 통하는 수준이에요.' },
{ min: 55, name: 'STALE WHILE REVALIDATE', line: '일단 응답은 나갑니다. 뒤에서 조용히 갱신할 부분이 남았네요.' },
{ min: 35, name: 'TTL EXPIRED', line: '알던 것들이 조금씩 만료됐습니다. 재검증할 때가 됐어요.' },
Expand Down Expand Up @@ -224,7 +224,7 @@ function grade(confidence) {
bad: {
sure: '틀렸습니다<small>확신했던 문항입니다. 이런 문항이 가장 오래 기억에 남습니다.</small>',
unsure: '틀렸습니다',
guess: '틀렸습니다<small>모르는 자리를 찾았습니다. 찍은 것을 정직하게 눌러 두면 결과에서 따로 모아 드립니다.</small>',
guess: '틀렸습니다<small>모르는 부분을 하나 찾았습니다. 찍었다고 정직하게 표시해 두면 결과에서 따로 모아 드립니다.</small>',
},
}
const verdict = $('#verdict')
Expand Down Expand Up @@ -339,8 +339,8 @@ function renderResult() {
$('#result-rate').innerHTML = `${rate}<span>%</span>`
$('#result-grade').textContent = g.name
$('#result-line').textContent = lucky
? `${total}문항 중 ${hits}문항 — 찍어서 맞은 ${lucky}문항을 빼면 ${solidRate}% · ${g.line}`
: `${total}문항 중 ${hits}문항 — ${g.line}`
? `${total}문항 중 ${hits}문항을 맞혔습니다. 찍어서 맞은 ${lucky}문항을 빼면 ${solidRate}%입니다. ${g.line}`
: `${total}문항 중 ${hits}문항을 맞혔습니다. ${g.line}`

// 이전 최고 기록 (재도전 라운드는 기록하지 않는다)
if (!state.isRetry) {
Expand Down Expand Up @@ -413,8 +413,8 @@ function renderResult() {
: `틀린 문항 ${weak.length}개 다시 풀기`

const text = lucky
? `나의 캐시 히트율은 ${rate}% ${g.name}\n찍어서 맞은 빼면 ${solidRate}%\nCDN·이미지·동영상 서빙 퀴즈`
: `나의 캐시 히트율은 ${rate}% ${g.name}\nCDN·이미지·동영상 서빙 퀴즈`
? `나의 캐시 히트율은 ${rate}% (${g.name})\n찍어서 맞은 것을 빼면 ${solidRate}%\nCDN·이미지·동영상 서빙 퀴즈`
: `나의 캐시 히트율은 ${rate}% (${g.name})\nCDN·이미지·동영상 서빙 퀴즈`
$('#btn-tweet').href =
`https://x.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(SITE_URL)}`

Expand Down Expand Up @@ -462,7 +462,7 @@ function drawCard() {
x.fillStyle = '#6f7787'
x.font = `400 28px ${F}`
x.fillText(lucky
? `${total}문항 중 ${hits}문항 정답 · 찍은 빼면 ${solidRate}%`
? `${total}문항 중 ${hits}문항 정답 · 찍은 것을 빼면 ${solidRate}%`
: `${total}문항 중 ${hits}문항 정답`, 80, 500)

// 영역별 미니 바
Expand Down
2 changes: 1 addition & 1 deletion data/questions.json

Large diffs are not rendered by default.

140 changes: 70 additions & 70 deletions data/questions/caching.json

Large diffs are not rendered by default.

182 changes: 91 additions & 91 deletions data/questions/cdn.json

Large diffs are not rendered by default.

168 changes: 84 additions & 84 deletions data/questions/image.json

Large diffs are not rendered by default.

78 changes: 39 additions & 39 deletions data/questions/storage.json

Large diffs are not rendered by default.

168 changes: 84 additions & 84 deletions data/questions/video.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ <h1>당신의 캐시 히트율은?</h1>
<ul>
<li><b>오답이 진짜입니다.</b> 모든 오답은 현업에서 실제로 흔한 오해에서 가져왔습니다. 아무 보기나 채워 넣지 않았습니다.</li>
<li><b>확신도를 함께 묻습니다.</b> 확신했는데 틀린 오류가 가장 강하게 교정됩니다(hypercorrection). 그래서 그 순간을 놓치지 않고 표시합니다.</li>
<li><b>모르겠으면 찍고 &ldquo;찍었어요&rdquo;를 누르세요.</b> 건너뛰기 버튼은 두지 않았습니다 &mdash; 틀릴 줄 알면서 한 인출 시도도 이후 학습을 돕고, 즉시 해설이 붙어 있으면 추측이 오답을 심을 위험은 사라집니다. 대신 <b>찍어서 맞은 문항</b>을 결과에서 따로 모아 드립니다.</li>
<li><b>모르겠으면 찍고 &ldquo;찍었어요&rdquo;를 누르세요.</b> 건너뛰기 버튼은 두지 않았습니다. 틀릴 줄 알면서 한 인출 시도도 이후 학습을 돕고, 즉시 해설이 붙어 있으면 추측한 내용이 오답인 채로 기억에 남을 위험도 사라지기 때문입니다. 대신 <b>찍어서 맞은 문항</b>을 결과에서 따로 모아 드립니다.</li>
<li><b>즉시 해설합니다.</b> 왜 정답인지뿐 아니라 <b>당신이 고른 오답이 왜 틀렸는지</b>를 함께 보여줍니다.</li>
<li><b>약한 것만 다시 풉니다.</b> 틀린 문항과 찍어서 맞은 문항을 함께 모읍니다. 피드백 직후 한 번 더 인출하지 않으면 교정한 오류가 되돌아옵니다.</li>
</ul>
<p class="how-foot">문항 저작 규약은 <a href="https://github.com/midagedev/cachehit/blob/main/AUTHORING.md" target="_blank" rel="noopener">AUTHORING.md</a>에 공개돼 있습니다.</p>
<p class="how-foot">문항 저작 규약은 <a href="https://github.com/midagedev/cachehit/blob/main/AUTHORING.md" target="_blank" rel="noopener">AUTHORING.md</a>에 공개되어 있습니다.</p>
</div>
</details>
</div>
Expand Down Expand Up @@ -127,13 +127,13 @@ <h3>영역별</h3>

<div id="hyper-block" class="hyper hidden">
<h3>확신했는데 틀린 문항</h3>
<p class="hyper-note">가장 값진 문항들입니다. 확신이 깨진 자리가 가장 오래 기억에 남습니다.</p>
<p class="hyper-note">가장 값진 문항들입니다. 확신이 깨진 순간이 가장 오래 기억에 남습니다.</p>
<ul id="hyper-list"></ul>
</div>

<div id="guess-block" class="hyper hidden">
<h3>찍어서 맞은 문항</h3>
<p class="hyper-note">채점은 정답이지만 아는 것은 아닙니다. 맞았기 때문에 그냥 지나치기 쉬워, 틀린 문항보다 오래 남습니다.</p>
<p class="hyper-note">채점은 정답이지만 아는 것은 아닙니다. 맞았기 때문에 그냥 지나치기 쉬워, 모르는 상태가 틀린 문항보다 오래 남습니다.</p>
<ul id="guess-list"></ul>
</div>

Expand Down
120 changes: 120 additions & 0 deletions tools/check-prose-only.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env node
// 표현만 고친 라운드가 정말 표현만 고쳤는지, 기준 커밋과 대조해서 단언한다.
//
// 왜 필요한가: 한국어 교정 라운드는 문항 149개의 문자열 1490개를 만진다. 그 diff 를 눈으로 읽어
// "정답이 그대로인가, 오답이 참이 되지 않았나"를 확인하는 것은 사람이 할 수 있는 일이 아니다.
// 사실 관계는 기계가 판정할 수 없지만, **구조가 바뀌지 않았다는 것**은 기계가 단언할 수 있다.
// 정답의 위치, 보기 개수와 순서, distractorType, source, tags, difficulty, topic, id 가 전부
// 같다면 남은 위험은 문장의 의미뿐이므로, 사람이 읽어야 하는 diff 가 그만큼 좁아진다.
//
// 실측(2026-08-27): 이 게이트를 만들기 전에는 검수 범위가 "diff 전체"였고, 만든 뒤에는
// "텍스트가 바뀐 필드 목록"으로 좁아졌다.
//
// 사용: node tools/check-prose-only.mjs <기준-git-ref> [--show <n>]
// --show <n> 텍스트가 바뀐 필드를 n건까지 before/after 로 출력한다 (기본 0)
import { readFileSync, readdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const base = process.argv[2]
if (!base) {
console.error('사용: node tools/check-prose-only.mjs <기준-git-ref> [--show <n>]')
process.exit(64)
}
const showIdx = process.argv.indexOf('--show')
const SHOW = showIdx > 0 ? Number(process.argv[showIdx + 1] || 0) : 0

const files = readdirSync(join(ROOT, 'data', 'questions')).filter((f) => f.endsWith('.json'))

// 표현 교정이 건드려도 되는 필드. 이 목록에 없는 모든 필드는 바뀌면 실패다.
const PROSE = new Set(['question', 'explanation', 'text', 'why'])

const load = (raw) => {
const map = new Map()
for (const q of JSON.parse(raw)) map.set(q.id, q)
return map
}

const errors = []
const changed = []

for (const f of files) {
const rel = `data/questions/${f}`
let beforeRaw
try {
beforeRaw = execFileSync('git', ['-C', ROOT, 'show', `${base}:${rel}`], {
encoding: 'utf8', maxBuffer: 1 << 28,
})
} catch {
console.log(` ${rel} 은 기준 커밋에 없다 (신규 파일) — 대조 생략`)
continue
}
const before = load(beforeRaw)
const after = load(readFileSync(join(ROOT, rel), 'utf8'))

for (const id of before.keys())
if (!after.has(id)) errors.push(`${rel}: 문항 ${id} 이 사라졌다`)
for (const id of after.keys())
if (!before.has(id)) errors.push(`${rel}: 문항 ${id} 이 새로 생겼다 (교정 라운드는 문항을 만들지 않는다)`)

for (const [id, b] of before) {
const a = after.get(id)
if (!a) continue

// 문항 수준의 비산문 필드
for (const k of ['topic', 'difficulty', 'source']) {
if (JSON.stringify(b[k]) !== JSON.stringify(a[k]))
errors.push(`${id}: ${k} 이 바뀌었다 — "${b[k]}" → "${a[k]}"`)
}
if (JSON.stringify(b.tags) !== JSON.stringify(a.tags))
errors.push(`${id}: tags 가 바뀌었다 — ${JSON.stringify(b.tags)} → ${JSON.stringify(a.tags)}`)

// 문항 수준에 새 키가 생기거나 없어지는 것도 표현 교정의 범위가 아니다
const bk = Object.keys(b).sort().join(',')
const ak = Object.keys(a).sort().join(',')
if (bk !== ak) errors.push(`${id}: 키 구성이 바뀌었다 — [${bk}] → [${ak}]`)

if (b.question !== a.question) changed.push({ id, field: 'question', b: b.question, a: a.question })
if (b.explanation !== a.explanation)
changed.push({ id, field: 'explanation', b: b.explanation, a: a.explanation })

if (b.options.length !== a.options.length) {
errors.push(`${id}: 보기 개수가 ${b.options.length} → ${a.options.length} 로 바뀌었다`)
continue
}
for (let i = 0; i < b.options.length; i++) {
const bo = b.options[i]
const ao = a.options[i]
// 정답 위치는 이 게이트의 핵심이다. correct 는 정답에만 있고 오답에는 키 자체가 없다.
if (Boolean(bo.correct) !== Boolean(ao.correct))
errors.push(`${id} 보기 ${i}: 정답 여부가 바뀌었다 (${!!bo.correct} → ${!!ao.correct})`)
if ((bo.distractorType || null) !== (ao.distractorType || null))
errors.push(`${id} 보기 ${i}: distractorType 이 "${bo.distractorType}" → "${ao.distractorType}" 로 바뀌었다`)
const bok = Object.keys(bo).sort().join(',')
const aok = Object.keys(ao).sort().join(',')
if (bok !== aok) errors.push(`${id} 보기 ${i}: 키 구성이 바뀌었다 — [${bok}] → [${aok}]`)
for (const k of Object.keys(bo)) {
if (!PROSE.has(k)) continue
if (bo[k] !== ao[k]) changed.push({ id, field: `options[${i}].${k}`, b: bo[k], a: ao[k] })
}
}
}
}

console.log(`기준 ${base} 대조 — 텍스트가 바뀐 필드 ${changed.length}건, 구조 위반 ${errors.length}건`)

if (SHOW) {
console.log(`\n바뀐 필드 ${Math.min(SHOW, changed.length)}건:`)
for (const c of changed.slice(0, SHOW))
console.log(` ${c.id} / ${c.field}\n before: ${c.b}\n after: ${c.a}`)
}

if (errors.length) {
console.log('\n구조 위반 — 표현 교정 라운드가 표현 밖을 건드렸다:')
for (const e of errors) console.log(` ${e}`)
console.log('\n실패. 이 항목들은 사람이 의도를 확인한 뒤에만 바뀔 수 있다.')
process.exit(1)
}
console.log('\n구조는 그대로다. 남은 검수 대상은 위 필드들의 의미뿐이다.')
12 changes: 12 additions & 0 deletions tools/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,18 @@ function checkQuestion(q) {
err(id, `길이 편향: 정답 ${cLen}자 / 오답 평균 ${wAvg.toFixed(1)}자 = ${(cLen / wAvg).toFixed(2)}배 (상한 ${LENGTH_RATIO_MAX})`)
}

// 문체: 사용자 노출 문자열의 엠대시 금지 (2026-08-27 래칫)
// "한국어가 좀 이상해요"라는 실사용자 피드백을 받고 문항 전체를 교정한 라운드에서
// 엠대시 116건을 콜론·접속 표현으로 바꿔 0으로 만들었다. 엠대시는 앞뒤 관계를 지나치게
// 함축해서 번역체로 읽히는 주범이었다. source 는 인용 문자열이라 검사하지 않는다.
for (const [k, s] of [['question', q.question], ['explanation', q.explanation]]) {
if (s && s.includes('—')) err(id, `${k} 에 엠대시(—) — 콜론이나 접속 표현으로 풀어 쓸 것`)
}
for (const [i, o] of opts.entries()) {
if (o.text && o.text.includes('—')) err(id, `보기 ${i} text 에 엠대시(—) — 콜론이나 접속 표현으로 풀어 쓸 것`)
if (o.why && o.why.includes('—')) err(id, `보기 ${i} why 에 엠대시(—) — 콜론이나 접속 표현으로 풀어 쓸 것`)
}

// §3-4 절대 표현이 오답에만 몰리는지
const hasAbs = (t) => ABSOLUTE_WORDS.some((w) => t.includes(w))
const wrongAbs = wrong.filter((o) => hasAbs(o.text || '')).length
Expand Down
Loading