# backend/tests/_fake_llm.py — 테스트 전용 결정적 LLM (구 HeuristicProvider 로직 이전, phase-16+) # 프로덕션에서 휴리스틱 폴백을 제거했으므로, 테스트 결정성은 이 FakeLLM 을 get_provider 에 # 주입(conftest)해 확보한다. 실 Ollama 의존 없이 분류/분석/회신을 규칙으로 재현한다. import re from app.llm.provider import Classification, LLMProvider RE_TIME = re.compile( r"(\d{1,2}\s*시|\d{1,2}:\d{2}|오전|오후|" r"월요일|화요일|수요일|목요일|금요일|토요일|일요일|" r"월요|화요|수요|목요|금요|토요|일요|내일|모레|오늘)" ) RE_ACTION = re.compile(r"(사기|구매|하기|맡기기|알아보기|보내기|작성|정리|예약|받|비교|확인|회신|준비)") RE_WORK = re.compile(r"(미팅|회의|리포트|온보딩|OKR|리뷰|배포|스프린트|기획|디자인|개발|보고|발표)") RE_LIFE = re.compile(r"(가족|여행|선물|구독|병원|생신|자전거|집|수영장|운동|독서|엄마|아빠|티켓)") class FakeLLMProvider(LLMProvider): name = "fake" def health(self) -> dict: return {"reachable": True, "provider": "fake", "model": "rules", "detail": "test fake"} # ── generate_json: 스키마로 분기(메일 분석 / 회신 초안 / 기타) ── def generate_json(self, prompt: str, schema: dict | None = None) -> dict: props = (schema or {}).get("properties", {}) if "summary" in props and "priority" in props: return self._analyze(prompt) if set(props) == {"body"}: return {"body": "안녕하세요,\n\n메일 확인했습니다. 빠르게 회신드리겠습니다.\n\n감사합니다."} return {} def _analyze(self, prompt: str) -> dict: m = re.search(r"제목:\s*(.+)", prompt) subject = (m.group(1).strip() if m else "") tasks, events = [], [] if re.search(r"\d{1,2}시|\d{1,2}:\d{2}", prompt): events.append( {"title": subject[:24], "date": "", "day": "", "time": "", "dur": "", "place": ""} ) if re.search(r"부탁|검토|회신|피드백|정리|확인", prompt): tasks.append({"text": subject[:30], "project": "받은편지함", "due": "", "prio": "보통"}) prio = "높음" if ("[중요]" in subject or "마감" in prompt) else "보통" return { "summary": subject[:60], "priority": prio, "category": "메일", "tasks": tasks, "events": events, "replies": [], "file": None, } # ── classify_capture: 인박스 캡처 분류(구 휴리스틱 규칙 동일) ── def classify_capture(self, raw: str, context: dict) -> Classification: text = raw or "" has_clock = bool(re.search(r"(\d{1,2}\s*시|\d{1,2}:\d{2})", text)) has_action = bool(RE_ACTION.search(text)) has_time = bool(RE_TIME.search(text)) if has_clock: ctype = "event" elif has_action: ctype = "task" elif has_time and not has_action: ctype = "event" else: ctype = "idea" if RE_WORK.search(text): sphere = "work" elif RE_LIFE.search(text): sphere = "life" else: sphere = "work" project_id, proj_label, tone = self._match_project(text, sphere, context) due_text, when_text = self._time_hints(text) extra = "가격 추적 알림 켜둠" if re.search(r"(티켓|구매|가격)", text) else "" reason = self._reason(ctype, sphere, proj_label) conf = 0.85 if has_clock or has_action else 0.6 return Classification( type=ctype, sphere=sphere, project_id=project_id, proj_label=proj_label, tone=tone, due_text=due_text, when_text=when_text, extra=extra, reason=reason, confidence=conf, model="fake", ) def _match_project(self, text, sphere, context): rules = [ (r"(여행|티켓|비행기|한국)", "life-trip", "개인 › 여행 — 한국", "coral"), (r"(엄마|아빠|가족|생신|부모)", "life-fam", "가족", "green"), (r"(온보딩)", "onb", "온보딩 리디자인 · 아이디어 보드", "violet"), (r"(자전거|수리|병원|예약)", None, "개인 캘린더", "blue"), ] for pat, pid, label, tone in rules: if re.search(pat, text): return pid, label, tone if sphere == "life": return "me", "개인 › 일상", "blue" return None, "업무 › 받은 작업", "ink" def _time_hints(self, text): m = re.search(r"(월|화|수|목|금|토|일)요일?\s*(\d{1,2})\s*시", text) if m: return "", f"{m.group(1)} {int(m.group(2)):02d}:00" if "다음 주" in text: return "출발 전 · ~6/14", "오늘 21:00 빈 시간 추천" return "", "" def _reason(self, ctype, sphere, proj_label): sph = "업무" if sphere == "work" else "개인" if ctype == "event": return "시간이 정해진 일은 작업이 아니라 일정으로 바로 등록해요." if ctype == "task": return ( f"행동이 있으니 '작업' 맞아요. {sph} 트리의 '{proj_label}'에 넣었어요 — " "따로 섹션이 생기는 게 아니라 다른 작업과 똑같이 보여요." ) return "아직 행동이 정해지지 않아 아이디어 보드에 보관했어요."