feat: 회고 시스템 Phase 1 캡처 파이프라인

chat_bridge에 회고 채널 텍스트 폴링 + n8n 포워딩 추가.
n8n 워크플로우(8노드): Webhook → Validate → Qwen 분류 → PostgreSQL INSERT → Chat 확인.
retrospect 스키마 + 3 테이블 (entries, reviews, patterns).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-19 14:57:13 +09:00
parent 5a067de189
commit de5dde43ab
6 changed files with 350 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
"""DSM Chat API Bridge — 사진 폴링 + 다운로드 서비스 (port 8091)"""
"""DSM Chat API Bridge — 사진 폴링 + 회고 텍스트 포워딩 서비스 (port 8091)"""
import asyncio
import base64
@@ -25,10 +25,15 @@ CHAT_CHANNEL_ID = int(os.getenv("CHAT_CHANNEL_ID", "17"))
SYNOLOGY_CHAT_WEBHOOK_URL = os.getenv("SYNOLOGY_CHAT_WEBHOOK_URL", "")
HEIC_CONVERTER_URL = os.getenv("HEIC_CONVERTER_URL", "http://127.0.0.1:8090")
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "5"))
RETROSPECT_CHANNEL_ID = int(os.getenv("RETROSPECT_CHANNEL_ID", "0"))
RETROSPECT_CHAT_WEBHOOK_URL = os.getenv("RETROSPECT_CHAT_WEBHOOK_URL", "")
N8N_RETROSPECT_WEBHOOK_URL = os.getenv("N8N_RETROSPECT_WEBHOOK_URL",
"http://localhost:5678/webhook/retrospect")
# State
sid: str = ""
last_seen_post_id: int = 0
retro_last_seen_post_id: int = 0
pending_photos: dict[int, dict] = {} # user_id -> {post_id, create_at, filename}
@@ -140,6 +145,47 @@ async def poll_channel(client: httpx.AsyncClient):
logger.error(f"Poll error: {e}")
async def forward_to_n8n(post: dict):
payload = {
"text": post.get("msg", ""),
"user_id": post.get("creator_id", 0),
"username": post.get("display_name", post.get("username", "unknown")),
"post_id": post.get("post_id", 0),
"timestamp": post.get("create_at", 0),
}
try:
async with httpx.AsyncClient(verify=False) as client:
resp = await client.post(N8N_RETROSPECT_WEBHOOK_URL,
json=payload, timeout=10)
logger.info(f"Forwarded retrospect post_id={post.get('post_id')} "
f"to n8n: {resp.status_code}")
except Exception as e:
logger.error(f"Failed to forward to n8n: {e}")
async def poll_retrospect_channel(client: httpx.AsyncClient):
global retro_last_seen_post_id
if not RETROSPECT_CHANNEL_ID:
return
try:
data = await api_call(client, "SYNO.Chat.Post", 8, "list",
{"channel_id": RETROSPECT_CHANNEL_ID, "limit": 10})
posts = extract_posts(data)
for post in posts:
post_id = post.get("post_id", 0)
if post_id <= retro_last_seen_post_id:
continue
# 텍스트 메시지만 포워딩 (파일/시스템 메시지 제외)
if post.get("type", "normal") == "normal" and post.get("msg", "").strip():
await forward_to_n8n(post)
if posts:
max_id = max(p.get("post_id", 0) for p in posts)
if max_id > retro_last_seen_post_id:
retro_last_seen_post_id = max_id
except Exception as e:
logger.error(f"Retrospect poll error: {e}")
async def polling_loop():
async with httpx.AsyncClient(verify=False) as client:
# Login
@@ -155,7 +201,7 @@ async def polling_loop():
await asyncio.sleep(5)
# Initialize last_seen_post_id
global last_seen_post_id
global last_seen_post_id, retro_last_seen_post_id
try:
data = await api_call(client, "SYNO.Chat.Post", 8, "list",
{"channel_id": CHAT_CHANNEL_ID, "limit": 5})
@@ -166,9 +212,23 @@ async def polling_loop():
except Exception as e:
logger.warning(f"Failed to init last_seen_post_id: {e}")
# Initialize retro_last_seen_post_id
if RETROSPECT_CHANNEL_ID:
try:
data = await api_call(client, "SYNO.Chat.Post", 8, "list",
{"channel_id": RETROSPECT_CHANNEL_ID, "limit": 1})
posts = extract_posts(data)
if posts:
retro_last_seen_post_id = max(p.get("post_id", 0) for p in posts)
logger.info(f"Initialized retro_last_seen_post_id={retro_last_seen_post_id}")
except Exception as e:
logger.warning(f"Failed to init retro_last_seen_post_id: {e}")
# Poll loop
while True:
await poll_channel(client)
await asyncio.sleep(0.5) # DSM API 호출 간격
await poll_retrospect_channel(client)
await asyncio.sleep(POLL_INTERVAL)
@@ -286,6 +346,8 @@ async def health():
"status": "ok",
"sid_active": bool(sid),
"last_seen_post_id": last_seen_post_id,
"retro_last_seen_post_id": retro_last_seen_post_id,
"retro_channel_id": RETROSPECT_CHANNEL_ID,
"pending_photos": {
str(uid): info["filename"]
for uid, info in pending_photos.items()