# AI 꾸러미 — 「거의 맞았어요」 판정 (근접 정답) — Partial-credit answer checking
> 답을 맞음 · 틀림 둘로만 가르지 않고 단위 빠짐 · 약분 안 한 분수 · 오차 범위를 구별해, 「한 걸음 더」라고 알려 준다.  
> 견본: https://ai-techstudio.web.app/#t/i400

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 「거의 맞았어요」 판정 (근접 정답) — Partial-credit answer checking

#### 1. 목표
자로 길이 재기 · 분수 문제의 답 판정을 「정답 · 한 걸음 더 · 다시 생각」 세 갈래로 나눠 줘 — 단위 빠짐 · 약분 안 한 분수 · 소수로 쓴 답 · 눈금 오차를 따로 알아채고, 갈래마다 다음에 할 일을 한 줄로. 말투는 다정한 선생님.

#### 2. 핵심 기술 용어
- **Partial-credit answer checking** — 근접 정답 판정 (부분 점수)
- **Tolerance (error margin)** — 오차 범위 — ±0.2cm 안이면 정답으로
- **Equivalent fraction detection** — 같은 값 분수 알아채기 (2/4 = 1/2)
- **Formative feedback** — 다음에 무엇을 고칠지 알려 주는 피드백

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 판정은 정답(ok) · 한 걸음 더(near) · 다시 생각(no) 세 갈래, 갈래마다 다음 행동 한 줄
- 단위가 있는 답은 단위를 하나로 바꿔 비교 (50mm = 5cm 는 정답)
- 실수끼리는 === 로 비교하지 않기 — 차이의 절댓값이 아주 작은지(1e-9)나 오차 범위로
- 「한 걸음 더」는 점수를 깎지 않고 다시 쓸 기회를 준다 (틀림 표시 금지)
- 알아볼 수 없는 글은 「수와 단위로 적어요 (예: 5cm)」처럼 적는 법을 알려 준다

#### 5. 완성 기준 (이게 보이면 성공)
- 5 → 「수는 맞아요! 단위를 붙여요」, 4.9cm → 정답(오차 안), 50mm → 정답(같은 길이)으로 판정된다
- 2/4 → 「같은 양이에요! 약분하면 더 간단해요」, 0.5 → 「분수로 써 볼까요?」, 1/2 → 정답
- 세 갈래 등(초록 · 노랑 · 빨강)과 도장 · 한 줄 말이 판정마다 바뀐다
- 폰 가로 화면에서 입력 칸 · 판정 단추가 손가락으로 누르기 충분히 크다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 먼저 글자를 정리한다 — 빈칸을 지우고, 수와 단위를 따로 읽는다 (정규식).
- 단위를 하나로 바꿔(mm → cm ÷ 10) 정답과 견준다. 차이가 오차(±0.2) 안이면 정답.
- 틀렸어도 「수는 맞는데 단위가 없다」 · 「값은 같은데 약분 안 했다」 · 「거의 다 왔다(±0.6)」처럼 이유가 보이면 「한 걸음 더」로 가른다.
- 갈래마다 다음에 할 일을 한 줄로 — 「단위를 붙여요」 · 「약분하면 더 간단해요」.

## 핵심 코드 — 길이 답 판정 — 단위 맞추기 · 오차 범위 · 「한 걸음 더」 갈래
(발췌: demos/demosLearn.ts judgeLen() (정답 5cm))
```ts
type Verdict = { v: 'ok' | 'near' | 'no'; msg: string };

function judgeLen(s: string): Verdict {
  const t = s.trim().toLowerCase().replace(/\s+/g, '');
  const m = t.match(/^(\d+(?:\.\d+)?)(cm|mm|m)?$/);
  if (!m) return { v: 'no', msg: '수와 단위로 적어요 (예: 5cm)' };
  const n = +m[1];
  const u = m[2] ?? '';
  // 단위가 빠진 답 — 수가 맞으면 「한 걸음 더」
  if (!u) {
    if (Math.abs(n - 5) <= 0.2) return { v: 'near', msg: '수는 맞아요! 단위를 붙여요 — 5 무엇?' };
    if (Math.abs(n - 50) <= 2) return { v: 'near', msg: '50 무엇일까요? 단위를 붙여요' };
    return { v: 'no', msg: '막대 끝이 닿은 눈금을 다시 봐요' };
  }
  // 단위를 cm 하나로 바꿔 비교
  const cm = u === 'mm' ? n / 10 : u === 'm' ? n * 100 : n;
  if (Math.abs(cm - 5) < 1e-9) return { v: 'ok', msg: u === 'mm' ? '정답! 50mm = 5cm, 같은 길이예요' : '정답! 눈금을 정확히 읽었어요' };
  if (Math.abs(cm - 5) <= 0.2) return { v: 'ok', msg: '정답 — 눈금 오차(±2mm) 안이에요' };
  // 수는 맞는데 단위가 다른 답 (5mm · 50cm …)
  if (Math.abs(cm * 10 - 5) < 1e-9 || Math.abs(cm / 10 - 5) < 1e-9 || Math.abs(cm / 100 - 5) < 1e-9)
    return { v: 'near', msg: '수는 맞는데 단위가 달라요 — cm 와 mm' };
  if (Math.abs(cm - 5) <= 0.6) return { v: 'near', msg: '거의 다 왔어요! 눈금을 한 번 더 세어 봐요' };
  return { v: 'no', msg: '막대 끝이 닿은 눈금을 다시 봐요' };
}

// 분수: n/d 가 1/2 와 같은 값이면(n * 2 === d) 1/2 만 정답, 2/4 · 4/8 은 「약분하면 더 간단해요」
```

## 흔한 실수 · 확인 목록
- [ ] **실수를 === 로 비교하면 0.1 + 0.2 같은 답이 틀림이 된다** — 차이의 절댓값이 1e-9 보다 작은지로 보고, 측정 문제는 오차 범위(±0.2)를 따로 둔다.
- [ ] **「거의 맞음」 범위를 너무 넓히면 찍어도 칭찬받는다** — 근접 범위(±0.6)는 오차 범위(±0.2)보다 조금만 넓게, 그 밖은 「다시 생각」으로.
- [ ] **공백 · 대문자 · 한글 단위를 안 거르면 맞는 답이 「알아볼 수 없음」이 된다** — 판정 전에 trim · toLowerCase · 빈칸 지우기, 「센티 · 밀리」 같은 한글 단위도 받아 준다 (견본은 센티미터 · 밀리까지).
- [ ] **근접 답에도 빨간 틀림 표시를 붙이면 아이가 포기한다** — 「한 걸음 더」는 노란 등 · 다시 쓸 기회, 점수는 깎지 않는다.

## 완성 기준 체크리스트
- [ ] 5 → 「수는 맞아요! 단위를 붙여요」, 4.9cm → 정답(오차 안), 50mm → 정답(같은 길이)으로 판정된다
- [ ] 2/4 → 「같은 양이에요! 약분하면 더 간단해요」, 0.5 → 「분수로 써 볼까요?」, 1/2 → 정답
- [ ] 세 갈래 등(초록 · 노랑 · 빨강)과 도장 · 한 줄 말이 판정마다 바뀐다
- [ ] 폰 가로 화면에서 입력 칸 · 판정 단추가 손가락으로 누르기 충분히 크다

## 이 기술 정보
- id: `i400` · 분류: 게임 시스템 · AI › 배움 · 피드백 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 글자 정리 · 정규식 한두 번 — 계산 비용은 없다. 일은 「어떤 거의 맞은 답이 있나」를 미리 적는 데 든다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i400
- 쓰면 좋을 때: 측정 · 분수 · 단위처럼 「거의 맞은 답」이 자주 나오는 문제 / 어린 학생이 틀림 표시에 쉽게 포기할 때 / 같은 값을 여러 꼴(1/2 · 0.5 · 50%)로 쓸 수 있는 문제
- 쓰지 말 때: 답이 하나뿐인 고르기 문제 — 맞음/틀림이면 충분하다 / 빨리 풀기 대결 — 「한 걸음 더」 대화가 흐름을 끊는다 (대신 끝난 뒤 해설로)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i400 — `src/demos/demosLearn.ts:1323`
```ts
const i400: DemoDom = {
  kind: 'dom',
  caption: '「틀림」 대신 「한 걸음 더」 — 단위 빠짐 · 약분 안 한 분수 · 소수로 쓴 답 · 오차 범위를 구별해 판정',
  make(box) {
    const AUTO: [number, string][] = [
      [0, '5'],
      [0, '4.9cm'],
      [0, '50mm'],
      [1, '2/4'],
      [1, '0.5'],
      [1, '1/2'],
    ];
    const R = rig(box, 'h400', {
      css: `
.h400 .qt{font-size:11px;font-weight:900;fill:#3b2a1e}
.h400 .qs{font-size:8.5px;font-weight:700;fill:#8a6a4a}
.h400 .tk{stroke:#5a4630;stroke-width:.8}
.h400 .tn{font-size:7.5px;font-weight:800;fill:#5a4630}
.h400 .ab{fill:#fffaf0;stroke:#ffb25e;stroke-width:2.4}
.h400 .at{font-size:20px;font-weight:900;fill:#2e5aac}
.h400 .al{font-size:9px;font-weight:900;fill:#ffcf99}
.h400 .car{fill:#2e5aac}
.h400 .lamp rect{fill:#ffffff08;stroke:#ffffff1a;stroke-width:1.2;transition:fill .2s,stroke .2s}
.h400 .lamp text{font-size:10px;font-weight:900;fill:#8f7a66;transition:fill .2s}
.h400 .lamp circle{fill:#4a3a2e;transition:fill .2s}
.h400 .msg{font-size:11px;font-weight:800;fill:#ffe6c4}
.h400 .stamp text{font-size:13px;font-weight:900}
`,
      svg: (id) => `
<g class="q0">
  <rect x="16" y="12" width="288" height="78" rx="10" fill="url(#${id}pp)" filter="url(#${id}sh)"/>
  <text class="qt" x="28" y="31">막대의 길이는 얼마일까요?</text><text class="qs" x="186" y="31">눈금 1칸 = 1cm</text>
  <rect x="40" y="40" width="150" height="10" rx="3" fill="url(#${id}or)" stroke="#a24a16" stroke-width="1"/>
  <rect x="34" y="54" width="252" height="26" rx="3" fill="#ffe7a8" stroke="#b08a3c" stroke-width="1.2"/>
  ${Array.from({ length: 81 }, (_, i) => `<line class="tk" x1="${40 + i * 3}" x2="${40 + i * 3}" y1="54" y2="${54 + (i % 10 === 0 ? 10 : i % 5 === 0 ? 7 : 4)}"/>`).join('')}
  ${Array.from({ length: 9 }, (_, i) => `<text class="tn" x="${40 + i * 30}" y="75" text-anchor="middle">${i}</text>`).join('')}
</g>
<g class="q1" style="opacity:0">
  <rect x="16" y="12" width="288" height="78" rx="10" fill="url(#${id}pp)" filter="url(#${id}sh)"/>
  <text class="qt" x="28" y="36">색칠한 부분을 분수로 나타내요</text><text class="qs" x="28" y="54">피자 한 판을 똑같이 8조각</text>
  <g transform="translate(250 51)">
    <circle r="33" fill="#e9b062" stroke="#a8742a" stroke-width="2"/>
    ${Array.from({ length: 8 }, (_, i) => {
      const a0 = (i * Math.PI) / 4 - Math.PI / 2;
      const a1 = a0 + Math.PI / 4;
      const on = i < 4;
      return `<path d="M0 0 L${(Math.cos(a0) * 29).toFixed(1)} ${(Math.sin(a0) * 29).toFixed(1)} A29 29 0 0 1 ${(Math.cos(a1) * 29).toFixed(1)} ${(Math.sin(a1) * 29).toFixed(1)}Z" fill="${on ? '#ff7b54' : '#fff1d0'}" stroke="#a8742a" stroke-width="1.2"/>${
        on ? `<circle cx="${(Math.cos(a0 + 0.39) * 17).toFixed(1)}" cy="${(Math.sin(a0 + 0.39) * 17).toFixed(1)}" r="3" fill="#c93a2a"/>` : ''
      }`;
    }).join('')}
  </g>
</g>
<text class="al" x="62" y="114">내 답</text>
<rect class="ab" x="60" y="100" width="150" height="34" rx="9"/>
<text class="at" x="135" y="125" text-anchor="middle"></text>
<rect class="car" x="0" y="107" width="2" height="21" rx="1"/>
<g class="stamp" style="opacity:0"><circle cx="246" cy="117" r="22" fill="none" stroke-width="3"/><text x="246" y="122" text-anchor="middle"></text></g>
${[
  ['ok', '정답', '#4fd69c'],
  ['near', '한 걸음 더', '#ffc14d'],
  ['no', '다시 생각', '#ff6b5a'],
]
  .map(
    ([k, n, c], i) => `<g class="lamp" data-k="${k}" data-c="${c}"><rect x="${30 + i * 90}" y="145" width="80" height="22" rx="11"/><circle cx="${44 + i * 90}" cy="156" r="5"/><text x="${54 + i * 90}" y="160">${n}</text></g>`,
  )
  .join('')}
<text class="msg" x="160" y="187" text-anchor="middle"></text>`,
      panel: `<button class="btn ghost" data-a="sw">${ic('swap')}문제 바꾸기</button><input class="ans" maxlength="10" placeholder="답"/><button class="btn" data-a="go">${ic('check')}판정</button><span class="lab hintx"></span>`,
    });
    const root = R.root;
    const q0 = q(root, '.q0');
    const q1 = q(root, '.q1');
    const at2 = q<SVGTextElement>(root, '.at');
    const car = q(root, '.car');
    const stamp = q<SVGGElement>(root, '.stamp');
    const stC = q(stamp, 'circle');
    const stT = q<SVGTextElement>(stamp, 'text');
    const lamps = qa<SVGGElement>(root, '.lamp');
    const msg = q<SVGTextElement>(root, '.msg');
    const input = root.querySelector('input.ans') as HTMLInputElement;
    const hintx = root.querySelector('.hintx') as HTMLElement;
    let now = 0;
    const man = { q: 0, s: '', v: null as Verdict | null, at: 0 };
    const A = autoCtl(() => undefined);
    const hints = ['5 · 5cm · 4.9cm · 50mm · 7cm', '1/2 · 2/4 · 4/8 · 0.5 · 50% · 1/3'];
    hintx.textContent = `넣어 보기: ${hints[0]}`;
    function submit(): void {
      A.manual = true;
      man.s = input.value;
      man.v = man.q === 0 ? judgeLen(man.s) : judgeFrac(man.s);
      man.at = now;
    }
    root.addEventListener('pointerdown', (e) => {
      const el = (e.target as Element).closest('[data-a]');
      if (!el || !R.sync()) return;
      e.preventDefault();
      const a = el.getAttribute('data-a');
      if (a === 'go') submit();
      else if (a === 'sw') {
        A.manual = true;
        man.q = 1 - man.q;
        man.v = null;
        man.s = '';
        input.value = '';
        hintx.textContent = `넣어 보기: ${hints[man.q]}`;
        input.focus();
      }
    });
    input.addEventListener('focus', () => (A.manual = true));
    input.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') submit();
    });
    function render(qi: number, s: string, v: Verdict | null, age: number, caret: boolean): void {
      q0.style.opacity = qi === 0 ? '1' : '0';
      q1.style.opacity = qi === 1 ? '1' : '0';
      at2.textContent = s;
      const w = s ? at2.getComputedTextLength() : 0;
      car.setAttribute('x', String(135 + w / 2 + 2));
      car.style.opacity = caret && Math.sin(now * 8) > 0 ? '1' : '0';
      const k = v ? backOut(seg(age, 0, 0.35)) : 0;
      stamp.style.opacity = v ? String(clamp(age / 0.15)) : '0';
      tr(stamp, `translate(246 117) scale(${(1.6 - 0.6 * k).toFixed(3)}) rotate(${-12 + 4 * k}) translate(-246 -117)`);
      const col = v ? (v.v === 'ok' ? '#4fd69c' : v.v === 'near' ? '#ffc14d' : '#ff6b5a') : '#fff';
      stC.setAttribute('stroke', col);
      stT.setAttribute('fill', col);
      stT.textContent = v ? (v.v === 'ok' ? '정답' : v.v === 'near' ? '거의!' : '다시') : '';
      lamps.forEach((l) => {
        const on = !!v && l.getAttribute('data-k') === v.v;
        const c = l.getAttribute('data-c')!;
        const r = q(l, 'rect');
        r.style.fill = on ? `${c}33` : '';
        r.style.stroke = on ? c : '';
        q(l, 'circle').style.fill = on ? c : '';
        q(l, 'text').style.fill = on ? '#fff' : '';
      });
      msg.textContent = v ? v.msg : qi === 0 ? '단위까지 써야 정답이에요' : '가장 간단한 분수로 써요';
      msg.style.opacity = v ? String(seg(age, 0.15, 0.4)) : '0.55';
    }
    return {
      controls: [A.ctl],
      update(t) {
        now = t;
        R.sync();
        if (A.base < 0) A.base = t;
        if (A.manual) {
          const typing = document.activeElement === input && input.value !== man.s;
          render(man.q, typing ? input.value : man.s, typing ? null : man.v, t - man.at, document.activeElement === input);
          return;
        }
        const lt = t - A.base;
        const CY = 2.5;
        const k = Math.floor(lt / CY);
        const p = lt - k * CY;
        const [qi, s] = AUTO[k % AUTO.length]!;
        const n = Math.min(s.length, Math.floor(p / 0.09));
        const done = p > s.length * 0.09 + 0.3;
        render(qi, s.slice(0, n), done ? (qi === 0 ? judgeLen(s) : judgeFrac(s)) : null, p - (s.length * 0.09 + 0.3), !done);
      },
      dispose: () => R.dispose(),
    };
  },
};
```

## 관련 기술
- 다음에 해 볼 기술: [알맞은 난이도 고르기 (적응형)](https://ai-techstudio.web.app/ai/t/i402.md) `i402` · [간격 반복 (잊을 때쯤 다시)](https://ai-techstudio.web.app/ai/t/i403.md) `i403`
