feat(nextcloud): Nextcloud 4모듈 + NC핸들러 + AI Foreman v0.1
- tools/nextcloud_client.py: WebDAV/OCS/CalDAV/CardDAV 공통 클라이언트 - tools/nc_files.py: 파일 검색/목록/최근/공유링크 - tools/nc_calendar.py: CalDAV 일정 CRUD + ICS 빌더 - tools/nc_mail.py: IMAP 메일 조회 (PLAIN auth for Mailcow) - tools/nc_contacts.py: CardDAV 연락처 + EasyOCR 명함 스캔 - handlers/nc_handler.py: 자연어→NC도구 자동 라우팅 - core/foreman.py: 목표 분해 + 상담 세션 + Vikunja 등록 - prompts/foreman.md: Foreman 시스템 프롬프트 - prompts/unified.md: nextcloud 모드 분류 추가 - config.py: .env 따옴표 파싱 버그 수정 - api/discord_bot.py: /goal 커맨드 + Foreman 스레드 라우팅
This commit is contained in:
294
core/foreman.py
Normal file
294
core/foreman.py
Normal file
@@ -0,0 +1,294 @@
|
||||
r"""AI Foreman v0.1 — 목표 분해 + 상담 모드.
|
||||
|
||||
/goal 명령어로 목표 입력 → Gemini가 작업 트리 분해 → 스레드에서 상담 →
|
||||
확정 시 Vikunja에 일괄 등록.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from core.gemini_caller import GeminiCaller, GeminiCallError
|
||||
|
||||
logger = logging.getLogger("variet.core.foreman")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskNode:
|
||||
"""작업 트리 노드."""
|
||||
id: str
|
||||
title: str
|
||||
description: str = ""
|
||||
children: list["TaskNode"] = field(default_factory=list)
|
||||
done: bool = False
|
||||
priority: int = 0 # 0=보통, 1=높음, -1=낮음
|
||||
estimated_hours: float = 0
|
||||
|
||||
def to_display(self, indent: int = 0) -> str:
|
||||
"""트리 표시용 문자열."""
|
||||
prefix = " " * indent
|
||||
check = "✅" if self.done else "⬜"
|
||||
est = f" (~{self.estimated_hours}h)" if self.estimated_hours else ""
|
||||
line = f"{prefix}{check} **{self.title}**{est}"
|
||||
if self.description:
|
||||
line += f"\n{prefix} _{self.description}_"
|
||||
for child in self.children:
|
||||
line += "\n" + child.to_display(indent + 1)
|
||||
return line
|
||||
|
||||
def to_flat_list(self, parent_title: str = "") -> list[dict]:
|
||||
"""Vikunja 등록용 플랫 리스트."""
|
||||
full_title = f"{parent_title} > {self.title}" if parent_title else self.title
|
||||
items = [{
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"full_path": full_title,
|
||||
"priority": self.priority,
|
||||
}]
|
||||
for child in self.children:
|
||||
items.extend(child.to_flat_list(full_title))
|
||||
return items
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForemanSession:
|
||||
"""Foreman 상담 세션."""
|
||||
id: str
|
||||
goal: str
|
||||
thread_id: int
|
||||
user_id: int
|
||||
tasks: list[TaskNode] = field(default_factory=list)
|
||||
created_at: str = ""
|
||||
history: list[dict] = field(default_factory=list) # 상담 대화 기록
|
||||
status: str = "drafting" # drafting | confirmed | cancelled
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.created_at:
|
||||
self.created_at = datetime.now().isoformat()
|
||||
|
||||
|
||||
class Foreman:
|
||||
"""AI Foreman — 목표 분해 + 상담."""
|
||||
|
||||
# 활성 세션 (thread_id → session)
|
||||
_sessions: dict[int, ForemanSession] = {}
|
||||
|
||||
def __init__(self):
|
||||
self.gemini = GeminiCaller()
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 세션 관리
|
||||
# ──────────────────────────────────────
|
||||
|
||||
def get_session(self, thread_id: int) -> Optional[ForemanSession]:
|
||||
return self._sessions.get(thread_id)
|
||||
|
||||
def create_session(self, goal: str, thread_id: int, user_id: int) -> ForemanSession:
|
||||
session = ForemanSession(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
goal=goal,
|
||||
thread_id=thread_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
self._sessions[thread_id] = session
|
||||
logger.info(f"Foreman 세션 생성: {session.id} — '{goal[:50]}'")
|
||||
return session
|
||||
|
||||
def close_session(self, thread_id: int):
|
||||
if thread_id in self._sessions:
|
||||
del self._sessions[thread_id]
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 목표 분해 (초기)
|
||||
# ──────────────────────────────────────
|
||||
|
||||
async def decompose_goal(self, session: ForemanSession) -> list[TaskNode]:
|
||||
"""Gemini로 목표를 작업 트리로 분해."""
|
||||
context = (
|
||||
f"## 목표\n{session.goal}\n\n"
|
||||
f"위 목표를 실행 가능한 작업 트리로 분해하세요.\n"
|
||||
f"JSON 형태로 응답하세요.\n\n"
|
||||
f"형식:\n"
|
||||
f"```json\n"
|
||||
f'{{"tasks": [\n'
|
||||
f' {{"title": "...", "description": "...", "estimated_hours": 2, "children": [\n'
|
||||
f' {{"title": "하위작업", "description": "...", "estimated_hours": 1, "children": []}}\n'
|
||||
f" ]}}\n"
|
||||
f"]}}\n"
|
||||
f"```\n\n"
|
||||
f"규칙:\n"
|
||||
f"- 최대 3단계 깊이\n"
|
||||
f"- 각 작업은 구체적이고 실행 가능해야 합니다\n"
|
||||
f"- estimated_hours는 예상 소요 시간\n"
|
||||
f"- 한국어로 작성\n"
|
||||
)
|
||||
|
||||
response = await self.gemini.call("foreman", context, timeout=120)
|
||||
|
||||
# JSON 추출
|
||||
tasks = self._parse_task_tree(response)
|
||||
session.tasks = tasks
|
||||
session.history.append({"role": "system", "content": f"초기 분해: {len(tasks)}개 최상위 작업"})
|
||||
return tasks
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 상담 명령 처리
|
||||
# ──────────────────────────────────────
|
||||
|
||||
async def handle_command(self, session: ForemanSession, command: str, args: str) -> str:
|
||||
"""스레드 내 상담 명령 처리.
|
||||
|
||||
Args:
|
||||
command: 확정|수정|추가|삭제|현황
|
||||
args: 명령 인자
|
||||
|
||||
Returns:
|
||||
응답 메시지
|
||||
"""
|
||||
session.history.append({"role": "user", "content": f"!{command} {args}"})
|
||||
|
||||
if command == "확정":
|
||||
session.status = "confirmed"
|
||||
return await self._confirm(session)
|
||||
|
||||
elif command == "현황":
|
||||
return self._show_status(session)
|
||||
|
||||
elif command in ("수정", "추가", "삭제"):
|
||||
# Gemini에게 수정 요청 위임
|
||||
return await self._modify_tree(session, command, args)
|
||||
|
||||
else:
|
||||
return f"알 수 없는 명령: `!{command}`\n사용 가능: `!확정`, `!수정`, `!추가`, `!삭제`, `!현황`"
|
||||
|
||||
async def handle_freeform(self, session: ForemanSession, text: str) -> str:
|
||||
"""자유 형식 메시지 처리 (명령어 없는 대화)."""
|
||||
session.history.append({"role": "user", "content": text})
|
||||
|
||||
# 현재 트리 컨텍스트 + 사용자 메시지를 Gemini에게 전달
|
||||
tree_str = "\n".join(t.to_display() for t in session.tasks)
|
||||
history_str = "\n".join(
|
||||
f"[{h['role']}] {h['content'][:200]}" for h in session.history[-6:]
|
||||
)
|
||||
|
||||
context = (
|
||||
f"## 현재 작업 트리\n{tree_str}\n\n"
|
||||
f"## 대화 기록\n{history_str}\n\n"
|
||||
f"## 사용자 메시지\n{text}\n\n"
|
||||
f"사용자의 피드백에 따라 작업 트리를 수정하고, "
|
||||
f"수정된 전체 트리를 JSON으로 응답하세요.\n"
|
||||
f"변경 사항을 간단히 설명하고, 마지막에 JSON을 포함하세요.\n"
|
||||
f"JSON 형식은 초기 분해와 동일합니다.\n"
|
||||
)
|
||||
|
||||
response = await self.gemini.call("foreman", context, timeout=120)
|
||||
|
||||
# JSON이 포함되어 있으면 트리 업데이트
|
||||
updated_tasks = self._parse_task_tree(response)
|
||||
if updated_tasks:
|
||||
session.tasks = updated_tasks
|
||||
|
||||
session.history.append({"role": "assistant", "content": response[:500]})
|
||||
return response
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 내부 함수
|
||||
# ──────────────────────────────────────
|
||||
|
||||
def _show_status(self, session: ForemanSession) -> str:
|
||||
"""현재 작업 트리 표시."""
|
||||
if not session.tasks:
|
||||
return "📋 아직 작업이 없습니다."
|
||||
|
||||
lines = [f"🎯 **목표:** {session.goal}\n"]
|
||||
for task in session.tasks:
|
||||
lines.append(task.to_display())
|
||||
total = sum(len(t.to_flat_list()) for t in session.tasks)
|
||||
lines.append(f"\n---\n📊 총 {total}개 작업 | 상태: `{session.status}`")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _modify_tree(self, session: ForemanSession, command: str, args: str) -> str:
|
||||
"""Gemini에게 트리 수정 위임."""
|
||||
tree_str = "\n".join(t.to_display() for t in session.tasks)
|
||||
|
||||
context = (
|
||||
f"## 현재 작업 트리\n{tree_str}\n\n"
|
||||
f"## 수정 요청\n명령: !{command}\n내용: {args}\n\n"
|
||||
f"수정된 전체 트리를 JSON으로 응답하세요.\n"
|
||||
f"변경 사항을 간단히 설명하세요.\n"
|
||||
)
|
||||
|
||||
response = await self.gemini.call("foreman", context, timeout=120)
|
||||
updated_tasks = self._parse_task_tree(response)
|
||||
if updated_tasks:
|
||||
session.tasks = updated_tasks
|
||||
|
||||
session.history.append({"role": "assistant", "content": response[:500]})
|
||||
return response
|
||||
|
||||
async def _confirm(self, session: ForemanSession) -> str:
|
||||
"""작업 트리 확정 → Vikunja 등록."""
|
||||
if not session.tasks:
|
||||
return "❌ 등록할 작업이 없습니다."
|
||||
|
||||
# Vikunja 등록
|
||||
try:
|
||||
from integrations.vikunja_client import VikunjaClient
|
||||
vik = VikunjaClient()
|
||||
flat_tasks = []
|
||||
for task in session.tasks:
|
||||
flat_tasks.extend(task.to_flat_list())
|
||||
|
||||
created = 0
|
||||
for t in flat_tasks:
|
||||
await vik.create_task(t["title"], t.get("description", ""))
|
||||
created += 1
|
||||
|
||||
logger.info(f"Foreman 확정: {created}개 작업 Vikunja 등록")
|
||||
return f"✅ **{created}개 작업**이 Vikunja에 등록되었습니다!\n\n" + self._show_status(session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Vikunja 등록 실패: {e}")
|
||||
return f"⚠️ Vikunja 등록 중 오류: {str(e)[:200]}\n\n작업 트리는 유지됩니다."
|
||||
|
||||
@staticmethod
|
||||
def _parse_task_tree(response: str) -> list[TaskNode]:
|
||||
"""Gemini 응답에서 JSON 작업 트리 파싱."""
|
||||
# JSON 블록 추출
|
||||
json_match = None
|
||||
# ```json ... ``` 블록
|
||||
m = __import__("re").search(r"```json\s*\n(.+?)```", response, __import__("re").DOTALL)
|
||||
if m:
|
||||
json_match = m.group(1)
|
||||
else:
|
||||
# { ... } 직접
|
||||
m = __import__("re").search(r"\{[\s\S]*\"tasks\"[\s\S]*\}", response)
|
||||
if m:
|
||||
json_match = m.group(0)
|
||||
|
||||
if not json_match:
|
||||
return []
|
||||
|
||||
try:
|
||||
data = json.loads(json_match)
|
||||
tasks_data = data.get("tasks", [])
|
||||
return [Foreman._dict_to_task(t) for t in tasks_data]
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.warning(f"작업 트리 파싱 실패: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _dict_to_task(d: dict) -> TaskNode:
|
||||
"""dict → TaskNode 재귀 변환."""
|
||||
children = [Foreman._dict_to_task(c) for c in d.get("children", [])]
|
||||
return TaskNode(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
title=d.get("title", ""),
|
||||
description=d.get("description", ""),
|
||||
estimated_hours=d.get("estimated_hours", 0),
|
||||
children=children,
|
||||
)
|
||||
@@ -25,6 +25,7 @@ ROLE_THINKING: dict[str, int] = {
|
||||
"agent": 4096,
|
||||
"summarizer": 512,
|
||||
"planner": 4096,
|
||||
"foreman": 4096,
|
||||
"coder": 8192,
|
||||
"reviewer": 8192,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user