흥분한 신고자는 말을 더듬고, 정정하고, 모순되게 말합니다. "A동, 아 A동 아니고 A동 연구실에서 문제 발생했어요" — 이 한 문장에 부정처럼 보이는 세분화 정정이 들어 있습니다. 규칙 기반 파서는 이런 발화 앞에서 무너지고, 프롬프트 없는 LLM은 그럴듯하게 지어냅니다. 이 문서는 이런 발화를 환각도 오판도 없이 받아 적어 관제·전파로 잇는 설계 전체를 공개합니다: 슬롯 스키마, 추출용 시스템 프롬프트 전문, few-shot 예시 5개, 후처리 로직, STT 오인식 대응, 시설 DB와 resolve() 구현, 재질문 대화 루프까지.
설계 전체를 관통하는 원칙은 하나입니다. 오판 비용 비대칭 — 위치를 잘못 확정해 출동이 지연되는 비용이 되묻기 1회 비용보다 훨씬 큽니다. 그래서 이 설계는 확신이 없으면 추측으로 채우지 않고, 낮은 확신도와 함께 되묻고, 해소가 안 되면 사람에게 넘깁니다.
{
"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 최종 판단이 가능하게 합니다.당신은 비상방재 관제 시스템의 신고 발화 분석기입니다.
신고자의 발화(음성 인식 결과)를 읽고, 아래 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에 원문 전체를 그대로 보존한다.
{
"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동 연구실에서 문제 발생했어요"
}
{
"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동이 맞습니까?"
}
}
{
"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
}
}
{
"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동 중 어느 곳입니까?"
}
}
{
"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
}
}
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
needs_clarification이면 clarification_question을 TTS로 재질문 (최대 2회 권장)escalate_to_human이면 관제요원 연결 + 지금까지의 구조화 결과와 원문 로그 동시 표시시설 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 오전사" }
]
}
사전에 없는 오전사도 잡기 위해, 한글 자모 분해 + 편집거리로 시설명과의 유사도를 계산합니다.
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...)]
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동, 본관, 연구동
시스템 프롬프트의 [추출 규칙] 뒤에 다음 블록을 동적으로 삽입합니다.
[시설 정보 — 이 목록에 있는 명칭으로만 정규화하라]
건물: {building_list}
발음 혼동 쌍: {confusion_pairs}
규칙:
- 들린 건물명이 목록에 없으면 혼동 쌍과 자모 유사 후보를 확인하고,
단정할 수 없으면 building = null, 후보들을 candidate_pool에 넣고
clarification_question으로 후보 중 선택을 요청하라.
- 목록 밖의 건물명을 임의로 만들어내지 마라.
-- 건물
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
);
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
입력 추상표현: ["정문 쪽 큰 건물", "실험실 같은 데"], 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동 중 어느 곳입니까?"
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 동시 표시
...