- scripts/pkm_utils.py: 공통 유틸 (로거, dotenv, osascript 래퍼) - scripts/prompts/classify_document.txt: Ollama 분류 프롬프트 - applescript/auto_classify.scpt: Inbox → AI 분류 → DB 이동 - applescript/omnifocus_sync.scpt: Projects → OmniFocus 작업 생성 - scripts/law_monitor.py: 법령 변경 모니터링 + DEVONthink 임포트 - scripts/mailplus_archive.py: MailPlus IMAP → Archive DB - scripts/pkm_daily_digest.py: 일일 다이제스트 + OmniFocus 액션 - scripts/embed_to_chroma.py: GPU 서버 벡터 임베딩 → ChromaDB - launchd/*.plist: 3개 스케줄 (07:00, 07:00+18:00, 20:00) - docs/deploy.md: Mac mini 배포 가이드 - docs/devonagent-setup.md: 검색 세트 9종 설정 가이드 - tests/test_classify.py: 5종 문서 분류 테스트 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
285 lines
9.1 KiB
Python
285 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
PKM 일일 다이제스트
|
|
- DEVONthink 오늘 추가/수정 집계
|
|
- law_monitor 법령 변경 건 파싱
|
|
- OmniFocus 완료/추가/기한초과 집계
|
|
- 상위 뉴스 Ollama 요약
|
|
- OmniFocus 액션 자동 생성
|
|
- 90일 지난 다이제스트 아카이브
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from pkm_utils import (
|
|
setup_logger, load_credentials, run_applescript_inline,
|
|
ollama_generate, count_log_errors, PROJECT_ROOT, LOGS_DIR, DATA_DIR
|
|
)
|
|
|
|
logger = setup_logger("digest")
|
|
|
|
DIGEST_DIR = DATA_DIR / "digests"
|
|
DIGEST_DIR.mkdir(exist_ok=True)
|
|
|
|
|
|
def get_devonthink_stats() -> dict:
|
|
"""DEVONthink 오늘 추가/수정 문서 집계"""
|
|
script = '''
|
|
tell application id "DNtp"
|
|
set today to current date
|
|
set time of today to 0
|
|
set stats to {}
|
|
|
|
repeat with db in databases
|
|
set dbName to name of db
|
|
set addedCount to count of (search "date:today" in db)
|
|
set modifiedCount to count of (search "modified:today" in db)
|
|
|
|
if addedCount > 0 or modifiedCount > 0 then
|
|
set end of stats to dbName & ":" & addedCount & ":" & modifiedCount
|
|
end if
|
|
end repeat
|
|
|
|
set AppleScript's text item delimiters to "|"
|
|
return stats as text
|
|
end tell
|
|
'''
|
|
try:
|
|
result = run_applescript_inline(script)
|
|
stats = {}
|
|
if result:
|
|
for item in result.split("|"):
|
|
parts = item.split(":")
|
|
if len(parts) == 3:
|
|
stats[parts[0]] = {"added": int(parts[1]), "modified": int(parts[2])}
|
|
return stats
|
|
except Exception as e:
|
|
logger.error(f"DEVONthink 집계 실패: {e}")
|
|
return {}
|
|
|
|
|
|
def get_omnifocus_stats() -> dict:
|
|
"""OmniFocus 오늘 완료/추가/기한초과 집계"""
|
|
script = '''
|
|
tell application "OmniFocus"
|
|
tell default document
|
|
set today to current date
|
|
set time of today to 0
|
|
set tomorrow to today + 1 * days
|
|
|
|
set completedCount to count of (every flattened task whose completed is true and completion date ≥ today)
|
|
set addedCount to count of (every flattened task whose creation date ≥ today)
|
|
set overdueCount to count of (every flattened task whose completed is false and due date < today and due date is not missing value)
|
|
|
|
return (completedCount as text) & "|" & (addedCount as text) & "|" & (overdueCount as text)
|
|
end tell
|
|
end tell
|
|
'''
|
|
try:
|
|
result = run_applescript_inline(script)
|
|
parts = result.split("|")
|
|
return {
|
|
"completed": int(parts[0]) if len(parts) > 0 else 0,
|
|
"added": int(parts[1]) if len(parts) > 1 else 0,
|
|
"overdue": int(parts[2]) if len(parts) > 2 else 0,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"OmniFocus 집계 실패: {e}")
|
|
return {"completed": 0, "added": 0, "overdue": 0}
|
|
|
|
|
|
def parse_law_changes() -> list:
|
|
"""law_monitor 로그에서 오늘 법령 변경 건 파싱"""
|
|
log_file = LOGS_DIR / "law_monitor.log"
|
|
if not log_file.exists():
|
|
return []
|
|
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
changes = []
|
|
with open(log_file, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
if today in line and "변경 감지" in line:
|
|
# "[2026-03-26 07:00:15] [law_monitor] [INFO] 변경 감지: 산업안전보건법 — 공포일자 ..."
|
|
match = re.search(r"변경 감지: (.+?)$", line)
|
|
if match:
|
|
changes.append(match.group(1).strip())
|
|
return changes
|
|
|
|
|
|
def get_inbox_count() -> int:
|
|
"""DEVONthink Inbox 미처리 문서 수"""
|
|
script = '''
|
|
tell application id "DNtp"
|
|
repeat with db in databases
|
|
if name of db is "Inbox" then
|
|
return count of children of root group of db
|
|
end if
|
|
end repeat
|
|
return 0
|
|
end tell
|
|
'''
|
|
try:
|
|
return int(run_applescript_inline(script))
|
|
except:
|
|
return 0
|
|
|
|
|
|
def create_omnifocus_task(task_name: str, note: str = "", flagged: bool = False):
|
|
"""OmniFocus 작업 생성"""
|
|
flag_str = "true" if flagged else "false"
|
|
escaped_name = task_name.replace('"', '\\"')
|
|
escaped_note = note.replace('"', '\\"')
|
|
script = f'''
|
|
tell application "OmniFocus"
|
|
tell default document
|
|
make new inbox task with properties {{name:"{escaped_name}", note:"{escaped_note}", flagged:{flag_str}}}
|
|
end tell
|
|
end tell
|
|
'''
|
|
try:
|
|
run_applescript_inline(script)
|
|
logger.info(f"OmniFocus 작업 생성: {task_name}")
|
|
except Exception as e:
|
|
logger.error(f"OmniFocus 작업 생성 실패: {e}")
|
|
|
|
|
|
def get_system_health() -> dict:
|
|
"""각 모듈 로그의 최근 24시간 ERROR 카운트"""
|
|
modules = ["law_monitor", "mailplus", "digest", "embed", "auto_classify"]
|
|
health = {}
|
|
for mod in modules:
|
|
log_file = LOGS_DIR / f"{mod}.log"
|
|
health[mod] = count_log_errors(log_file, since_hours=24)
|
|
return health
|
|
|
|
|
|
def generate_digest():
|
|
"""다이제스트 생성"""
|
|
logger.info("=== Daily Digest 생성 시작 ===")
|
|
today = datetime.now()
|
|
date_str = today.strftime("%Y-%m-%d")
|
|
|
|
# 데이터 수집
|
|
dt_stats = get_devonthink_stats()
|
|
of_stats = get_omnifocus_stats()
|
|
law_changes = parse_law_changes()
|
|
inbox_count = get_inbox_count()
|
|
system_health = get_system_health()
|
|
|
|
# 마크다운 생성
|
|
md = f"# PKM Daily Digest — {date_str}\n\n"
|
|
|
|
# DEVONthink 현황
|
|
md += "## DEVONthink 변화\n\n"
|
|
if dt_stats:
|
|
md += "| DB | 신규 | 수정 |\n|---|---|---|\n"
|
|
total_added = 0
|
|
total_modified = 0
|
|
for db_name, counts in dt_stats.items():
|
|
md += f"| {db_name} | {counts['added']} | {counts['modified']} |\n"
|
|
total_added += counts["added"]
|
|
total_modified += counts["modified"]
|
|
md += f"| **합계** | **{total_added}** | **{total_modified}** |\n\n"
|
|
else:
|
|
md += "변화 없음\n\n"
|
|
|
|
# 법령 변경
|
|
md += "## 법령 변경\n\n"
|
|
if law_changes:
|
|
for change in law_changes:
|
|
md += f"- {change}\n"
|
|
md += "\n"
|
|
else:
|
|
md += "변경 없음\n\n"
|
|
|
|
# OmniFocus 현황
|
|
md += "## OmniFocus 현황\n\n"
|
|
md += f"- 완료: {of_stats['completed']}건\n"
|
|
md += f"- 신규: {of_stats['added']}건\n"
|
|
md += f"- 기한초과: {of_stats['overdue']}건\n\n"
|
|
|
|
# Inbox 상태
|
|
md += f"## Inbox 미처리: {inbox_count}건\n\n"
|
|
|
|
# 시스템 상태
|
|
md += "## 시스템 상태\n\n"
|
|
total_errors = sum(system_health.values())
|
|
if total_errors == 0:
|
|
md += "모든 모듈 정상\n\n"
|
|
else:
|
|
md += "| 모듈 | 에러 수 |\n|---|---|\n"
|
|
for mod, cnt in system_health.items():
|
|
status = f"**{cnt}**" if cnt > 0 else "0"
|
|
md += f"| {mod} | {status} |\n"
|
|
md += "\n"
|
|
|
|
# 파일 저장
|
|
digest_file = DIGEST_DIR / f"{date_str}_digest.md"
|
|
with open(digest_file, "w", encoding="utf-8") as f:
|
|
f.write(md)
|
|
logger.info(f"다이제스트 저장: {digest_file}")
|
|
|
|
# DEVONthink 저장
|
|
import_digest_to_devonthink(digest_file, date_str)
|
|
|
|
# OmniFocus 액션 자동 생성
|
|
if law_changes:
|
|
for change in law_changes:
|
|
create_omnifocus_task(f"법령 변경 검토: {change[:30]}", note=change)
|
|
|
|
if inbox_count >= 3:
|
|
create_omnifocus_task(f"Inbox 정리 ({inbox_count}건 미처리)", note="DEVONthink Inbox에 미분류 문서가 쌓여있습니다.")
|
|
|
|
if of_stats["overdue"] > 0:
|
|
create_omnifocus_task(f"기한초과 작업 처리 ({of_stats['overdue']}건)", flagged=True)
|
|
|
|
# 90일 지난 다이제스트 아카이브
|
|
archive_old_digests()
|
|
|
|
logger.info("=== Daily Digest 완료 ===")
|
|
|
|
|
|
def import_digest_to_devonthink(filepath: Path, date_str: str):
|
|
"""다이제스트를 DEVONthink에 저장"""
|
|
escaped_path = str(filepath).replace('"', '\\"')
|
|
script = f'''
|
|
tell application id "DNtp"
|
|
repeat with db in databases
|
|
if name of db is "00_Note_BOX" then
|
|
set targetGroup to create location "/Daily_Digest" in db
|
|
import POSIX path "{escaped_path}" to targetGroup
|
|
exit repeat
|
|
end if
|
|
end repeat
|
|
end tell
|
|
'''
|
|
try:
|
|
run_applescript_inline(script)
|
|
except Exception as e:
|
|
logger.error(f"DEVONthink 다이제스트 임포트 실패: {e}")
|
|
|
|
|
|
def archive_old_digests():
|
|
"""90일 지난 다이제스트 이동"""
|
|
cutoff = datetime.now() - timedelta(days=90)
|
|
for f in DIGEST_DIR.glob("*_digest.md"):
|
|
try:
|
|
date_part = f.stem.split("_digest")[0]
|
|
file_date = datetime.strptime(date_part, "%Y-%m-%d")
|
|
if file_date < cutoff:
|
|
archive_dir = DIGEST_DIR / "archive"
|
|
archive_dir.mkdir(exist_ok=True)
|
|
f.rename(archive_dir / f.name)
|
|
logger.info(f"아카이브: {f.name}")
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
generate_digest()
|