Files
syn-chat-bot/inbox_processor.py
Hyungi Ahn 1543abded6 Phase 7a: GPU 모델 id-9b:latest 전환 + 워크플로우 배포 자동화
- qwen3.5:9b-q8_0 → id-9b:latest 전체 교체 (워크플로우, Python 스크립트)
- deploy_workflows.sh 생성 (n8n REST API 자동 배포)
- .env.example: CalDAV/IMAP/Karakeep 기본값 수정
- 문서 업데이트: tk_qc_issues 컬렉션, 맥미니 Ollama 기동 안내

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 09:13:24 +09:00

226 lines
7.0 KiB
Python

"""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": "id-9b:latest", "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()