비상 방재 신고 발화, AI로 제대로 받아 적는 법

STT+LLM 구조화 추출 설계 공개 — 누구나 구현할 수 있게
MD 파일 다운로드

흥분한 신고자는 말을 더듬고, 정정하고, 모순되게 말합니다. "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 구분율)를 측정하세요.
MD 파일 다운로드