Skip to content

[GMSS-101] feat: 위험 표현 감지 및 상담 기관 안내 - #13

Open
soyeonLee126 wants to merge 10 commits into
developfrom
feature/risk-keyword-guidance
Open

[GMSS-101] feat: 위험 표현 감지 및 상담 기관 안내#13
soyeonLee126 wants to merge 10 commits into
developfrom
feature/risk-keyword-guidance

Conversation

@soyeonLee126

@soyeonLee126 soyeonLee126 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

작업 개요

사용자가 일기를 저장할 때 원문에서 자살·자해 위험 표현을 감지해, 전문 상담 기관을 즉시 안내합니다.
위험도가 CRITICAL이면 감정 분석 실행을 차단하고, WARNING이면 안내만 하고 진행합니다.

감지 사전은 앱에 내장된 JSON이 기본이고, Firestore app_config/risk_lexicon 문서의 version이 더 크면 그쪽을 채택한다. 앱 배포 없이 단어와 등급을 조정할 수 있게 하려는 목적입니다.

원문은 기기 밖으로 나가지 않습니다. 사전만 원격에서 받아오고 검사는 전부 온디바이스에서 진행합니다.

작업 유형

  • feat: 새 기능
  • fix: 버그 수정
  • refactor: 리팩토링 (동작 변경 없음)
  • build: 빌드/의존성 설정
  • chore: 기타 (문서, 설정 등)
  • test: 테스트 추가/수정

변경 사항

  • domain/safety 위험 표현 감지 도메인. RiskTermMatcher(매칭), DetectRiskInTextUseCase, RefreshRiskLexiconUseCase
  • data/safety 내장 assets JSON → DataStore 캐시 → Firestore 오버라이드 3단 폴백. 원격 조회는 앱 시작 시 1회, 캐시 TTL 24시간
  • core/ui GamssSupportAgencyDialog(레포 최초 다이얼로그), dial()전화 연결 유틸,safety_` 문자열 리소스
  • feature/emotion onAnalyze()에서 감정 분석 전에 원문 검사. CRITICAL이면 분석을 실행하지 않되 입력 state는 유지
  • firestore.rules, firebase.json app_config를 인증 사용자 읽기 전용으로 제한, 쓰기 전면 차단
  • 의존성 firebase-firestore 추가 (기존 firebase-bom, kotlinx-coroutines-play-services 재사용)

매칭 규칙에서 짚어둘 점

공백과 구두점을 지운 뒤 부분 문자열로 찾되, 두 글자 이하 표현은 어절 경계에서 시작할 때만 인정합니다.

공백을 지우면 "과자 살까 말까""혼자 살아보니" 안에 "자살"이 생겨 평범한 일기가 차단됨.
초기 구현에서 실제로 이 문제가 있었고, 어절 경계 조건으로 해결.
세 글자 이상은 "죽고 싶다"처럼 어절이 갈려도 잡아야 하므로 위치를 보지 않습니다.

오탐 방지를 위해 사전에 safePhrases를 둔다. "배고파 죽겠다", "죽을 것 같아", "죽을 뻔", "너 죽을래?", "유서 깊은", "자살골" 같은 관용 표현·합성어를 마스킹합니다.

사전 구성

terms 41개(CRITICAL 28 / WARNING 13), safePhrases 29개.

어휘는 보건복지부·자살예방센터가 정리한 언어적 경고신호를 기준으로 뽑았습니다.
공개된 한국어 자살위험 어휘 사전은 존재하지 않아, 관련 연구와 평가도구는 문항 형태라 텍스트 매칭에 쓸 수 없었음.

검증 과정에서 희망이없, 짐만되는"이 프로젝트는 희망이 없다", "걔 진짜 짐만 되는 스타일이야" 같은 일상 문장에 걸려 제외. 그 문장들을 회귀 테스트의 통과 대상으로 고정. 대가로 "이제 희망이 없어" 류의 신호는 놓침..

안전 설계

  • 사전 조회는 실패하지 않는다. 원격·캐시가 모두 불가하면 내장 사전을 쓴다. 안전 기능이 네트워크 상태에 좌우되면 안됨
  • termsagencies가 빈 원격 사전은 채택하지 않는다. 필드명 오타로 감지가 통째로 꺼지는 사고를 막음
  • 알 수 없는 등급 문자열은 차단하지 않는 WARNING으로 내린다. 운영 오타가 과차단이 되면 안됨
  • 차단해도 입력 state는 건드리지 않는다. 안내를 닫았을 때 사용자가 쓴 글이 남아 있어야 함
  • 원문과 매칭된 단어는 로그·Analytics·Crashlytics에 남기지 않음

관련 이슈

  • 없음

관련 작업 (Notion)

스크린샷 / 동작 화면

  • 죽고싶다 입력 → 상담 기관 안내 다이얼로그 노출, 감정 분석 실행되지 않음
  • 배고파죽겠네 입력 → 다이얼로그 없이 정상 감정 분석, 캐릭터 결과 노출
  • 과자 살까 말까 입력 → 다이얼로그 없음 (어절 경계 규칙 확인)
  • Firestore 원격 사전 채택 경로 확인. version: 2 문서를 올린 뒤 앱이 그것을 채택하는 것을 DataStore 캐시에서 확인
  • Firestore 비활성 상태에서도 크래시 없이 내장 사전으로 동작하는 것을 확인

체크리스트

  • base 브랜치가 develop으로 설정
  • 로컬 빌드 및 실행을 확인
  • 커밋 메시지가 컨벤션(feat:, fix: 등)을 따른다
  • 불필요한 로그/주석/디버그 코드를 제거
  • 리뷰어가 이해할 수 있도록 설명을 작성

리뷰 요청 사항

1. 차단 정책의 적정성

CRITICAL은 전송을 차단한다. 감정 일기 앱에서 사용자가 감정을 적는 통로를 막는 셈이라 트레이드오프가 있습니다.
WARNING 등급을 따로 두고, 등급을 원격 사전에서 조정할 수 있게 했다. 차단 범위가 적절한지 봐주시면 감사하겠습니다

2. 내장 사전의 단어·등급 구성

data/src/main/assets/risk_lexicon.json의 terms 41개, safePhrases 29개. 오탐과 미탐 둘 다 비용이 큰 영역이라 리뷰가 필요합니다.

등급을 내린 판단 두 건을 특히 봐주시면 감사하겠습니다.

  • 번개탄 : 자살 수단 신호이지만 캠핑·구이 용례가 흔해서 WARNING
  • 죽었으면좋겠 : "걔 죽었으면 좋겠어" 처럼 타인을 향한 발화일 수 있어 WARNING

3. 기관 정보

자살예방 상담전화는 2024년 1월부터 1393에서 109로 통합됨. 1577-0199(정신건강), 1388(청소년)은 각자 번호를 계속 운영. 이 세 개만 넣었습니다.

테스트

신규 단위 테스트 28개. CI가 게이트하는 detekt / lint / test / assembleDebug / assembleRelease 모두 통과

  • RiskTermMatcherTest (14) 등급 판정, safePhrase 마스킹, 어절 경계, 띄어쓰기·구두점 우회
  • DetectRiskInTextUseCaseTest (3) 기관 priority 정렬, 빈 사전
  • RiskLexiconRepositoryImplTest (7) Firestore 실패 폴백, version 비교, 캐시 손상, TTL
  • BundledRiskLexiconContentTest (4) 실제 assets JSON을 읽어 검증. 차단 대상 20문장, 안내 대상 8문장, 걸리면 안 되는 일상 문장 30문장을 고정

…DetectRiskInTextUseCase 추가. CRITICAL은 전송 차단, WARNING은 안내만 하도록 등급을 나눠 원격 사전에서 조정할 수 있게 했다 - RiskTermMatcher는 공백·구두점을 지운 뒤 매칭하되, 두 글자 이하 표현은 어절 경계에서 시작할 때만 인정한다. 공백을 지우면 "과자 살까", "혼자 살아보니" 안에 "자살"이 생겨 평범한 일기가 차단되기 때문 - safePhrases로 "배고파 죽겠다" 류의 관용 표현을 걸러낸다
…fig/risk_lexicon 의 version 이 더 클 때만 채택한다. 조회 결과는 DataStore 에 캐시하고 24시간 동안 재조회하지 않는다 - 원격 조회·캐시 파싱·assets 로딩이 모두 실패해도 예외를 내보내지 않는다. 안전 기능이 네트워크 상태에 좌우되면 안 된다 - terms 나 agencies 가 빈 원격 사전은 채택하지 않는다. 필드명 오타로 감지가 통째로 꺼지는 사고를 막는다 - 원문은 기기 밖으로 나가지 않는다. 사전만 받아와 온디바이스로 검사한다 - firestore.rules 로 app_config 를 인증 사용자 읽기 전용으로 제한
… 제공하는 GamssSupportAgencyDialog 추가. 차단된 경우와 안내만 하는 경우의 문구를 구분한다 - dial() 은 ACTION_DIAL 로 번호만 채우고 발신은 사용자가 누른다. CALL_PHONE 권한을 요구하지 않기 위해서다 - 다이얼러가 없는 기기에서 버튼이 무반응으로 보이지 않도록 dial() 이 성공 여부를 반환한다 - Android 11+ 패키지 가시성 정책에 막히지 않도록 AndroidManifest 에 DIAL queries 선언
…CAL 이면 분석을 실행하지 않는다. 차단해도 입력 state 는 그대로 둬 안내를 닫았을 때 사용자가 쓴 글이 남아 있게 한다 - 사전 로딩이 suspend 지점이라 연타로 중복 실행될 수 있어 isRunning 갱신을 감지 호출 앞으로 옮겼다 - 원문은 노출·기록하지 않는다는 기존 정책을 그대로 따른다 - 앱 시작 시 1회 사전을 갱신한다. 전송마다 네트워크를 타면 감지가 느려지고 읽기 비용이 사용량에 비례해 늘어난다
…ider 로 받도록 변경. 인스턴스 생성 시점에 gRPC 채널과 ConnectivityManager 콜백이 함께 올라오는데, 앱 시작 시 사전 갱신은 캐시가 신선하면 조회를 건너뛰므로 그 경우 초기화 비용을 치를 이유가 없다
… 어절 경계 규칙만 남겼다. 두 글자 이하 표현을 어절 경계로 제한하는 이유는 코드만 봐서는 알 수 없고, 제거하면 "과자 살까" 오탐이 되살아난다
…낫, 죽으면좋겠, 죽는수밖에 9개를 CRITICAL 로 추가. 기존 죽어버리 는 죽어버릴거야 를 잡지 못해 죽어버릴 을 따로 넣었다 - 이 계열은 과장 표현과 겹치는 범위가 넓어 safePhrase 9개를 함께 추가했다. 죽을것같, 죽을거같, 죽을뻔, 죽을각오, 죽을힘, 죽을죄, 너죽을래, 죽을때까지, 죽을고생 - 회귀 테스트에 차단 대상 8개, 통과 대상 9개 문장 추가
…추가했다. CRITICAL 4개(차라리죽, 죽어야편, 죽는게답, 다끝내버리고싶)와 WARNING 5개(죽었으면좋겠, 나만없으면, 태어나지말았, 살아있을이유, 없어지는게낫) - 죽었으면좋겠 는 타인을 향한 발화일 수 있어 차단하지 않고 안내만 하는 WARNING 으로 두었다 - 희망이없 과 짐만되는 은 검증에서 일상 문장에 걸려 제외했다. 회귀 테스트에 해당 문장을 통과 대상으로 고정했다
@soyeonLee126 soyeonLee126 changed the title feat: 위험 표현 감지 및 상담 기관 안내 [GMSS-101] feat: 위험 표현 감지 및 상담 기관 안내 Aug 5, 2026
…ms 와 SupportAgency.url 제거. 소비처가 없는데 matchedTerms 는 UI state 까지 올라가 사용자가 쓴 표현이 상주했다 - 상담 기관 안내 다이얼로그와 전화 유틸을 core:ui 에서 feature:emotion 으로 이동. core:ui 는 domain 을 모르는 순수 컴포저블 모듈로 되돌렸다 - MaterialTheme 대신 GamssTheme 토큰 사용(spacing200, body4Regular, body5Medium, subtitle4) - 다이얼로그의 isBlocking Boolean 플래그를 RiskLevel 로 교체. 등급이 늘면 when 이 컴파일 시점에 누락을 잡는다 - 기관 priority 정렬을 matcher 에서 DTO 매핑으로 이동해 RiskLexicon.agencies 가 항상 정렬돼 있다는 불변식으로 만들었다 - RiskLexiconRepository 에 무예외 계약을 KDoc 으로 명시. 보장이 runSafely 구현 디테일에만 있었다 - MASK 길이 보존 제약 주석 추가. 길이가 바뀌면 wordStarts 인덱스가 밀려 짧은 표현 매칭이 조용히 깨진다 - 사전 회귀 테스트가 첫 실패에서 멈추지 않고 어긋난 문장을 모아 보고하도록 변경
@soyeonLee126
soyeonLee126 marked this pull request as ready for review August 6, 2026 05:59
name = it.string(FIELD_NAME),
description = it.string(FIELD_DESCRIPTION),
phoneNumber = it[FIELD_PHONE_NUMBER] as? String,
url = it[FIELD_URL] as? String,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIELD_URL을 읽어 SupportAgencyDto.url에 채우고 toDomain()에서 버리는 죽은 데이터 경로인데 혹시 나중에 쓸 계획 있나요? 아니라면 FIELD_URL/SupportAgencyDto.url도 같이 제거하는 게 깔끔할 것 같습니다!

firestoreProvider.get()
.collection(COLLECTION)
.document(DOCUMENT)
.get()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Source 미지정 시 기본값(Source.DEFAULT)은 서버 접속 실패 시 예외 대신 Firestore SDK 자체 오프라인 캐시의 낡은 스냅샷을 조용히 반환합니다. adopt()는 fetch()가 non-null이기만 하면 버전 비교 전에 markFetched(now())부터 호출해 24시간 TTL을 리셋하므로, 오프라인 상태에서 낡은 캐시를 받아도 "방금 서버 확인 완료"로 잘못 기록됩니다 —>실제로는 신규 원격 사전을 놓친 채로 하루를 더 기다리게 됩니다.

.get(Source.SERVER)로 바꾸면 서버 접속 실패 시 확실히 예외가 발생하게 될것 같습니다!

@seunghee17

Copy link
Copy Markdown
Collaborator

1. 차단 정책 — 동의

2. 번개탄 / 죽었으면좋겠 → WARNING — 둘 다 동의

  • 번개탄: 단독으로 등장하는 경우만으로 CRITICAL로 판단하기에는 범위가 넓어 WARNING이 적절해 보입니다. 다른 위험 키워드가 함께 포함된 경우에는 더 높은 위험도로 판단되므로 현재 구조에서도 대응 가능합니다!
  • 죽었으면좋겠: 대상이 본인인지 타인인지 키워드만으로 판단하기 어렵기 때문에 바로 차단하기보다는 WARNING으로 분류해 안내하는 것이 적절해 보입니다!

3. 기관 정보 — 3개면 충분, 다만 텍스트 상담 채널 부재

지금 다 전화만 있는데 뭔가 이런 위험 신호를 내뱉는 사용자 특성상 바로 전화가 어려울 수도 있을거 같다는 생각이 들었습니다..! 텍스트 상담을 선호할 수도 있을 거 같아서 (관련 상담소 채팅 서비스가 있다면) 문자, 카톡 상담 URL 추가를 고려하면 어떨까 합니다!
(근데 이건 합의가 필요한 것이기에 그냥 참고만 해주십쇼)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants