Phase 5-6: API usage tracking + Calendar/Mail/DEVONthink/OmniFocus/News pipeline

- 파이프라인 42→51노드 확장 (calendar/mail/note 핸들러 추가)
- 네이티브 서비스 6개: heic_converter(:8090), chat_bridge(:8091),
  caldav_bridge(:8092), devonthink_bridge(:8093), inbox_processor, news_digest
- 분류기 v2→v3: calendar, reminder, mail, note intent 추가
- Mail Processing Pipeline (7노드, IMAP 폴링)
- LaunchAgent plist 6개 + manage_services.sh
- migrate-v3.sql: news_digest_log + calendar_events 확장
- 개발 문서 현행화 (CLAUDE.md, QUICK_REFERENCE.md, docs/architecture.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-03-14 17:09:04 +09:00
parent f7cccc9c5e
commit 612933c2d3
23 changed files with 2492 additions and 67 deletions

225
inbox_processor.py Normal file
View File

@@ -0,0 +1,225 @@
"""OmniFocus Inbox Processor — Inbox 항목 분류 + 라우팅 (LaunchAgent, 5분 주기)"""
import json
import logging
import os
import subprocess
import sys
import httpx
from dotenv import load_dotenv
load_dotenv()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("inbox_processor")
GPU_OLLAMA_URL = os.getenv("GPU_OLLAMA_URL", "http://192.168.1.186:11434")
CALDAV_BRIDGE_URL = os.getenv("CALDAV_BRIDGE_URL", "http://127.0.0.1:8092")
DEVONTHINK_BRIDGE_URL = os.getenv("DEVONTHINK_BRIDGE_URL", "http://127.0.0.1:8093")
def run_applescript(script: str, timeout: int = 15) -> str:
result = subprocess.run(
["osascript", "-e", script],
capture_output=True, text=True, timeout=timeout,
)
if result.returncode != 0:
raise RuntimeError(f"AppleScript error: {result.stderr.strip()}")
return result.stdout.strip()
def get_inbox_items() -> list[dict]:
"""OmniFocus Inbox에서 bot-processed 태그가 없는 항목 조회."""
script = '''
tell application "OmniFocus"
tell default document
set output to ""
set inboxTasks to every inbox task
repeat with t in inboxTasks
set tagNames to {}
repeat with tg in (tags of t)
set end of tagNames to name of tg
end repeat
set AppleScript's text item delimiters to ","
set tagStr to tagNames as text
if tagStr does not contain "bot-processed" then
set taskId to id of t
set taskName to name of t
set taskNote to note of t
set output to output & taskId & "|||" & taskName & "|||" & taskNote & "\\n"
end if
end repeat
return output
end tell
end tell'''
try:
result = run_applescript(script)
items = []
for line in result.strip().split("\n"):
if "|||" not in line:
continue
parts = line.split("|||", 2)
items.append({
"id": parts[0].strip(),
"name": parts[1].strip() if len(parts) > 1 else "",
"note": parts[2].strip() if len(parts) > 2 else "",
})
return items
except Exception as e:
logger.error(f"Failed to get inbox items: {e}")
return []
def mark_processed(task_id: str) -> None:
"""항목에 bot-processed 태그 추가."""
script = f'''
tell application "OmniFocus"
tell default document
try
set theTag to first tag whose name is "bot-processed"
on error
set theTag to make new tag with properties {{name:"bot-processed"}}
end try
set theTask to first flattened task whose id is "{task_id}"
add theTag to tags of theTask
end tell
end tell'''
try:
run_applescript(script)
logger.info(f"Marked as processed: {task_id}")
except Exception as e:
logger.error(f"Failed to mark processed {task_id}: {e}")
def complete_task(task_id: str) -> None:
"""OmniFocus 항목 완료 처리."""
script = f'''
tell application "OmniFocus"
tell default document
set theTask to first flattened task whose id is "{task_id}"
mark complete theTask
end tell
end tell'''
try:
run_applescript(script)
logger.info(f"Completed: {task_id}")
except Exception as e:
logger.error(f"Failed to complete {task_id}: {e}")
def classify_item(name: str, note: str) -> dict:
"""Qwen 3.5로 항목 분류."""
prompt = f"""OmniFocus Inbox 항목을 분류하세요. JSON만 출력.
{{
"type": "calendar|note|task|reminder",
"title": "제목/일정 이름",
"start": "YYYY-MM-DDTHH:MM:SS (calendar/reminder일 때)",
"location": "장소 (calendar일 때, 없으면 null)",
"content": "메모 내용 (note일 때)"
}}
type 판단:
- calendar: 시간이 명시된 일정/약속/회의
- reminder: 알림/리마인드 (시간 기반)
- note: 메모/기록/아이디어
- task: 할 일/업무 (OmniFocus에 유지)
항목: {name}
{f'메모: {note}' if note else ''}"""
try:
resp = httpx.post(
f"{GPU_OLLAMA_URL}/api/generate",
json={"model": "qwen3.5:9b-q8_0", "prompt": prompt, "stream": False, "format": "json", "think": False},
timeout=15,
)
return json.loads(resp.json()["response"])
except Exception as e:
logger.error(f"Classification failed: {e}")
return {"type": "task", "title": name, "content": note}
def route_calendar(cls: dict, task_id: str) -> None:
"""CalDAV 브릿지로 일정 생성."""
try:
resp = httpx.post(
f"{CALDAV_BRIDGE_URL}/calendar/create",
json={
"title": cls.get("title", ""),
"start": cls.get("start", ""),
"location": cls.get("location"),
},
timeout=10,
)
if resp.json().get("success"):
logger.info(f"Calendar event created: {cls.get('title')}")
mark_processed(task_id)
complete_task(task_id)
else:
logger.error(f"Calendar create failed: {resp.text[:200]}")
mark_processed(task_id)
except Exception as e:
logger.error(f"Calendar routing failed: {e}")
mark_processed(task_id)
def route_note(cls: dict, task_id: str) -> None:
"""DEVONthink 브릿지로 메모 저장."""
content = cls.get("content") or cls.get("title", "")
title = cls.get("title", "OmniFocus 메모")
try:
resp = httpx.post(
f"{DEVONTHINK_BRIDGE_URL}/save",
json={
"title": title,
"content": content,
"type": "markdown",
"tags": ["omnifocus", "inbox"],
},
timeout=10,
)
if resp.json().get("success"):
logger.info(f"Note saved to DEVONthink: {title}")
mark_processed(task_id)
complete_task(task_id)
except Exception as e:
logger.error(f"Note routing failed: {e}")
mark_processed(task_id)
def route_task(cls: dict, task_id: str) -> None:
"""Task는 OmniFocus에 유지. 태그만 추가."""
mark_processed(task_id)
logger.info(f"Task kept in OmniFocus: {cls.get('title', '')}")
def main():
logger.info("Inbox processor started")
items = get_inbox_items()
if not items:
logger.info("No unprocessed inbox items")
return
logger.info(f"Processing {len(items)} inbox items")
for item in items:
logger.info(f"Processing: {item['name']}")
cls = classify_item(item["name"], item["note"])
item_type = cls.get("type", "task")
if item_type == "calendar" or item_type == "reminder":
route_calendar(cls, item["id"])
elif item_type == "note":
route_note(cls, item["id"])
else: # task
route_task(cls, item["id"])
logger.info("Inbox processing complete")
if __name__ == "__main__":
main()