"""뉴스 sentiment 키워드 룰 사전 + 매칭 함수. bareunaapi/finance_dict.py와 동일 사전 (컨테이너 분리로 직접 임포트 불가 → 중복 허용). """ import re POSITIVE_TERMS: set[str] = { "어닝서프라이즈","수주","대형수주","계약체결","계약수주","임상성공","FDA승인", "품목허가","신고가","52주신고가","역사적신고가","급등","폭등","상한가", "따상","따따상","골든크로스","외국인순매수","기관순매수","배당증가", "자사주매입","자사주소각","M&A","인수합병","기술이전","라이센스아웃", "마일스톤","가이던스상향","실적개선","흑자전환","증익","대왕고래발견", "특허등록","임상승인","조건부허가","수출수주","폴란드수출", } NEGATIVE_TERMS: set[str] = { "어닝쇼크","어닝미스","임상실패","FDA거절","품목허가취소","리콜","급락", "폭락","하한가","신저가","52주신저가","상장폐지","관리종목","거래정지", "데드크로스","외국인순매도","기관순매도","공매도잔고증가","감자","유상증자", "유상감자","최대주주변경","CEO교체","대표이사사임","감사의견거절","의견거절", "한정의견","부적정의견","횡령","배임","분식회계","회계부정","피소","제재", "영업정지","과징금","가이던스하향","적자전환","감익","파산","법정관리", "특허침해","불승인","무산","해지", } CATALYST_MAP: dict[str, str] = { "수주":"수주","대형수주":"수주","계약체결":"수주","계약수주":"수주", "MOU":"수주","LOI":"수주","수출수주":"수주","폴란드수출":"수주", "어닝서프라이즈":"실적","어닝쇼크":"실적","어닝미스":"실적", "가이던스상향":"실적","가이던스하향":"실적","실적개선":"실적", "흑자전환":"실적","적자전환":"실적","증익":"실적","감익":"실적", "FDA승인":"실적","임상성공":"실적","품목허가":"실적","임상승인":"실적", "기술이전":"실적","라이센스아웃":"실적","마일스톤":"실적","특허등록":"실적", "배당증가":"배당","자사주매입":"배당","자사주소각":"배당", "분식회계":"리스크","회계부정":"리스크","리콜":"리스크","감자":"리스크", "유상증자":"리스크","유상감자":"리스크","상장폐지":"리스크","관리종목":"리스크", "거래정지":"리스크","감사의견거절":"리스크","의견거절":"리스크", "부적정의견":"리스크","한정의견":"리스크","횡령":"리스크","배임":"리스크", "피소":"리스크","제재":"리스크","영업정지":"리스크","과징금":"리스크", "임상실패":"리스크","FDA거절":"리스크","품목허가취소":"리스크","특허침해":"리스크", "파산":"리스크","법정관리":"리스크","최대주주변경":"리스크","CEO교체":"리스크", "신고가":"모멘텀","52주신고가":"모멘텀","역사적신고가":"모멘텀", "급등":"모멘텀","폭등":"모멘텀","상한가":"모멘텀","따상":"모멘텀","따따상":"모멘텀", "신저가":"모멘텀","52주신저가":"모멘텀","급락":"모멘텀","폭락":"모멘텀","하한가":"모멘텀", "골든크로스":"모멘텀","데드크로스":"모멘텀", "외국인순매수":"모멘텀","기관순매수":"모멘텀","외국인순매도":"모멘텀","기관순매도":"모멘텀", "공매도잔고증가":"모멘텀", } def keyword_rule(text: str) -> dict: """text 안의 POSITIVE/NEGATIVE 단어 카운트 → 룰 기반 sentiment 후보. sentiment None이면 결정 불가 (애매) → LLM으로 위임. """ if not text: return {"pos": 0, "neg": 0, "sentiment": None, "catalyst": "기타", "intensity": 0, "pos_hits": [], "neg_hits": []} pos_hits = sorted({w for w in POSITIVE_TERMS if w in text}) neg_hits = sorted({w for w in NEGATIVE_TERMS if w in text}) pos, neg = len(pos_hits), len(neg_hits) sentiment = None if pos >= 2 and neg == 0: sentiment = "호재" elif neg >= 2 and pos == 0: sentiment = "악재" catalyst = "기타" hits_for_cat = pos_hits if sentiment == "호재" else neg_hits for w in hits_for_cat: if w in CATALYST_MAP: catalyst = CATALYST_MAP[w] break return { "pos": pos, "neg": neg, "sentiment": sentiment, "catalyst": catalyst, "intensity": min(max(pos, neg), 5) if sentiment else 0, "pos_hits": pos_hits, "neg_hits": neg_hits, } _NOUN_RE = re.compile(r"[가-힣]{2,}") _STOPWORDS = { "있다","없다","있으며","위해","대해","대한","따라","기업","종목","주가", "투자","시장","최근","이번","현재","오늘","전망","판단","결과","증가","감소", "보이","나타","제시","의미","수준","상황","경우","가능","필요","효과","영향", } def hallucination_match_ratio(reason: str, source_text: str) -> float: """reason의 한글 명사(2자 이상)가 source_text에 등장하는 비율(0~1). 1.0=모든 단어 원문에 있음, 0.0=전부 지어냄. stopword 제외. """ if not reason or not source_text: return 1.0 words = {w for w in _NOUN_RE.findall(reason) if w not in _STOPWORDS} if not words: return 1.0 matched = sum(1 for w in words if w in source_text) return matched / len(words)