feat: DEVONthink 제거 + 모닝 브리핑 추가

- DEVONthink 의존성 제거 → kb_writer 전환 (news_digest, inbox_processor, mail pipeline)
- devonthink_bridge.py, plist 삭제
- morning_briefing.py 신규 (매일 07:30, 일정·메일·보고·뉴스 → Synology Chat)
- intent_service.py 분류기 프롬프트 개선 + 키워드 fallback
- migrate-v5.sql (news_digest_log kb_path 컬럼)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-19 14:12:38 +09:00
parent fd8925637d
commit 782caf5130
15 changed files with 479 additions and 240 deletions

View File

@@ -48,26 +48,51 @@ ID_SYSTEM_PROMPT = """너는 '이드'라는 이름의 AI 비서야. 한국어로
간결하고 실용적으로 답변하되, 친근한 톤을 유지해.
불필요한 인사나 꾸밈말은 생략하고 핵심만 전달해."""
# 의도 분류 프롬프트
CLASSIFY_PROMPT = """사용자 메시지를 분석하여 JSON으로 응답하라.
# 의도 분류 프롬프트 (n8n 파이프라인 호환)
def _build_classify_prompt(user_text: str) -> str:
now = datetime.now(KST)
today = now.strftime("%Y-%m-%d")
current_time = now.strftime("%H:%M:%S")
day_names = ["", "", "", "", "", "", ""]
day_of_week = day_names[now.weekday()]
분류 기준:
- calendar: 일정/약속/회의 등 시간이 정해진 이벤트
- todo: 작업/할일/과제 등 기한이 있는 태스크
- note: 메모/기록/저장 요청
- chat: 일반 대화, 질문, 인사
return f"""현재: {today} {current_time} (KST, {day_of_week}요일). 사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.
반드시 아래 JSON 형식만 출력:
{"intent": "calendar|todo|note|chat", "confidence": 0.0~1.0, "title": "추출된 제목", "raw_datetime": "원문의 날짜/시간 표현"}
{{
"intent": "greeting|question|log_event|calendar|todo|reminder|mail|note|photo|command|report|other",
"response_tier": "local|api_light|api_heavy",
"needs_rag": true/false,
"rag_target": ["documents", "tk_company", "chat_memory"],
"department_hint": "안전|생산|구매|품질|총무|시설|null",
"report_domain": "안전|시설설비|품질|null",
"query": "검색용 쿼리 (needs_rag=false면 null)",
"title": "추출된 제목 (calendar/todo/note 시)",
"raw_datetime": "원문의 날짜/시간 표현 (calendar/todo 시)"
}}
예시:
- "내일 3시 회의"{"intent": "calendar", "confidence": 0.95, "title": "회의", "raw_datetime": "내일 3시"}
- "이번주까지 보고서 작성"{"intent": "todo", "confidence": 0.9, "title": "보고서 작성", "raw_datetime": "이번주까지"}
- "메모해둬: 부품 발주 필요"{"intent": "note", "confidence": 0.95, "title": "부품 발주 필요", "raw_datetime": ""}
- "안녕"{"intent": "chat", "confidence": 0.99, "title": "", "raw_datetime": ""}
- "내일 자료 정리"{"intent": "todo", "confidence": 0.6, "title": "자료 정리", "raw_datetime": "내일"}
intent 분류:
- log_event: 사실 기록/등록 요청 ("~구입","~완료","~교체","~점검","~수령","~입고","~등록")
- report: 긴급 사고/재해 신고만 ("사고","부상","화재","누수","폭발","붕괴" + 즉각 대응 필요)
- question: 정보 질문/조회
- greeting: 인사/잡담/감사
- calendar: 일정 등록/조회/삭제 ("일정","회의","미팅","약속","~시에 ~등록","오늘 일정","내일 뭐 있어")
- todo: 작업/할일/과제 ("~까지 ~작성","~해야 해","할 일","작업")
- reminder: 알림 설정 ("~시에 알려줘","리마인드") → calendar로 처리
- mail: 메일 관련 조회 ("메일 확인","받은 메일","이메일","메일 왔어?")
- note: 메모/기록 요청 ("기록해","메모해","저장해","적어둬")
※ 애매하면 log_event로 분류 (기록 누락보다 안전)
사용자 메시지: """
response_tier 판단:
- local: 인사, 잡담, log_event, report, calendar, todo, reminder, note, 단순 질문, mail 간단조회
- api_light: 장문 요약(200자+), 다국어 번역, 비교 분석, RAG 결과 종합
- api_heavy: 법률 해석, 복잡한 다단계 추론, 다중 문서 교차 분석
※ 판단이 애매하면 local 우선
needs_rag 판단:
- true: 회사문서/절차 질문, 이전 기록 조회("최근","아까","전에"), 기술질문
- false: 인사, 잡담, 일반상식, log_event, report, calendar, todo, note
사용자 메시지: {user_text}"""
app = FastAPI(title="Intent Service")
@@ -369,18 +394,60 @@ async def _call_claude(prompt: str, system: str | None = None,
# ==================== 엔드포인트 ====================
def _keyword_fallback(text: str) -> dict:
"""AI 실패 시 키워드 기반 분류 (ultimate safety net)."""
t = text
intent = "question"
response_tier = "api_light"
needs_rag = False
rag_target = []
if re.search(r'일정|회의|미팅|약속|스케줄|캘린더', t) and re.search(r'등록|잡아|추가|만들|넣어|수정|삭제|취소', t):
intent, response_tier = "calendar", "local"
elif re.search(r'일정|스케줄|뭐\s*있', t) and re.search(r'오늘|내일|이번|다음', t):
intent, response_tier = "calendar", "local"
elif re.search(r'까지|해야|할\s*일|작업', t) and re.search(r'작성|보고서|정리|준비|제출', t):
intent, response_tier = "todo", "local"
elif re.search(r'기록해|메모해|저장해|적어둬|메모\s*저장|노트', t):
intent, response_tier = "note", "local"
elif re.search(r'메일|이메일|받은\s*편지|mail', t) or (re.search(r'매일', t) and re.search(r'확인|왔|온|요약|읽', t)):
intent, response_tier = "mail", "local"
elif re.search(r'\d+시', t) and re.search(r'알려|리마인드|알림', t):
intent, response_tier = "calendar", "local"
elif re.search(r'구입|완료|교체|점검|수령|입고|발주', t) and not re.search(r'\?|까$|나$', t):
intent, response_tier = "log_event", "local"
else:
if len(text) <= 30 and not re.search(r'요약|번역|분석|비교', t):
response_tier = "local"
needs_rag = bool(re.search(r'회사|절차|문서|안전|품질|규정|아까|전에|기억', t))
if needs_rag:
rag_target = ["documents"]
if re.search(r'회사|절차|안전|품질', t):
rag_target.append("tk_company")
if re.search(r'아까|이전|전에|기억', t):
rag_target.append("chat_memory")
return {
"intent": intent, "response_tier": response_tier,
"needs_rag": needs_rag, "rag_target": rag_target,
"department_hint": None, "report_domain": None,
"query": text, "title": "", "raw_datetime": "",
"fallback": True, "fallback_method": "keyword",
}
@app.post("/classify")
async def classify_intent(request: Request):
"""의도 분류. body: {message: str}
Returns: {intent, confidence, title, raw_datetime, source: "ollama"|"claude"}
n8n 호환 출력: {intent, response_tier, needs_rag, rag_target, ..., title, raw_datetime, source}
"""
body = await request.json()
message = body.get("message", "").strip()
if not message:
return JSONResponse({"success": False, "error": "message required"}, status_code=400)
prompt = CLASSIFY_PROMPT + message
prompt = _build_classify_prompt(message)
# 1차: Ollama
result_text = await _call_ollama(prompt, system="/no_think")
@@ -392,40 +459,50 @@ async def classify_intent(request: Request):
result_text, _, _ = await _call_claude(prompt, system="JSON만 출력하라. 다른 텍스트 없이.")
source = "claude"
# 완전 실패
# 완전 실패 → 키워드 fallback
if not result_text:
return JSONResponse({"success": False,
"error": "AI 서비스 일시 중단. 잠시 후 다시 시도해주세요."})
logger.warning("All AI classification failed, using keyword fallback")
fb = _keyword_fallback(message)
fb["source"] = "keyword"
fb["success"] = True
return fb
# JSON 파싱
try:
# Ollama가 JSON 외 텍스트를 붙일 수 있으므로 추출
json_match = re.search(r'\{[^}]+\}', result_text)
json_match = re.search(r'\{[^}]+\}', result_text, re.DOTALL)
if json_match:
parsed = json.loads(json_match.group())
else:
parsed = json.loads(result_text)
except json.JSONDecodeError:
logger.warning(f"JSON parse failed: {result_text[:200]}")
# 파싱 실패 → chat으로 폴백
parsed = {"intent": "chat", "confidence": 0.5, "title": "", "raw_datetime": ""}
fb = _keyword_fallback(message)
fb["source"] = source
fb["success"] = True
return fb
intent = parsed.get("intent", "chat")
confidence = float(parsed.get("confidence", 0.5))
intent = parsed.get("intent", "question")
response_tier = parsed.get("response_tier", "api_light")
needs_rag = parsed.get("needs_rag", False)
rag_target = parsed.get("rag_target", [])
if not isinstance(rag_target, list):
rag_target = []
title = parsed.get("title", "")
raw_datetime = parsed.get("raw_datetime", "")
# confidence 낮으면 재질문 신호
needs_clarification = confidence < 0.7
return {
"success": True,
"intent": intent,
"confidence": confidence,
"response_tier": response_tier,
"needs_rag": needs_rag,
"rag_target": rag_target,
"department_hint": parsed.get("department_hint"),
"report_domain": parsed.get("report_domain"),
"query": parsed.get("query", message),
"title": title,
"raw_datetime": raw_datetime,
"needs_clarification": needs_clarification,
"source": source,
"fallback": False,
}
@@ -439,25 +516,32 @@ async def parse_date(request: Request):
@app.post("/chat")
async def chat(request: Request):
"""자유 대화. body: {message: str, system?: str}
"""자유 대화. body: {message: str, system?: str, rag_context?: str}
1차 Ollama → 실패 시 Claude API (응답에 source 표시).
1차 Ollama → 실패 시 Claude API (응답에 ☁️ 표시).
"""
body = await request.json()
message = body.get("message", "").strip()
system = body.get("system", ID_SYSTEM_PROMPT)
rag_context = body.get("rag_context", "")
if not message:
return JSONResponse({"success": False, "error": "message required"}, status_code=400)
# RAG 컨텍스트가 있으면 프롬프트에 추가
prompt = ""
if rag_context:
prompt += f"[참고 자료]\n{rag_context}\n\n"
prompt += f"사용자: {message}\n이드:"
# 1차: Ollama (id-9b, 대화 모델)
response = await _call_ollama(message, system=system, model=OLLAMA_CHAT_MODEL, timeout=30)
response = await _call_ollama(prompt, system=system, model=OLLAMA_CHAT_MODEL, timeout=30)
source = "ollama"
# 2차: Claude fallback
if response is None:
logger.info("Chat fallback to Claude API")
response, _, _ = await _call_claude(message, system=system)
response, _, _ = await _call_claude(prompt, system=system)
source = "claude"
# 완전 실패