<!-- crag 공개 문서 · https://crag.kr/guide-fab-stt/ · 2026-07-22 내려받기 판 -->

# 비상 방재 신고 발화, AI로 제대로 받아 적는 법

*STT+LLM 구조화 추출 설계 공개 — 누구나 구현할 수 있게*

흥분한 신고자는 말을 더듬고, 정정하고, 모순되게 말합니다. **"A동, 아 A동 아니고 A동 연구실에서 문제 발생했어요"** — 이 한 문장에 부정처럼 보이는 세분화 정정이 들어 있습니다. 규칙 기반 파서는 이런 발화 앞에서 무너지고, 프롬프트 없는 LLM은 그럴듯하게 지어냅니다. 이 문서는 이런 발화를 **환각도 오판도 없이** 받아 적어 관제·전파로 잇는 설계 전체를 공개합니다: 슬롯 스키마, 추출용 시스템 프롬프트 전문, few-shot 예시 5개, 후처리 로직, STT 오인식 대응, 시설 DB와 resolve() 구현, 재질문 대화 루프까지.

설계 전체를 관통하는 원칙은 하나입니다. **오판 비용 비대칭** — 위치를 잘못 확정해 출동이 지연되는 비용이 되묻기 1회 비용보다 훨씬 큽니다. 그래서 이 설계는 확신이 없으면 추측으로 채우지 않고, 낮은 확신도와 함께 되묻고, 해소가 안 되면 사람에게 넘깁니다.


## 1. 슬롯 스키마 (JSON)

```
{
  "incident": {
    "type": "string | null",            // 화재, 가스누출, 붕괴, 침수, 정전, 인명사고, 기타
    "type_confidence": 0.0,             // 0.0 ~ 1.0
    "severity_hint": "string | null"    // 신고자 표현 기반: "연기만 남", "폭발음" 등 원문 근거
  },
  "location": {
    "building": "string | null",        // 확정된 동/건물명
    "floor": "string | null",
    "room": "string | null",            // 호실/공간명 (예: 연구실 302호)
    "granularity": "building | floor | room | area | unknown",
    "confidence": 0.0,
    "raw_mentions": [                   // 발화에 등장한 모든 위치 언급 (시간순, 원문 그대로)
      { "text": "A동", "polarity": "affirm | negate | uncertain", "turn": 1 }
    ],
    "excluded_candidates": ["string"],  // 배타명제로 제외된 위치 목록 (버리지 말고 저장)
    "abstract_expressions": ["string"], // "큰 건물 쪽", "실험실 같은 데" 등 매핑 필요 표현
    "candidate_pool": ["string"]        // 추상표현 + 제외조건 적용 후 남은 후보 (GIS/시설DB 매핑 결과)
  },
  "casualties": {
    "reported": "yes | no | unknown",
    "count_estimate": "string | null",  // "여러 명", "한 명쯤" 등 원문 기반
    "confidence": 0.0
  },
  "reporter": {
    "position": "string | null",        // 신고자 현재 위치 (사고 위치와 구분!)
    "is_at_scene": "yes | no | unknown"
  },
  "flags": {
    "contradiction_detected": false,    // 동일 슬롯에 상충 값 존재 여부
    "correction_detected": false,       // 정정 발화("아니고", "아니라") 감지 여부
    "correction_type": "replacement | refinement | null",
    "needs_clarification": false,
    "clarification_question": "string | null",  // 관제요원/TTS가 바로 쓸 수 있는 되묻기 문장
    "escalate_to_human": false          // 임계값 미달 또는 모순 미해소 시 true
  },
  "raw_transcript": "string"            // 원문 발화 전체 로그 (사람 최종 판단용, 필수 보존)
}
```


### 스키마 설계 포인트

- `raw_mentions`: 최종값만 남기지 않고 **모든 언급을 극성(polarity)과 함께 보존**합니다. 나중에 사람이 검증할 수 있고, 모순 감지의 근거가 됩니다.
- `excluded_candidates`: "A동은 아니에요" 같은 배타 정보를 제외 조건으로 저장 → 후보 필터링에 사용.
- `abstract_expressions`: 추상 표현은 그대로 두지 말고 별도 필드에 담아 GIS/시설 DB 매핑 단계로 넘깁니다.
- `correction_type`: **replacement**(B동→A동, 값 교체)와 **refinement**(A동→A동 연구실, 세분화)를 구분합니다. "A동 아니고 A동 연구실" 케이스가 refinement입니다.
- `raw_transcript`: 비상 시스템이므로 원문 로그를 항상 함께 전달해 human-in-the-loop 최종 판단이 가능하게 합니다.


## 2. 시스템 프롬프트 (추출용 LLM)

```
당신은 비상방재 관제 시스템의 신고 발화 분석기입니다.
신고자의 발화(음성 인식 결과)를 읽고, 아래 JSON 스키마에 맞춰 재난 정보를 추출합니다.
신고자는 흥분 상태이므로 말을 더듬거나, 정정하거나, 모순되게 말할 수 있습니다.

[추출 규칙]

1. 최신 발화 우선 원칙
   - 같은 슬롯에 대해 여러 값이 언급되면, 시간상 나중 발화를 최종값으로 간주한다.
   - 단, 앞선 값도 raw_mentions에 반드시 보존한다.

2. 정정 발화 인식 ("아니고", "아니라", "그게 아니고", "잘못 말했어요")
   - 정정 대상과 정정 값이 다른 개체면 correction_type = "replacement"
     예: "B동 아니고 A동" → building = "A동", raw_mentions에 B동은 negate로 기록
   - 정정 대상과 정정 값이 같은 개체의 상세화면 correction_type = "refinement"
     예: "A동 아니고 A동 연구실" → building = "A동", room = "연구실",
        granularity = "room". 이것은 부정이 아니라 위치의 세분화 정정이다.
        A동을 excluded_candidates에 넣지 말 것.

3. 부정·배타 명제 처리
   - "X는 아니에요", "X 말고요" → X를 excluded_candidates에 추가한다.
   - 부정된 값을 절대 최종 슬롯값으로 채우지 않는다.
   - 부정만 있고 긍정 정보가 없으면 해당 슬롯은 null, needs_clarification = true.

4. 추상 표현 처리
   - "큰 건물 쪽", "실험실 같은 데", "저쪽 끝" 등 구체적 매핑이 불가능한 표현은
     abstract_expressions에 원문 그대로 담는다. 임의로 특정 건물명으로 추측하지 않는다.

5. 신고자 위치와 사고 위치 구분
   - "저는 지금 B동인데, A동에서 불이 났어요" → 사고 위치는 A동, reporter.position은 B동.
   - 혼동 소지가 있으면 confidence를 낮추고 clarification_question을 생성한다.

6. 모순 감지
   - 동일 슬롯에 상충하는 affirm 값이 2개 이상 남으면 contradiction_detected = true,
     confidence는 0.5 이하로 설정하고 clarification_question을 반드시 생성한다.

7. confidence 산출 기준
   - 0.9 이상: 명시적·일관된 단일 언급
   - 0.6~0.9: 정정을 거쳤으나 최종값이 명확
   - 0.6 미만: 모순 미해소, 추상 표현만 존재, 또는 부정만 존재
   - location.confidence < 0.7 이면 needs_clarification = true

8. clarification_question 작성 규칙
   - 신고자에게 바로 읽어줄 수 있는 한 문장의 확인 질문으로 쓴다.
   - 예/아니오 또는 짧은 선택으로 답할 수 있게 만든다.
     좋은 예: "사고 위치가 A동 연구실이 맞습니까?"
     좋은 예: "A동이 아니라고 하셨는데, 그러면 몇 동입니까?"

9. 출력 형식
   - 지정된 JSON 스키마만 출력한다. 설명이나 마크다운을 붙이지 않는다.
   - 확실하지 않은 값은 추측으로 채우지 말고 null + 낮은 confidence로 둔다.
   - 어떤 경우에도 raw_transcript에 원문 전체를 그대로 보존한다.
```


## 3. Few-shot 예시 (프롬프트에 함께 포함)


### 예시 1 — 세분화 정정 (핵심 케이스)

> 입력: **"A동, 아 A동 아니고 A동 연구실에서 문제 발생했어요"**

```
{
  "incident": { "type": null, "type_confidence": 0.2, "severity_hint": "문제 발생" },
  "location": {
    "building": "A동", "floor": null, "room": "연구실",
    "granularity": "room", "confidence": 0.75,
    "raw_mentions": [
      { "text": "A동", "polarity": "affirm", "turn": 1 },
      { "text": "A동 연구실", "polarity": "affirm", "turn": 1 }
    ],
    "excluded_candidates": [],
    "abstract_expressions": [],
    "candidate_pool": []
  },
  "casualties": { "reported": "unknown", "count_estimate": null, "confidence": 0.0 },
  "reporter": { "position": null, "is_at_scene": "unknown" },
  "flags": {
    "contradiction_detected": false,
    "correction_detected": true,
    "correction_type": "refinement",
    "needs_clarification": true,
    "clarification_question": "A동 연구실이 맞습니까? 연구실이 여러 곳이면 몇 호인지 말씀해 주세요.",
    "escalate_to_human": false
  },
  "raw_transcript": "A동, 아 A동 아니고 A동 연구실에서 문제 발생했어요"
}
```

> 포인트: "아니고"가 있어도 A동을 부정한 것이 아니라 상세화. A동은 유지, room 슬롯 추가. 단 재난 유형이 없고 호실이 불명확하므로 되묻기.


### 예시 2 — 값 교체 정정

> 입력: **"B동에 불났어요! 아 아니다, A동이요 A동!"**

```
{
  "location": {
    "building": "A동", "granularity": "building", "confidence": 0.85,
    "raw_mentions": [
      { "text": "B동", "polarity": "negate", "turn": 1 },
      { "text": "A동", "polarity": "affirm", "turn": 1 }
    ],
    "excluded_candidates": ["B동"]
  },
  "flags": {
    "correction_detected": true,
    "correction_type": "replacement",
    "needs_clarification": true,
    "clarification_question": "화재 위치가 B동이 아니라 A동이 맞습니까?"
  }
}
```

> (지면상 변경된 핵심 필드만 표기. 실제 출력은 전체 스키마.)


### 예시 3 — 배타명제만 존재

> 입력: **"어디였더라... A동은 확실히 아니에요"**

```
{
  "location": {
    "building": null, "confidence": 0.1,
    "raw_mentions": [ { "text": "A동", "polarity": "negate", "turn": 1 } ],
    "excluded_candidates": ["A동"]
  },
  "flags": {
    "needs_clarification": true,
    "clarification_question": "A동이 아니라고 하셨는데, 그러면 어느 건물입니까?",
    "escalate_to_human": true
  }
}
```


### 예시 4 — 추상 표현

> 입력: **"정문 쪽 큰 건물 있잖아요, 실험실 같은 데서 연기가 나요"**

```
{
  "incident": { "type": "화재", "type_confidence": 0.7, "severity_hint": "연기가 나요" },
  "location": {
    "building": null, "confidence": 0.3,
    "abstract_expressions": ["정문 쪽 큰 건물", "실험실 같은 데"],
    "candidate_pool": []
  },
  "flags": {
    "needs_clarification": true,
    "clarification_question": "정문 쪽 큰 건물이라면 A동과 C동 중 어느 곳입니까?"
  }
}
```

> candidate_pool은 LLM이 아니라 후처리 단계에서 시설 DB 매핑으로 채웁니다. clarification_question의 선택지도 매핑 결과를 반영해 후처리에서 재생성하는 것을 권장합니다.


### 예시 5 — 모순 미해소

> 입력: **"A동이에요! ... 아니 B동인가... A동 맞아요, 아 B동!"**

```
{
  "location": {
    "building": "B동", "confidence": 0.4,
    "raw_mentions": [
      { "text": "A동", "polarity": "affirm", "turn": 1 },
      { "text": "B동", "polarity": "uncertain", "turn": 1 },
      { "text": "A동", "polarity": "affirm", "turn": 1 },
      { "text": "B동", "polarity": "affirm", "turn": 1 }
    ]
  },
  "flags": {
    "contradiction_detected": true,
    "needs_clarification": true,
    "clarification_question": "A동과 B동 중 어느 곳입니까?",
    "escalate_to_human": true
  }
}
```

> 최신 발화 우선으로 B동을 잠정값으로 두되, 왕복 정정이 반복됐으므로 모순 플래그 + 사람 에스컬레이션.


## 4. 후처리 로직 (의사코드)

```
def postprocess(extraction, facility_db, thresholds={"loc": 0.7, "type": 0.6}):
    loc = extraction["location"]

    # 1) 추상 표현 → 시설 DB 매핑으로 후보 좁히기
    if loc["abstract_expressions"]:
        candidates = facility_db.resolve(loc["abstract_expressions"])
        # 2) 배타 조건 적용
        candidates = [c for c in candidates if c not in loc["excluded_candidates"]]
        loc["candidate_pool"] = candidates
        if len(candidates) == 1:
            loc["building"] = candidates[0]
            loc["confidence"] = max(loc["confidence"], 0.8)
        elif len(candidates) >= 2:
            extraction["flags"]["needs_clarification"] = True
            extraction["flags"]["clarification_question"] = \
                f"{' 또는 '.join(candidates)} 중 어느 곳입니까?"

    # 3) 임계값 검사 → 되묻기 / 에스컬레이션
    f = extraction["flags"]
    if loc["confidence"] < thresholds["loc"] or f["contradiction_detected"]:
        f["needs_clarification"] = True
    if f["contradiction_detected"] or (f["needs_clarification"] and is_retry_exhausted()):
        f["escalate_to_human"] = True   # 관제요원 직접 개입 + raw_transcript 전달

    return extraction
```


### 대화 루프 설계

1. STT → LLM 추출 → 후처리
2. `needs_clarification`이면 `clarification_question`을 TTS로 재질문 (최대 2회 권장)
3. 재질문 답변은 이전 추출 결과와 함께 다시 LLM에 입력 (누적 컨텍스트)
4. 2회 내 해소 실패 또는 `escalate_to_human`이면 관제요원 연결 + 지금까지의 구조화 결과와 원문 로그 동시 표시
5. 확정 즉시 출동 지령 시스템으로 전달하되, 미확정 슬롯은 "미확인" 표기로 넘김 (기다리느라 지연되지 않게)


## 5. 운영 시 주의사항

- **오판 비용 비대칭**: 위치를 잘못 확정해 출동이 지연되는 비용이 되묻기 1회 비용보다 훨씬 큽니다. 임계값은 보수적으로(높게) 시작해서 로그 기반으로 조정하세요.
- **STT 오인식 대비**: "A동/H동", "이동/2동" 같은 음성 혼동 쌍을 시설 DB에 별칭으로 등록하고, 프롬프트에 "발음 유사 건물명 목록"을 주입하면 정확도가 올라갑니다.
- **평가셋 구축**: 실제 신고 녹취(익명화)에서 정정/부정/추상/배타 케이스를 태깅해 회귀 테스트셋으로 유지하세요. 프롬프트를 수정할 때마다 돌려서 케이스별 정확도를 확인하는 것이 필수입니다.
- **로그 보존**: raw_transcript와 raw_mentions는 감사·사후분석·모델 개선의 핵심 자료이므로 어떤 단계에서도 삭제하지 마세요.


## 6. STT 오인식 대응 — 구체 설계


### 6-1. 혼동 쌍 사전 (confusion pair dictionary)

시설 DB에 별칭 테이블을 두고, 음성 혼동 가능성이 있는 표기를 명시적으로 등록합니다.

```
{
  "confusion_pairs": [
    { "canonical": "A동",  "confusable_with": ["에이동", "H동", "8동"], "note": "에이/에이치/팔 발음 혼동" },
    { "canonical": "2동",  "confusable_with": ["이동", "E동"],          "note": "'이동(移動)' 일반명사와 충돌" },
    { "canonical": "B동",  "confusable_with": ["비동", "D동", "E동"],   "note": "비/디/이 혼동" },
    { "canonical": "C동",  "confusable_with": ["씨동", "G동", "Z동"],   "note": "씨/지 혼동" },
    { "canonical": "본관", "confusable_with": ["본간", "본과"],         "note": "STT 오전사" }
  ]
}
```


### 6-2. 자모 유사도 기반 자동 후보 생성

사전에 없는 오전사도 잡기 위해, 한글 자모 분해 + 편집거리로 시설명과의 유사도를 계산합니다.

```
from jamo import h2j, j2hcj   # pip install jamo
import Levenshtein            # pip install python-Levenshtein

def jamo_similarity(a: str, b: str) -> float:
    ja, jb = j2hcj(h2j(a)), j2hcj(h2j(b))
    dist = Levenshtein.distance(ja, jb)
    return 1 - dist / max(len(ja), len(jb), 1)

def phonetic_candidates(heard: str, facility_names: list[str], threshold=0.6):
    scored = [(name, jamo_similarity(heard, name)) for name in facility_names]
    return sorted([s for s in scored if s[1] >= threshold],
                  key=lambda x: -x[1])[:3]

# 예: phonetic_candidates("에이치동", ["A동","B동","H동","본관"])
#   → [("H동", 0.8...), ("A동", 0.6...)]
```


### 6-3. STT n-best 활용

STT 엔진이 n-best(상위 후보 여러 개)를 제공하면 1위 결과만 쓰지 말고 함께 LLM에 전달합니다.

```
[STT 결과]
1순위: "에이치동 삼층에서 불났어요" (conf 0.71)
2순위: "에이 치동 삼층에서 불났어요" (conf 0.65)
3순위: "A동 3층에서 불났어요" (conf 0.61)

위 후보들과 아래 시설 목록을 대조하여 가장 타당한 위치를 추출하되,
후보 간 건물명이 갈리면 confidence를 0.6 이하로 낮추고 확인 질문을 생성하라.
[시설 목록] A동, B동, C동, H동, 본관, 연구동
```


### 6-4. 프롬프트 주입 템플릿

시스템 프롬프트의 [추출 규칙] 뒤에 다음 블록을 동적으로 삽입합니다.

```
[시설 정보 — 이 목록에 있는 명칭으로만 정규화하라]
건물: {building_list}
발음 혼동 쌍: {confusion_pairs}

규칙:
- 들린 건물명이 목록에 없으면 혼동 쌍과 자모 유사 후보를 확인하고,
  단정할 수 없으면 building = null, 후보들을 candidate_pool에 넣고
  clarification_question으로 후보 중 선택을 요청하라.
- 목록 밖의 건물명을 임의로 만들어내지 마라.
```


## 7. 시설 DB 설계 및 resolve() 구현


### 7-1. 테이블 스키마

```
-- 건물
CREATE TABLE building (
  id          TEXT PRIMARY KEY,      -- 'BLD_A'
  name        TEXT NOT NULL,         -- 'A동'
  lat REAL, lon REAL,
  floors      INTEGER,
  size_rank   INTEGER,               -- 크기 순위: "큰 건물" 해석용
  built_year  INTEGER                -- "새 건물/옛날 건물" 해석용
);

-- 별칭 (혼동 쌍 + 통칭 + 구칭 모두 여기로)
CREATE TABLE building_alias (
  building_id TEXT REFERENCES building(id),
  alias       TEXT NOT NULL,         -- '에이동', '공학관', '구 본관'
  alias_type  TEXT                   -- 'phonetic' | 'common' | 'legacy'
);

-- 공간(호실)
CREATE TABLE room (
  id          TEXT PRIMARY KEY,
  building_id TEXT REFERENCES building(id),
  floor       INTEGER,
  name        TEXT,                  -- '연구실 302호'
  room_type   TEXT                   -- 'lab' | 'office' | 'lecture' | 'storage'
);

-- 랜드마크와 상대위치: "정문 쪽", "주차장 옆" 해석용
CREATE TABLE landmark (
  id   TEXT PRIMARY KEY,
  name TEXT,                          -- '정문', '주차장', '운동장'
  lat REAL, lon REAL
);

CREATE TABLE building_landmark (
  building_id TEXT REFERENCES building(id),
  landmark_id TEXT REFERENCES landmark(id),
  relation    TEXT,                  -- 'near' | 'facing' | 'behind'
  distance_m  REAL
);
```


### 7-2. 추상 표현 resolve() 구현

```
class FacilityResolver:
    def __init__(self, db):
        self.db = db

    def resolve(self, expressions: list[str],
                excluded: list[str] = []) -> list[str]:
        pools = []
        for expr in expressions:
            pools.append(set(self._resolve_one(expr)))
        # 여러 추상 표현의 교집합 → 없으면 합집합으로 완화
        candidates = set.intersection(*pools) if pools else set()
        if not candidates and pools:
            candidates = set.union(*pools)
        return [c for c in candidates if c not in excluded]

    def _resolve_one(self, expr: str) -> list[str]:
        out = []
        # (a) 랜드마크 상대위치: "정문 쪽", "주차장 옆"
        for lm in self.db.landmarks():
            if lm.name in expr:
                out += self.db.buildings_near(lm.id, max_dist=100)
        # (b) 크기/연식 수식어
        if "큰" in expr or "제일 큰" in expr:
            out += self.db.top_by_size(3)
        if "새" in expr or "신축" in expr:
            out += self.db.newest(2)
        # (c) 공간 유형: "실험실 같은 데" → lab 보유 건물
        for kw, rtype in [("실험실","lab"), ("연구실","lab"),
                          ("강의실","lecture"), ("창고","storage")]:
            if kw in expr:
                out += self.db.buildings_with_room_type(rtype)
        # (d) 별칭 직접 매칭
        out += self.db.match_alias(expr)
        return out
```


### 7-3. 예시 흐름 (섹션 3 예시 4 이어서)

```
입력 추상표현: ["정문 쪽 큰 건물", "실험실 같은 데"], excluded: []

(a) "정문" 랜드마크 근접 → {A동, C동}
(b) "큰" → size_rank 상위 → {A동, 본관}
(c) "실험실" → lab 보유 → {A동, C동, 연구동}

교집합 = {A동}  → building = "A동", confidence 0.8로 상향
→ clarification_question: "정문 쪽 A동이 맞습니까?" (확정 전 1회 확인 권장)

만약 교집합이 {A동, C동} 두 개였다면:
→ "정문 쪽 큰 건물이라면 A동과 C동 중 어느 곳입니까?"
```


## 8. 재질문 대화 루프 — 실제 구현 예시

```
import json, anthropic

client = anthropic.Anthropic()
MAX_CLARIFY = 2

def build_system_prompt(facility_ctx: str) -> str:
    return EXTRACTION_RULES + "\n" + facility_ctx   # 섹션 2 + 섹션 6-4

def run_report_session(stt_stream, tts, resolver, facility_ctx):
    messages = []
    for attempt in range(MAX_CLARIFY + 1):
        utterance = stt_stream.next_final_text()     # STT 결과 (n-best 포함 가능)
        messages.append({"role": "user", "content": utterance})

        resp = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1500,
            system=build_system_prompt(facility_ctx),
            messages=messages,                        # 누적 컨텍스트 유지가 핵심
        )
        extraction = json.loads(resp.content[0].text)
        extraction = postprocess(extraction, resolver)   # 섹션 4

        f = extraction["flags"]
        if not f["needs_clarification"]:
            return dispatch(extraction)               # 확정 → 출동 지령

        if attempt < MAX_CLARIFY:
            tts.speak(f["clarification_question"])    # 재질문
            # 어시스턴트 턴으로 기록해 다음 추출 시 맥락 유지
            messages.append({"role": "assistant",
                             "content": json.dumps(extraction, ensure_ascii=False)})
        else:
            return escalate(extraction)               # 관제요원 연결

def dispatch(extraction):
    # 미확정 슬롯은 '미확인'으로 표기하되 확정된 정보만으로 즉시 지령
    ...

def escalate(extraction):
    # 관제요원 화면: 구조화 결과 + raw_transcript + raw_mentions 동시 표시
    ...
```


### 구현 시 체크포인트

- **누적 컨텍스트**: 재질문 답변("네 맞아요")만 단독으로 LLM에 넣으면 무의미합니다. 이전 추출 결과를 assistant 턴으로 끼워 넣어 대화 전체를 유지하세요.
- **부분 확정 즉시 지령**: 호실이 미확정이어도 건물이 확정됐으면 건물 단위로 먼저 출동 지령을 내리고, 이후 확정되는 정보를 지령에 갱신(update) 방식으로 추가하는 것이 골든타임에 유리합니다.
- **타임아웃**: 재질문에 신고자가 5~7초 내 응답이 없으면 즉시 에스컬레이션. 신고자가 의식을 잃거나 대피 중일 수 있습니다.
- **회귀 테스트**: 섹션 3의 예시 5개 + 실녹취 태깅 케이스를 CI에 넣고, 프롬프트/사전/DB를 수정할 때마다 슬롯 단위 정확도(특히 refinement vs replacement 구분율)를 측정하세요.
