|
|
# 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 "아직 행동이 정해지지 않아 아이디어 보드에 보관했어요."
|