test1 / app /views.py
msh3907's picture
Return NLI detail even when score is 0
c1e8ae0
Raw
History Blame Contribute Delete
13.4 kB
import os
import json
import re
import requests
import traceback
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
BUILD_VERSION = "semantic-v5-nli-resize-2026-05-15b"
# -----------------------
# 1) ๋ฌธ์žฅ ๋ถ„๋ฆฌ
# -----------------------
SENT_SPLIT_RE = re.compile(r"[.!?]\s+|\n+")
def split_sentences(text: str, limit: int = 10):
t = (text or "").strip()
if not t:
return []
parts = [p.strip() for p in SENT_SPLIT_RE.split(t) if p.strip()]
return parts[:limit]
# -----------------------
# 2) (AI) ๊ฐ์„ฑ ๋ถ„๋ฅ˜ ํŒŒ์ดํ”„๋ผ์ธ
# -----------------------
_SENTIMENT = None
def get_sentiment_pipeline():
global _SENTIMENT
if _SENTIMENT is None:
from transformers import pipeline
_SENTIMENT = pipeline(
"text-classification",
model="daekeun-ml/koelectra-small-v3-nsmc",
top_k=None
)
return _SENTIMENT
# -----------------------
# 3) (AI) NLI ๋ชจ๋ธ (๋ฌธ์žฅ์Œ ์ถ”๋ก  ์ง์ ‘ ์ˆ˜ํ–‰ + ์ž„๋ฒ ๋”ฉ ํฌ๊ธฐ ๋ณด์ •)
# -----------------------
_NLI = None
def get_nli():
global _NLI
if _NLI is None:
import torch
import torch.nn.functional as F
from transformers import AutoTokenizer, AutoModelForSequenceClassification
model_id = "Huffon/klue-roberta-base-nli"
# fast tokenizer์—์„œ ๋“œ๋ฌผ๊ฒŒ mismatch๊ฐ€ ๋‚˜๋Š” ์ผ€์ด์Šค๋ฅผ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด slow tokenizer ์‹œ๋„
tok = AutoTokenizer.from_pretrained(model_id, use_fast=False)
mdl = AutoModelForSequenceClassification.from_pretrained(model_id)
mdl.eval()
# tokenizer vocab๊ณผ model embedding ํฌ๊ธฐ๊ฐ€ ๋‹ค๋ฅด๋ฉด resize(IndexError ๋ฐฉ์ง€)
try:
vocab_size = len(tok)
emb = mdl.get_input_embeddings()
emb_size = int(emb.num_embeddings) if emb is not None else None
if emb_size is not None and vocab_size != emb_size:
mdl.resize_token_embeddings(vocab_size)
except Exception:
# resize ์‹คํŒจํ•ด๋„ ์ผ๋‹จ ๊ณ„์†(์—๋Ÿฌ๋Š” semantic_crisis_confidence์—์„œ ์žกํž˜)
pass
# entailment ๋ผ๋ฒจ ์ธ๋ฑ์Šค ์ฐพ๊ธฐ(๋ฐฉ์–ด์ ์œผ๋กœ)
entail_id = None
label2id = getattr(mdl.config, "label2id", None) or {}
id2label = getattr(mdl.config, "id2label", None) or {}
for k, v in label2id.items():
if str(k).upper() == "ENTAILMENT":
entail_id = int(v)
break
if entail_id is None:
for k, v in id2label.items():
if str(v).upper() == "ENTAILMENT":
entail_id = int(k)
break
if entail_id is None:
entail_id = 0 # fallback
_NLI = (tok, mdl, entail_id, torch, F)
return _NLI
def nli_entail_prob(premise: str, hypothesis: str) -> float:
tok, mdl, entail_id, torch, F = get_nli()
inputs = tok(
premise,
hypothesis,
return_tensors="pt",
truncation=True,
max_length=256
)
inputs.pop("token_type_ids", None) # <- ์ถ”๊ฐ€: RoBERTa์—์„œ token_type_ids ๋•Œ๋ฌธ์— ํ„ฐ์ง€๋Š” ๋ฌธ์ œ ๋ฐฉ์ง€
with torch.no_grad():
logits = mdl(**inputs).logits
probs = F.softmax(logits, dim=-1)[0]
n = int(probs.shape[-1])
# entail_id ์•ˆ์ „ ๋ฒ”์œ„ ์ฒดํฌ
if entail_id < 0 or entail_id >= n:
entail_id = 0
return float(probs[entail_id].item())
# -----------------------
# 4) ์ง์ ‘ ์œ„๊ธฐ ์‹ ํ˜ธ(๋ ˆ๋ฒจ 4) + ๋ถ€์ •๋ฌธ ํ•„ํ„ฐ
# -----------------------
SUICIDE_RE = re.compile(r"(์ž์‚ด|์žํ•ด|์ฃฝ๊ณ \s?์‹ถ|์ฃฝ๊ณ ์‹ถ|๋ชฉ์ˆจ)", re.IGNORECASE)
NEGATION_RE = re.compile(r"(์•„๋‹ˆ|์•Š|์—†|์ง€\s?์•Š)", re.IGNORECASE)
def _is_negated_around(text: str, start: int, end: int, window: int = 10) -> bool:
left = text[max(0, start - window):start]
right = text[end:min(len(text), end + window)]
return bool(NEGATION_RE.search(left) or NEGATION_RE.search(right))
def has_direct_selfharm_signal(text: str) -> bool:
t = text or ""
m = SUICIDE_RE.search(t)
if not m:
return False
if _is_negated_around(t, m.start(), m.end()):
return False
return True
def has_negated_selfharm_mention(text: str) -> bool:
t = text or ""
m = SUICIDE_RE.search(t)
if not m:
return False
return _is_negated_around(t, m.start(), m.end())
# -----------------------
# 5) ๊ทœ์น™ ๊ธฐ๋ฐ˜ ์นดํ…Œ๊ณ ๋ฆฌ + ๊ฐ€์ค‘์น˜
# -----------------------
CATEGORIES = {
"์šฐ์šธ/์ ˆ๋ง": re.compile(r"(์šฐ์šธ|์ ˆ๋ง|ํฌ๋ง์ด\s?์—†|ํ—ˆ๋ฌด|๊ณตํ—ˆ)", re.IGNORECASE),
"๋ฌด๊ธฐ๋ ฅ/ํ”ผ๋กœ": re.compile(r"(๋ฌด๊ธฐ๋ ฅ|๊ธฐ์šด์ด\s?์—†|ํ”ผ๊ณค|์ง€์นจ)", re.IGNORECASE),
"๋ฌด์พŒ๊ฐ": re.compile(r"(์žฌ๋ฏธ๊ฐ€\s?์—†|ํฅ๋ฏธ๊ฐ€\s?์—†|์ฆ๊ฒ์ง€\s?์•Š)", re.IGNORECASE),
"์ž๊ธฐ๋น„๋‚œ": re.compile(r"(์‹คํŒจ์ž|์“ธ๋ชจ์—†|์ž์ฑ…|ํ˜์˜ค|์ฃ„์ฑ…๊ฐ)", re.IGNORECASE),
"์ˆ˜๋ฉด๋ฌธ์ œ": re.compile(r"(๋ถˆ๋ฉด|์ž ์ด\s?์•ˆ|์ž์ฃผ\s?๊นจ|๊ณผ์ˆ˜๋ฉด|๋„ˆ๋ฌด\s?์ž )", re.IGNORECASE),
"์ง‘์ค‘๊ณค๋ž€": re.compile(r"(์ง‘์ค‘์ด\s?์•ˆ|๋ฉํ•˜|๊ฒฐ์ •์ด\s?์–ด๋ ต)", re.IGNORECASE),
}
CATEGORY_WEIGHTS = {
"์šฐ์šธ/์ ˆ๋ง": 1.0,
"๋ฌด๊ธฐ๋ ฅ/ํ”ผ๋กœ": 0.2,
"๋ฌด์พŒ๊ฐ": 0.9,
"์ž๊ธฐ๋น„๋‚œ": 1.0,
"์ˆ˜๋ฉด๋ฌธ์ œ": 0.5,
"์ง‘์ค‘๊ณค๋ž€": 0.5,
}
# -----------------------
# 6) ๋ ˆ๋ฒจ/ํ†ค ๋ฉ”์‹œ์ง€
# -----------------------
RISK_LEVELS = [
(0, "์•ˆ์ •", "ํ˜„์žฌ ๊ธฐ๋ก์—์„œ ํฐ ์œ„ํ—˜ ์‹ ํ˜ธ๋Š” ๋‚ฎ์•„ ๋ณด์ž…๋‹ˆ๋‹ค. ๋‹ค๋งŒ ๋ณ€ํ™”๊ฐ€ ์žˆ์œผ๋ฉด ๊ณ„์† ๊ธฐ๋กํ•ด ์ฃผ์„ธ์š”."),
(1, "์ฃผ์˜", "๋ถ€์ • ์ •์„œ/์ŠคํŠธ๋ ˆ์Šค ์‹ ํ˜ธ๊ฐ€ ์ผ๋ถ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์ˆ˜๋ฉดยท์‹์‚ฌยทํ™œ๋™ ๋ฆฌ๋“ฌ์„ ์ ๊ฒ€ํ•˜๊ณ  ์ถ”์ ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค."),
(2, "๊ด€์ฐฐ ํ•„์š”", "๋ฐ˜๋ณต๋˜๋Š” ๋ฌด๊ธฐ๋ ฅ/์ž๊ธฐ๋น„๋‚œ ๋“ฑ์˜ ์‹ ํ˜ธ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์ƒ๋‹ด/์ง„๋ฃŒ๋ฅผ ๊ณ ๋ คํ•ด ๋ณด์„ธ์š”."),
(3, "๋„์›€ ๊ถŒ์žฅ", "์œ„ํ—˜ ์‹ ํ˜ธ๊ฐ€ ๋šœ๋ ทํ•ฉ๋‹ˆ๋‹ค. ๊ฐ€๋Šฅํ•œ ํ•œ ๋น ๋ฅด๊ฒŒ ์ „๋ฌธ๊ฐ€ ์ƒ๋‹ด/์ง„๋ฃŒ๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค."),
(4, "๊ธด๊ธ‰(์ฆ‰์‹œ ๋„์›€)", "์œ„๊ธฐ ์‹ ํ˜ธ๊ฐ€ ๊ฐ์ง€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ง€๊ธˆ์€ ์ฆ‰์‹œ ๋„์›€์„ ๋ฐ›๋Š” ๊ฒƒ์ด ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค."),
]
# -----------------------
# 7) ์นด์นด์˜ค ์žฅ์†Œ ์ถ”์ฒœ(ํ‚ค ์—†์œผ๋ฉด ๋น„ํ™œ์„ฑ)
# -----------------------
def kakao_places(lat, lng, radius=3000):
key = os.getenv("KAKAO_REST_API_KEY")
if not key or lat is None or lng is None:
return []
url = "https://dapi.kakao.com/v2/local/search/keyword.json"
headers = {"Authorization": f"KakaoAK {key}"}
params = {
"query": "์ •์‹ ๊ฑด๊ฐ•์˜ํ•™๊ณผ",
"y": str(lat),
"x": str(lng),
"radius": str(radius),
"sort": "distance",
"size": 10,
}
r = requests.get(url, headers=headers, params=params, timeout=5)
if r.status_code != 200:
return []
return r.json().get("documents", [])
# -----------------------
# 8) ๊ฐ์„ฑ ๋ชจ๋ธ ๋ถ€์ • ํ™•๋ฅ  p_neg
# -----------------------
def negative_prob_from_text(pipe, text: str) -> float:
t = (text or "").strip()
if not t:
return 0.5
def _p_neg(one: str) -> float:
out = pipe(one)
p_neg = None
if isinstance(out, list) and len(out) > 0 and isinstance(out[0], list):
for item in out[0]:
label = str(item.get("label", "")).lower()
score = float(item.get("score", 0.0))
if ("neg" in label) or (label in ["0", "label_0", "negative"]):
p_neg = score
elif isinstance(out, list) and len(out) > 0 and isinstance(out[0], dict):
label = str(out[0].get("label", "")).lower()
score = float(out[0].get("score", 0.0))
if ("neg" in label) or (label in ["0", "label_0", "negative"]):
p_neg = score
else:
p_neg = 1.0 - score
return 0.5 if p_neg is None else p_neg
if len(t) <= 900:
return _p_neg(t[:1000])
head = t[:700]
tail = t[-700:]
return (_p_neg(head) + _p_neg(tail)) / 2.0
# -----------------------
# 9) ์˜๋ฏธ ๊ธฐ๋ฐ˜(๊ฐ„์ ‘) ์œ„๊ธฐ ํ‘œํ˜„ โ†’ ๋ ˆ๋ฒจ 3์œผ๋กœ๋งŒ ์ƒํ–ฅ
# -----------------------
H_CRISE = "์ด ๋ฌธ์žฅ์€ ์‚ถ์„ ํฌ๊ธฐํ•˜๊ณ  ์‹ถ๋‹ค๋Š” ๋œป์ด๋‹ค."
H_FATIGUE = "์ด ๋ฌธ์žฅ์€ ๋‹จ์ˆœํžˆ ํ”ผ๊ณคํ•˜๊ฑฐ๋‚˜ ์ŠคํŠธ๋ ˆ์Šค๊ฐ€ ์žˆ๋‹ค๋Š” ๋œป์ด๋‹ค."
H_NEGATE = "์ด ๋ฌธ์žฅ์€ ๊ทธ๋Ÿฐ ๋œป์ด ์•„๋‹ˆ๋ผ๊ณ  ๋ถ€์ •ํ•œ๋‹ค."
SEMANTIC_TRIGGER_RE = re.compile(r"(์‚ด๊ธฐ\s?์‹ซ|์˜๋ฏธ\s?์—†|๊ทธ๋งŒ\s?ํ•˜๊ณ \s?์‹ถ|ํฌ๊ธฐํ•˜๊ณ \s?์‹ถ)", re.IGNORECASE)
def semantic_crisis_confidence(text: str) -> dict:
t = (text or "").strip()
if not t or not SEMANTIC_TRIGGER_RE.search(t):
return {"score": 0.0, "sentence": None, "detail": None, "error": None}
try:
sents = split_sentences(t, limit=6)
if not sents:
return {"score": 0.0, "sentence": None, "detail": {"reason": "no_sentences"}, "error": None}
best = None
for s in sents:
e_crisis = nli_entail_prob(s, H_CRISE)
e_fatigue = nli_entail_prob(s, H_FATIGUE)
e_negate = nli_entail_prob(s, H_NEGATE)
raw = e_crisis - max(e_fatigue, e_negate)
score = max(0.0, raw)
cand = {
"score": float(score),
"sentence": s,
"detail": {
"e_crisis": float(e_crisis),
"e_fatigue": float(e_fatigue),
"e_negate": float(e_negate),
"raw": float(raw),
},
"error": None
}
if best is None or cand["score"] > best["score"]:
best = cand
return best
except Exception as e:
return {"score": 0.0, "sentence": None, "detail": None, "error": repr(e)}
# -----------------------
# 10) ์ตœ์ข… ์œ„ํ—˜๋„
# -----------------------
def risk_from_text(diary: str):
t = (diary or "").strip()
if has_direct_selfharm_signal(t):
lvl, name, tone = RISK_LEVELS[4]
return {
"level": lvl,
"name": name,
"tone_message": tone,
"override": "direct_selfharm",
"signals": {"build_version": BUILD_VERSION}
}
pipe = get_sentiment_pipeline()
p_neg = negative_prob_from_text(pipe, t)
matched = []
weighted_sum = 0.0
weighted_max = 0.0
for cat, rx in CATEGORIES.items():
w = float(CATEGORY_WEIGHTS.get(cat, 0.5))
hits = len(rx.findall(t))
if hits > 0:
matched.append(cat)
weighted_sum += w * min(2, hits)
weighted_max += w * 2.0
cat_norm = 0.0 if weighted_max == 0 else min(1.0, weighted_sum / weighted_max)
# ๊ฐ์„ฑ ์˜ํ–ฅ ๋‚ฎ์ถ”๊ธฐ
R = 0.2 * p_neg + 0.8 * cat_norm
if R < 0.20:
idx = 0
elif R < 0.40:
idx = 1
elif R < 0.60:
idx = 2
elif R < 0.80:
idx = 3
else:
idx = 4
# ๋ถ€์ •๋œ ์žํ•ด ํ‘œํ˜„์€ ์ตœ์†Œ ๋ ˆ๋ฒจ 1
if has_negated_selfharm_mention(t) and idx < 1:
idx = 1
lvl, name, tone = RISK_LEVELS[idx]
# ๊ฐ„์ ‘ ์œ„๊ธฐ(์˜๋ฏธ) โ†’ ๋ ˆ๋ฒจ 3๊นŒ์ง€๋งŒ ์ƒํ–ฅ
sem = semantic_crisis_confidence(t)
SEM_THRESHOLD = 0.20
if sem.get("score", 0.0) >= SEM_THRESHOLD and idx < 3:
lvl, name, tone = RISK_LEVELS[3]
return {
"level": lvl,
"name": name,
"tone_message": tone,
"override": "semantic_crisis_to_level3",
"signals": {
"build_version": BUILD_VERSION,
"semantic_crisis": sem,
"p_negative": p_neg,
"matched_categories": matched,
"category_norm": cat_norm,
"R": R,
}
}
return {
"level": lvl,
"name": name,
"tone_message": tone,
"signals": {
"build_version": BUILD_VERSION,
"semantic_crisis": sem,
"p_negative": p_neg,
"matched_categories": matched,
"category_norm": cat_norm,
"R": R,
}
}
# -----------------------
# 11) API
# -----------------------
@csrf_exempt
def api_analyze(request):
payload = json.loads(request.body.decode("utf-8"))
diary = payload.get("diary", "")
lat = payload.get("lat", None)
lng = payload.get("lng", None)
risk = risk_from_text(diary)
kakao_key_present = bool(os.getenv("KAKAO_REST_API_KEY"))
places = kakao_places(lat, lng) if (kakao_key_present and lat is not None and lng is not None) else []
safety = None
if risk.get("override") == "direct_selfharm":
safety = {
"message": "์œ„๊ธฐ ์‹ ํ˜ธ๊ฐ€ ๊ฐ์ง€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ฆ‰์‹œ ๋„์›€์„ ๋ฐ›๋Š” ๊ฒƒ์ด ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.",
"hotline": "์ž์‚ด์˜ˆ๋ฐฉ์ƒ๋‹ด์ „ํ™” 109 (24์‹œ๊ฐ„)",
"source": "https://www.korea.kr/multi/visualNewsView.do?newsId=148921874"
}
return JsonResponse({
"build_version": BUILD_VERSION,
"risk": risk,
"safety": safety,
"places": places,
"hospital_recommendation": {
"enabled": kakao_key_present,
"reason_disabled": None if kakao_key_present else "KAKAO_REST_API_KEY๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•„ ๋ณ‘์› ์ถ”์ฒœ ๊ธฐ๋Šฅ์ด ๋น„ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค."
},
"disclaimer": "๋ณธ ๊ฒฐ๊ณผ๋Š” ์˜ํ•™์  ์ง„๋‹จ์ด ์•„๋‹ˆ๋ผ ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ ์„ ๋ณ„/์•ˆ๋‚ด์ž…๋‹ˆ๋‹ค. ์ •ํ™•ํ•œ ํ‰๊ฐ€/์ง„๋‹จ์€ ์ „๋ฌธ๊ฐ€ ์ƒ๋‹ด์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."
})