| 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" |
|
|
| |
| |
| |
| 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] |
|
|
|
|
| |
| |
| |
| _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 |
|
|
|
|
| |
| |
| |
| _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" |
|
|
| |
| tok = AutoTokenizer.from_pretrained(model_id, use_fast=False) |
| mdl = AutoModelForSequenceClassification.from_pretrained(model_id) |
| mdl.eval() |
|
|
| |
| 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: |
| |
| pass |
|
|
| |
| 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 |
|
|
| _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) |
|
|
| with torch.no_grad(): |
| logits = mdl(**inputs).logits |
| probs = F.softmax(logits, dim=-1)[0] |
| n = int(probs.shape[-1]) |
|
|
| |
| if entail_id < 0 or entail_id >= n: |
| entail_id = 0 |
|
|
| return float(probs[entail_id].item()) |
|
|
|
|
| |
| |
| |
| 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()) |
|
|
|
|
| |
| |
| |
| 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, |
| } |
|
|
|
|
| |
| |
| |
| RISK_LEVELS = [ |
| (0, "์์ ", "ํ์ฌ ๊ธฐ๋ก์์ ํฐ ์ํ ์ ํธ๋ ๋ฎ์ ๋ณด์
๋๋ค. ๋ค๋ง ๋ณํ๊ฐ ์์ผ๋ฉด ๊ณ์ ๊ธฐ๋กํด ์ฃผ์ธ์."), |
| (1, "์ฃผ์", "๋ถ์ ์ ์/์คํธ๋ ์ค ์ ํธ๊ฐ ์ผ๋ถ ๋ณด์
๋๋ค. ์๋ฉดยท์์ฌยทํ๋ ๋ฆฌ๋ฌ์ ์ ๊ฒํ๊ณ ์ถ์ ์ ๊ถ์ฅํฉ๋๋ค."), |
| (2, "๊ด์ฐฐ ํ์", "๋ฐ๋ณต๋๋ ๋ฌด๊ธฐ๋ ฅ/์๊ธฐ๋น๋ ๋ฑ์ ์ ํธ๊ฐ ๋ณด์
๋๋ค. ์๋ด/์ง๋ฃ๋ฅผ ๊ณ ๋ คํด ๋ณด์ธ์."), |
| (3, "๋์ ๊ถ์ฅ", "์ํ ์ ํธ๊ฐ ๋๋ ทํฉ๋๋ค. ๊ฐ๋ฅํ ํ ๋น ๋ฅด๊ฒ ์ ๋ฌธ๊ฐ ์๋ด/์ง๋ฃ๋ฅผ ๊ถ์ฅํฉ๋๋ค."), |
| (4, "๊ธด๊ธ(์ฆ์ ๋์)", "์๊ธฐ ์ ํธ๊ฐ ๊ฐ์ง๋์์ต๋๋ค. ์ง๊ธ์ ์ฆ์ ๋์์ ๋ฐ๋ ๊ฒ์ด ์์ ํฉ๋๋ค."), |
| ] |
|
|
|
|
| |
| |
| |
| 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", []) |
|
|
|
|
| |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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)} |
|
|
|
|
| |
| |
| |
| 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 |
|
|
| |
| if has_negated_selfharm_mention(t) and idx < 1: |
| idx = 1 |
|
|
| lvl, name, tone = RISK_LEVELS[idx] |
|
|
| |
| 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, |
| } |
| } |
|
|
|
|
| |
| |
| |
| @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": "๋ณธ ๊ฒฐ๊ณผ๋ ์ํ์ ์ง๋จ์ด ์๋๋ผ ํ
์คํธ ๊ธฐ๋ฐ ์ ๋ณ/์๋ด์
๋๋ค. ์ ํํ ํ๊ฐ/์ง๋จ์ ์ ๋ฌธ๊ฐ ์๋ด์ด ํ์ํฉ๋๋ค." |
| }) |