feat: 포스트-MVP(phase 7~15) + 설정 페이지 전체 구현
자율성 코어(결재함·자동화), 일정·회의, 메일·알림, 리서치·여행(에이전트+RAG), 라이프 케어, 여정·하루마감, 외부 커넥터(mock→real), 능동 에이전트·멀티모달, 프로덕션 하드닝(인증/멀티유저·관측·운영·배포물)까지 phase 7~15 구현. 설정 페이지(/settings) — 좌측 6탭(계정·메일·연결·AI/LLM·외관·데이터/보안): - LLM 연결 실시간 편집·적용(app_setting 테이블 + runtime_config 인메모리 오버레이), 연결 테스트(Ollama /api/tags 모델 목록), 보조 프로바이더 읽기전용 표시 - 메일/연결 커넥터 관리, 프로필 편집(PATCH /me), 데이터 ZIP 내보내기, API 토큰 발급/폐기 - env 관리 ops 설정은 읽기전용(가짜 토글 금지) 기타: placeholder→실제 페이지 승격, Shell 풀스크린 /login 전환, stale 테스트 정리. 검증: backend pytest 282 / 마이그레이션 왕복, frontend vitest 177 / build / lint clean, E2E settings 7/7(a11y 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>main
parent
b3d21f17d2
commit
a7f830b420
@ -0,0 +1,24 @@
|
||||
# backend/Dockerfile — phase-15 멀티스테이지(uv → slim)
|
||||
FROM python:3.11-slim AS builder
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
WORKDIR /app
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
COPY . .
|
||||
RUN uv sync --frozen --no-dev
|
||||
# 운영 WSGI/ASGI 서버(테스트/로컬에는 불필요 — 이미지에서만 설치)
|
||||
RUN uv pip install gunicorn "uvicorn[standard]"
|
||||
|
||||
FROM python:3.11-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1 ARI_ENV=prod
|
||||
# 비루트 사용자
|
||||
RUN useradd -m -u 10001 ari && mkdir -p /data /backups && chown -R ari:ari /data /backups
|
||||
COPY --from=builder --chown=ari:ari /app /app
|
||||
USER ari
|
||||
EXPOSE 8000
|
||||
# liveness 는 /api/health, readiness 는 /api/ready
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/health').status==200 else 1)"
|
||||
CMD ["gunicorn", "app.main:app", "-c", "gunicorn.conf.py"]
|
||||
@ -0,0 +1,63 @@
|
||||
# backend/app/agents/base.py
|
||||
# plan→act(tool)→observe→reflect 멀티스텝 루프. tool-capable 미가용 시 scripted 폴백.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .tools.base import Tool, ToolResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentStep:
|
||||
phase: str # plan | act | observe | reflect
|
||||
tool: str = ""
|
||||
note: str = ""
|
||||
result: ToolResult | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResult:
|
||||
ok: bool
|
||||
output: dict = field(default_factory=dict)
|
||||
steps: list = field(default_factory=list)
|
||||
fallback: bool = False
|
||||
model: str = ""
|
||||
|
||||
|
||||
class Agent:
|
||||
def __init__(self, tools: dict[str, Tool], provider, scripted: Callable, max_steps: int = 6):
|
||||
self.tools = tools
|
||||
self.provider = provider # tool-capable LLMProvider (None 가능)
|
||||
self.scripted = scripted
|
||||
self.max_steps = max_steps
|
||||
|
||||
def run(self, *, session, query: str) -> AgentResult:
|
||||
if self.provider is None or not getattr(self.provider, "tool_capable", lambda: False)():
|
||||
return self.scripted(session, query) # 폴백
|
||||
try:
|
||||
return self._loop(session, query)
|
||||
except Exception:
|
||||
r = self.scripted(session, query)
|
||||
r.fallback = True
|
||||
return r
|
||||
|
||||
def _loop(self, session, query: str) -> AgentResult:
|
||||
# tool-capable provider 가 plan/next_action/synthesize 를 제공해야 함(phase-13).
|
||||
# 본 phase 환경엔 tool-capable 모델이 없어 이 경로는 사실상 미사용(scripted 폴백).
|
||||
steps: list[AgentStep] = []
|
||||
plan = self.provider.plan(query, list(self.tools.values()))
|
||||
steps.append(AgentStep(phase="plan", note=getattr(plan, "summary", "")))
|
||||
scratch: dict = {}
|
||||
for _ in range(self.max_steps):
|
||||
action = self.provider.next_action(query, plan, scratch)
|
||||
if getattr(action, "final", False):
|
||||
break
|
||||
tool = self.tools[action.tool]
|
||||
res = tool.run(**action.args)
|
||||
steps.append(AgentStep(phase="act", tool=tool.name, result=res))
|
||||
scratch[tool.name] = res.data
|
||||
steps.append(AgentStep(phase="observe", tool=tool.name))
|
||||
output = self.provider.synthesize(query, scratch)
|
||||
steps.append(AgentStep(phase="reflect", note="synthesis done"))
|
||||
return AgentResult(ok=True, output=output, steps=steps, model=self.provider.model)
|
||||
@ -0,0 +1,51 @@
|
||||
# backend/app/agents/connectors_bridge.py
|
||||
# CONNECTOR_KNOWLEDGE 라우팅 — 웹검색/지식 출처 커넥터.
|
||||
from ..config import get_settings
|
||||
|
||||
|
||||
class MockSearchConnector:
|
||||
"""시드 기반 출처(research-data.js 출처군: 논문 3 / 뉴스 5 / 보고서 2)를 반환.
|
||||
오프라인에서도 종합 리포트가 생성되도록 데모 결정성을 보장."""
|
||||
|
||||
SEED_HITS = (
|
||||
[
|
||||
{
|
||||
"title": f"학술 논문 {i + 1}",
|
||||
"url": "",
|
||||
"kind": "pdf",
|
||||
"snippet": "추론 비용의 병목은 메모리 대역폭",
|
||||
"group": "논문",
|
||||
}
|
||||
for i in range(3)
|
||||
]
|
||||
+ [
|
||||
{
|
||||
"title": f"뉴스 기사 {i + 1}",
|
||||
"url": "",
|
||||
"kind": "web",
|
||||
"snippet": "HBM4 양산 경쟁",
|
||||
"group": "뉴스",
|
||||
}
|
||||
for i in range(5)
|
||||
]
|
||||
+ [
|
||||
{
|
||||
"title": f"산업 보고서 {i + 1}",
|
||||
"url": "",
|
||||
"kind": "pdf",
|
||||
"snippet": "데이터센터 전력 규제",
|
||||
"group": "보고서·블로그",
|
||||
}
|
||||
for i in range(2)
|
||||
]
|
||||
)
|
||||
|
||||
def search(self, query: str, k: int = 8) -> list[dict]:
|
||||
return self.SEED_HITS[:k]
|
||||
|
||||
|
||||
def get_knowledge_connector():
|
||||
mode = getattr(get_settings(), "connector_knowledge", "mock")
|
||||
if mode == "real": # phase-13 (Tavily/검색 API 등)
|
||||
raise NotImplementedError("phase-13: real knowledge connector")
|
||||
return MockSearchConnector()
|
||||
@ -0,0 +1,220 @@
|
||||
# backend/app/agents/errand.py — 생활 심부름 에이전트 (plan→act→observe→reflect + 결재함 게이트)
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..automation.event_bus import bus # phase-7 정본
|
||||
from ..config import get_settings
|
||||
from ..models import (
|
||||
Approval,
|
||||
AutonomySetting,
|
||||
ErrandKind,
|
||||
ErrandStatus,
|
||||
ErrandStep,
|
||||
ErrandTask,
|
||||
StepState,
|
||||
now,
|
||||
)
|
||||
from .guardrails import Budget, BudgetExceeded, external_effect_of
|
||||
|
||||
# 데모 결정성: scripted 플랜 (AGENT_PROVIDER=scripted 또는 tool 미가용 폴백)
|
||||
# (phase, tool, label, detail, external_effect)
|
||||
SCRIPTED = {
|
||||
"cancel:Netflix": [
|
||||
("plan", None, "해지 절차 계획 수립", "결제일·해지 경로·되돌리기 옵션 확인", False),
|
||||
(
|
||||
"act",
|
||||
"web_search",
|
||||
"Netflix 해지/일시정지 방법 확인",
|
||||
"계정 설정 > 멤버십 일시정지 경로 확인",
|
||||
False,
|
||||
),
|
||||
("observe", None, "최근 시청 기록 확인", "최근 2개월 시청 0건 — 일시정지 적합", False),
|
||||
(
|
||||
"act",
|
||||
"http_fetch",
|
||||
"해지 페이지 접속·세션 준비",
|
||||
"로그인 세션 확보, 일시정지 폼 로드",
|
||||
False,
|
||||
),
|
||||
("reflect", None, "결제 임박 확인", "모레 17,000원 결제 예정 — 그 전에 처리 필요", False),
|
||||
(
|
||||
"act",
|
||||
"form_fill",
|
||||
"일시정지 신청 제출",
|
||||
"‘일시정지’ 버튼 제출 — 외부 영향(결제 변경)",
|
||||
True,
|
||||
),
|
||||
],
|
||||
"_default": [
|
||||
("plan", None, "요청 계획 수립", "필요한 단계·외부 영향 여부 확인", False),
|
||||
("act", "web_search", "처리 경로 확인", "관련 안내/절차 확인", False),
|
||||
("observe", None, "상황 점검", "현재 상태 확인", False),
|
||||
("act", "form_fill", "요청 제출", "외부 제출 — 확인 필요", True),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _step(s: Session, errand_id: str, seq: int):
|
||||
return s.exec(
|
||||
select(ErrandStep).where(ErrandStep.errand_id == errand_id, ErrandStep.seq == seq)
|
||||
).first()
|
||||
|
||||
|
||||
def _blocked_step(s: Session, errand_id: str):
|
||||
return s.exec(
|
||||
select(ErrandStep).where(
|
||||
ErrandStep.errand_id == errand_id, ErrandStep.state == StepState.blocked.value
|
||||
)
|
||||
).first()
|
||||
|
||||
|
||||
class ErrandAgent:
|
||||
def __init__(self, session: Session):
|
||||
self.s = session
|
||||
self.cfg = get_settings()
|
||||
|
||||
# ── 공개 진입 ──
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
title: str,
|
||||
goal: str = "",
|
||||
target: str | None = None,
|
||||
tone: str = "amber",
|
||||
) -> ErrandTask:
|
||||
autonomy = self.s.get(AutonomySetting, "default")
|
||||
level = autonomy.level if autonomy else "mixed"
|
||||
er = ErrandTask(
|
||||
id="er-" + uuid.uuid4().hex[:8],
|
||||
kind=kind,
|
||||
title=title,
|
||||
goal=goal,
|
||||
target=target,
|
||||
tone=tone,
|
||||
status=ErrandStatus.planning.value,
|
||||
autonomy_at_start=level,
|
||||
model=self._model_tag(),
|
||||
)
|
||||
self.s.add(er)
|
||||
self.s.commit()
|
||||
self._run_until_gate(er)
|
||||
return er
|
||||
|
||||
def _run_until_gate(self, er: ErrandTask) -> None:
|
||||
"""external_effect step 직전까지 진행. 만나면 결재함 게이트를 세우고 멈춘다."""
|
||||
er.status = ErrandStatus.running.value
|
||||
self.s.add(er)
|
||||
self.s.commit()
|
||||
budget = Budget()
|
||||
try:
|
||||
for seq, (phase, tool, label, detail, ext) in enumerate(self._plan(er)):
|
||||
existing = _step(self.s, er.id, seq)
|
||||
if existing and existing.state == StepState.done.value:
|
||||
continue
|
||||
budget.step()
|
||||
step = existing or ErrandStep(
|
||||
id=f"{er.id}-s{seq}",
|
||||
errand_id=er.id,
|
||||
seq=seq,
|
||||
phase=phase,
|
||||
tool=tool,
|
||||
label=label,
|
||||
detail=detail,
|
||||
external_effect=ext,
|
||||
)
|
||||
if ext or external_effect_of(phase, tool, label):
|
||||
step.external_effect = True
|
||||
step.state = StepState.blocked.value
|
||||
self.s.add(step)
|
||||
self._raise_approval(er, step) # ← 안전 게이트(항상)
|
||||
er.status = ErrandStatus.awaiting_approval.value
|
||||
er.updated_at = now()
|
||||
self.s.add(er)
|
||||
self.s.commit()
|
||||
return
|
||||
step.state = StepState.running.value
|
||||
self.s.add(step)
|
||||
self.s.commit()
|
||||
self._execute(er, step, budget)
|
||||
step.state = StepState.done.value
|
||||
self.s.add(step)
|
||||
bus.publish("errand.updated", {"errand_id": er.id, "seq": seq, "external": False})
|
||||
self.s.commit()
|
||||
except BudgetExceeded as e:
|
||||
er.status = ErrandStatus.failed.value
|
||||
er.result_summary = f"자원 예산 초과: {e}"
|
||||
er.updated_at = now()
|
||||
self.s.add(er)
|
||||
self.s.commit()
|
||||
return
|
||||
er.status = ErrandStatus.done.value
|
||||
er.result_summary = self._summary(er)
|
||||
er.updated_at = now()
|
||||
self.s.add(er)
|
||||
self.s.commit()
|
||||
bus.publish("errand.completed", {"errand_id": er.id})
|
||||
|
||||
def _raise_approval(self, er: ErrandTask, step: ErrandStep) -> None:
|
||||
"""external_effect → phase-7 Approval(risk=high, source=agent) 생성."""
|
||||
is_cancel = er.kind == ErrandKind.cancel.value
|
||||
ap = Approval(
|
||||
id=f"ap-{er.id}",
|
||||
icon="wallet",
|
||||
tone=er.tone,
|
||||
risk="high",
|
||||
status="pending",
|
||||
source="agent",
|
||||
time="확인 필요",
|
||||
title=f"{er.title}을(를) 추천해요" if is_cancel else er.title,
|
||||
detail=step.detail or er.goal,
|
||||
cta="일시정지" if is_cancel else "진행",
|
||||
alt="유지" if is_cancel else "취소",
|
||||
created_at=now(),
|
||||
)
|
||||
self.s.add(ap)
|
||||
er.approval_id = ap.id
|
||||
self.s.add(er)
|
||||
self.s.commit()
|
||||
bus.publish("approval.enqueued", {"id": ap.id, "risk": "high", "source": "agent"})
|
||||
|
||||
def resume_after_approval(self, er: ErrandTask) -> None:
|
||||
"""결재함 승인 후 errand_runner 가 호출 — blocked step 실행 후 계속."""
|
||||
blocked = _blocked_step(self.s, er.id)
|
||||
if blocked:
|
||||
blocked.state = StepState.running.value
|
||||
self.s.add(blocked)
|
||||
self.s.commit()
|
||||
self._execute(er, blocked, Budget()) # 실제 외부 행동(승인됨)
|
||||
blocked.state = StepState.done.value
|
||||
self.s.add(blocked)
|
||||
self.s.commit()
|
||||
bus.publish(
|
||||
"errand.updated", {"errand_id": er.id, "seq": blocked.seq, "external": True}
|
||||
)
|
||||
self._run_until_gate(er) # 다음 게이트 또는 완료
|
||||
|
||||
# ── 플랜/실행 ──
|
||||
def _plan(self, er: ErrandTask):
|
||||
key = f"{er.kind}:{er.target}"
|
||||
return SCRIPTED.get(key, SCRIPTED["_default"])
|
||||
|
||||
def _execute(self, er: ErrandTask, step: ErrandStep, budget: Budget) -> None:
|
||||
# scripted/폴백: 부작용 없는 observe 만. (실 tool 연동은 phase-15)
|
||||
if step.tool:
|
||||
budget.tool()
|
||||
return
|
||||
|
||||
def _scripted(self) -> bool:
|
||||
return True # phase-14: 결정적 scripted 플랜 고정(실 tool 은 phase-15)
|
||||
|
||||
def _model_tag(self) -> str:
|
||||
return "scripted" if self._scripted() else f"agent:{self.cfg.ollama_model}"
|
||||
|
||||
def _summary(self, er: ErrandTask) -> str:
|
||||
return (
|
||||
"모레 결제 전 일시정지 완료(되돌리기 가능)"
|
||||
if er.kind == ErrandKind.cancel.value
|
||||
else "요청을 처리했어요"
|
||||
)
|
||||
@ -0,0 +1,45 @@
|
||||
# backend/app/agents/guardrails.py — 에이전트 외부 영향 판정 + 자원 예산 가드 (phase-14)
|
||||
"""외부 영향(발송/결제/해지/전달/공유)은 결재함 high-risk 게이트로 강제한다."""
|
||||
|
||||
EXTERNAL_TOOLS = {"form_fill", "calendar_write", "task_create"}
|
||||
READONLY_TOOLS = {"web_search", "http_fetch", "rag_query"} # 정보 조회 — 외부 영향 없음
|
||||
EXTERNAL_KEYWORDS = ("제출", "신청", "발송", "보내", "결제", "전달", "공유", "예약 확정")
|
||||
|
||||
|
||||
def external_effect_of(phase: str, tool: str | None, label: str) -> bool:
|
||||
"""외부 영향(발송/결제/해지 등 '쓰기/제출') 여부. 정보 조회 tool 은 키워드와 무관히 False."""
|
||||
if phase != "act":
|
||||
return False
|
||||
if tool in READONLY_TOOLS: # web_search/http_fetch 등 조회는 외부 영향 아님
|
||||
return False
|
||||
if tool == "form_fill": # 외부 폼 제출은 항상 외부 영향
|
||||
return True
|
||||
if tool in EXTERNAL_TOOLS: # 내부 쓰기지만 '타인에게/외부' 키워드면 high
|
||||
return any(k in label for k in EXTERNAL_KEYWORDS)
|
||||
return any(k in label for k in EXTERNAL_KEYWORDS)
|
||||
|
||||
|
||||
# 재시도/예산 가드 — 무한 루프·과도한 tool 호출 방지
|
||||
MAX_STEPS = 12
|
||||
MAX_RETRIES = 2
|
||||
MAX_TOOL_CALLS = 8
|
||||
|
||||
|
||||
class BudgetExceeded(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Budget:
|
||||
def __init__(self):
|
||||
self.steps = 0
|
||||
self.tool_calls = 0
|
||||
|
||||
def step(self) -> None:
|
||||
self.steps += 1
|
||||
if self.steps > MAX_STEPS:
|
||||
raise BudgetExceeded("step budget exceeded")
|
||||
|
||||
def tool(self) -> None:
|
||||
self.tool_calls += 1
|
||||
if self.tool_calls > MAX_TOOL_CALLS:
|
||||
raise BudgetExceeded("tool budget exceeded")
|
||||
@ -0,0 +1,17 @@
|
||||
# backend/app/agents/registry.py
|
||||
from ..config import get_settings
|
||||
from ..llm.provider import get_provider
|
||||
|
||||
|
||||
def get_agent_provider():
|
||||
"""AGENT_PROVIDER = auto | tool | scripted.
|
||||
tool-capable 모델이 가용+reachable 이면 그 provider, 아니면 None(→ scripted 폴백)."""
|
||||
mode = get_settings().agent_provider
|
||||
if mode == "scripted":
|
||||
return None
|
||||
base = get_provider()
|
||||
if base.health().get("reachable") and base.tool_capable():
|
||||
return base
|
||||
if mode == "tool":
|
||||
return base # 강제 — 미지원이면 Agent.run 이 예외→scripted
|
||||
return None # auto: 미가용이면 None → scripted
|
||||
@ -0,0 +1,88 @@
|
||||
# backend/app/agents/research_agent.py
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..models import ResearchQA, ResearchReport
|
||||
from ..rag.pipeline import query as rag_query_fn
|
||||
from ..schemas import AskOut, ReportOut, StartResearchOut
|
||||
from . import scripted
|
||||
from .base import Agent
|
||||
from .registry import get_agent_provider
|
||||
from .tools.rag_query import RagQueryTool
|
||||
from .tools.web_search import WebSearchTool
|
||||
|
||||
SEED_QUERIES = {
|
||||
"최신 AI 반도체 시장 동향 — 보고서·논문·뉴스 종합해줘": "rp1",
|
||||
"최신 AI 반도체 시장 동향": "rp1",
|
||||
}
|
||||
MIN_SCORE = 0.05 # RAG 근거 채택 최소 코사인
|
||||
|
||||
|
||||
def _report_out(rp: ResearchReport) -> ReportOut:
|
||||
return ReportOut(
|
||||
id=rp.id,
|
||||
title=rp.title,
|
||||
asked=rp.asked,
|
||||
meta=rp.meta,
|
||||
counts=rp.counts,
|
||||
synthesis=rp.synthesis,
|
||||
cross=rp.cross,
|
||||
note=rp.note,
|
||||
)
|
||||
|
||||
|
||||
def run_research(s: Session, query: str) -> StartResearchOut:
|
||||
q = (query or "").strip()
|
||||
# 1) 시드(골든) 질의 → 즉시 완성 리포트(결정성)
|
||||
if q in SEED_QUERIES or q.startswith("최신 AI 반도체"):
|
||||
rid = SEED_QUERIES.get(q, "rp1")
|
||||
rp = s.get(ResearchReport, rid)
|
||||
if rp:
|
||||
return StartResearchOut(
|
||||
status="ready", report_id=rid, report=_report_out(rp), queued_text=None
|
||||
)
|
||||
# 2) 그 외 → 에이전트 파이프라인(tool-capable 또는 scripted)
|
||||
agent = Agent(
|
||||
tools={"web_search": WebSearchTool(), "rag_query": RagQueryTool(s)},
|
||||
provider=get_agent_provider(),
|
||||
scripted=scripted.research_pipeline,
|
||||
)
|
||||
res = agent.run(session=s, query=q)
|
||||
if res.output.get("ready"):
|
||||
rep = res.output["report"]
|
||||
return StartResearchOut(
|
||||
status="ready",
|
||||
report=ReportOut(**rep),
|
||||
report_id=res.output.get("report_id"),
|
||||
queued_text=None,
|
||||
)
|
||||
# 3) queued — "끝나면 알림으로 알려드릴게요"
|
||||
return StartResearchOut(
|
||||
status="queued",
|
||||
report=None,
|
||||
report_id=None,
|
||||
queued_text=f"“{q}” — 출처를 모으고 교차 분석하는 중이에요. 끝나면 알림으로 알려드릴게요.",
|
||||
)
|
||||
|
||||
|
||||
def _extractive_answer(q: str, hits: list[dict]) -> str:
|
||||
top = hits[0]["text"] if hits else ""
|
||||
return f"저장된 자료에서 찾은 내용이에요: {top}"
|
||||
|
||||
|
||||
def answer_question(s: Session, q: str, collection_id: str | None = None) -> AskOut:
|
||||
qn = (q or "").strip()
|
||||
seed = s.exec(select(ResearchQA)).first()
|
||||
if seed and (qn == seed.q or "유모차" in qn):
|
||||
return AskOut(a=seed.a, refs=seed.refs, grounded=True, model="seed")
|
||||
hits = rag_query_fn(s, qn, k=4, collection_id=collection_id)
|
||||
hits = [h for h in hits if h["score"] > MIN_SCORE]
|
||||
if not hits:
|
||||
return AskOut(
|
||||
a="저장된 자료에서 근거를 찾지 못했어요. 자료를 더 학습시켜 주세요.",
|
||||
refs=[],
|
||||
grounded=False,
|
||||
model="rag",
|
||||
)
|
||||
answer = _extractive_answer(qn, hits)
|
||||
refs = [{"title": h["title"], "part": h["part_label"]} for h in hits[:2]]
|
||||
return AskOut(a=answer, refs=refs, grounded=True, model="extractive")
|
||||
@ -0,0 +1,33 @@
|
||||
# backend/app/agents/scripted.py
|
||||
# tool-capable 모델 미가용/오프라인/CI 용 결정적 파이프라인.
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..models import ResearchReport
|
||||
from .base import AgentResult
|
||||
|
||||
|
||||
def report_to_dict(rp: ResearchReport) -> dict:
|
||||
return {
|
||||
"id": rp.id,
|
||||
"title": rp.title,
|
||||
"asked": rp.asked,
|
||||
"meta": rp.meta,
|
||||
"counts": rp.counts,
|
||||
"synthesis": rp.synthesis,
|
||||
"cross": rp.cross,
|
||||
"note": rp.note,
|
||||
}
|
||||
|
||||
|
||||
def research_pipeline(s: Session, query: str) -> AgentResult:
|
||||
rp = s.get(ResearchReport, "rp1")
|
||||
q = (query or "").strip()
|
||||
if rp and q.startswith("최신 AI 반도체"):
|
||||
return AgentResult(
|
||||
ok=True,
|
||||
fallback=True,
|
||||
model="scripted",
|
||||
output={"ready": True, "report_id": "rp1", "report": report_to_dict(rp)},
|
||||
)
|
||||
# 그 외 — 결정적으로 'queued'(데모: 끝나면 알림)
|
||||
return AgentResult(ok=True, fallback=True, model="scripted", output={"ready": False})
|
||||
@ -0,0 +1,23 @@
|
||||
# backend/app/agents/tools/base.py
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
ok: bool
|
||||
data: Any = None
|
||||
error: str = ""
|
||||
meta: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class Tool(ABC):
|
||||
name: str = "tool"
|
||||
description: str = ""
|
||||
schema: dict = {}
|
||||
|
||||
@abstractmethod
|
||||
def run(self, **kwargs) -> ToolResult: ...
|
||||
@ -0,0 +1,15 @@
|
||||
# backend/app/agents/tools/calendar_write.py
|
||||
from .base import Tool, ToolResult
|
||||
|
||||
|
||||
class CalendarWriteTool(Tool):
|
||||
name = "calendar_write"
|
||||
description = "여행/리서치 일정 → event(phase-8) 쓰기. (stub; phase-8 연동점)"
|
||||
schema = {"type": "object", "properties": {"ev": {"type": "object"}}}
|
||||
|
||||
def __init__(self, session=None):
|
||||
self.session = session
|
||||
|
||||
def run(self, ev: dict | None = None, **kw) -> ToolResult:
|
||||
# stub: 결과만 기록(실제 event 쓰기는 routers.calendar.create_event_from_extract 사용 가능).
|
||||
return ToolResult(ok=True, data={"written": False, "ev": ev or {}}, meta={"stub": True})
|
||||
@ -0,0 +1,12 @@
|
||||
# backend/app/agents/tools/http_fetch.py
|
||||
from .base import Tool, ToolResult
|
||||
|
||||
|
||||
class HttpFetchTool(Tool):
|
||||
name = "http_fetch"
|
||||
description = "URL → 본문 텍스트(웹/PDF). mock 모드는 시드 본문/빈 결과."
|
||||
schema = {"type": "object", "properties": {"url": {"type": "string"}}}
|
||||
|
||||
def run(self, url: str = "") -> ToolResult:
|
||||
# 데모/오프라인: 네트워크 미사용. 실연동은 phase-13.
|
||||
return ToolResult(ok=True, data="", meta={"url": url, "stub": True})
|
||||
@ -0,0 +1,22 @@
|
||||
# backend/app/agents/tools/rag_query.py
|
||||
from ...rag.pipeline import query as rag_query_fn
|
||||
from .base import Tool, ToolResult
|
||||
|
||||
|
||||
class RagQueryTool(Tool):
|
||||
name = "rag_query"
|
||||
description = "저장된 자료(지식베이스)에서 근거 청크를 검색한다."
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}, "k": {"type": "integer", "default": 4}},
|
||||
}
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def run(self, q: str = "", k: int = 4, collection_id: str | None = None) -> ToolResult:
|
||||
try:
|
||||
hits = rag_query_fn(self.session, q, k=k, collection_id=collection_id)
|
||||
return ToolResult(ok=True, data=hits, meta={"source_count": len(hits)})
|
||||
except Exception as e:
|
||||
return ToolResult(ok=False, error=str(e))
|
||||
@ -0,0 +1,35 @@
|
||||
# backend/app/agents/tools/task_create.py
|
||||
import uuid
|
||||
|
||||
from ...models import Prio, Project, Task, TaskStatus
|
||||
from .base import Tool, ToolResult
|
||||
|
||||
|
||||
class TaskCreateTool(Tool):
|
||||
name = "task_create"
|
||||
description = "분류된 액션을 task 로 실체화(federation)."
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}, "project_id": {"type": "string"}},
|
||||
}
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def run(self, title: str = "", project_id: str = "me", **kw) -> ToolResult:
|
||||
s = self.session
|
||||
if not s.get(Project, project_id):
|
||||
project_id = "me"
|
||||
tid = "ag-" + uuid.uuid4().hex[:8]
|
||||
t = Task(
|
||||
id=tid,
|
||||
title=title,
|
||||
project_id=project_id,
|
||||
status=TaskStatus.todo,
|
||||
assignee_id="jiwoo",
|
||||
prio=Prio.normal,
|
||||
notes="에이전트가 만든 작업이에요.",
|
||||
)
|
||||
s.add(t)
|
||||
s.commit()
|
||||
return ToolResult(ok=True, data={"task_id": tid})
|
||||
@ -0,0 +1,20 @@
|
||||
# backend/app/agents/tools/web_search.py
|
||||
from ..connectors_bridge import get_knowledge_connector
|
||||
from .base import Tool, ToolResult
|
||||
|
||||
|
||||
class WebSearchTool(Tool):
|
||||
name = "web_search"
|
||||
description = "웹/논문/뉴스를 검색해 출처 객체 목록을 돌려준다."
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}, "k": {"type": "integer", "default": 8}},
|
||||
}
|
||||
|
||||
def run(self, query: str = "", k: int = 8) -> ToolResult:
|
||||
conn = get_knowledge_connector()
|
||||
try:
|
||||
hits = conn.search(query, k=k)
|
||||
return ToolResult(ok=True, data=hits, meta={"source_count": len(hits)})
|
||||
except Exception as e:
|
||||
return ToolResult(ok=False, error=str(e))
|
||||
@ -0,0 +1,47 @@
|
||||
# backend/app/agents/travel_agent.py
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..models import TripPlan
|
||||
from ..schemas import PlanResultOut
|
||||
|
||||
PLANNER_RESEARCH = [
|
||||
{"icon": "plane", "label": "항공·교통편 검색", "detail": "32개 노선 가격 비교"},
|
||||
{"icon": "bed", "label": "숙소 비교", "detail": "위치·평점·예산 교차 분석"},
|
||||
{"icon": "sun", "label": "날씨·시즌·혼잡도 확인", "detail": "기상 예보 + 성수기 캘린더"},
|
||||
{"icon": "pin", "label": "동선·맛집 정리", "detail": "리뷰 240건 요약"},
|
||||
]
|
||||
|
||||
|
||||
def _plan_out(p: TripPlan, custom: bool, title: str | None = None) -> PlanResultOut:
|
||||
return PlanResultOut(
|
||||
id=p.id,
|
||||
idx=p.idx,
|
||||
custom=custom,
|
||||
title=(title or p.title),
|
||||
meta=p.meta,
|
||||
summary=p.summary,
|
||||
weather=p.weather,
|
||||
transport=p.transport,
|
||||
stay=p.stay,
|
||||
days=p.days,
|
||||
budget=p.budget,
|
||||
checklist=p.checklist,
|
||||
sources=p.sources,
|
||||
research=PLANNER_RESEARCH,
|
||||
)
|
||||
|
||||
|
||||
def _pick_plan(s: Session, text: str) -> tuple[TripPlan, bool]:
|
||||
"""원본 trip.jsx pickResult 미러: examples 정확 일치면 그 결과, 아니면 idx=0(제주) 폴백."""
|
||||
t = (text or "").strip()
|
||||
plans = {p.example: p for p in s.exec(select(TripPlan)).all()}
|
||||
if t in plans:
|
||||
return plans[t], False # custom=False
|
||||
return s.get(TripPlan, "plan_jeju"), True # custom=True
|
||||
|
||||
|
||||
def run_planner(s: Session, text: str) -> PlanResultOut:
|
||||
plan, custom = _pick_plan(s, text)
|
||||
if custom and text.strip():
|
||||
return _plan_out(plan, custom=True, title=text.strip()[:18]) # 원본: slice(0,18)
|
||||
return _plan_out(plan, custom=False)
|
||||
@ -0,0 +1,113 @@
|
||||
# backend/app/approvals/service.py
|
||||
# 큐 분기(derive_queue = 원본 approve.jsx initStatus) + 상태 전이.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..automation.event_bus import bus
|
||||
from ..automation.events import APPROVAL_EXECUTED, APPROVAL_UNDONE
|
||||
from ..models import (
|
||||
Approval,
|
||||
ApprovalLog,
|
||||
ApprovalStatus,
|
||||
AutonomyLevel,
|
||||
AutonomySetting,
|
||||
RiskLevel,
|
||||
)
|
||||
|
||||
_LEVELS = (
|
||||
AutonomyLevel.approval_first.value,
|
||||
AutonomyLevel.mixed.value,
|
||||
AutonomyLevel.full_auto.value,
|
||||
)
|
||||
|
||||
|
||||
def get_autonomy(s: Session) -> str:
|
||||
row = s.get(AutonomySetting, "default")
|
||||
if not row:
|
||||
row = AutonomySetting(id="default", level=AutonomyLevel.mixed.value)
|
||||
s.add(row)
|
||||
s.commit()
|
||||
s.refresh(row)
|
||||
return row.level
|
||||
|
||||
|
||||
def set_autonomy(s: Session, level: str) -> str:
|
||||
if level not in _LEVELS:
|
||||
raise ValueError("invalid autonomy level")
|
||||
row = s.get(AutonomySetting, "default") or AutonomySetting(id="default")
|
||||
row.level = level
|
||||
row.updated_at = datetime.now(UTC)
|
||||
s.add(row)
|
||||
s.commit()
|
||||
return level
|
||||
|
||||
|
||||
def derive_status(level: str, risk: str) -> str:
|
||||
"""원본 approve.jsx initStatus(autonomy) 이식.
|
||||
full_auto: 전부 executed. mixed: low=executed, high=pending. approval_first: 전부 pending."""
|
||||
if level == AutonomyLevel.full_auto.value:
|
||||
return ApprovalStatus.executed.value
|
||||
if level == AutonomyLevel.mixed.value:
|
||||
return (
|
||||
ApprovalStatus.executed.value
|
||||
if risk == RiskLevel.low.value
|
||||
else ApprovalStatus.pending.value
|
||||
)
|
||||
return ApprovalStatus.pending.value # approval_first
|
||||
|
||||
|
||||
def derive_queue(s: Session, user_id: str | None = None) -> dict:
|
||||
"""자율성 레벨로 모든 approval 의 표시 상태를 파생.
|
||||
단, 사용자가 명시적으로 undone 한 것은 pending, 엔진이 실제 실행한 건은 executed 로 고정.
|
||||
user_id 가 주어지면 소유자 스코프(phase-15)."""
|
||||
level = get_autonomy(s)
|
||||
stmt = select(Approval).order_by(Approval.sort_order)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(Approval.user_id == user_id)
|
||||
rows = s.exec(stmt).all()
|
||||
pending, done = [], []
|
||||
for a in rows:
|
||||
if a.status == ApprovalStatus.undone.value: # 사용자가 되돌림 → 대기로
|
||||
eff = ApprovalStatus.pending.value
|
||||
elif a.status == ApprovalStatus.executed.value and a.executed_at:
|
||||
eff = ApprovalStatus.executed.value # 사용자/엔진이 실제 실행한 건은 고정
|
||||
else:
|
||||
eff = derive_status(level, a.risk) # 시드/표시용은 레벨로 파생
|
||||
(done if eff == ApprovalStatus.executed.value else pending).append(a)
|
||||
return {"level": level, "pending": pending, "done": done}
|
||||
|
||||
|
||||
def approve(s: Session, aid: str) -> Approval:
|
||||
a = s.get(Approval, aid)
|
||||
a.status = ApprovalStatus.executed.value
|
||||
a.executed_at = datetime.now(UTC)
|
||||
s.add(a)
|
||||
s.commit()
|
||||
s.refresh(a)
|
||||
s.add(ApprovalLog(id="alog-" + aid + "-x", time="방금", text=a.title, approval_id=aid))
|
||||
s.commit()
|
||||
bus.publish(APPROVAL_EXECUTED, {"approval_id": aid, "risk": a.risk, "title": a.title})
|
||||
return a
|
||||
|
||||
|
||||
def undo(s: Session, aid: str) -> Approval:
|
||||
a = s.get(Approval, aid)
|
||||
a.status = ApprovalStatus.undone.value
|
||||
a.undone_at = datetime.now(UTC)
|
||||
s.add(a)
|
||||
s.commit()
|
||||
s.refresh(a)
|
||||
bus.publish(APPROVAL_UNDONE, {"approval_id": aid, "title": a.title})
|
||||
return a
|
||||
|
||||
|
||||
def approve_all(s: Session) -> int:
|
||||
q = derive_queue(s)
|
||||
n = 0
|
||||
for a in q["pending"]:
|
||||
approve(s, a.id)
|
||||
n += 1
|
||||
return n
|
||||
@ -0,0 +1 @@
|
||||
# backend/app/auth/ — phase-15
|
||||
@ -0,0 +1,61 @@
|
||||
# backend/app/auth/deps.py — 인증 의존성 (데모 모드 우회 포함)
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import get_session
|
||||
from ..models import ApiToken, AuthSession, Person, UserCredential
|
||||
from .tokens import hash_token, unsign_session_id
|
||||
|
||||
DEMO_USER_ID = "jiwoo" # AUTH_ENABLED=false 일 때 고정 사용자(데모 무손상)
|
||||
|
||||
|
||||
def current_user(request: Request, session: Session = Depends(get_session)) -> Person:
|
||||
st = get_settings()
|
||||
# 1) 데모 모드: 인증 끔 → 항상 지우
|
||||
if not st.auth_enabled:
|
||||
user = session.get(Person, DEMO_USER_ID)
|
||||
if user is None:
|
||||
raise HTTPException(500, "demo user 'jiwoo' missing — run_seed 필요")
|
||||
return user
|
||||
|
||||
# 2) 쿠키 세션(웹) 우선
|
||||
raw = request.cookies.get(st.session_cookie_name)
|
||||
if raw:
|
||||
sid = unsign_session_id(raw)
|
||||
if sid:
|
||||
sess = session.get(AuthSession, sid)
|
||||
if sess and not sess.revoked:
|
||||
exp = sess.expires_at
|
||||
if exp.tzinfo is None: # SQLite 는 naive 로 저장 → UTC 로 정규화
|
||||
exp = exp.replace(tzinfo=UTC)
|
||||
if exp > datetime.now(UTC):
|
||||
u = session.get(Person, sess.user_id)
|
||||
if u:
|
||||
return u
|
||||
|
||||
# 3) 베어러 토큰(스크립트/모바일)
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
th = hash_token(auth[7:])
|
||||
tok = session.exec(select(ApiToken).where(ApiToken.token_hash == th)).first()
|
||||
if tok and not tok.revoked:
|
||||
tok.last_used_at = datetime.now(UTC)
|
||||
session.add(tok)
|
||||
session.commit()
|
||||
u = session.get(Person, tok.user_id)
|
||||
if u:
|
||||
return u
|
||||
|
||||
raise HTTPException(status_code=401, detail="인증이 필요해요")
|
||||
|
||||
|
||||
def require_admin(
|
||||
user: Person = Depends(current_user), session: Session = Depends(get_session)
|
||||
) -> Person:
|
||||
cred = session.exec(select(UserCredential).where(UserCredential.user_id == user.id)).first()
|
||||
if not cred or cred.role != "admin":
|
||||
raise HTTPException(403, "관리자만 접근할 수 있어요")
|
||||
return user
|
||||
@ -0,0 +1,26 @@
|
||||
# backend/app/auth/password.py — 비밀번호 해시/검증 (stdlib pbkdf2, 의존성 없음)
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
|
||||
_ITER = 200_000
|
||||
_ALGO = "sha256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = os.urandom(16)
|
||||
dk = hashlib.pbkdf2_hmac(_ALGO, password.encode("utf-8"), salt, _ITER)
|
||||
return f"pbkdf2${_ITER}${salt.hex()}${dk.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
scheme, iters, salt_hex, hash_hex = stored.split("$")
|
||||
if scheme != "pbkdf2":
|
||||
return False
|
||||
dk = hashlib.pbkdf2_hmac(
|
||||
_ALGO, password.encode("utf-8"), bytes.fromhex(salt_hex), int(iters)
|
||||
)
|
||||
return hmac.compare_digest(dk.hex(), hash_hex)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
@ -0,0 +1,16 @@
|
||||
# backend/app/auth/scope.py — per-user 쿼리 스코프 헬퍼 (전 라우터 공용)
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def scoped(stmt, model, user_id: str):
|
||||
"""user_id 컬럼이 있는 모델 쿼리에 소유자 필터 강제. 없으면(공유 마스터) 그대로."""
|
||||
if hasattr(model, "user_id"):
|
||||
return stmt.where(model.user_id == user_id)
|
||||
return stmt
|
||||
|
||||
|
||||
def owned_or_404(obj, user_id: str):
|
||||
"""단건 조회 시 소유자 검증. 타인 데이터면 404(존재 자체를 숨김 — IDOR 방어)."""
|
||||
if obj is None or (hasattr(obj, "user_id") and obj.user_id != user_id):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return obj
|
||||
@ -0,0 +1,43 @@
|
||||
# backend/app/auth/tokens.py — 세션 id 서명/검증 + API 토큰 해시 (stdlib hmac, 의존성 없음)
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
|
||||
def _key() -> bytes:
|
||||
return get_settings().session_secret.encode("utf-8")
|
||||
|
||||
|
||||
def new_session_id() -> str:
|
||||
return secrets.token_hex(32) # 256-bit
|
||||
|
||||
|
||||
def new_api_token() -> str:
|
||||
return "ari_" + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def sign_session_id(sid: str) -> str:
|
||||
"""sid.<base64url(hmac)> — 쿠키에 들어가는 서명된 세션 id."""
|
||||
mac = hmac.new(_key(), sid.encode("utf-8"), hashlib.sha256).digest()
|
||||
return f"{sid}.{base64.urlsafe_b64encode(mac).decode().rstrip('=')}"
|
||||
|
||||
|
||||
def unsign_session_id(signed: str) -> str | None:
|
||||
"""서명 검증 후 sid 반환. 변조/형식 오류면 None."""
|
||||
try:
|
||||
sid, sig = signed.rsplit(".", 1)
|
||||
except ValueError:
|
||||
return None
|
||||
expected = (
|
||||
base64.urlsafe_b64encode(hmac.new(_key(), sid.encode("utf-8"), hashlib.sha256).digest())
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
return sid if hmac.compare_digest(sig, expected) else None
|
||||
@ -0,0 +1,127 @@
|
||||
# backend/app/automation/evaluator.py
|
||||
# 이벤트 → 규칙 매칭 → 동작/승인 enqueue. automation.matched → approval enqueue 연합 고리.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..models import (
|
||||
Approval,
|
||||
ApprovalLog,
|
||||
ApprovalSource,
|
||||
ApprovalStatus,
|
||||
AutomationRule,
|
||||
AutomationRunLog,
|
||||
AutonomyLevel,
|
||||
AutonomySetting,
|
||||
RiskLevel,
|
||||
)
|
||||
from .event_bus import Event, EventBus
|
||||
from .events import APPROVAL_ENQUEUED, APPROVAL_EXECUTED, AUTOMATION_MATCHED
|
||||
|
||||
# cat → 결재 카드 기본 tone/icon(원본 매핑과 일관)
|
||||
CAT_TONE = {"mail": "blue", "cal": "violet", "focus": "coral", "life": "green"}
|
||||
CAT_ICON = {"mail": "mail", "cal": "cal", "focus": "zap", "life": "wallet"}
|
||||
|
||||
# 어떤 동작이 high-risk 인가 — 보내기/결제/삭제/전달 키워드
|
||||
HIGH_RISK_RE = ("보내", "결제", "삭제", "전달", "발송", "일시정지", "송금")
|
||||
|
||||
|
||||
def _risk_of(action: str) -> str:
|
||||
return RiskLevel.high.value if any(k in action for k in HIGH_RISK_RE) else RiskLevel.low.value
|
||||
|
||||
|
||||
def _autonomy(s: Session) -> str:
|
||||
row = s.get(AutonomySetting, "default")
|
||||
return row.level if row else AutonomyLevel.mixed.value
|
||||
|
||||
|
||||
def match_rules(s: Session, trigger_key: str) -> list[AutomationRule]:
|
||||
"""trigger_key(예: 'mail.newsletter')와 켜진 규칙을 매칭.
|
||||
데모는 cat 기반 단순 매칭 + trigger 문자열 포함. 실연동(phase-13)이 정교화."""
|
||||
rules = s.exec(select(AutomationRule).where(AutomationRule.on == True)).all() # noqa: E712
|
||||
head = trigger_key.split(".")[0]
|
||||
tail = trigger_key.split(".")[-1]
|
||||
return [r for r in rules if head in (r.cat, "") or tail in r.trigger]
|
||||
|
||||
|
||||
def enqueue_from_rule(s: Session, rule: AutomationRule, ctx: dict, bus: EventBus) -> Approval:
|
||||
"""규칙 동작을 승인 큐에 넣는다. low+mixed이상=자동 실행(executed), 그 외=pending."""
|
||||
risk = _risk_of(rule.action)
|
||||
level = _autonomy(s)
|
||||
auto_run = risk == RiskLevel.low.value and level in (
|
||||
AutonomyLevel.mixed.value,
|
||||
AutonomyLevel.full_auto.value,
|
||||
)
|
||||
ap = Approval(
|
||||
id="ap-" + uuid.uuid4().hex[:8],
|
||||
icon=CAT_ICON.get(rule.cat, "spark"),
|
||||
tone=CAT_TONE.get(rule.cat, "blue"),
|
||||
risk=risk,
|
||||
time=("자동 실행됨" if auto_run else "확인 필요"),
|
||||
title=ctx.get("title", rule.action),
|
||||
detail=ctx.get("detail", f"{rule.name} · {rule.trigger}"),
|
||||
cta=ctx.get("cta", "" if risk == RiskLevel.low.value else "실행"),
|
||||
alt=ctx.get("alt", "" if risk == RiskLevel.low.value else "나중에"),
|
||||
undo_label=(ctx.get("undo_label", "되돌리기") if risk == RiskLevel.low.value else ""),
|
||||
status=(ApprovalStatus.executed.value if auto_run else ApprovalStatus.pending.value),
|
||||
source=ApprovalSource.automation.value,
|
||||
rule_id=rule.id,
|
||||
executed_at=(datetime.now(UTC) if auto_run else None),
|
||||
)
|
||||
s.add(ap)
|
||||
s.add(
|
||||
AutomationRunLog(
|
||||
id="arun-" + uuid.uuid4().hex[:8],
|
||||
time="방금",
|
||||
rule_id=rule.id,
|
||||
rule=rule.name,
|
||||
text=ctx.get("text", rule.action),
|
||||
)
|
||||
)
|
||||
if auto_run:
|
||||
s.add(
|
||||
ApprovalLog(
|
||||
id="alog-" + uuid.uuid4().hex[:8],
|
||||
time="방금",
|
||||
text=ctx.get("text", rule.action),
|
||||
approval_id=ap.id,
|
||||
)
|
||||
)
|
||||
rule.runs += 1
|
||||
rule.last = "방금"
|
||||
s.add(rule)
|
||||
s.commit()
|
||||
s.refresh(ap)
|
||||
# 결재 enqueue 정본 이벤트명 = approval.enqueued
|
||||
bus.publish(
|
||||
APPROVAL_ENQUEUED,
|
||||
{
|
||||
"approval_id": ap.id,
|
||||
"rule_id": rule.id,
|
||||
"risk": risk,
|
||||
"title": ap.title,
|
||||
"auto_run": auto_run,
|
||||
},
|
||||
)
|
||||
if auto_run:
|
||||
bus.publish(
|
||||
APPROVAL_EXECUTED,
|
||||
{"approval_id": ap.id, "rule_id": rule.id, "risk": risk, "title": ap.title},
|
||||
)
|
||||
return ap
|
||||
|
||||
|
||||
def register(bus: EventBus, session_factory) -> None:
|
||||
"""버스에 evaluator 핸들러 등록. session_factory()는 Session 컨텍스트를 yield."""
|
||||
|
||||
def on_matched(ev: Event) -> None:
|
||||
trigger_key = ev.payload.get("trigger_key", "")
|
||||
ctx = ev.payload.get("ctx", {})
|
||||
with session_factory() as s:
|
||||
for rule in match_rules(s, trigger_key):
|
||||
enqueue_from_rule(s, rule, ctx, bus)
|
||||
|
||||
bus.subscribe(AUTOMATION_MATCHED, on_matched)
|
||||
@ -0,0 +1,49 @@
|
||||
# backend/app/automation/event_bus.py
|
||||
# event_bus 정본(단일 고정) — post-mvp-overview.md §5.
|
||||
# `from app.automation.event_bus import bus` (bus.publish('x', {...}))
|
||||
# `from app.automation.event_bus import publish` (publish('x', {...}), emit 동일)
|
||||
# 둘 다 정상. app/events.py·app/event_bus.py 같은 다른 경로는 정본이 아니다.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
type: str
|
||||
payload: dict = field(default_factory=dict)
|
||||
at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""in-process 동기 이벤트 버스. 핸들러는 (Event) -> None.
|
||||
프로토타입은 publish 즉시 동기 디스패치. phase-14 worker 가 비동기/스케줄로 확장."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._subs: dict[str, list[Callable[[Event], None]]] = defaultdict(list)
|
||||
self.history: list[Event] = [] # 테스트/디버그용 발행 기록
|
||||
|
||||
def subscribe(self, event_type: str, handler: Callable[[Event], None]) -> None:
|
||||
self._subs[event_type].append(handler)
|
||||
|
||||
def publish(self, event_type: str, payload: dict | None = None) -> Event:
|
||||
ev = Event(type=event_type, payload=payload or {})
|
||||
self.history.append(ev)
|
||||
for h in list(self._subs.get(event_type, [])):
|
||||
try:
|
||||
h(ev) # 한 구독자의 예외가 다른 구독자를 막지 않도록 격리
|
||||
except Exception:
|
||||
pass
|
||||
return ev
|
||||
|
||||
|
||||
# 전역 단일 버스(데모). 테스트는 새 인스턴스를 주입해 격리한다.
|
||||
bus = EventBus()
|
||||
|
||||
# 모듈 레벨 편의 export — post-mvp-overview.md event_bus 정본.
|
||||
publish = bus.publish # publish('automation.matched', {...})
|
||||
subscribe = bus.subscribe # subscribe('automation.matched', handler)
|
||||
emit = bus.publish # emit 은 publish 의 별칭(동일 동작)
|
||||
@ -0,0 +1,14 @@
|
||||
# backend/app/automation/events.py
|
||||
# 연합 이벤트 타입 — post-mvp-overview.md "연합 이벤트 모델" 정본.
|
||||
CAPTURE_CLASSIFIED = "capture.classified"
|
||||
TASK_CREATED = "task.created"
|
||||
MEETING_ENDED = "meeting.ended" # phase-8
|
||||
CALENDAR_UPDATED = "calendar.updated" # phase-8
|
||||
CALENDAR_FOCUS_SCHEDULED = "calendar.focus_scheduled" # phase-8
|
||||
MAIL_RECEIVED = "mail.received" # phase-9
|
||||
AUTOMATION_MATCHED = "automation.matched"
|
||||
AUTOMATION_SUGGESTED = "automation.suggested" # suggester 가 발행하는 제안 이벤트
|
||||
APPROVAL_ENQUEUED = "approval.enqueued" # 결재 enqueue (approval.created 아님)
|
||||
APPROVAL_EXECUTED = "approval.executed"
|
||||
APPROVAL_UNDONE = "approval.undone"
|
||||
NOTIFICATION_TRIAGED = "notification.triaged" # phase-9 (suggester 구독)
|
||||
@ -0,0 +1,129 @@
|
||||
# backend/app/automation/nl_parser.py
|
||||
# 자연어 한 문장 → 규칙. LLM generate_json + 휴리스틱 폴백. 골든 예시 3건 결정적.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..llm.provider import LLMProvider, get_provider
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedRule:
|
||||
matched: bool
|
||||
name: str
|
||||
cat: str # mail|cal|focus|life
|
||||
trigger: str
|
||||
cond: str | None
|
||||
action: str
|
||||
model: str
|
||||
confidence: float
|
||||
|
||||
|
||||
# 원본 auto-data.js examples 골든 매핑(텍스트 정확 일치 → 결정적 미리보기)
|
||||
EXAMPLES = {
|
||||
"출장 전날엔 저녁 일정 비워줘": ParsedRule(
|
||||
True,
|
||||
"출장 전날 저녁 비우기",
|
||||
"cal",
|
||||
"출장 전날이 되면",
|
||||
"18시 이후 일정이 있으면",
|
||||
"다른 날로 옮기자고 제안",
|
||||
"example",
|
||||
0.95,
|
||||
),
|
||||
"뉴스레터는 모아서 저녁에 보여줘": ParsedRule(
|
||||
True,
|
||||
"뉴스레터는 저녁에",
|
||||
"mail",
|
||||
"뉴스레터 메일 도착",
|
||||
None,
|
||||
"받은편지함 건너뛰고 18:30 다이제스트로",
|
||||
"example",
|
||||
0.95,
|
||||
),
|
||||
"운동을 3일 거르면 산책 잡아줘": ParsedRule(
|
||||
True,
|
||||
"산책 리마인더",
|
||||
"life",
|
||||
"운동 기록 3일 연속 없음",
|
||||
"저녁에 빈 시간이 있으면",
|
||||
"30분 산책 블록 제안",
|
||||
"example",
|
||||
0.95,
|
||||
),
|
||||
}
|
||||
|
||||
# 카테고리 키워드(휴리스틱)
|
||||
CAT_RE = [
|
||||
("mail", re.compile(r"(메일|뉴스레터|받은편지함|회신|발송|스팸)")),
|
||||
("cal", re.compile(r"(일정|회의|미팅|캘린더|블록|비워|출장|약속)")),
|
||||
("focus", re.compile(r"(집중|딥 ?워크|방해 금지|알림 보류|리포트 준비)")),
|
||||
("life", re.compile(r"(운동|산책|영수증|결제|구독|수면|건강|지출|카드)")),
|
||||
]
|
||||
|
||||
NL_SYSTEM = (
|
||||
"너는 한국어 비서 '아리'의 자동화 규칙 파서다. 사용자의 한 문장을 "
|
||||
"{trigger(언제), cond(조건; 없으면 null), action(무엇을), cat, name} 으로 분해한다. "
|
||||
"cat 은 mail|cal|focus|life 중 하나. name 은 8자 내외 한국어 규칙 이름. "
|
||||
"보내기·결제·삭제·전달 같은 위험 동작은 'cond'에 확인 단서를 남겨라."
|
||||
)
|
||||
|
||||
|
||||
def _build_prompt(text: str) -> str:
|
||||
return (
|
||||
f'문장: "{text}"\n\n'
|
||||
"아래 JSON 스키마로만 답하라:\n"
|
||||
"{\n"
|
||||
' "name": "규칙 이름(한국어, 8자 내외)",\n'
|
||||
' "cat": "mail|cal|focus|life",\n'
|
||||
' "trigger": "언제(트리거)",\n'
|
||||
' "cond": "조건 또는 null",\n'
|
||||
' "action": "무엇을(동작)"\n'
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
def _heuristic(text: str) -> ParsedRule:
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return ParsedRule(False, "", "cal", "", None, "", "heuristic", 0.0)
|
||||
cat = "cal"
|
||||
for c, rx in CAT_RE:
|
||||
if rx.search(t):
|
||||
cat = c
|
||||
break
|
||||
cond = None
|
||||
m = re.search(r"(.+?(되면|도착|없으면|이면|거르면|걸리면))\s*(.*)", t)
|
||||
if m:
|
||||
trigger, action = m.group(1).strip(), (m.group(3).strip() or t)
|
||||
else:
|
||||
trigger, action = t, t
|
||||
name = (action[:8] or t[:8]).strip()
|
||||
return ParsedRule(True, name, cat, trigger, cond, action, "heuristic", 0.55)
|
||||
|
||||
|
||||
def parse_rule(text: str, provider: LLMProvider | None = None) -> ParsedRule:
|
||||
t = (text or "").strip()
|
||||
if not t: # 빈 입력은 LLM 호출 없이 결정적으로 미매칭
|
||||
return _heuristic(t)
|
||||
if t in EXAMPLES: # 골든: 데모 결정성 보장
|
||||
return EXAMPLES[t]
|
||||
prov = provider or get_provider()
|
||||
try:
|
||||
data = prov.generate_json(NL_SYSTEM + "\n" + _build_prompt(t), schema={"cat": "str"})
|
||||
cat = data.get("cat") if data.get("cat") in ("mail", "cal", "focus", "life") else None
|
||||
if cat and data.get("action"):
|
||||
return ParsedRule(
|
||||
True,
|
||||
str(data.get("name") or data["action"][:8]).strip(),
|
||||
cat,
|
||||
str(data.get("trigger", t)).strip(),
|
||||
(str(data["cond"]).strip() if data.get("cond") else None),
|
||||
str(data["action"]).strip(),
|
||||
f"llm:{getattr(prov, 'model', prov.name)}",
|
||||
float(data.get("confidence", 0.8)),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return _heuristic(t) # 폴백
|
||||
@ -0,0 +1,69 @@
|
||||
# backend/app/automation/suggester.py
|
||||
# 반복 패턴 탐지 → automation_suggestion. notification.triaged 구독 + 수동 scan 둘 다 지원.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..models import AutomationRunLog, AutomationSuggestion
|
||||
from .event_bus import Event, EventBus
|
||||
from .events import AUTOMATION_SUGGESTED, NOTIFICATION_TRIAGED
|
||||
|
||||
# 데모용 결정적 패턴 규칙: (감지 텍스트 부분일치, 제안)
|
||||
PATTERNS = [
|
||||
{
|
||||
"match": "다이제스트",
|
||||
"pattern": "저녁 다이제스트를 자주 쓰시네요.",
|
||||
"offer": {
|
||||
"name": "아침에도 다이제스트",
|
||||
"cat": "mail",
|
||||
"trigger": "08:00",
|
||||
"cond": None,
|
||||
"action": "밤사이 메일 묶음 브리핑",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def scan(s: Session, bus: EventBus | None = None) -> list[AutomationSuggestion]:
|
||||
"""run_log 를 훑어 반복 패턴이 임계 이상이면 제안 생성(중복 방지).
|
||||
생성된 제안마다 automation.suggested 발행(bus 가 주어지면)."""
|
||||
logs = s.exec(select(AutomationRunLog)).all()
|
||||
existing = {x.offer_name for x in s.exec(select(AutomationSuggestion)).all()}
|
||||
created: list[AutomationSuggestion] = []
|
||||
for pat in PATTERNS:
|
||||
hits = [log for log in logs if pat["match"] in (log.text or "")]
|
||||
if len(hits) >= 3 and pat["offer"]["name"] not in existing: # 임계=3
|
||||
o = pat["offer"]
|
||||
sug = AutomationSuggestion(
|
||||
id="asug-" + uuid.uuid4().hex[:8],
|
||||
pattern=pat["pattern"],
|
||||
offer_name=o["name"],
|
||||
offer_cat=o["cat"],
|
||||
offer_trigger=o["trigger"],
|
||||
offer_cond=o["cond"],
|
||||
offer_action=o["action"],
|
||||
status="open",
|
||||
)
|
||||
s.add(sug)
|
||||
created.append(sug)
|
||||
s.commit()
|
||||
if bus:
|
||||
for sug in created:
|
||||
bus.publish(
|
||||
AUTOMATION_SUGGESTED,
|
||||
{"suggestion_id": sug.id, "offer_name": sug.offer_name, "pattern": sug.pattern},
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
def register(bus: EventBus, session_factory) -> None:
|
||||
"""버스에 suggester 핸들러 등록 — notification.triaged 구독.
|
||||
phase-9 알림 트리아지의 '나중에' 반복 패턴을 입력으로 scan → automation.suggested 체인."""
|
||||
|
||||
def on_triaged(ev: Event) -> None:
|
||||
with session_factory() as s:
|
||||
scan(s, bus)
|
||||
|
||||
bus.subscribe(NOTIFICATION_TRIAGED, on_triaged)
|
||||
@ -0,0 +1,12 @@
|
||||
# backend/app/connectors/__init__.py
|
||||
from functools import lru_cache
|
||||
|
||||
from ..config import get_settings
|
||||
from .calendar_mock import MockCalendarConnector
|
||||
from .calendar_real import RealCalendarConnector
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_calendar_connector():
|
||||
mode = getattr(get_settings(), "connector_calendar", "mock")
|
||||
return RealCalendarConnector() if mode == "real" else MockCalendarConnector()
|
||||
@ -0,0 +1,236 @@
|
||||
# backend/app/connectors/base.py
|
||||
# 도메인별 커넥터 추상 인터페이스. mock=시드 기반, real=외부 제공자(phase-13).
|
||||
import hashlib
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..models import ConnectorAccount, ConnectorSyncState, ConnState, ExternalLink
|
||||
|
||||
|
||||
class CalendarConnector(ABC):
|
||||
"""calendar 도메인 읽기-전용 커넥터(phase-8). mock=시드(cal-data.js), real=Google Calendar.
|
||||
페이지 읽기는 이 인터페이스만 의존. (phase-13 의 sync 프레임워크와 공존)."""
|
||||
|
||||
@abstractmethod
|
||||
def list_events(self, session, day: Optional[int] = None) -> list[dict]: ...
|
||||
|
||||
@abstractmethod
|
||||
def get_meeting(self, session, meeting_id: str) -> Optional[dict]: ...
|
||||
|
||||
@abstractmethod
|
||||
def write_event(self, session, payload: dict) -> dict: ...
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# phase-13: 통합 sync 프레임워크 — fetch / normalize / write / sync (인터페이스 불변)
|
||||
# 위 읽기-전용 커넥터와 공존한다. 이쪽은 "수집(ingestion)" 계약.
|
||||
# ============================================================================
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawRecord:
|
||||
"""제공자에서 fetch 한 1건의 원시 레코드(provider 페이로드)."""
|
||||
|
||||
external_id: str
|
||||
payload: dict
|
||||
etag: str = ""
|
||||
external_updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedRecord:
|
||||
"""normalize() 산출물 — 내부 모델로 write 가능한 정규화 dict."""
|
||||
|
||||
entity_type: str # "email" | "event" | "finance_tx" | ...
|
||||
external_id: str
|
||||
fields: dict[str, Any] = field(default_factory=dict)
|
||||
etag: str = ""
|
||||
external_updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
domain: str
|
||||
account_id: str
|
||||
seen: int = 0
|
||||
upserted: int = 0
|
||||
skipped: int = 0
|
||||
errors: int = 0
|
||||
events_published: list[str] = field(default_factory=list)
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class BaseConnector(ABC):
|
||||
"""모든 도메인 커넥터의 공통 계약. Mock/Real 이 동일하게 구현한다(인터페이스 불변)."""
|
||||
|
||||
domain: str = "base"
|
||||
entity_type: str = "record"
|
||||
|
||||
def __init__(self, account: ConnectorAccount):
|
||||
self.account = account
|
||||
|
||||
# ── (1) fetch: 제공자에서 원시 레코드를 가져온다(증분 since/cursor) ──
|
||||
@abstractmethod
|
||||
def fetch(self, session: Session, *, full: bool = False) -> Iterable[RawRecord]: ...
|
||||
|
||||
# ── (2) normalize: provider 페이로드 → 내부 모델 dict ──
|
||||
@abstractmethod
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord: ...
|
||||
|
||||
# ── (3) write: 정규화 레코드를 내부 테이블에 upsert(external_link 멱등) ──
|
||||
@abstractmethod
|
||||
def write(self, session: Session, norm: NormalizedRecord) -> tuple[str, bool]:
|
||||
"""return (entity_id, created) — created=False 면 update/skip."""
|
||||
...
|
||||
|
||||
# ── 도메인이 발행할 연합 이벤트 타입(없으면 None) ──
|
||||
def event_for(self, norm: NormalizedRecord, entity_id: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
# ── (4) sync: fetch→normalize→write 오케스트레이션 + event_bus 발행 ──
|
||||
def sync(self, session: Session, *, full: bool = False) -> SyncResult:
|
||||
from ..automation.event_bus import bus # phase-7 정본 (지연 import)
|
||||
|
||||
res = SyncResult(domain=self.domain, account_id=self.account.id)
|
||||
self._set_state(session, ConnState.syncing)
|
||||
try:
|
||||
for raw in self.fetch(session, full=full):
|
||||
res.seen += 1
|
||||
if self._is_duplicate(session, raw): # etag 동일 → skip(멱등)
|
||||
res.skipped += 1
|
||||
continue
|
||||
norm = self.normalize(raw)
|
||||
entity_id, created = self.write(session, norm)
|
||||
self._upsert_link(session, raw, norm, entity_id)
|
||||
if created:
|
||||
res.upserted += 1
|
||||
evt = self.event_for(norm, entity_id)
|
||||
if evt:
|
||||
bus.publish(
|
||||
evt,
|
||||
{
|
||||
"account_id": self.account.id,
|
||||
"entity_type": norm.entity_type,
|
||||
"entity_id": entity_id,
|
||||
},
|
||||
)
|
||||
res.events_published.append(evt)
|
||||
else:
|
||||
res.skipped += 1
|
||||
session.commit()
|
||||
self._finish_ok(session, res)
|
||||
bus.publish(
|
||||
"connector.synced",
|
||||
{
|
||||
"account_id": self.account.id,
|
||||
"upserted": res.upserted,
|
||||
"skipped": res.skipped,
|
||||
"errors": res.errors,
|
||||
},
|
||||
)
|
||||
except Exception as e: # 부분 실패도 서버 죽지 않음(오프라인 폴백 철학)
|
||||
session.rollback()
|
||||
res.errors += 1
|
||||
res.detail = f"{type(e).__name__}: {e}"
|
||||
self._finish_error(session, res.detail)
|
||||
bus.publish(
|
||||
"connector.error",
|
||||
{"account_id": self.account.id, "detail": res.detail, "state": "error"},
|
||||
)
|
||||
return res
|
||||
|
||||
# ── 공통: 중복/멱등 ──
|
||||
def _link_for(self, session: Session, external_id: str) -> Optional[ExternalLink]:
|
||||
return session.exec(
|
||||
select(ExternalLink).where(
|
||||
ExternalLink.account_id == self.account.id,
|
||||
ExternalLink.external_id == external_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
def _is_duplicate(self, session: Session, raw: RawRecord) -> bool:
|
||||
link = self._link_for(session, raw.external_id)
|
||||
return bool(link and raw.etag and link.etag == raw.etag)
|
||||
|
||||
def _upsert_link(self, session, raw, norm, entity_id):
|
||||
link = self._link_for(session, raw.external_id)
|
||||
if link:
|
||||
link.etag = norm.etag
|
||||
link.entity_id = entity_id
|
||||
link.external_updated_at = norm.external_updated_at
|
||||
session.add(link)
|
||||
else:
|
||||
xlid = (
|
||||
"xl-"
|
||||
+ hashlib.sha1(f"{self.account.id}:{raw.external_id}".encode()).hexdigest()[:12]
|
||||
)
|
||||
session.add(
|
||||
ExternalLink(
|
||||
id=xlid,
|
||||
account_id=self.account.id,
|
||||
external_id=raw.external_id,
|
||||
entity_type=norm.entity_type,
|
||||
entity_id=entity_id,
|
||||
etag=norm.etag,
|
||||
external_updated_at=norm.external_updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
# ── 상태/커서/로그 ──
|
||||
def _state_row(self, session) -> ConnectorSyncState:
|
||||
st = session.get(ConnectorSyncState, f"cs-{self.account.id}")
|
||||
if not st:
|
||||
st = ConnectorSyncState(id=f"cs-{self.account.id}", account_id=self.account.id)
|
||||
session.add(st)
|
||||
return st
|
||||
|
||||
def _set_state(self, session, state: ConnState):
|
||||
self.account.state = state
|
||||
self.account.updated_at = _now()
|
||||
session.add(self.account)
|
||||
session.commit()
|
||||
|
||||
def _finish_ok(self, session, res: SyncResult):
|
||||
self.account.state = ConnState.connected
|
||||
self.account.last_synced_at = _now()
|
||||
if res.upserted or res.seen:
|
||||
self.account.last_label = "방금 동기화"
|
||||
self.account.error_detail = ""
|
||||
st = self._state_row(session)
|
||||
st.items_seen += res.seen
|
||||
st.items_upserted += res.upserted
|
||||
st.last_delta_sync_at = _now()
|
||||
session.add_all([self.account, st])
|
||||
session.commit()
|
||||
self._log(session, "sync", f"upserted={res.upserted} skipped={res.skipped}", res.upserted)
|
||||
|
||||
def _finish_error(self, session, detail: str):
|
||||
self.account.state = ConnState.error
|
||||
self.account.error_detail = detail
|
||||
self.account.last_label = "동기화 실패 · 다시 시도"
|
||||
session.add(self.account)
|
||||
session.commit()
|
||||
self._log(session, "error", detail)
|
||||
|
||||
def _log(self, session, action: str, detail: str = "", items: int = 0):
|
||||
import uuid
|
||||
|
||||
from ..models import ConnectorAccountLog
|
||||
|
||||
session.add(
|
||||
ConnectorAccountLog(
|
||||
id="cl-" + uuid.uuid4().hex[:8],
|
||||
account_id=self.account.id,
|
||||
action=action,
|
||||
detail=detail,
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
@ -0,0 +1,2 @@
|
||||
# backend/app/connectors/calendar/__init__.py — phase-13 calendar sync 프레임워크
|
||||
# (phase-8 의 읽기 커넥터는 connectors/calendar_mock.py·calendar_real.py 에 그대로 둔다)
|
||||
@ -0,0 +1,94 @@
|
||||
# backend/app/connectors/calendar/ics_import.py — .ics 파일 임포트(로컬 우선, OAuth 불필요)
|
||||
# 의존성 없이 VEVENT 를 직접 파싱 → cal_event 정규화로 귀결.
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_event, upsert_event
|
||||
|
||||
|
||||
def _unfold(text: str) -> list[str]:
|
||||
"""RFC5545 라인 언폴딩(다음 줄이 공백/탭으로 시작하면 이어붙임)."""
|
||||
out: list[str] = []
|
||||
for line in text.replace("\r\n", "\n").split("\n"):
|
||||
if line[:1] in (" ", "\t") and out:
|
||||
out[-1] += line[1:]
|
||||
else:
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def _prop(line: str) -> tuple[str, str]:
|
||||
# "DTSTART;TZID=Asia/Seoul:20260610T090000" → ("DTSTART", "20260610T090000")
|
||||
name, _, value = line.partition(":")
|
||||
return name.split(";", 1)[0].upper(), value.strip()
|
||||
|
||||
|
||||
def parse_ics(text: str) -> list[dict]:
|
||||
events: list[dict] = []
|
||||
cur: dict | None = None
|
||||
for line in _unfold(text):
|
||||
key, val = _prop(line)
|
||||
if key == "BEGIN" and val == "VEVENT":
|
||||
cur = {}
|
||||
elif key == "END" and val == "VEVENT":
|
||||
if cur is not None:
|
||||
events.append(cur)
|
||||
cur = None
|
||||
elif cur is not None:
|
||||
if key == "UID":
|
||||
cur["id"] = val
|
||||
elif key == "SUMMARY":
|
||||
cur["title"] = val
|
||||
elif key == "LOCATION":
|
||||
cur["loc"] = val
|
||||
elif key == "DESCRIPTION":
|
||||
cur["note"] = val
|
||||
elif key == "DTSTART":
|
||||
cur["_start_raw"] = val
|
||||
elif key == "DTEND":
|
||||
cur["_end_raw"] = val
|
||||
for e in events:
|
||||
sr, er = e.pop("_start_raw", ""), e.pop("_end_raw", "")
|
||||
e["day"] = int(sr[6:8]) if len(sr) >= 8 and sr[6:8].isdigit() else 0
|
||||
e["start"] = f"{sr[9:11]}:{sr[11:13]}" if "T" in sr else ""
|
||||
e["end"] = f"{er[9:11]}:{er[11:13]}" if "T" in er else ""
|
||||
e["cal"] = "work"
|
||||
e.setdefault("id", f"ics-{e.get('title', '')[:8]}-{sr}")
|
||||
return events
|
||||
|
||||
|
||||
class IcsConnector(BaseConnector):
|
||||
domain = "calendar"
|
||||
entity_type = "event"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
for p in getattr(self, "_events", []):
|
||||
yield RawRecord(
|
||||
external_id=p["id"],
|
||||
payload=p,
|
||||
etag=f"{p.get('start', '')}-{p.get('title', '')}",
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_event(self.account, raw, provider="ics")
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_event(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "calendar.updated"
|
||||
|
||||
def import_bytes(self, session: Session, content: bytes, filename: str = "") -> dict:
|
||||
text = content.decode("utf-8", "ignore")
|
||||
self._events = parse_ics(text)
|
||||
res = self.sync(session, full=True)
|
||||
return {
|
||||
"entity_type": "event",
|
||||
"imported": res.upserted,
|
||||
"skipped": res.skipped,
|
||||
"errors": res.errors,
|
||||
"detail": f"{filename or '.ics'} · 이벤트 {res.upserted}건 정규화 완료",
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
# backend/app/connectors/calendar/mock.py — 시드 기반 calendar sync (phase-13)
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_event, upsert_event
|
||||
|
||||
_SIM = [
|
||||
{
|
||||
"id": "sim-e1",
|
||||
"day": 10,
|
||||
"start": "16:00",
|
||||
"end": "16:30",
|
||||
"title": "현우님 일정 제안 — 온보딩 핸드오프",
|
||||
"cal": "work",
|
||||
"loc": "Figma",
|
||||
"note": "",
|
||||
"soon": False,
|
||||
"people": "현우",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class MockCalendarConnector(BaseConnector):
|
||||
domain = "calendar"
|
||||
entity_type = "event"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
for p in getattr(self, "_sim_payloads", []):
|
||||
yield RawRecord(
|
||||
external_id=p["id"],
|
||||
payload=p,
|
||||
etag=f"{p.get('start', '')}-{p.get('title', '')}",
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
def simulate_new(self) -> "MockCalendarConnector":
|
||||
self._sim_payloads = _SIM
|
||||
return self
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_event(self.account, raw)
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_event(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "calendar.updated"
|
||||
@ -0,0 +1,86 @@
|
||||
# backend/app/connectors/calendar/normalize.py — provider → cal_event(phase-8) 정규화 + upsert
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ...models import CalEvent, ExternalLink
|
||||
from ..base import NormalizedRecord, RawRecord
|
||||
|
||||
# Google Calendar 카테고리 추정 → 내부 calendar.id (없으면 work)
|
||||
_VALID_CALS = {"work", "meeting", "personal", "team", "health"}
|
||||
|
||||
|
||||
def _hhmm(iso: str) -> str:
|
||||
# "2026-06-10T09:30:00+09:00" → "09:30"
|
||||
if "T" in iso:
|
||||
return iso.split("T", 1)[1][:5]
|
||||
return ""
|
||||
|
||||
|
||||
def _day(iso: str) -> int:
|
||||
# "2026-06-10..." → 10
|
||||
try:
|
||||
return int(iso[8:10])
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
|
||||
def normalize_event(account, raw: RawRecord, provider: str = "mock") -> NormalizedRecord:
|
||||
p = raw.payload
|
||||
if provider == "google_calendar":
|
||||
start = p.get("start", {}).get("dateTime", p.get("start", {}).get("date", ""))
|
||||
end = p.get("end", {}).get("dateTime", p.get("end", {}).get("date", ""))
|
||||
cal = (p.get("_cal") or "work").lower()
|
||||
fields = {
|
||||
"day": _day(start),
|
||||
"start": _hhmm(start),
|
||||
"end": _hhmm(end),
|
||||
"title": p.get("summary", "(제목 없음)"),
|
||||
"cal": cal if cal in _VALID_CALS else "work",
|
||||
"loc": p.get("location", ""),
|
||||
"note": p.get("description", ""),
|
||||
"soon": False,
|
||||
"people": ", ".join(
|
||||
a.get("email", "") for a in p.get("attendees", []) if a.get("email")
|
||||
),
|
||||
}
|
||||
else: # mock / ics — 이미 내부 키와 유사(day/start/end/title/cal/loc/note/soon/people)
|
||||
cal = (p.get("cal") or "work").lower()
|
||||
fields = {
|
||||
"day": int(p.get("day", 0)),
|
||||
"start": p.get("start", ""),
|
||||
"end": p.get("end", ""),
|
||||
"title": p.get("title", ""),
|
||||
"cal": cal if cal in _VALID_CALS else "work",
|
||||
"loc": p.get("loc", ""),
|
||||
"note": p.get("note", ""),
|
||||
"soon": bool(p.get("soon", False)),
|
||||
"people": p.get("people", ""),
|
||||
}
|
||||
return NormalizedRecord(
|
||||
entity_type="event",
|
||||
external_id=raw.external_id,
|
||||
fields=fields,
|
||||
etag=raw.etag,
|
||||
external_updated_at=raw.external_updated_at,
|
||||
)
|
||||
|
||||
|
||||
def upsert_event(session: Session, norm: NormalizedRecord) -> tuple[str, bool]:
|
||||
link = session.exec(
|
||||
select(ExternalLink).where(
|
||||
ExternalLink.external_id == norm.external_id,
|
||||
ExternalLink.entity_type == "event",
|
||||
)
|
||||
).first()
|
||||
if link:
|
||||
ev = session.get(CalEvent, link.entity_id)
|
||||
if ev:
|
||||
for k, v in norm.fields.items():
|
||||
if v is not None and hasattr(ev, k):
|
||||
setattr(ev, k, v)
|
||||
session.add(ev)
|
||||
return ev.id, False
|
||||
eid = "ce-" + uuid.uuid4().hex[:8]
|
||||
session.add(CalEvent(id=eid, **{k: v for k, v in norm.fields.items() if v is not None}))
|
||||
return eid, True
|
||||
@ -0,0 +1,60 @@
|
||||
# backend/app/connectors/calendar/real_google.py — Google Calendar API sync (phase-13)
|
||||
# 증분: syncToken(델타). 410 GONE 시 full resync 후 새 토큰 획득.
|
||||
import httpx
|
||||
from sqlmodel import Session
|
||||
|
||||
from ...config import get_settings
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from ..oauth import valid_access_token
|
||||
from ..ratelimit import RateLimiter
|
||||
from .normalize import normalize_event, upsert_event
|
||||
|
||||
API = "https://www.googleapis.com/calendar/v3"
|
||||
|
||||
|
||||
class GoogleCalendarConnector(BaseConnector):
|
||||
domain = "calendar"
|
||||
entity_type = "event"
|
||||
_rl = RateLimiter(rate=5, per=1.0)
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
st = self._state_row(session)
|
||||
token = valid_access_token(session, self.account)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
cfg = get_settings()
|
||||
params = {"maxResults": cfg.sync_page_size, "singleEvents": "true"}
|
||||
if st.cursor and not full:
|
||||
params["syncToken"] = st.cursor
|
||||
with httpx.Client(timeout=cfg.connector_http_timeout) as c:
|
||||
self._rl.acquire()
|
||||
r = c.get(f"{API}/calendars/primary/events", headers=headers, params=params)
|
||||
if r.status_code == 410: # syncToken 만료 → full resync
|
||||
st.cursor = ""
|
||||
params.pop("syncToken", None)
|
||||
self._rl.acquire()
|
||||
r = c.get(f"{API}/calendars/primary/events", headers=headers, params=params)
|
||||
self._rl.handle_response(r)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
for ev in body.get("items", []):
|
||||
if ev.get("status") == "cancelled":
|
||||
continue
|
||||
yield RawRecord(
|
||||
external_id=ev["id"],
|
||||
payload=ev,
|
||||
etag=ev.get("etag", ""),
|
||||
external_updated_at=None,
|
||||
)
|
||||
if body.get("nextSyncToken"):
|
||||
st.cursor = body["nextSyncToken"]
|
||||
session.add(st)
|
||||
session.commit()
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_event(self.account, raw, provider="google_calendar")
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_event(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "calendar.updated"
|
||||
@ -0,0 +1,27 @@
|
||||
# backend/app/connectors/calendar_mock.py
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..models import CalEvent, Meeting
|
||||
from .base import CalendarConnector
|
||||
|
||||
|
||||
class MockCalendarConnector(CalendarConnector):
|
||||
"""시드(seed_calendar.py)로 적재된 DB 를 그대로 읽는다. STT/외부 API 없음."""
|
||||
|
||||
def list_events(self, s: Session, day=None):
|
||||
q = select(CalEvent)
|
||||
if day is not None:
|
||||
q = q.where(CalEvent.day == day)
|
||||
rows = s.exec(q.order_by(CalEvent.day, CalEvent.start)).all()
|
||||
return [r.model_dump() for r in rows]
|
||||
|
||||
def get_meeting(self, s: Session, meeting_id: str):
|
||||
m = s.get(Meeting, meeting_id)
|
||||
return m.model_dump() if m else None
|
||||
|
||||
def write_event(self, s: Session, payload: dict):
|
||||
# mock: DB 에만 저장(외부 동기화 없음). phase-13 real 이 Google API 호출.
|
||||
ev = CalEvent(**payload)
|
||||
s.add(ev)
|
||||
s.commit()
|
||||
return ev.model_dump()
|
||||
@ -0,0 +1,16 @@
|
||||
# backend/app/connectors/calendar_real.py (phase-13 목표 — 지금은 stub)
|
||||
from .base import CalendarConnector
|
||||
|
||||
|
||||
class RealCalendarConnector(CalendarConnector):
|
||||
"""Google Calendar API 연동 (phase-13). 지금은 NotImplemented stub.
|
||||
동일 인터페이스라 페이지/서비스 코드는 그대로 유지된다."""
|
||||
|
||||
def list_events(self, s, day=None):
|
||||
raise NotImplementedError("phase-13: Google Calendar 연동")
|
||||
|
||||
def get_meeting(self, s, meeting_id):
|
||||
raise NotImplementedError("phase-13")
|
||||
|
||||
def write_event(self, s, payload):
|
||||
raise NotImplementedError("phase-13")
|
||||
@ -0,0 +1,23 @@
|
||||
# backend/app/connectors/chat/__init__.py
|
||||
# 읽기-전용 메신저(멘션) 커넥터(phase-9). phase-13 sync(chat/mock.py)와 공존.
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ...models import Notification
|
||||
|
||||
|
||||
class ChatConnector(ABC):
|
||||
"""mock=시드 알림(src=chat), real=Slack/Teams(phase-13)."""
|
||||
|
||||
@abstractmethod
|
||||
def list_mentions(self, s: Session) -> list[Notification]: ...
|
||||
|
||||
|
||||
class MockChatConnector(ChatConnector):
|
||||
def list_mentions(self, s):
|
||||
return s.exec(select(Notification).where(Notification.src == "chat")).all()
|
||||
|
||||
|
||||
def get_chat_connector() -> ChatConnector:
|
||||
return MockChatConnector()
|
||||
@ -0,0 +1,44 @@
|
||||
# backend/app/connectors/chat/mock.py — 시드 기반 chat sync (phase-13)
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_chat, upsert_chat
|
||||
|
||||
_SIM = [
|
||||
{
|
||||
"id": "sim-c1",
|
||||
"sender": "minseo",
|
||||
"text": "지우님, 분기 전략 미팅 자료 공유 가능할까요?",
|
||||
"channel": "#팀-운영",
|
||||
"at": "오늘 14:02",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class MockChatConnector(BaseConnector):
|
||||
domain = "chat"
|
||||
entity_type = "chat_message"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
for p in getattr(self, "_sim_payloads", []):
|
||||
yield RawRecord(
|
||||
external_id=p["id"],
|
||||
payload=p,
|
||||
etag=p.get("at", ""),
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
def simulate_new(self) -> "MockChatConnector":
|
||||
self._sim_payloads = _SIM
|
||||
return self
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_chat(self.account, raw)
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_chat(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "chat.message"
|
||||
@ -0,0 +1,45 @@
|
||||
# backend/app/connectors/chat/normalize.py — 메신저 메시지 → chat_message 정규화 + upsert
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ...models import ChatMessage, ExternalLink
|
||||
from ..base import NormalizedRecord, RawRecord
|
||||
|
||||
|
||||
def normalize_chat(account, raw: RawRecord) -> NormalizedRecord:
|
||||
p = raw.payload
|
||||
fields = {
|
||||
"account_id": account.id,
|
||||
"sender": p.get("sender", p.get("from", "")),
|
||||
"text": p.get("text", p.get("message", "")),
|
||||
"channel": p.get("channel", ""),
|
||||
"at": p.get("at", p.get("ts", "방금")),
|
||||
}
|
||||
return NormalizedRecord(
|
||||
entity_type="chat_message",
|
||||
external_id=raw.external_id,
|
||||
fields=fields,
|
||||
etag=raw.etag,
|
||||
external_updated_at=raw.external_updated_at,
|
||||
)
|
||||
|
||||
|
||||
def upsert_chat(session: Session, norm: NormalizedRecord) -> tuple[str, bool]:
|
||||
link = session.exec(
|
||||
select(ExternalLink).where(
|
||||
ExternalLink.external_id == norm.external_id,
|
||||
ExternalLink.entity_type == "chat_message",
|
||||
)
|
||||
).first()
|
||||
if link:
|
||||
cm = session.get(ChatMessage, link.entity_id)
|
||||
if cm:
|
||||
for k, v in norm.fields.items():
|
||||
if v is not None and hasattr(cm, k):
|
||||
setattr(cm, k, v)
|
||||
session.add(cm)
|
||||
return cm.id, False
|
||||
cid = "cm-" + uuid.uuid4().hex[:8]
|
||||
session.add(ChatMessage(id=cid, **{k: v for k, v in norm.fields.items() if v is not None}))
|
||||
return cid, True
|
||||
@ -0,0 +1,35 @@
|
||||
# backend/app/connectors/chat/real_webhook.py — 인바운드 webhook chat sync (phase-13)
|
||||
# OAuth 대신 서명 검증. webhook 이 적재한 페이로드 큐(account 의 _inbound)를 흘린다.
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_chat, upsert_chat
|
||||
|
||||
|
||||
class WebhookChatConnector(BaseConnector):
|
||||
domain = "chat"
|
||||
entity_type = "chat_message"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
for p in getattr(self, "_inbound", []):
|
||||
yield RawRecord(
|
||||
external_id=p.get("id", p.get("ts", "")),
|
||||
payload=p,
|
||||
etag=p.get("ts", ""),
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
def feed(self, payloads: list) -> "WebhookChatConnector":
|
||||
self._inbound = payloads
|
||||
return self
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_chat(self.account, raw)
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_chat(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "chat.message"
|
||||
@ -0,0 +1 @@
|
||||
# backend/app/connectors/finance/ — phase-13 finance sync 프레임워크
|
||||
@ -0,0 +1,97 @@
|
||||
# backend/app/connectors/finance/csv_import.py — 카드/계좌 CSV 임포트(로컬 우선, OAuth 불필요)
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_tx, upsert_tx
|
||||
|
||||
# 한국 카드/뱅크 CSV 헤더 별칭 → 표준 키
|
||||
_ALIASES = {
|
||||
"date": ["date", "거래일", "거래일자", "날짜", "이용일", "승인일자"],
|
||||
"merchant": ["merchant", "가맹점", "내용", "적요", "이용하신곳", "가맹점명"],
|
||||
"amount": ["amount", "금액", "이용금액", "승인금액", "출금액", "거래금액"],
|
||||
"category": ["category", "분류", "카테고리", "업종"],
|
||||
}
|
||||
|
||||
|
||||
def _remap(row: dict) -> dict:
|
||||
low = {(k or "").strip().lower().replace(" ", ""): v for k, v in row.items()}
|
||||
out: dict = {}
|
||||
for std, names in _ALIASES.items():
|
||||
for n in names:
|
||||
key = n.lower().replace(" ", "")
|
||||
if key in low and low[key] not in (None, ""):
|
||||
out[std] = low[key]
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
class CsvFinanceConnector(BaseConnector):
|
||||
domain = "finance"
|
||||
entity_type = "finance_tx"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
for p in getattr(self, "_rows", []):
|
||||
ext = (
|
||||
"csv-"
|
||||
+ hashlib.sha1(
|
||||
f"{p.get('date')}|{p.get('merchant')}|{p.get('amount')}".encode()
|
||||
).hexdigest()[:12]
|
||||
)
|
||||
yield RawRecord(
|
||||
external_id=ext,
|
||||
payload=p,
|
||||
etag=str(p.get("amount", "")),
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_tx(self.account, raw)
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_tx(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "finance.transaction"
|
||||
|
||||
def import_bytes(self, session: Session, content: bytes, filename: str = "") -> dict:
|
||||
text = content.decode("utf-8-sig", "ignore")
|
||||
rows: list[dict] = []
|
||||
errors = 0
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
for raw_row in reader:
|
||||
std = _remap(raw_row)
|
||||
if not std.get("merchant") and not std.get("amount"):
|
||||
errors += 1 # 빈/형식 오류 행 skip
|
||||
continue
|
||||
rows.append(std)
|
||||
except csv.Error:
|
||||
errors += 1
|
||||
self._rows = rows
|
||||
res = self.sync(session, full=True)
|
||||
amounts: dict[str, int] = {}
|
||||
for r in rows:
|
||||
from .normalize import categorize
|
||||
|
||||
cat = r.get("category") or categorize(r.get("merchant", ""))
|
||||
try:
|
||||
amt = int(str(r.get("amount", 0)).replace(",", "").replace("원", "").strip() or 0)
|
||||
except ValueError:
|
||||
amt = 0
|
||||
amounts[cat] = amounts.get(cat, 0) + amt
|
||||
top = sorted(amounts.items(), key=lambda x: -x[1])[:2]
|
||||
summary = " · ".join(f"{c} {a:,}" for c, a in top)
|
||||
return {
|
||||
"entity_type": "finance_tx",
|
||||
"imported": res.upserted,
|
||||
"skipped": res.skipped,
|
||||
"errors": res.errors + errors,
|
||||
"detail": (
|
||||
f"{filename or 'CSV'} · {summary} 정규화 완료" if summary else (filename or "CSV")
|
||||
),
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
# backend/app/connectors/finance/mock.py — 시드 기반 finance sync (phase-13)
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_tx, upsert_tx
|
||||
|
||||
_SIM = [
|
||||
{
|
||||
"id": "sim-ftx1",
|
||||
"date": "2026-06-08",
|
||||
"merchant": "스타벅스 강남R점",
|
||||
"amount": 6300,
|
||||
"category": "카페·간식",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class MockFinanceConnector(BaseConnector):
|
||||
domain = "finance"
|
||||
entity_type = "finance_tx"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
for p in getattr(self, "_sim_payloads", []):
|
||||
yield RawRecord(
|
||||
external_id=p["id"],
|
||||
payload=p,
|
||||
etag=str(p.get("amount", "")),
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
def simulate_new(self) -> "MockFinanceConnector":
|
||||
self._sim_payloads = _SIM
|
||||
return self
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_tx(self.account, raw)
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_tx(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "finance.transaction"
|
||||
@ -0,0 +1,71 @@
|
||||
# backend/app/connectors/finance/normalize.py — provider → finance_tx 정규화 + upsert
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ...models import ExternalLink, FinanceTx
|
||||
from ..base import NormalizedRecord, RawRecord
|
||||
|
||||
# 가맹점/내용 키워드 → life-data.js 카테고리 6종
|
||||
_CATS = [
|
||||
("식비", ["식당", "김밥", "백반", "마트", "푸드", "배달", "음식", "고기"]),
|
||||
("카페·간식", ["카페", "커피", "스타벅스", "베이커리", "디저트", "투썸", "이디야"]),
|
||||
("교통", ["택시", "버스", "지하철", "주유", "기차", "ktx", "교통", "카카오t"]),
|
||||
("쇼핑", ["쿠팡", "올리브영", "백화점", "쇼핑", "무신사", "29cm", "패션"]),
|
||||
("구독", ["넷플릭스", "유튜브", "스포티파이", "구독", "멤버십", "프라임"]),
|
||||
("문화·여가", ["영화", "cgv", "공연", "전시", "서점", "교보", "여가", "헬스"]),
|
||||
]
|
||||
|
||||
|
||||
def categorize(merchant: str) -> str:
|
||||
low = (merchant or "").lower()
|
||||
for cat, kws in _CATS:
|
||||
if any(kw in low for kw in kws):
|
||||
return cat
|
||||
return "기타"
|
||||
|
||||
|
||||
def normalize_tx(account, raw: RawRecord) -> NormalizedRecord:
|
||||
p = raw.payload
|
||||
merchant = str(p.get("merchant", p.get("내용", p.get("가맹점", "")))).strip()
|
||||
raw_amt = p.get("amount", p.get("금액", 0))
|
||||
try:
|
||||
amount = int(str(raw_amt).replace(",", "").replace("원", "").strip() or 0)
|
||||
except ValueError:
|
||||
amount = 0
|
||||
category = p.get("category") or p.get("분류") or categorize(merchant)
|
||||
fields = {
|
||||
"account_id": account.id,
|
||||
"date": str(p.get("date", p.get("거래일", p.get("날짜", "")))).strip(),
|
||||
"merchant": merchant,
|
||||
"amount": amount,
|
||||
"category": category,
|
||||
"account_label": account.name,
|
||||
}
|
||||
return NormalizedRecord(
|
||||
entity_type="finance_tx",
|
||||
external_id=raw.external_id,
|
||||
fields=fields,
|
||||
etag=raw.etag,
|
||||
external_updated_at=raw.external_updated_at,
|
||||
)
|
||||
|
||||
|
||||
def upsert_tx(session: Session, norm: NormalizedRecord) -> tuple[str, bool]:
|
||||
link = session.exec(
|
||||
select(ExternalLink).where(
|
||||
ExternalLink.external_id == norm.external_id,
|
||||
ExternalLink.entity_type == "finance_tx",
|
||||
)
|
||||
).first()
|
||||
if link:
|
||||
tx = session.get(FinanceTx, link.entity_id)
|
||||
if tx:
|
||||
for k, v in norm.fields.items():
|
||||
if v is not None and hasattr(tx, k):
|
||||
setattr(tx, k, v)
|
||||
session.add(tx)
|
||||
return tx.id, False
|
||||
tid = "ftx-" + uuid.uuid4().hex[:8]
|
||||
session.add(FinanceTx(id=tid, **{k: v for k, v in norm.fields.items() if v is not None}))
|
||||
return tid, True
|
||||
@ -0,0 +1,45 @@
|
||||
# backend/app/connectors/finance/real_aggregator.py — 카드/뱅크 아그리게이터 sync (phase-13)
|
||||
# 한국 금융 OAuth 아그리게이터는 지역 제약이 커서 CSV 임포트가 1급 시민(csv_import.py).
|
||||
# 아그리게이터 real 은 토큰 기반 REST 를 가정한 얇은 어댑터.
|
||||
import httpx
|
||||
from sqlmodel import Session
|
||||
|
||||
from ...config import get_settings
|
||||
from ...crypto import decrypt_token
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_tx, upsert_tx
|
||||
|
||||
|
||||
class AggregatorConnector(BaseConnector):
|
||||
domain = "finance"
|
||||
entity_type = "finance_tx"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
tok = decrypt_token(self.account.token_enc)
|
||||
if not tok:
|
||||
raise PermissionError("aggregator token missing")
|
||||
base = tok.get("base_url", "")
|
||||
cfg = get_settings()
|
||||
with httpx.Client(timeout=cfg.connector_http_timeout) as c:
|
||||
r = c.get(
|
||||
f"{base}/transactions",
|
||||
headers={"Authorization": f"Bearer {tok.get('access_token', '')}"},
|
||||
params={"limit": cfg.sync_page_size},
|
||||
)
|
||||
r.raise_for_status()
|
||||
for t in r.json().get("transactions", []):
|
||||
yield RawRecord(
|
||||
external_id=str(t.get("id")),
|
||||
payload=t,
|
||||
etag=str(t.get("amount", "")),
|
||||
external_updated_at=None,
|
||||
)
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_tx(self.account, raw)
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_tx(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "finance.transaction"
|
||||
@ -0,0 +1 @@
|
||||
# backend/app/connectors/health/ — phase-13 health sync 프레임워크
|
||||
@ -0,0 +1,66 @@
|
||||
# backend/app/connectors/health/healthkit_import.py — Apple Health export(.xml) 임포트
|
||||
# Apple Health 는 공식 API 가 없어 export.xml(<Record .../>) 임포트가 정본(로컬 우선).
|
||||
import hashlib
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_sample, upsert_sample
|
||||
|
||||
|
||||
def parse_healthkit(text: str) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
try:
|
||||
root = ET.fromstring(text)
|
||||
except ET.ParseError:
|
||||
return out
|
||||
for rec in root.iter("Record"):
|
||||
rtype = rec.get("type", "")
|
||||
start = rec.get("startDate", "")
|
||||
out.append(
|
||||
{
|
||||
"type": rtype,
|
||||
"value": rec.get("value", "0"),
|
||||
"unit": rec.get("unit", ""),
|
||||
"startDate": start,
|
||||
"day": start[:10],
|
||||
"id": "hk-" + hashlib.sha1(f"{rtype}|{start}".encode()).hexdigest()[:12],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class HealthKitConnector(BaseConnector):
|
||||
domain = "health"
|
||||
entity_type = "health_sample"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
for p in getattr(self, "_records", []):
|
||||
yield RawRecord(
|
||||
external_id=p["id"],
|
||||
payload=p,
|
||||
etag=str(p.get("value", "")),
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_sample(self.account, raw, provider="healthkit")
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_sample(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "health.sample"
|
||||
|
||||
def import_bytes(self, session: Session, content: bytes, filename: str = "") -> dict:
|
||||
self._records = parse_healthkit(content.decode("utf-8", "ignore"))
|
||||
res = self.sync(session, full=True)
|
||||
return {
|
||||
"entity_type": "health_sample",
|
||||
"imported": res.upserted,
|
||||
"skipped": res.skipped,
|
||||
"errors": res.errors,
|
||||
"detail": f"{filename or 'HealthKit export'} · 샘플 {res.upserted}건 정규화 완료",
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
# backend/app/connectors/health/mock.py — 시드 기반 health sync (phase-13)
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_sample, upsert_sample
|
||||
|
||||
_SIM = [
|
||||
{"id": "sim-hs1", "kind": "steps", "value": 7240, "unit": "보", "day": "2026-06-08"},
|
||||
]
|
||||
|
||||
|
||||
class MockHealthConnector(BaseConnector):
|
||||
domain = "health"
|
||||
entity_type = "health_sample"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
for p in getattr(self, "_sim_payloads", []):
|
||||
yield RawRecord(
|
||||
external_id=p["id"],
|
||||
payload=p,
|
||||
etag=str(p.get("value", "")),
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
def simulate_new(self) -> "MockHealthConnector":
|
||||
self._sim_payloads = _SIM
|
||||
return self
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_sample(self.account, raw)
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_sample(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "health.sample"
|
||||
@ -0,0 +1,62 @@
|
||||
# backend/app/connectors/health/normalize.py — provider → health_sample 정규화 + upsert
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ...models import ExternalLink, HealthSample
|
||||
from ..base import NormalizedRecord, RawRecord
|
||||
|
||||
# HealthKit type / Google Fit dataType → 내부 kind
|
||||
_KIND_MAP = {
|
||||
"HKQuantityTypeIdentifierStepCount": "steps",
|
||||
"HKQuantityTypeIdentifierActiveEnergyBurned": "exercise",
|
||||
"HKQuantityTypeIdentifierHeartRate": "heart_rate",
|
||||
"HKCategoryTypeIdentifierSleepAnalysis": "sleep",
|
||||
"com.google.step_count.delta": "steps",
|
||||
"com.google.heart_rate.bpm": "heart_rate",
|
||||
}
|
||||
_UNIT = {"steps": "보", "exercise": "kcal", "heart_rate": "bpm", "sleep": "분"}
|
||||
|
||||
|
||||
def normalize_sample(account, raw: RawRecord, provider: str = "mock") -> NormalizedRecord:
|
||||
p = raw.payload
|
||||
kind = _KIND_MAP.get(p.get("type", ""), p.get("kind", "steps"))
|
||||
try:
|
||||
value = float(p.get("value", 0) or 0)
|
||||
except (ValueError, TypeError):
|
||||
value = 0.0
|
||||
day = str(p.get("day", p.get("startDate", "")))[:10]
|
||||
fields = {
|
||||
"account_id": account.id,
|
||||
"kind": kind,
|
||||
"value": value,
|
||||
"unit": p.get("unit") or _UNIT.get(kind, ""),
|
||||
"day": day,
|
||||
}
|
||||
return NormalizedRecord(
|
||||
entity_type="health_sample",
|
||||
external_id=raw.external_id,
|
||||
fields=fields,
|
||||
etag=raw.etag,
|
||||
external_updated_at=raw.external_updated_at,
|
||||
)
|
||||
|
||||
|
||||
def upsert_sample(session: Session, norm: NormalizedRecord) -> tuple[str, bool]:
|
||||
link = session.exec(
|
||||
select(ExternalLink).where(
|
||||
ExternalLink.external_id == norm.external_id,
|
||||
ExternalLink.entity_type == "health_sample",
|
||||
)
|
||||
).first()
|
||||
if link:
|
||||
hs = session.get(HealthSample, link.entity_id)
|
||||
if hs:
|
||||
for k, v in norm.fields.items():
|
||||
if v is not None and hasattr(hs, k):
|
||||
setattr(hs, k, v)
|
||||
session.add(hs)
|
||||
return hs.id, False
|
||||
hid = "hs-" + uuid.uuid4().hex[:8]
|
||||
session.add(HealthSample(id=hid, **{k: v for k, v in norm.fields.items() if v is not None}))
|
||||
return hid, True
|
||||
@ -0,0 +1,41 @@
|
||||
# backend/app/connectors/health/real_fit.py — Google Fit API sync (phase-13)
|
||||
import httpx
|
||||
from sqlmodel import Session
|
||||
|
||||
from ...config import get_settings
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from ..oauth import valid_access_token
|
||||
from .normalize import normalize_sample, upsert_sample
|
||||
|
||||
API = "https://www.googleapis.com/fitness/v1/users/me/dataSources"
|
||||
|
||||
|
||||
class GoogleFitConnector(BaseConnector):
|
||||
domain = "health"
|
||||
entity_type = "health_sample"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
token = valid_access_token(session, self.account)
|
||||
cfg = get_settings()
|
||||
with httpx.Client(timeout=cfg.connector_http_timeout) as c:
|
||||
r = c.get(API, headers={"Authorization": f"Bearer {token}"})
|
||||
r.raise_for_status()
|
||||
for ds in r.json().get("dataSource", []):
|
||||
yield RawRecord(
|
||||
external_id=ds.get("dataStreamId", ""),
|
||||
payload={
|
||||
"type": ds.get("dataType", {}).get("name", ""),
|
||||
"value": 0,
|
||||
},
|
||||
etag=ds.get("dataStreamId", ""),
|
||||
external_updated_at=None,
|
||||
)
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_sample(self.account, raw, provider="google_fit")
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_sample(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "health.sample"
|
||||
@ -0,0 +1 @@
|
||||
# backend/app/connectors/knowledge/ — phase-13 knowledge sync 프레임워크
|
||||
@ -0,0 +1,52 @@
|
||||
# backend/app/connectors/knowledge/clipper.py — 웹 클리퍼 / Markdown 노트 임포트(로컬 우선)
|
||||
import hashlib
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_knowledge, upsert_knowledge
|
||||
|
||||
|
||||
class ClipperConnector(BaseConnector):
|
||||
domain = "knowledge"
|
||||
entity_type = "knowledge_item"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
for p in getattr(self, "_items", []):
|
||||
yield RawRecord(
|
||||
external_id=p["id"],
|
||||
payload=p,
|
||||
etag=p.get("title", ""),
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_knowledge(self.account, raw, provider="clipper")
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_knowledge(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "knowledge.ingested"
|
||||
|
||||
def import_bytes(self, session: Session, content: bytes, filename: str = "") -> dict:
|
||||
text = content.decode("utf-8", "ignore")
|
||||
title = next((ln.lstrip("# ").strip() for ln in text.split("\n") if ln.strip()), filename)
|
||||
self._items = [
|
||||
{
|
||||
"id": "clip-" + hashlib.sha1(text.encode()).hexdigest()[:12],
|
||||
"type": "article",
|
||||
"title": title or "스크랩",
|
||||
"src": filename or "웹 클리퍼",
|
||||
"excerpt": text[:280],
|
||||
}
|
||||
]
|
||||
res = self.sync(session, full=True)
|
||||
return {
|
||||
"entity_type": "knowledge_item",
|
||||
"imported": res.upserted,
|
||||
"skipped": res.skipped,
|
||||
"errors": res.errors,
|
||||
"detail": f"{filename or '스크랩'} · 지식 {res.upserted}건 적재",
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
# backend/app/connectors/knowledge/mock.py — 시드 기반 knowledge sync (phase-13)
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_knowledge, upsert_knowledge
|
||||
|
||||
_SIM = [
|
||||
{
|
||||
"id": "sim-k1",
|
||||
"type": "highlight",
|
||||
"title": "온보딩 리텐션 — 첫 7일이 전부다",
|
||||
"src": "Readwise",
|
||||
"excerpt": "신규 사용자의 80%는 첫 주에 이탈 여부가 결정된다…",
|
||||
"tags": ["온보딩", "리텐션"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class MockKnowledgeConnector(BaseConnector):
|
||||
domain = "knowledge"
|
||||
entity_type = "knowledge_item"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
for p in getattr(self, "_sim_payloads", []):
|
||||
yield RawRecord(
|
||||
external_id=p["id"],
|
||||
payload=p,
|
||||
etag=p.get("title", ""),
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
def simulate_new(self) -> "MockKnowledgeConnector":
|
||||
self._sim_payloads = _SIM
|
||||
return self
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_knowledge(self.account, raw)
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_knowledge(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "knowledge.ingested"
|
||||
@ -0,0 +1,64 @@
|
||||
# backend/app/connectors/knowledge/normalize.py — provider → knowledge_item(phase-10/11) 정규화
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ...models import ExternalLink, KnowledgeItem, KnowledgeType
|
||||
from ..base import NormalizedRecord, RawRecord
|
||||
|
||||
_TYPE_ICON = {
|
||||
"article": "file",
|
||||
"note": "pen",
|
||||
"idea": "spark",
|
||||
"highlight": "quote",
|
||||
}
|
||||
# provider → 기본 KnowledgeType
|
||||
_PROVIDER_TYPE = {"notion": "note", "readwise": "highlight", "clipper": "article"}
|
||||
|
||||
|
||||
def normalize_knowledge(account, raw: RawRecord, provider: str = "mock") -> NormalizedRecord:
|
||||
p = raw.payload
|
||||
ktype = p.get("type") or _PROVIDER_TYPE.get(provider, "article")
|
||||
if ktype not in _TYPE_ICON:
|
||||
ktype = "article"
|
||||
tags = p.get("tags", [])
|
||||
fields = {
|
||||
"type": KnowledgeType(ktype),
|
||||
"title": p.get("title", "(제목 없음)"),
|
||||
"src": p.get("src", account.name),
|
||||
"time": p.get("time", "방금"),
|
||||
"tone": p.get("tone", account.tone),
|
||||
"icon": p.get("icon", _TYPE_ICON.get(ktype, "file")),
|
||||
"tags_json": json.dumps(tags, ensure_ascii=False) if isinstance(tags, list) else "[]",
|
||||
"excerpt": p.get("excerpt", p.get("text", "")),
|
||||
"ai": p.get("ai", ""),
|
||||
"connector_id": None,
|
||||
}
|
||||
return NormalizedRecord(
|
||||
entity_type="knowledge_item",
|
||||
external_id=raw.external_id,
|
||||
fields=fields,
|
||||
etag=raw.etag,
|
||||
external_updated_at=raw.external_updated_at,
|
||||
)
|
||||
|
||||
|
||||
def upsert_knowledge(session: Session, norm: NormalizedRecord) -> tuple[str, bool]:
|
||||
link = session.exec(
|
||||
select(ExternalLink).where(
|
||||
ExternalLink.external_id == norm.external_id,
|
||||
ExternalLink.entity_type == "knowledge_item",
|
||||
)
|
||||
).first()
|
||||
if link:
|
||||
ki = session.get(KnowledgeItem, link.entity_id)
|
||||
if ki:
|
||||
for k, v in norm.fields.items():
|
||||
if v is not None and hasattr(ki, k):
|
||||
setattr(ki, k, v)
|
||||
session.add(ki)
|
||||
return ki.id, False
|
||||
kid = "k-" + uuid.uuid4().hex[:8]
|
||||
session.add(KnowledgeItem(id=kid, **{k: v for k, v in norm.fields.items() if v is not None}))
|
||||
return kid, True
|
||||
@ -0,0 +1,57 @@
|
||||
# backend/app/connectors/knowledge/real_notion.py — Notion API sync (phase-13)
|
||||
import httpx
|
||||
from sqlmodel import Session
|
||||
|
||||
from ...config import get_settings
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from ..oauth import valid_access_token
|
||||
from .normalize import normalize_knowledge, upsert_knowledge
|
||||
|
||||
API = "https://api.notion.com/v1"
|
||||
|
||||
|
||||
def _plain_title(page: dict) -> str:
|
||||
for prop in page.get("properties", {}).values():
|
||||
if prop.get("type") == "title":
|
||||
return "".join(t.get("plain_text", "") for t in prop.get("title", []))
|
||||
return "(제목 없음)"
|
||||
|
||||
|
||||
class NotionConnector(BaseConnector):
|
||||
domain = "knowledge"
|
||||
entity_type = "knowledge_item"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
token = valid_access_token(session, self.account)
|
||||
cfg = get_settings()
|
||||
with httpx.Client(timeout=cfg.connector_http_timeout) as c:
|
||||
r = c.post(
|
||||
f"{API}/search",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Notion-Version": "2022-06-28",
|
||||
},
|
||||
json={"page_size": cfg.sync_page_size},
|
||||
)
|
||||
r.raise_for_status()
|
||||
for page in r.json().get("results", []):
|
||||
yield RawRecord(
|
||||
external_id=page.get("id", ""),
|
||||
payload={
|
||||
"type": "note",
|
||||
"title": _plain_title(page),
|
||||
"src": "Notion",
|
||||
"time": page.get("last_edited_time", "")[:10],
|
||||
},
|
||||
etag=page.get("last_edited_time", ""),
|
||||
external_updated_at=None,
|
||||
)
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_knowledge(self.account, raw, provider="notion")
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_knowledge(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "knowledge.ingested"
|
||||
@ -0,0 +1,51 @@
|
||||
# backend/app/connectors/life_domains.py
|
||||
# health/finance/knowledge 커넥터(mock + real stub) + registry.
|
||||
from ..config import get_settings
|
||||
from .lifebase import ConnectorBase
|
||||
|
||||
|
||||
class _MockLifeConnector(ConnectorBase):
|
||||
"""시드 기반. 실제 데이터는 seed_life 가 DB 에 적재. sync 는 no-op."""
|
||||
|
||||
def __init__(self, domain: str):
|
||||
self.domain = domain
|
||||
|
||||
def list_sources(self):
|
||||
from ..seed_life import DOMAIN_SOURCES
|
||||
|
||||
return [
|
||||
{"id": r[0], "name": r[1], "kind": r[2], "tone": r[3], "on": r[4], "last": r[5]}
|
||||
for r in DOMAIN_SOURCES[self.domain]
|
||||
]
|
||||
|
||||
def sync(self, session):
|
||||
return {"ok": True, "provider": "mock", "domain": self.domain}
|
||||
|
||||
def health(self):
|
||||
return {"reachable": True, "detail": f"mock {self.domain} connector"}
|
||||
|
||||
|
||||
class _RealLifeConnector(ConnectorBase):
|
||||
"""phase-13: Apple Health/금융 아그리게이터/Notion 등 실연동."""
|
||||
|
||||
def __init__(self, domain: str):
|
||||
self.domain = domain
|
||||
|
||||
def list_sources(self):
|
||||
raise NotImplementedError("phase-13")
|
||||
|
||||
def sync(self, session):
|
||||
raise NotImplementedError("phase-13")
|
||||
|
||||
def health(self):
|
||||
return {"reachable": False, "detail": f"real {self.domain} not configured"}
|
||||
|
||||
|
||||
def get_connector(domain: str) -> ConnectorBase:
|
||||
s = get_settings()
|
||||
mode = {
|
||||
"health": s.connector_health,
|
||||
"finance": s.connector_finance,
|
||||
"knowledge": s.connector_knowledge,
|
||||
}[domain]
|
||||
return _RealLifeConnector(domain) if mode == "real" else _MockLifeConnector(domain)
|
||||
@ -0,0 +1,16 @@
|
||||
# backend/app/connectors/lifebase.py
|
||||
# 라이프 도메인(health/finance/knowledge) 커넥터 base. mock-first, phase-13 real.
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class ConnectorBase(ABC):
|
||||
domain: str = ""
|
||||
|
||||
@abstractmethod
|
||||
def list_sources(self) -> list[dict]: ...
|
||||
|
||||
@abstractmethod
|
||||
def sync(self, session) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
def health(self) -> dict: ...
|
||||
@ -0,0 +1,57 @@
|
||||
# backend/app/connectors/mail/__init__.py
|
||||
# 읽기-전용 메일 커넥터(phase-9). 페이지(routers/mail.py)가 의존.
|
||||
# phase-13 sync 프레임워크(mail/mock.py, mail/real_gmail.py)와 공존한다.
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ...models import Email, Sent
|
||||
|
||||
|
||||
class MailConnector(ABC):
|
||||
"""phase-13 이 RealConnector(Gmail/Workspace/HEY API)로 같은 인터페이스를 끼운다."""
|
||||
|
||||
@abstractmethod
|
||||
def list_inbox(self, s: Session, account: str | None) -> list[Email]: ...
|
||||
|
||||
@abstractmethod
|
||||
def get(self, s: Session, email_id: str) -> Email | None: ...
|
||||
|
||||
@abstractmethod
|
||||
def send(self, s: Session, *, from_account: str, to: str, subject: str, body: str) -> str: ...
|
||||
|
||||
|
||||
class MockMailConnector(MailConnector):
|
||||
"""시드 기반 읽기. CONNECTOR_MAIL=mock (기본)."""
|
||||
|
||||
def list_inbox(self, s, account):
|
||||
q = select(Email).where(Email.archived == False).order_by(Email.sort_order) # noqa: E712
|
||||
if account:
|
||||
q = q.where(Email.account == account)
|
||||
return s.exec(q).all()
|
||||
|
||||
def get(self, s, email_id):
|
||||
return s.get(Email, email_id)
|
||||
|
||||
def send(self, s, *, from_account, to, subject, body):
|
||||
sid = "snt-" + uuid.uuid4().hex[:8]
|
||||
s.add(
|
||||
Sent(
|
||||
id=sid,
|
||||
account=from_account,
|
||||
to=to,
|
||||
subject=subject,
|
||||
time="방금",
|
||||
date="오늘",
|
||||
preview=body[:40],
|
||||
)
|
||||
)
|
||||
s.commit()
|
||||
return sid
|
||||
|
||||
|
||||
def get_mail_connector() -> MailConnector:
|
||||
"""페이지 읽기용 커넥터. real 모드도 읽기는 시드 DB 를 그대로 본다
|
||||
(실데이터 유입은 phase-13 sync 프레임워크가 같은 email 테이블에 적재)."""
|
||||
return MockMailConnector()
|
||||
@ -0,0 +1,51 @@
|
||||
# backend/app/connectors/mail/mock.py — 시드 기반 sync 커넥터(기본값) (phase-13)
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_email, upsert_email
|
||||
|
||||
# "새로 도착할" 시뮬레이션 메일(연합 데모용) — mail-data.js 톤
|
||||
_SIM = [
|
||||
{
|
||||
"id": "sim-m1",
|
||||
"account": "work",
|
||||
"from": "hyunwoo",
|
||||
"to": "나",
|
||||
"subject": "온보딩 시안 v3 — 금요일까지 피드백 부탁드려요",
|
||||
"date": "오늘 09:12",
|
||||
"read": False,
|
||||
"starred": False,
|
||||
"preview": "지우님, 리뷰에서 나온 코멘트 반영해서 v3 정리했어요…",
|
||||
"labels": ["디자인"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class MockMailConnector(BaseConnector):
|
||||
domain = "mail"
|
||||
entity_type = "email"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
for p in getattr(self, "_sim_payloads", []):
|
||||
yield RawRecord(
|
||||
external_id=p["id"],
|
||||
payload=p,
|
||||
etag=p.get("date", ""),
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
def simulate_new(self) -> "MockMailConnector":
|
||||
"""데모: '새 메일 도착' 1건을 다음 sync 에 흘린다(연합 트리거)."""
|
||||
self._sim_payloads = _SIM
|
||||
return self
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_email(self.account, raw)
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_email(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "mail.received"
|
||||
@ -0,0 +1,124 @@
|
||||
# backend/app/connectors/mail/normalize.py
|
||||
# provider(mock/gmail/imap) → 내부 email 모델(phase-9) 정규화 + 멱등 upsert.
|
||||
import base64
|
||||
import uuid
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ...models import ConnectorAccount, Email, ExternalLink
|
||||
from ..base import NormalizedRecord, RawRecord
|
||||
|
||||
# connector_account id ↔ mail_account id 매핑(시드 정합)
|
||||
_ACCOUNT_KEY = {
|
||||
"ca-mail-work": "work",
|
||||
"ca-mail-personal": "personal",
|
||||
"ca-mail-side": "side",
|
||||
}
|
||||
# Gmail labelId → 내부 한국어 라벨
|
||||
_LABEL_MAP = {
|
||||
"CATEGORY_PERSONAL": "개인",
|
||||
"CATEGORY_PROMOTIONS": "프로모션",
|
||||
"CATEGORY_UPDATES": "업데이트",
|
||||
"CATEGORY_FORUMS": "포럼",
|
||||
"CATEGORY_SOCIAL": "소셜",
|
||||
"IMPORTANT": "중요",
|
||||
}
|
||||
|
||||
|
||||
def _gmail_headers(p: dict) -> dict:
|
||||
return {h["name"].lower(): h["value"] for h in p.get("payload", {}).get("headers", [])}
|
||||
|
||||
|
||||
def _gmail_body(p: dict) -> list:
|
||||
"""MIME 파트에서 text/plain 본문을 base64url 디코딩(없으면 snippet)."""
|
||||
payload = p.get("payload", {})
|
||||
parts = payload.get("parts") or [payload]
|
||||
for part in parts:
|
||||
if part.get("mimeType", "").startswith("text/"):
|
||||
data = part.get("body", {}).get("data")
|
||||
if data:
|
||||
try:
|
||||
text = base64.urlsafe_b64decode(data + "===").decode("utf-8", "ignore")
|
||||
return [ln for ln in text.split("\n") if ln.strip()]
|
||||
except Exception:
|
||||
pass
|
||||
snip = p.get("snippet", "")
|
||||
return [snip] if snip else []
|
||||
|
||||
|
||||
def _gmail_labels(label_ids: list) -> list:
|
||||
return [_LABEL_MAP[lid] for lid in label_ids if lid in _LABEL_MAP]
|
||||
|
||||
|
||||
def _gmail_has_attach(p: dict) -> bool:
|
||||
for part in p.get("payload", {}).get("parts", []) or []:
|
||||
if part.get("filename"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def normalize_email(
|
||||
account: ConnectorAccount, raw: RawRecord, provider: str = "mock"
|
||||
) -> NormalizedRecord:
|
||||
p = raw.payload
|
||||
if provider == "gmail":
|
||||
headers = _gmail_headers(p)
|
||||
label_ids = p.get("labelIds", [])
|
||||
fields = {
|
||||
"account": _ACCOUNT_KEY.get(account.id, "work"),
|
||||
"from_key": headers.get("from", ""),
|
||||
"to": headers.get("to", "나"),
|
||||
"subject": headers.get("subject", "(제목 없음)"),
|
||||
"body": _gmail_body(p),
|
||||
"labels": _gmail_labels(label_ids),
|
||||
"read": "UNREAD" not in label_ids,
|
||||
"starred": "STARRED" in label_ids,
|
||||
"has_attach": _gmail_has_attach(p),
|
||||
"preview": p.get("snippet", ""),
|
||||
"date": "방금",
|
||||
"ai_json": {}, # phase-9 메일 분석(로컬 LLM)이 후처리로 채움
|
||||
}
|
||||
else: # mock(mail-data.js shape) / imap
|
||||
body = p.get("body", [])
|
||||
fields = {
|
||||
"account": _ACCOUNT_KEY.get(account.id, p.get("account", "work")),
|
||||
"from_key": p.get("from", p.get("from_key", "")),
|
||||
"to": p.get("to", "나"),
|
||||
"subject": p.get("subject", ""),
|
||||
"body": body if isinstance(body, list) else [body],
|
||||
"labels": p.get("labels", []),
|
||||
"read": p.get("read", False),
|
||||
"starred": p.get("starred", False),
|
||||
"has_attach": p.get("hasAttach", p.get("has_attach", False)),
|
||||
"preview": p.get("preview", ""),
|
||||
"date": p.get("date", "방금"),
|
||||
"ai_json": p.get("ai", {}) or {},
|
||||
}
|
||||
return NormalizedRecord(
|
||||
entity_type="email",
|
||||
external_id=raw.external_id,
|
||||
fields=fields,
|
||||
etag=raw.etag,
|
||||
external_updated_at=raw.external_updated_at,
|
||||
)
|
||||
|
||||
|
||||
def upsert_email(session: Session, norm: NormalizedRecord) -> tuple[str, bool]:
|
||||
link = session.exec(
|
||||
select(ExternalLink).where(
|
||||
ExternalLink.external_id == norm.external_id,
|
||||
ExternalLink.entity_type == "email",
|
||||
)
|
||||
).first()
|
||||
if link: # 이미 존재 → update(멱등)
|
||||
e = session.get(Email, link.entity_id)
|
||||
if e:
|
||||
for k, v in norm.fields.items():
|
||||
if v is not None and hasattr(e, k):
|
||||
setattr(e, k, v)
|
||||
session.add(e)
|
||||
return e.id, False
|
||||
eid = "m-" + uuid.uuid4().hex[:8]
|
||||
e = Email(id=eid, **{k: v for k, v in norm.fields.items() if v is not None})
|
||||
session.add(e)
|
||||
return eid, True
|
||||
@ -0,0 +1,69 @@
|
||||
# backend/app/connectors/mail/real_gmail.py — Gmail API sync 커넥터 (phase-13)
|
||||
# OAuth2 액세스 토큰으로 Gmail REST(messages.list + messages.get) 증분 호출.
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
from sqlmodel import Session
|
||||
|
||||
from ...config import get_settings
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from ..oauth import valid_access_token
|
||||
from ..ratelimit import RateLimiter
|
||||
from .normalize import normalize_email, upsert_email
|
||||
|
||||
API = "https://gmail.googleapis.com/gmail/v1"
|
||||
|
||||
|
||||
def _epoch_to_dt(ms):
|
||||
return datetime.fromtimestamp(int(ms) / 1000, tz=UTC) if ms else None
|
||||
|
||||
|
||||
class GmailConnector(BaseConnector):
|
||||
domain = "mail"
|
||||
entity_type = "email"
|
||||
_rl = RateLimiter(rate=4, per=1.0) # Gmail per-user QPS 보수적 제한
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
st = self._state_row(session)
|
||||
token = valid_access_token(session, self.account) # token_expired 면 예외 → sync error
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
cfg = get_settings()
|
||||
params = {
|
||||
"maxResults": cfg.sync_page_size,
|
||||
"q": "" if full else "newer_than:7d",
|
||||
}
|
||||
if st.cursor and not full:
|
||||
params["q"] = f"after:{st.cursor}"
|
||||
with httpx.Client(timeout=cfg.connector_http_timeout) as c:
|
||||
self._rl.acquire()
|
||||
r = c.get(f"{API}/users/me/messages", headers=headers, params=params)
|
||||
self._rl.handle_response(r)
|
||||
r.raise_for_status()
|
||||
for m in r.json().get("messages", []):
|
||||
self._rl.acquire()
|
||||
d = c.get(
|
||||
f"{API}/users/me/messages/{m['id']}",
|
||||
headers=headers,
|
||||
params={"format": "full"},
|
||||
)
|
||||
self._rl.handle_response(d)
|
||||
d.raise_for_status()
|
||||
msg = d.json()
|
||||
yield RawRecord(
|
||||
external_id=msg["id"],
|
||||
payload=msg,
|
||||
etag=str(msg.get("historyId", "")),
|
||||
external_updated_at=_epoch_to_dt(msg.get("internalDate")),
|
||||
)
|
||||
st.cursor = str(int(datetime.now(UTC).timestamp()))
|
||||
session.add(st)
|
||||
session.commit()
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_email(self.account, raw, provider="gmail")
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_email(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "mail.received"
|
||||
@ -0,0 +1,55 @@
|
||||
# backend/app/connectors/mail/real_imap.py — IMAP(HEY/일반) sync 커넥터 (phase-13)
|
||||
# 로컬 우선 대안: OAuth 불필요. UID SEARCH SINCE 증분 → 동일 normalize_email 로 귀결.
|
||||
import email as email_lib
|
||||
import imaplib
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ...crypto import decrypt_token # token_enc 에 {host,user,password} 저장
|
||||
from ..base import BaseConnector, NormalizedRecord, RawRecord
|
||||
from .normalize import normalize_email, upsert_email
|
||||
|
||||
|
||||
class ImapConnector(BaseConnector):
|
||||
domain = "mail"
|
||||
entity_type = "email"
|
||||
|
||||
def fetch(self, session: Session, *, full: bool = False):
|
||||
creds = decrypt_token(self.account.token_enc)
|
||||
if not creds:
|
||||
raise PermissionError("imap credentials missing")
|
||||
m = imaplib.IMAP4_SSL(creds["host"])
|
||||
m.login(creds["user"], creds["password"])
|
||||
m.select("INBOX")
|
||||
crit = "ALL" if full else "UNSEEN"
|
||||
_typ, data = m.search(None, crit)
|
||||
for uid in data[0].split():
|
||||
_t, msgdata = m.fetch(uid, "(RFC822)")
|
||||
raw_bytes = msgdata[0][1]
|
||||
msg = email_lib.message_from_bytes(raw_bytes)
|
||||
payload = {
|
||||
"from": msg.get("From", ""),
|
||||
"subject": msg.get("Subject", ""),
|
||||
"to": msg.get("To", "나"),
|
||||
"preview": (
|
||||
(msg.get_payload(decode=False) or "")[:120] if not msg.is_multipart() else ""
|
||||
),
|
||||
"date": msg.get("Date", "방금"),
|
||||
}
|
||||
yield RawRecord(
|
||||
external_id=msg.get("Message-ID", uid.decode()),
|
||||
payload=payload,
|
||||
etag=msg.get("Date", ""),
|
||||
external_updated_at=datetime.now(UTC),
|
||||
)
|
||||
m.logout()
|
||||
|
||||
def normalize(self, raw: RawRecord) -> NormalizedRecord:
|
||||
return normalize_email(self.account, raw, provider="imap")
|
||||
|
||||
def write(self, session: Session, norm: NormalizedRecord):
|
||||
return upsert_email(session, norm)
|
||||
|
||||
def event_for(self, norm, entity_id):
|
||||
return "mail.received"
|
||||
@ -0,0 +1,170 @@
|
||||
# backend/app/connectors/oauth.py — OAuth2 Authorization Code + PKCE (phase-13)
|
||||
# start(인증 URL·state) → finish(code→token 교환·암호화) → valid_access_token(만료 자동 refresh).
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import httpx
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..config import get_settings
|
||||
from ..crypto import decrypt_token, encrypt_token
|
||||
from ..models import ConnectorAccount, ConnectorMode, ConnState, OAuthState
|
||||
|
||||
PROVIDERS = {
|
||||
"google": { # Gmail + Google Calendar
|
||||
"auth": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token": "https://oauth2.googleapis.com/token",
|
||||
"scopes": {
|
||||
"gmail": ["https://www.googleapis.com/auth/gmail.readonly"],
|
||||
"google_calendar": ["https://www.googleapis.com/auth/calendar.events"],
|
||||
},
|
||||
},
|
||||
"notion": {
|
||||
"auth": "https://api.notion.com/v1/oauth/authorize",
|
||||
"token": "https://api.notion.com/v1/oauth/token",
|
||||
"scopes": {"notion": []},
|
||||
},
|
||||
}
|
||||
|
||||
# provider → (domain, name, kind, tone) 새 real 계정 메타
|
||||
_REAL_META = {
|
||||
"gmail": ("mail", "Gmail", "Google", "blue"),
|
||||
"google_calendar": ("calendar", "Google 캘린더", "캘린더", "blue"),
|
||||
"notion": ("knowledge", "Notion", "메모·문서", "violet"),
|
||||
}
|
||||
|
||||
|
||||
def _family(provider: str) -> str:
|
||||
return "google" if provider in ("gmail", "google_calendar") else provider
|
||||
|
||||
|
||||
def start_oauth(session: Session, domain: str, provider: str, redirect_after: str = "/life") -> str:
|
||||
st = get_settings()
|
||||
conf = PROVIDERS[_family(provider)]
|
||||
state = secrets.token_urlsafe(24)
|
||||
verifier = base64.urlsafe_b64encode(os.urandom(40)).decode().rstrip("=")
|
||||
challenge = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
|
||||
)
|
||||
session.add(
|
||||
OAuthState(
|
||||
id=state,
|
||||
domain=domain,
|
||||
provider=provider,
|
||||
code_verifier=verifier,
|
||||
redirect_after=redirect_after,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
fam = _family(provider)
|
||||
params = {
|
||||
"client_id": st.google_client_id if fam == "google" else st.notion_client_id,
|
||||
"redirect_uri": st.google_redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(conf["scopes"].get(provider, [])),
|
||||
"state": state,
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
return f"{conf['auth']}?" + urllib.parse.urlencode(params)
|
||||
|
||||
|
||||
def _ensure_account(session: Session, domain, provider: str) -> ConnectorAccount:
|
||||
dom = domain.value if hasattr(domain, "value") else str(domain)
|
||||
meta = _REAL_META.get(provider, (dom, provider, "", "ink"))
|
||||
acct_id = f"ca-{dom}-{provider}"
|
||||
acct = session.get(ConnectorAccount, acct_id)
|
||||
if not acct:
|
||||
acct = ConnectorAccount(
|
||||
id=acct_id,
|
||||
user_id="jiwoo",
|
||||
domain=domain,
|
||||
provider=provider,
|
||||
name=meta[1],
|
||||
kind=meta[2],
|
||||
tone=meta[3],
|
||||
)
|
||||
session.add(acct)
|
||||
return acct
|
||||
|
||||
|
||||
def finish_oauth(session: Session, code: str, state: str) -> ConnectorAccount:
|
||||
os_row = session.get(OAuthState, state)
|
||||
if not os_row:
|
||||
raise ValueError("invalid oauth state")
|
||||
st = get_settings()
|
||||
fam = _family(os_row.provider)
|
||||
conf = PROVIDERS[fam]
|
||||
data = {
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": st.google_redirect_uri,
|
||||
"code_verifier": os_row.code_verifier,
|
||||
"client_id": st.google_client_id if fam == "google" else st.notion_client_id,
|
||||
"client_secret": st.google_client_secret if fam == "google" else st.notion_client_secret,
|
||||
}
|
||||
r = httpx.post(conf["token"], data=data, timeout=st.connector_http_timeout)
|
||||
r.raise_for_status()
|
||||
tok = r.json()
|
||||
tok["expires_at"] = int(time.time()) + int(tok.get("expires_in", 3600))
|
||||
acct = _ensure_account(session, os_row.domain, os_row.provider)
|
||||
acct.mode = ConnectorMode.real
|
||||
acct.state = ConnState.connected
|
||||
acct.token_enc = encrypt_token(tok)
|
||||
acct.scopes = tok.get("scope", "")
|
||||
acct.last_label = "방금 연결됨"
|
||||
session.add(acct)
|
||||
session.delete(os_row)
|
||||
session.commit()
|
||||
return acct
|
||||
|
||||
|
||||
def valid_access_token(session: Session, account: ConnectorAccount) -> str:
|
||||
"""만료되면 refresh_token 으로 갱신. 갱신 불가면 token_expired 로 표시 후 예외."""
|
||||
tok = decrypt_token(account.token_enc)
|
||||
if not tok:
|
||||
account.state = ConnState.token_expired
|
||||
session.add(account)
|
||||
session.commit()
|
||||
raise PermissionError("token missing/corrupt")
|
||||
if tok.get("expires_at", 0) > int(time.time()) + 60:
|
||||
return tok["access_token"]
|
||||
st = get_settings()
|
||||
fam = _family(account.provider)
|
||||
conf = PROVIDERS[fam]
|
||||
rt = tok.get("refresh_token")
|
||||
if not rt:
|
||||
account.state = ConnState.token_expired
|
||||
session.add(account)
|
||||
session.commit()
|
||||
raise PermissionError("no refresh_token")
|
||||
r = httpx.post(
|
||||
conf["token"],
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": rt,
|
||||
"client_id": st.google_client_id if fam == "google" else st.notion_client_id,
|
||||
"client_secret": (
|
||||
st.google_client_secret if fam == "google" else st.notion_client_secret
|
||||
),
|
||||
},
|
||||
timeout=st.connector_http_timeout,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
account.state = ConnState.token_expired
|
||||
session.add(account)
|
||||
session.commit()
|
||||
raise PermissionError("refresh failed")
|
||||
new = r.json()
|
||||
new["refresh_token"] = rt
|
||||
new["expires_at"] = int(time.time()) + int(new.get("expires_in", 3600))
|
||||
account.token_enc = encrypt_token(new)
|
||||
session.add(account)
|
||||
session.commit()
|
||||
return new["access_token"]
|
||||
@ -0,0 +1,37 @@
|
||||
# backend/app/connectors/ratelimit.py — 토큰 버킷 + 429/Retry-After 지수 백오프 (phase-13)
|
||||
import threading
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""간단 토큰 버킷 + 429/Retry-After 지수 백오프."""
|
||||
|
||||
def __init__(self, rate: float = 4, per: float = 1.0, max_backoff: float = 32.0):
|
||||
self.rate, self.per = rate, per
|
||||
self.allowance = rate
|
||||
self.last = time.monotonic()
|
||||
self.max_backoff = max_backoff
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def acquire(self) -> None:
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
self.allowance += (now - self.last) * (self.rate / self.per)
|
||||
self.last = now
|
||||
if self.allowance > self.rate:
|
||||
self.allowance = self.rate
|
||||
if self.allowance < 1.0:
|
||||
time.sleep((1.0 - self.allowance) * (self.per / self.rate))
|
||||
self.allowance = 0.0
|
||||
else:
|
||||
self.allowance -= 1.0
|
||||
|
||||
def handle_response(self, r: "httpx.Response", attempt: int = 0) -> bool:
|
||||
"""429 면 Retry-After(또는 지수 백오프)만큼 대기 후 True(재시도) 반환."""
|
||||
if r.status_code == 429:
|
||||
retry = float(r.headers.get("Retry-After", min(2**attempt, self.max_backoff)))
|
||||
time.sleep(min(retry, self.max_backoff))
|
||||
return True
|
||||
return False
|
||||
@ -0,0 +1,30 @@
|
||||
# backend/app/crypto.py — 커넥터 토큰 대칭 암복호화 (phase-13)
|
||||
# 평문 토큰은 절대 DB 에 저장하지 않는다. ARI_SECRET_KEY → Fernet 키 파생.
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
# ARI_SECRET_KEY -> sha256(32바이트) -> urlsafe base64 -> Fernet 키
|
||||
raw = get_settings().ari_secret_key.encode("utf-8")
|
||||
key = base64.urlsafe_b64encode(hashlib.sha256(raw).digest())
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_token(token_dict: dict) -> str:
|
||||
"""{'access_token':..., 'refresh_token':..., 'expires_at':...} -> 암호문 str."""
|
||||
return _fernet().encrypt(json.dumps(token_dict).encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_token(enc: str) -> dict:
|
||||
if not enc:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(_fernet().decrypt(enc.encode("utf-8")).decode("utf-8"))
|
||||
except (InvalidToken, ValueError):
|
||||
return {} # 키 회전/손상 → 빈 dict (호출측은 token_expired 처리)
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1 @@
|
||||
# backend/app/multimodal/ — 멀티모달 캡처 추상화(STT/Vision)
|
||||
@ -0,0 +1,24 @@
|
||||
# backend/app/multimodal/factory.py — get_stt()/get_vision() (env 주입 + auto 폴백)
|
||||
from ..config import get_settings
|
||||
from .stt import HeuristicSTT, OllamaWhisperSTT, STTProvider
|
||||
from .vision import HeuristicVision, OllamaVisionVision, VisionProvider
|
||||
|
||||
|
||||
def get_stt(force: str | None = None) -> STTProvider:
|
||||
mode = force or get_settings().stt_provider # auto|ollama|heuristic
|
||||
if mode == "heuristic":
|
||||
return HeuristicSTT()
|
||||
if mode == "ollama":
|
||||
return OllamaWhisperSTT()
|
||||
p = OllamaWhisperSTT() # auto
|
||||
return p if p.health().get("reachable") else HeuristicSTT()
|
||||
|
||||
|
||||
def get_vision(force: str | None = None) -> VisionProvider:
|
||||
mode = force or get_settings().vision_provider
|
||||
if mode == "heuristic":
|
||||
return HeuristicVision()
|
||||
if mode == "ollama":
|
||||
return OllamaVisionVision()
|
||||
p = OllamaVisionVision()
|
||||
return p if p.health().get("reachable") else HeuristicVision()
|
||||
@ -0,0 +1,66 @@
|
||||
# backend/app/multimodal/stt.py — 음성→텍스트(STT) 추상화 (LLM Provider 패턴)
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Transcript:
|
||||
text: str
|
||||
seconds: float = 0.0
|
||||
model: str = "" # "stt:<model>" | "heuristic"
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
class STTProvider(ABC):
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def health(self) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
def transcribe(
|
||||
self, audio: bytes, *, mime: str = "audio/webm", hint: str = ""
|
||||
) -> Transcript: ...
|
||||
|
||||
|
||||
class OllamaWhisperSTT(STTProvider):
|
||||
"""STT_MODEL(whisper류) 주입. 로컬 whisper/멀티모달 서버에 오디오 전송.
|
||||
미가용 시 factory.auto 가 HeuristicSTT 로 폴백."""
|
||||
|
||||
name = "stt"
|
||||
|
||||
def __init__(self):
|
||||
from ..config import get_settings
|
||||
|
||||
self.st = get_settings()
|
||||
|
||||
def health(self) -> dict:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
r = httpx.get(f"{self.st.ollama_host}/api/tags", timeout=2.0)
|
||||
return {"reachable": r.status_code == 200, "model": self.st.stt_model}
|
||||
except Exception:
|
||||
return {"reachable": False}
|
||||
|
||||
def transcribe(self, audio: bytes, *, mime="audio/webm", hint="") -> Transcript:
|
||||
# 실모델 연동은 phase-15. 현재는 health 불가 시 폴백되며, 가능 시에도
|
||||
# 안전하게 heuristic 텍스트로 환원(데모 결정성 보장).
|
||||
return HeuristicSTT().transcribe(audio, mime=mime, hint=hint)
|
||||
|
||||
|
||||
class HeuristicSTT(STTProvider):
|
||||
"""오프라인/CI 폴백. 골든 입력(data.js magicInbox / sinbox 음성메모)을 결정적으로 재현."""
|
||||
|
||||
name = "heuristic"
|
||||
GOLDEN = {
|
||||
"pool": "음성 메모 0:14 — 수영장 차광막 부품 알아보기",
|
||||
"gift": "음성 메모 0:09 — 엄마 생신 선물 미리 알아보기",
|
||||
}
|
||||
|
||||
def health(self) -> dict:
|
||||
return {"reachable": True, "model": "heuristic"}
|
||||
|
||||
def transcribe(self, audio: bytes, *, mime="audio/webm", hint="") -> Transcript:
|
||||
text = self.GOLDEN.get(hint) or hint or "음성 메모 — (텍스트로 적어주세요)"
|
||||
return Transcript(text=text, seconds=0.0, model="heuristic", confidence=0.4)
|
||||
@ -0,0 +1,57 @@
|
||||
# backend/app/multimodal/vision.py — 이미지→캡션/OCR(Vision) 추상화 (LLM Provider 패턴)
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Caption:
|
||||
text: str # 캡션 또는 OCR 텍스트
|
||||
ocr: str = "" # OCR 전용(영수증/스크린샷)
|
||||
model: str = "" # "vision:<model>" | "heuristic"
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
class VisionProvider(ABC):
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def health(self) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
def describe(self, image: bytes, *, mime: str = "image/jpeg", hint: str = "") -> Caption: ...
|
||||
|
||||
|
||||
class OllamaVisionVision(VisionProvider):
|
||||
"""VISION_MODEL(멀티모달 비전) 주입. 이미지→캡션/OCR. 미가용 시 폴백."""
|
||||
|
||||
name = "vision"
|
||||
|
||||
def __init__(self):
|
||||
from ..config import get_settings
|
||||
|
||||
self.st = get_settings()
|
||||
|
||||
def health(self) -> dict:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
r = httpx.get(f"{self.st.ollama_host}/api/tags", timeout=2.0)
|
||||
return {"reachable": r.status_code == 200, "model": self.st.vision_model}
|
||||
except Exception:
|
||||
return {"reachable": False}
|
||||
|
||||
def describe(self, image: bytes, *, mime="image/jpeg", hint="") -> Caption:
|
||||
# 실모델 연동은 phase-15. 데모 결정성을 위해 heuristic 으로 환원.
|
||||
return HeuristicVision().describe(image, mime=mime, hint=hint)
|
||||
|
||||
|
||||
class HeuristicVision(VisionProvider):
|
||||
name = "heuristic"
|
||||
GOLDEN = {"clip": "캡처 사진 — 차광막 클립 부품"}
|
||||
|
||||
def health(self) -> dict:
|
||||
return {"reachable": True, "model": "heuristic"}
|
||||
|
||||
def describe(self, image: bytes, *, mime="image/jpeg", hint="") -> Caption:
|
||||
text = self.GOLDEN.get(hint) or hint or "이미지 캡처 — (자동 인식 결과 없음)"
|
||||
return Caption(text=text, ocr="", model="heuristic", confidence=0.4)
|
||||
@ -0,0 +1 @@
|
||||
# backend/app/observability/ — phase-15
|
||||
@ -0,0 +1,44 @@
|
||||
# backend/app/observability/logging.py — 구조화 JSON 로깅 (request_id·user_id 컨텍스트)
|
||||
import json
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
|
||||
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
user_id_ctx: ContextVar[str] = ContextVar("user_id", default="-")
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload = {
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
"request_id": request_id_ctx.get(),
|
||||
"user_id": user_id_ctx.get(),
|
||||
}
|
||||
for k in ("method", "path", "status", "dur_ms", "event"):
|
||||
v = getattr(record, k, None)
|
||||
if v is not None:
|
||||
payload[k] = v
|
||||
if record.exc_info:
|
||||
payload["exc"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
_configured = False
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO") -> None:
|
||||
global _configured
|
||||
if _configured:
|
||||
return
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(JsonFormatter())
|
||||
root = logging.getLogger()
|
||||
root.handlers = [handler]
|
||||
root.setLevel(level.upper())
|
||||
_configured = True
|
||||
|
||||
|
||||
def get_logger(name: str = "ari") -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
@ -0,0 +1,82 @@
|
||||
# backend/app/observability/metrics.py — 경량 Prometheus 메트릭 (의존성 없음)
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self.counters: dict[tuple[str, tuple], float] = defaultdict(float)
|
||||
self.gauges: dict[tuple[str, tuple], float] = {}
|
||||
self.hist_sum: dict[tuple[str, tuple], float] = defaultdict(float)
|
||||
self.hist_count: dict[tuple[str, tuple], int] = defaultdict(int)
|
||||
self._help: dict[str, str] = {}
|
||||
|
||||
def _key(self, name, labels):
|
||||
return (name, tuple(sorted((labels or {}).items())))
|
||||
|
||||
def inc(self, name, amount=1.0, **labels):
|
||||
with self._lock:
|
||||
self.counters[self._key(name, labels)] += amount
|
||||
|
||||
def set_gauge(self, name, value, **labels):
|
||||
with self._lock:
|
||||
self.gauges[self._key(name, labels)] = value
|
||||
|
||||
def observe(self, name, value, **labels):
|
||||
with self._lock:
|
||||
k = self._key(name, labels)
|
||||
self.hist_sum[k] += value
|
||||
self.hist_count[k] += 1
|
||||
|
||||
def help(self, name, text):
|
||||
self._help[name] = text
|
||||
|
||||
def _fmt_labels(self, labels: tuple) -> str:
|
||||
if not labels:
|
||||
return ""
|
||||
inner = ",".join(f'{k}="{v}"' for k, v in labels)
|
||||
return "{" + inner + "}"
|
||||
|
||||
def render(self) -> str:
|
||||
lines: list[str] = []
|
||||
with self._lock:
|
||||
for (name, labels), v in sorted(self.counters.items()):
|
||||
lines.append(f"{name}_total{self._fmt_labels(labels)} {v}")
|
||||
for (name, labels), v in sorted(self.gauges.items()):
|
||||
lines.append(f"{name}{self._fmt_labels(labels)} {v}")
|
||||
for (name, labels), s in sorted(self.hist_sum.items()):
|
||||
lines.append(f"{name}_sum{self._fmt_labels(labels)} {s}")
|
||||
lines.append(
|
||||
f"{name}_count{self._fmt_labels(labels)} {self.hist_count[(name, labels)]}"
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
registry = _Registry()
|
||||
|
||||
|
||||
# 핵심 메트릭 헬퍼(운영 런북 §15.2)
|
||||
def http_request(method: str, path: str, status: int, dur_s: float) -> None:
|
||||
registry.inc("ari_http_requests", method=method, status=str(status))
|
||||
registry.observe("ari_http_latency_seconds", dur_s, method=method)
|
||||
|
||||
|
||||
def llm_call(outcome: str) -> None:
|
||||
registry.inc("ari_llm_calls", outcome=outcome)
|
||||
|
||||
|
||||
def worker_job(job: str, outcome: str) -> None:
|
||||
registry.inc("ari_worker_jobs", job=job, outcome=outcome)
|
||||
|
||||
|
||||
def approval(risk: str, status: str) -> None:
|
||||
registry.inc("ari_approvals", risk=risk, status=status)
|
||||
|
||||
|
||||
def add_saved_minutes(m: float) -> None:
|
||||
registry.inc("ari_saved_minutes", m)
|
||||
|
||||
|
||||
def render() -> str:
|
||||
return registry.render()
|
||||
@ -0,0 +1,34 @@
|
||||
# backend/app/observability/middleware.py — 요청 컨텍스트(request_id) + 접근 로그 + 메트릭
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
from . import metrics
|
||||
from .logging import get_logger, request_id_ctx
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
rid = request.headers.get("X-Request-Id") or uuid.uuid4().hex[:12]
|
||||
token = request_id_ctx.set(rid)
|
||||
start = time.monotonic()
|
||||
status = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status = response.status_code
|
||||
return response
|
||||
finally:
|
||||
dur = time.monotonic() - start
|
||||
metrics.http_request(request.method, request.url.path, status, dur)
|
||||
get_logger("ari.access").info(
|
||||
"request",
|
||||
extra={
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status": status,
|
||||
"dur_ms": round(dur * 1000, 1),
|
||||
},
|
||||
)
|
||||
request_id_ctx.reset(token)
|
||||
@ -0,0 +1,20 @@
|
||||
# backend/app/rag/chunk.py
|
||||
import re
|
||||
|
||||
|
||||
def split_chunks(text: str, size: int = 400, overlap: int = 60) -> list[str]:
|
||||
"""한국어 문단/문장 경계 우선 분할. 데모 시드 본문은 짧아 1청크."""
|
||||
text = (text or "").strip()
|
||||
if len(text) <= size:
|
||||
return [text] if text else []
|
||||
sentences = re.split(r"(?<=[.!?。])\s+|\n+", text)
|
||||
chunks, cur = [], ""
|
||||
for sent in sentences:
|
||||
if len(cur) + len(sent) > size and cur:
|
||||
chunks.append(cur.strip())
|
||||
cur = cur[-overlap:] + sent
|
||||
else:
|
||||
cur = (cur + " " + sent).strip()
|
||||
if cur.strip():
|
||||
chunks.append(cur.strip())
|
||||
return chunks
|
||||
@ -0,0 +1,93 @@
|
||||
# backend/app/rag/embed.py
|
||||
# 임베딩 추상화 — Ollama embeddings | heuristic TF-hash(결정적 폴백).
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
|
||||
class EmbeddingProvider(ABC):
|
||||
name: str = "base"
|
||||
dim: int = 0
|
||||
|
||||
@abstractmethod
|
||||
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
||||
|
||||
|
||||
def _tokenize(t: str) -> list[str]:
|
||||
return re.findall(r"[가-힣]{2,}|[A-Za-z]{2,}|\d+", (t or "").lower())
|
||||
|
||||
|
||||
def _stable_hash(tok: str) -> int:
|
||||
"""프로세스 간 결정적 해시(파이썬 builtin hash 는 str 에 대해 PYTHONHASHSEED 랜덤)."""
|
||||
return int.from_bytes(hashlib.md5(tok.encode("utf-8")).digest()[:8], "little")
|
||||
|
||||
|
||||
class HeuristicEmbedding(EmbeddingProvider):
|
||||
"""오프라인/CI 폴백 — TF 해시 임베딩(코사인 검색용). 결정적."""
|
||||
|
||||
name = "heuristic-tfhash"
|
||||
|
||||
def __init__(self, dim: int = 256):
|
||||
self.dim = dim
|
||||
|
||||
def embed(self, texts):
|
||||
vecs = []
|
||||
for t in texts:
|
||||
v = [0.0] * self.dim
|
||||
for tok in _tokenize(t):
|
||||
v[_stable_hash(tok) % self.dim] += 1.0
|
||||
n = math.sqrt(sum(x * x for x in v)) or 1.0
|
||||
vecs.append([x / n for x in v])
|
||||
return vecs
|
||||
|
||||
|
||||
class OllamaEmbedding(EmbeddingProvider):
|
||||
"""Ollama embeddings API. EMBED_MODEL 주입(모델 비종속)."""
|
||||
|
||||
def __init__(self):
|
||||
st = get_settings()
|
||||
self.host = st.ollama_host
|
||||
self.model = st.embed_model
|
||||
self.name = f"ollama:{self.model}"
|
||||
self.dim = 0
|
||||
|
||||
def embed(self, texts):
|
||||
out = []
|
||||
with httpx.Client(timeout=20.0) as c:
|
||||
for t in texts:
|
||||
r = c.post(f"{self.host}/api/embeddings", json={"model": self.model, "prompt": t})
|
||||
r.raise_for_status()
|
||||
v = r.json()["embedding"]
|
||||
self.dim = len(v)
|
||||
out.append(v)
|
||||
return out
|
||||
|
||||
|
||||
_cached: EmbeddingProvider | None = None
|
||||
|
||||
|
||||
def get_embedding_provider() -> EmbeddingProvider:
|
||||
global _cached
|
||||
if _cached is not None:
|
||||
return _cached
|
||||
provider = get_settings().embed_provider
|
||||
if provider == "heuristic":
|
||||
_cached = HeuristicEmbedding()
|
||||
return _cached
|
||||
try:
|
||||
p = OllamaEmbedding()
|
||||
p.embed(["헬스체크"]) # reachable + 모델 확인
|
||||
_cached = p
|
||||
except Exception:
|
||||
_cached = HeuristicEmbedding() # 폴백
|
||||
return _cached
|
||||
|
||||
|
||||
def reset_embedding_cache() -> None:
|
||||
global _cached
|
||||
_cached = None
|
||||
@ -0,0 +1,89 @@
|
||||
# backend/app/rag/pipeline.py
|
||||
# ingest(pdf/web/note)→chunk→embed→store→query 오케스트레이션.
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..models import RagChunk, ResearchSource
|
||||
from .embed import get_embedding_provider
|
||||
from .store import add_chunks, query_store
|
||||
|
||||
# 학습 자료 본문(데모 시드). 실제는 http_fetch/PDF 파서가 채운다.
|
||||
# (text, part_label) — 근거 표시용 part_label 은 qa.refs[].part 와 정합.
|
||||
_SEED_BODIES = {
|
||||
"s1": [
|
||||
(
|
||||
"2026 반도체 시장 전망: 추론 가속기 수요가 학습용을 앞서며 메모리 대역폭이 핵심 변수로 부상. 전력 효율 규제가 칩 설계 방향을 바꾼다.",
|
||||
"시장 전망 요약",
|
||||
)
|
||||
],
|
||||
"s2": [
|
||||
(
|
||||
"HBM4 양산 경쟁: 메모리 제조사들의 양산 시점이 올해 시장 점유율을 가른다는 분석. 업계 발표를 인용.",
|
||||
"HBM4 단락",
|
||||
)
|
||||
],
|
||||
"s3": [
|
||||
(
|
||||
"추론 가속기 아키텍처 비교: 추론 비용의 병목은 연산이 아니라 메모리 대역폭이라는 벤치마크 근거.",
|
||||
"아키텍처 비교 표",
|
||||
)
|
||||
],
|
||||
"s4": [
|
||||
(
|
||||
"온보딩 인터뷰 메모 5건 요약: 첫 화면 카피와 권한 요청 순서에서 이탈이 집중됨.",
|
||||
"인터뷰 요약",
|
||||
)
|
||||
],
|
||||
"s5": [
|
||||
(
|
||||
"신생아는 풀플랫(완전 평탄) 시트와 양대면 전환이 필수입니다. KC 인증과 5점식 벨트를 최소 기준으로 보세요. 차 트렁크 크기를 먼저 재라는 조언이 많습니다.",
|
||||
"‘인증·벨트’ 단락",
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def ingest_seed_sources(s: Session) -> None:
|
||||
emb = get_embedding_provider()
|
||||
for src in s.exec(select(ResearchSource)).all():
|
||||
bodies = _SEED_BODIES.get(src.id) or [(src.title, src.title)]
|
||||
texts = [b[0] for b in bodies]
|
||||
vecs = emb.embed(texts)
|
||||
chunks = [
|
||||
RagChunk(
|
||||
id=f"chk_{src.id}_{i}",
|
||||
source_id=src.id,
|
||||
seq=i,
|
||||
text=texts[i],
|
||||
part_label=bodies[i][1],
|
||||
embedding=vecs[i],
|
||||
dim=len(vecs[i]),
|
||||
model=emb.name,
|
||||
)
|
||||
for i in range(len(texts))
|
||||
]
|
||||
add_chunks(s, chunks)
|
||||
|
||||
|
||||
def query(s: Session, q: str, k: int = 4, collection_id: str | None = None) -> list[dict]:
|
||||
emb = get_embedding_provider()
|
||||
qvec = emb.embed([q])[0]
|
||||
src_ids = None
|
||||
if collection_id:
|
||||
src_ids = [
|
||||
r.id
|
||||
for r in s.exec(select(ResearchSource).where(ResearchSource.col == collection_id)).all()
|
||||
]
|
||||
scored = query_store(s, qvec, k, src_ids)
|
||||
out = []
|
||||
for score, ch in scored:
|
||||
src = s.get(ResearchSource, ch.source_id)
|
||||
out.append(
|
||||
{
|
||||
"text": ch.text,
|
||||
"source_id": ch.source_id,
|
||||
"title": src.title if src else "",
|
||||
"part_label": ch.part_label,
|
||||
"score": round(score, 4),
|
||||
}
|
||||
)
|
||||
return out
|
||||
@ -0,0 +1,30 @@
|
||||
# backend/app/rag/store.py
|
||||
import math
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..models import RagChunk
|
||||
|
||||
|
||||
def cosine(a: list[float], b: list[float]) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b, strict=False))
|
||||
na = math.sqrt(sum(x * x for x in a)) or 1.0
|
||||
nb = math.sqrt(sum(y * y for y in b)) or 1.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
def add_chunks(s: Session, chunks: list[RagChunk]) -> None:
|
||||
for c in chunks:
|
||||
s.add(c)
|
||||
s.commit()
|
||||
|
||||
|
||||
def query_store(s: Session, qvec: list[float], k: int, source_ids: list[str] | None):
|
||||
rows = s.exec(select(RagChunk)).all()
|
||||
if source_ids:
|
||||
rows = [r for r in rows if r.source_id in source_ids]
|
||||
scored = [(cosine(qvec, r.embedding), r) for r in rows]
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return scored[:k]
|
||||
@ -0,0 +1,54 @@
|
||||
# backend/app/routers/agents.py — 심부름 에이전트 API (phase-14). 내부 prefix 없음.
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..agents.errand import ErrandAgent
|
||||
from ..db import get_session
|
||||
from ..models import ErrandStep, ErrandTask
|
||||
from ..schemas import ErrandStartIn, ErrandStepOut, ErrandTaskOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _errand_out(s: Session, er: ErrandTask) -> ErrandTaskOut:
|
||||
steps = s.exec(
|
||||
select(ErrandStep).where(ErrandStep.errand_id == er.id).order_by(ErrandStep.seq)
|
||||
).all()
|
||||
return ErrandTaskOut(
|
||||
id=er.id,
|
||||
kind=er.kind,
|
||||
title=er.title,
|
||||
goal=er.goal,
|
||||
target=er.target,
|
||||
tone=er.tone,
|
||||
status=er.status,
|
||||
approval_id=er.approval_id,
|
||||
result_summary=er.result_summary,
|
||||
model=er.model,
|
||||
steps=[ErrandStepOut.model_validate(st, from_attributes=True) for st in steps],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agents/errand", response_model=ErrandTaskOut)
|
||||
def start_errand(body: ErrandStartIn, s: Session = Depends(get_session)):
|
||||
er = ErrandAgent(s).start(
|
||||
kind=body.kind,
|
||||
title=body.title,
|
||||
goal=body.goal,
|
||||
target=body.target,
|
||||
tone=body.tone or "amber",
|
||||
)
|
||||
return _errand_out(s, er)
|
||||
|
||||
|
||||
@router.get("/agents/errand", response_model=list[ErrandTaskOut])
|
||||
def list_errands(s: Session = Depends(get_session)):
|
||||
return [_errand_out(s, e) for e in s.exec(select(ErrandTask)).all()]
|
||||
|
||||
|
||||
@router.get("/agents/errand/{eid}", response_model=ErrandTaskOut)
|
||||
def get_errand(eid: str, s: Session = Depends(get_session)):
|
||||
er = s.get(ErrandTask, eid)
|
||||
if not er:
|
||||
raise HTTPException(404, "errand not found")
|
||||
return _errand_out(s, er)
|
||||
@ -0,0 +1,104 @@
|
||||
# backend/app/routers/approvals.py
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..approvals import service
|
||||
from ..auth.deps import current_user
|
||||
from ..db import get_session
|
||||
from ..models import Approval, ApprovalLog, Person
|
||||
from ..schemas import (
|
||||
ApprovalLogOut,
|
||||
ApprovalOut,
|
||||
ApprovalQueueOut,
|
||||
AutonomyOut,
|
||||
AutonomyPatch,
|
||||
)
|
||||
|
||||
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
|
||||
|
||||
SAVED_TODAY = "47분" # 원본 approve-data.js savedToday
|
||||
AUTO_COUNT_NIGHT = 7 # 원본 autoCountNight
|
||||
|
||||
|
||||
def _out(a: Approval) -> ApprovalOut:
|
||||
return ApprovalOut(
|
||||
id=a.id,
|
||||
icon=a.icon,
|
||||
tone=a.tone,
|
||||
risk=a.risk,
|
||||
time=a.time,
|
||||
title=a.title,
|
||||
detail=a.detail,
|
||||
cta=a.cta,
|
||||
alt=a.alt,
|
||||
undo_label=a.undo_label,
|
||||
status=a.status,
|
||||
source=a.source,
|
||||
rule_id=a.rule_id,
|
||||
sort_order=a.sort_order,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/approvals", response_model=ApprovalQueueOut)
|
||||
def get_queue(s: Session = Depends(get_session), user: Person = Depends(current_user)):
|
||||
q = service.derive_queue(s, user_id=user.id)
|
||||
logs = s.exec(select(ApprovalLog).order_by(ApprovalLog.sort_order)).all()
|
||||
return ApprovalQueueOut(
|
||||
autonomy=q["level"],
|
||||
saved_today=SAVED_TODAY,
|
||||
auto_count_night=AUTO_COUNT_NIGHT,
|
||||
pending=[_out(a) for a in q["pending"]],
|
||||
done=[_out(a) for a in q["done"]],
|
||||
log=[
|
||||
ApprovalLogOut(id=log.id, time=log.time, text=log.text, approval_id=log.approval_id)
|
||||
for log in logs
|
||||
],
|
||||
badges_appr=len(q["pending"]),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/approvals/approve-all")
|
||||
def approve_all(s: Session = Depends(get_session)):
|
||||
return {"approved": service.approve_all(s)}
|
||||
|
||||
|
||||
@router.get("/approvals/autonomy", response_model=AutonomyOut)
|
||||
def get_autonomy(s: Session = Depends(get_session)):
|
||||
return AutonomyOut(level=service.get_autonomy(s))
|
||||
|
||||
|
||||
@router.get("/approvals/{aid}", response_model=ApprovalOut)
|
||||
def get_one(aid: str, s: Session = Depends(get_session)):
|
||||
a = s.get(Approval, aid)
|
||||
if not a:
|
||||
raise HTTPException(404, "approval not found")
|
||||
return _out(a)
|
||||
|
||||
|
||||
@router.patch("/approvals/autonomy", response_model=AutonomyOut)
|
||||
def set_autonomy(body: AutonomyPatch, s: Session = Depends(get_session)):
|
||||
try:
|
||||
return AutonomyOut(level=service.set_autonomy(s, body.level))
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/approvals/{aid}/approve", response_model=ApprovalOut)
|
||||
def approve(aid: str, s: Session = Depends(get_session)):
|
||||
if not s.get(Approval, aid):
|
||||
raise HTTPException(404, "approval not found")
|
||||
return _out(service.approve(s, aid))
|
||||
|
||||
|
||||
@router.post("/approvals/{aid}/execute", response_model=ApprovalOut)
|
||||
def execute(aid: str, s: Session = Depends(get_session)):
|
||||
if not s.get(Approval, aid):
|
||||
raise HTTPException(404, "approval not found")
|
||||
return _out(service.approve(s, aid)) # high-risk cta 실행 = approve 와 동일 전이
|
||||
|
||||
|
||||
@router.post("/approvals/{aid}/undo", response_model=ApprovalOut)
|
||||
def undo(aid: str, s: Session = Depends(get_session)):
|
||||
if not s.get(Approval, aid):
|
||||
raise HTTPException(404, "approval not found")
|
||||
return _out(service.undo(s, aid))
|
||||
@ -0,0 +1,175 @@
|
||||
# backend/app/routers/auth.py — 로그인/로그아웃/세션/내보내기/토큰 (phase-15)
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..auth.deps import current_user
|
||||
from ..auth.password import verify_password
|
||||
from ..auth.tokens import (
|
||||
hash_token,
|
||||
new_api_token,
|
||||
new_session_id,
|
||||
sign_session_id,
|
||||
unsign_session_id,
|
||||
)
|
||||
from ..config import get_settings
|
||||
from ..db import get_session
|
||||
from ..models import ApiToken, AuthSession, Person, UserCredential
|
||||
from ..schemas import LoginIn, MeOut, ProfileUpdateIn, TokenInfoOut, TokenOut
|
||||
from ..security.audit import audit
|
||||
from ..services.export_service import build_export
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_LOGIN_FAIL = "이메일 또는 비밀번호가 올바르지 않아요"
|
||||
|
||||
|
||||
def _me(session: Session, user: Person) -> MeOut:
|
||||
cred = session.exec(select(UserCredential).where(UserCredential.user_id == user.id)).first()
|
||||
return MeOut(
|
||||
id=user.id,
|
||||
name=user.name,
|
||||
initial=user.initial,
|
||||
email=cred.email if cred else (user.email or ""),
|
||||
role=cred.role if cred else (user.role or "member"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=MeOut)
|
||||
def login(body: LoginIn, request: Request, response: Response, s: Session = Depends(get_session)):
|
||||
st = get_settings()
|
||||
ip = request.client.host if request.client else None
|
||||
cred = s.exec(select(UserCredential).where(UserCredential.email == body.email)).first()
|
||||
if not cred or not cred.is_active or not verify_password(body.password, cred.password_hash):
|
||||
audit(s, cred.user_id if cred else None, "login.fail", ip=ip, detail=body.email)
|
||||
raise HTTPException(401, _LOGIN_FAIL)
|
||||
now = datetime.now(UTC)
|
||||
sid = new_session_id()
|
||||
s.add(
|
||||
AuthSession(
|
||||
id=sid,
|
||||
user_id=cred.user_id,
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(seconds=st.session_ttl_s),
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
ip=ip,
|
||||
)
|
||||
)
|
||||
cred.last_login_at = now
|
||||
s.add(cred)
|
||||
s.commit()
|
||||
response.set_cookie(
|
||||
st.session_cookie_name,
|
||||
sign_session_id(sid),
|
||||
max_age=st.session_ttl_s,
|
||||
httponly=True,
|
||||
secure=st.cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
audit(s, cred.user_id, "login", ip=ip)
|
||||
user = s.get(Person, cred.user_id)
|
||||
return _me(s, user)
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
def logout(request: Request, response: Response, s: Session = Depends(get_session)):
|
||||
st = get_settings()
|
||||
raw = request.cookies.get(st.session_cookie_name)
|
||||
uid = None
|
||||
if raw:
|
||||
sid = unsign_session_id(raw)
|
||||
if sid:
|
||||
sess = s.get(AuthSession, sid)
|
||||
if sess:
|
||||
sess.revoked = True
|
||||
uid = sess.user_id
|
||||
s.add(sess)
|
||||
s.commit()
|
||||
response.delete_cookie(st.session_cookie_name)
|
||||
audit(s, uid, "logout")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeOut)
|
||||
def me(user: Person = Depends(current_user), s: Session = Depends(get_session)):
|
||||
return _me(s, user)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=MeOut)
|
||||
def update_me(
|
||||
body: ProfileUpdateIn,
|
||||
user: Person = Depends(current_user),
|
||||
s: Session = Depends(get_session),
|
||||
):
|
||||
"""표시 이름/역할 갱신. role 은 자격(UserCredential)이 있으면 거기에, 없으면 Person 에 저장."""
|
||||
if body.name is not None and body.name.strip():
|
||||
user.name = body.name.strip()
|
||||
user.initial = body.name.strip()[0]
|
||||
cred = s.exec(select(UserCredential).where(UserCredential.user_id == user.id)).first()
|
||||
if body.role is not None:
|
||||
role = body.role.strip()
|
||||
if cred:
|
||||
cred.role = role
|
||||
s.add(cred)
|
||||
else:
|
||||
user.role = role
|
||||
s.add(user)
|
||||
s.commit()
|
||||
s.refresh(user)
|
||||
audit(s, user.id, "profile.update")
|
||||
return _me(s, user)
|
||||
|
||||
|
||||
@router.get("/me/export")
|
||||
def export_me(user: Person = Depends(current_user), s: Session = Depends(get_session)):
|
||||
data = build_export(s, user)
|
||||
audit(s, user.id, "export")
|
||||
return StreamingResponse(
|
||||
iter([data]),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="ari-export-{user.id}.zip"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/tokens", response_model=list[TokenInfoOut])
|
||||
def list_tokens(user: Person = Depends(current_user), s: Session = Depends(get_session)):
|
||||
rows = s.exec(
|
||||
select(ApiToken).where(ApiToken.user_id == user.id).order_by(ApiToken.created_at.desc())
|
||||
).all()
|
||||
return [
|
||||
TokenInfoOut(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
created_at=t.created_at.isoformat(),
|
||||
last_used_at=t.last_used_at.isoformat() if t.last_used_at else None,
|
||||
revoked=t.revoked,
|
||||
)
|
||||
for t in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/me/tokens", response_model=TokenOut)
|
||||
def create_token(
|
||||
name: str = "API 토큰", user: Person = Depends(current_user), s: Session = Depends(get_session)
|
||||
):
|
||||
plain = new_api_token()
|
||||
tid = "tok-" + uuid.uuid4().hex[:8]
|
||||
s.add(ApiToken(id=tid, user_id=user.id, name=name, token_hash=hash_token(plain)))
|
||||
s.commit()
|
||||
audit(s, user.id, "api_token.create", target=tid)
|
||||
return TokenOut(id=tid, name=name, token=plain, created_at=datetime.now(UTC).isoformat())
|
||||
|
||||
|
||||
@router.delete("/me/tokens/{tid}")
|
||||
def revoke_token(tid: str, user: Person = Depends(current_user), s: Session = Depends(get_session)):
|
||||
tok = s.get(ApiToken, tid)
|
||||
if not tok or tok.user_id != user.id:
|
||||
raise HTTPException(404, "not found")
|
||||
tok.revoked = True
|
||||
s.add(tok)
|
||||
s.commit()
|
||||
audit(s, user.id, "api_token.revoke", target=tid)
|
||||
return {"ok": True}
|
||||
@ -0,0 +1,225 @@
|
||||
# backend/app/routers/automation.py
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..automation.event_bus import bus
|
||||
from ..automation.events import AUTOMATION_MATCHED
|
||||
from ..automation.nl_parser import EXAMPLES, parse_rule
|
||||
from ..automation.suggester import scan
|
||||
from ..db import get_session
|
||||
from ..models import (
|
||||
AutomationRule,
|
||||
AutomationRunLog,
|
||||
AutomationStats,
|
||||
AutomationSuggestion,
|
||||
)
|
||||
from ..schemas import (
|
||||
AutomationPageOut,
|
||||
AutomationStatsOut,
|
||||
FlowOut,
|
||||
ParsePreviewOut,
|
||||
ParseRequest,
|
||||
RuleCreate,
|
||||
RuleOut,
|
||||
RulePatch,
|
||||
RunLogOut,
|
||||
SuggestionOut,
|
||||
)
|
||||
|
||||
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
|
||||
|
||||
|
||||
def _rule_out(r: AutomationRule) -> RuleOut:
|
||||
return RuleOut(
|
||||
id=r.id,
|
||||
name=r.name,
|
||||
cat=r.cat,
|
||||
trigger=r.trigger,
|
||||
cond=r.cond,
|
||||
action=r.action,
|
||||
on=r.on,
|
||||
last=r.last,
|
||||
runs=r.runs,
|
||||
fresh=r.fresh,
|
||||
source=r.source,
|
||||
)
|
||||
|
||||
|
||||
def _sug_out(g: AutomationSuggestion) -> SuggestionOut:
|
||||
return SuggestionOut(
|
||||
id=g.id,
|
||||
pattern=g.pattern,
|
||||
offer=FlowOut(trigger=g.offer_trigger, cond=g.offer_cond, action=g.offer_action),
|
||||
offer_name=g.offer_name,
|
||||
offer_cat=g.offer_cat,
|
||||
status=g.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/automation", response_model=AutomationPageOut)
|
||||
def get_page(s: Session = Depends(get_session)):
|
||||
rules = s.exec(select(AutomationRule).order_by(AutomationRule.created_at)).all()
|
||||
sugs = s.exec(select(AutomationSuggestion).where(AutomationSuggestion.status == "open")).all()
|
||||
logs = s.exec(select(AutomationRunLog).order_by(AutomationRunLog.sort_order)).all()
|
||||
st = s.exec(select(AutomationStats)).first()
|
||||
active = sum(1 for r in rules if r.on)
|
||||
stats = AutomationStatsOut(
|
||||
active=active,
|
||||
runs_week=(st.runs_week if st else 0),
|
||||
saved=(st.saved if st else ""),
|
||||
)
|
||||
examples = [
|
||||
ParsePreviewOut(
|
||||
matched=True,
|
||||
name=pr.name,
|
||||
cat=pr.cat,
|
||||
parse=FlowOut(trigger=pr.trigger, cond=pr.cond, action=pr.action),
|
||||
model=pr.model,
|
||||
confidence=pr.confidence,
|
||||
)
|
||||
for pr in EXAMPLES.values()
|
||||
]
|
||||
return AutomationPageOut(
|
||||
stats=stats,
|
||||
rules=[_rule_out(r) for r in rules],
|
||||
suggests=[_sug_out(g) for g in sugs],
|
||||
log=[
|
||||
RunLogOut(id=log.id, time=log.time, rule=log.rule, text=log.text, undone=log.undone)
|
||||
for log in logs
|
||||
],
|
||||
examples=examples,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/automation/parse", response_model=ParsePreviewOut)
|
||||
def parse(body: ParseRequest):
|
||||
pr = parse_rule(body.text)
|
||||
return ParsePreviewOut(
|
||||
matched=pr.matched,
|
||||
name=pr.name,
|
||||
cat=pr.cat,
|
||||
parse=FlowOut(trigger=pr.trigger, cond=pr.cond, action=pr.action),
|
||||
model=pr.model,
|
||||
confidence=pr.confidence,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/automation/rules", response_model=RuleOut)
|
||||
def create_rule(body: RuleCreate, s: Session = Depends(get_session)):
|
||||
r = AutomationRule(
|
||||
id="rule-" + uuid.uuid4().hex[:8],
|
||||
name=body.name,
|
||||
cat=body.cat,
|
||||
trigger=body.trigger,
|
||||
cond=body.cond,
|
||||
action=body.action,
|
||||
on=True,
|
||||
last="방금 만듦",
|
||||
runs=0,
|
||||
fresh=True,
|
||||
source=body.source or "user",
|
||||
)
|
||||
s.add(r)
|
||||
s.commit()
|
||||
s.refresh(r)
|
||||
return _rule_out(r)
|
||||
|
||||
|
||||
@router.patch("/automation/rules/{rid}", response_model=RuleOut)
|
||||
def patch_rule(rid: str, body: RulePatch, s: Session = Depends(get_session)):
|
||||
r = s.get(AutomationRule, rid)
|
||||
if not r:
|
||||
raise HTTPException(404, "rule not found")
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(r, k, v)
|
||||
s.add(r)
|
||||
s.commit()
|
||||
s.refresh(r)
|
||||
return _rule_out(r)
|
||||
|
||||
|
||||
@router.post("/automation/rules/{rid}/toggle", response_model=RuleOut)
|
||||
def toggle_rule(rid: str, s: Session = Depends(get_session)):
|
||||
r = s.get(AutomationRule, rid)
|
||||
if not r:
|
||||
raise HTTPException(404, "rule not found")
|
||||
r.on = not r.on
|
||||
s.add(r)
|
||||
s.commit()
|
||||
s.refresh(r)
|
||||
return _rule_out(r)
|
||||
|
||||
|
||||
@router.delete("/automation/rules/{rid}")
|
||||
def delete_rule(rid: str, s: Session = Depends(get_session)):
|
||||
r = s.get(AutomationRule, rid)
|
||||
if not r:
|
||||
raise HTTPException(404, "rule not found")
|
||||
s.delete(r)
|
||||
s.commit()
|
||||
return {"deleted": rid}
|
||||
|
||||
|
||||
@router.post("/automation/suggestions/{sid}/accept", response_model=RuleOut)
|
||||
def accept_suggestion(sid: str, s: Session = Depends(get_session)):
|
||||
g = s.get(AutomationSuggestion, sid)
|
||||
if not g:
|
||||
raise HTTPException(404, "suggestion not found")
|
||||
g.status = "accepted"
|
||||
s.add(g)
|
||||
r = AutomationRule(
|
||||
id="rule-" + uuid.uuid4().hex[:8],
|
||||
name=g.offer_name,
|
||||
cat=g.offer_cat,
|
||||
trigger=g.offer_trigger,
|
||||
cond=g.offer_cond,
|
||||
action=g.offer_action,
|
||||
on=True,
|
||||
last="방금 만듦",
|
||||
runs=0,
|
||||
fresh=True,
|
||||
source="suggestion",
|
||||
)
|
||||
s.add(r)
|
||||
s.commit()
|
||||
s.refresh(r)
|
||||
return _rule_out(r)
|
||||
|
||||
|
||||
@router.post("/automation/suggestions/{sid}/dismiss")
|
||||
def dismiss_suggestion(sid: str, s: Session = Depends(get_session)):
|
||||
g = s.get(AutomationSuggestion, sid)
|
||||
if not g:
|
||||
raise HTTPException(404, "suggestion not found")
|
||||
g.status = "dismissed"
|
||||
s.add(g)
|
||||
s.commit()
|
||||
return {"dismissed": sid}
|
||||
|
||||
|
||||
@router.post("/automation/suggest/scan")
|
||||
def suggest_scan(s: Session = Depends(get_session)):
|
||||
created = scan(s, bus) # 생성 제안마다 automation.suggested 발행
|
||||
return {"created": [g.id for g in created]}
|
||||
|
||||
|
||||
@router.get("/automation/log", response_model=list[RunLogOut])
|
||||
def get_log(s: Session = Depends(get_session)):
|
||||
logs = s.exec(select(AutomationRunLog).order_by(AutomationRunLog.sort_order)).all()
|
||||
return [
|
||||
RunLogOut(id=log.id, time=log.time, rule=log.rule, text=log.text, undone=log.undone)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.post("/automation/trigger")
|
||||
def manual_trigger(trigger_key: str, s: Session = Depends(get_session)):
|
||||
"""데모/테스트용 수동 트리거 — AUTOMATION_MATCHED 발행(스케줄러는 phase-14).
|
||||
evaluator 가 구독해 매칭 규칙을 승인 큐에 enqueue 한다."""
|
||||
ev = bus.publish(
|
||||
AUTOMATION_MATCHED,
|
||||
{"trigger_key": trigger_key, "ctx": {"title": f"{trigger_key} 규칙 실행"}},
|
||||
)
|
||||
return {"published": ev.type, "trigger_key": trigger_key}
|
||||
@ -0,0 +1,229 @@
|
||||
# backend/app/routers/calendar.py
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..connectors import get_calendar_connector
|
||||
from ..db import get_session
|
||||
from ..models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction, Task
|
||||
from ..schemas import (
|
||||
CalendarOut,
|
||||
CalEventOut,
|
||||
DayBundleOut,
|
||||
EventActionOut,
|
||||
FocusBlockOut,
|
||||
FocusSuggestRequest,
|
||||
FocusSuggestResponse,
|
||||
MaterializeResponse,
|
||||
MeetingActionOut,
|
||||
MeetingOut,
|
||||
WeekBundleOut,
|
||||
)
|
||||
from ..services.focus import suggest_focus
|
||||
from ..services.meetings import assemble_meeting, materialize_action, materialize_all
|
||||
from .tasks import to_node # phase-2 TaskNode 빌더 재사용
|
||||
|
||||
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
|
||||
|
||||
WEEK = [7, 8, 9, 10, 11, 12, 13]
|
||||
WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"]
|
||||
TODAY = 8
|
||||
|
||||
|
||||
def _end_from(start: str, dur: int) -> str:
|
||||
h, m = map(int, start.split(":"))
|
||||
total = h * 60 + m + dur
|
||||
return f"{total // 60:02d}:{total % 60:02d}"
|
||||
|
||||
|
||||
def meet_label(m: Meeting | None) -> str:
|
||||
if m is None:
|
||||
return ""
|
||||
phase = getattr(m.phase, "value", m.phase)
|
||||
if m.one_on_one:
|
||||
return "1:1 어시스턴트 브리핑"
|
||||
if phase == "upcoming":
|
||||
return "사전 브리핑 보기"
|
||||
if phase == "live":
|
||||
return "실시간 기록 보기"
|
||||
return "회의 노트 · 액션 보기"
|
||||
|
||||
|
||||
def _parse_day(ev: dict) -> int:
|
||||
"""ev 에서 6월 날짜(int) 추출. 'date'가 'M/D'면 일(D), 'day'가 숫자면 그 값, 아니면 TODAY."""
|
||||
date_str = str(ev.get("date") or "")
|
||||
if "/" in date_str:
|
||||
try:
|
||||
return int(date_str.split("/")[1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
try:
|
||||
return int(ev.get("day"))
|
||||
except (TypeError, ValueError):
|
||||
return TODAY
|
||||
|
||||
|
||||
def create_event_from_extract(s: Session, ev: dict, source: str) -> str:
|
||||
"""메일→일정 federation 헬퍼. ev = {title, date|day, time, dur, place?} →
|
||||
CalEvent 생성(연합 출처 source 기록) 후 id 반환. phase-9 routers/mail.py 가 import.
|
||||
메일 AI 이벤트(date='6/10', day='오늘', time='14:00'|'종일')도 견고하게 처리."""
|
||||
eid = "ce-" + uuid.uuid4().hex[:8]
|
||||
day = _parse_day(ev)
|
||||
m = re.match(r"(\d{1,2}):(\d{2})", str(ev.get("time") or ""))
|
||||
if m:
|
||||
start = f"{int(m.group(1)):02d}:{m.group(2)}"
|
||||
dur_digits = re.sub(r"\D", "", str(ev.get("dur") or "")) or "60"
|
||||
end = _end_from(start, int(dur_digits))
|
||||
else: # 종일/미정 시간
|
||||
start, end = "09:00", "09:30"
|
||||
conn = get_calendar_connector()
|
||||
conn.write_event(
|
||||
s,
|
||||
dict(
|
||||
id=eid,
|
||||
day=day,
|
||||
start=start,
|
||||
end=end,
|
||||
title=ev["title"],
|
||||
cal="meeting",
|
||||
loc=ev.get("place", ""),
|
||||
note=f"연합 출처: {source}",
|
||||
soon=False,
|
||||
people="",
|
||||
sort_order=0,
|
||||
),
|
||||
)
|
||||
return eid
|
||||
|
||||
|
||||
def _event_out(s: Session, e: CalEvent) -> CalEventOut:
|
||||
m = s.get(Meeting, e.id)
|
||||
eacts = s.exec(
|
||||
select(MeetingAction)
|
||||
.where(MeetingAction.event_id == e.id, MeetingAction.source == "event")
|
||||
.order_by(MeetingAction.idx)
|
||||
).all()
|
||||
return CalEventOut(
|
||||
id=e.id,
|
||||
day=e.day,
|
||||
start=e.start,
|
||||
end=e.end,
|
||||
title=e.title,
|
||||
cal=e.cal,
|
||||
loc=e.loc,
|
||||
note=e.note,
|
||||
soon=e.soon,
|
||||
people=[p.strip() for p in e.people.split(",") if p.strip()],
|
||||
actions=[EventActionOut(text=a.text, when=a.when_text) for a in eacts],
|
||||
has_meeting=m is not None,
|
||||
meet_label=meet_label(m),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/calendar/week", response_model=WeekBundleOut)
|
||||
def get_week(s: Session = Depends(get_session)):
|
||||
conn = get_calendar_connector() # mock-first
|
||||
rows = [s.get(CalEvent, r["id"]) for r in conn.list_events(s)]
|
||||
cals = s.exec(select(Calendar).order_by(Calendar.sort_order)).all()
|
||||
counts: dict[str, int] = {}
|
||||
for e in rows:
|
||||
counts[e.cal] = counts.get(e.cal, 0) + 1
|
||||
fbs = s.exec(select(FocusBlock).order_by(FocusBlock.sort_order)).all()
|
||||
return WeekBundleOut(
|
||||
today=TODAY,
|
||||
week=WEEK,
|
||||
weekdays=WEEKDAYS,
|
||||
calendars=[
|
||||
CalendarOut(id=c.id, name=c.name, tone=c.tone, on=c.on, count=counts.get(c.id, 0))
|
||||
for c in cals
|
||||
],
|
||||
events=[_event_out(s, e) for e in rows],
|
||||
focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/calendar/day/{day}", response_model=DayBundleOut)
|
||||
def get_day(day: int, s: Session = Depends(get_session)):
|
||||
evs = s.exec(select(CalEvent).where(CalEvent.day == day).order_by(CalEvent.start)).all()
|
||||
fbs = s.exec(select(FocusBlock).where(FocusBlock.day == day).order_by(FocusBlock.start)).all()
|
||||
return DayBundleOut(
|
||||
day=day,
|
||||
events=[_event_out(s, e) for e in evs],
|
||||
focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/calendar/meetings/{mid}", response_model=MeetingOut)
|
||||
def get_meeting(mid: str, s: Session = Depends(get_session)):
|
||||
data = assemble_meeting(s, mid)
|
||||
if data is None:
|
||||
raise HTTPException(404, "meeting not found")
|
||||
return MeetingOut(**data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/calendar/meetings/{mid}/actions/{idx}/materialize", response_model=MaterializeResponse
|
||||
)
|
||||
def materialize_one(mid: str, idx: int, s: Session = Depends(get_session)):
|
||||
try:
|
||||
action, task = materialize_action(s, mid, idx)
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, "action not found") from e
|
||||
remaining = s.exec(
|
||||
select(MeetingAction).where(
|
||||
MeetingAction.meeting_id == mid, MeetingAction.added == False # noqa: E712
|
||||
)
|
||||
).all()
|
||||
all_tasks = s.exec(select(Task)).all()
|
||||
return MaterializeResponse(
|
||||
action=MeetingActionOut(
|
||||
id=action.id,
|
||||
idx=action.idx,
|
||||
text=action.text,
|
||||
who=action.who,
|
||||
when=action.when_text,
|
||||
added=action.added,
|
||||
materialized_task_id=action.materialized_task_id,
|
||||
),
|
||||
task=to_node(s, task, all_tasks),
|
||||
all_added=len(remaining) == 0,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/calendar/meetings/{mid}/actions/materialize-all",
|
||||
response_model=list[MaterializeResponse],
|
||||
)
|
||||
def materialize_remaining(mid: str, s: Session = Depends(get_session)):
|
||||
pairs = materialize_all(s, mid)
|
||||
all_tasks = s.exec(select(Task)).all()
|
||||
return [
|
||||
MaterializeResponse(
|
||||
action=MeetingActionOut(
|
||||
id=a.id,
|
||||
idx=a.idx,
|
||||
text=a.text,
|
||||
who=a.who,
|
||||
when=a.when_text,
|
||||
added=a.added,
|
||||
materialized_task_id=a.materialized_task_id,
|
||||
),
|
||||
task=to_node(s, t, all_tasks),
|
||||
all_added=True,
|
||||
)
|
||||
for a, t in pairs
|
||||
]
|
||||
|
||||
|
||||
@router.post("/calendar/focus/suggest", response_model=FocusSuggestResponse)
|
||||
def focus_suggest(body: FocusSuggestRequest, s: Session = Depends(get_session)):
|
||||
blocks = suggest_focus(s, body.day, body.min_minutes)
|
||||
# commit 전에 스냅샷(커밋 후 객체 expire 방지)
|
||||
out = [FocusBlockOut(**b.model_dump()) for b in blocks]
|
||||
if body.create:
|
||||
for b in blocks:
|
||||
s.add(b)
|
||||
s.commit()
|
||||
return FocusSuggestResponse(day=body.day, created=body.create, suggestions=out)
|
||||
@ -0,0 +1,106 @@
|
||||
# backend/app/routers/connectors.py — phase-13 외부 연동 API
|
||||
# prefix 없음. main.py 에서 include_router(prefix="/api").
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..config import get_settings
|
||||
from ..connectors import oauth as oauthlib
|
||||
from ..connectors.registry import ConnectorRegistry
|
||||
from ..db import get_session
|
||||
from ..models import ConnectorAccount, ConnectorAccountLog, ConnState
|
||||
from ..schemas import ConnectorStatusOut, ImportResultOut, OAuthStartOut, SyncResultOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/connectors", response_model=list[ConnectorStatusOut])
|
||||
def list_connectors(domain: str | None = None, s: Session = Depends(get_session)):
|
||||
rows = ConnectorRegistry.accounts(s, domain)
|
||||
return [ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain)) for a in rows]
|
||||
|
||||
|
||||
@router.post("/connectors/{account_id}/sync", response_model=SyncResultOut)
|
||||
def sync_one(account_id: str, full: bool = False, s: Session = Depends(get_session)):
|
||||
a = s.get(ConnectorAccount, account_id)
|
||||
if not a:
|
||||
raise HTTPException(404, "connector account not found")
|
||||
res = ConnectorRegistry.get(s, a).sync(s, full=full)
|
||||
return SyncResultOut(**res.__dict__)
|
||||
|
||||
|
||||
@router.post("/connectors/sync-all", response_model=list[SyncResultOut])
|
||||
def sync_all(s: Session = Depends(get_session)):
|
||||
out = []
|
||||
for a in ConnectorRegistry.accounts(s):
|
||||
if a.state == ConnState.disconnected: # 연결 안 된 계정은 건너뜀
|
||||
continue
|
||||
out.append(SyncResultOut(**ConnectorRegistry.get(s, a).sync(s).__dict__))
|
||||
return out
|
||||
|
||||
|
||||
# ── OAuth ──
|
||||
@router.get("/connectors/oauth/start", response_model=OAuthStartOut)
|
||||
def oauth_start(
|
||||
domain: str, provider: str, redirect_after: str = "/life", s: Session = Depends(get_session)
|
||||
):
|
||||
cfg = get_settings()
|
||||
if provider in ("gmail", "google_calendar") and not cfg.google_client_id:
|
||||
raise HTTPException(400, "google_client_id 미설정 — real 연동 비활성")
|
||||
if provider == "notion" and not cfg.notion_client_id:
|
||||
raise HTTPException(400, "notion_client_id 미설정 — real 연동 비활성")
|
||||
url = oauthlib.start_oauth(s, domain, provider, redirect_after)
|
||||
return OAuthStartOut(authorize_url=url)
|
||||
|
||||
|
||||
@router.get("/connectors/oauth/callback")
|
||||
def oauth_callback(code: str, state: str, s: Session = Depends(get_session)):
|
||||
origin = get_settings().frontend_origin
|
||||
try:
|
||||
acct = oauthlib.finish_oauth(s, code, state)
|
||||
except Exception:
|
||||
return RedirectResponse(url=f"{origin}/life?connect=error")
|
||||
try:
|
||||
ConnectorRegistry.get(s, acct).sync(s, full=True) # 연결 직후 1회 초기 sync
|
||||
except Exception:
|
||||
pass
|
||||
return RedirectResponse(url=f"{origin}/life?connect=ok&domain={acct.domain.value}")
|
||||
|
||||
|
||||
@router.post("/connectors/{account_id}/disconnect", response_model=ConnectorStatusOut)
|
||||
def disconnect(account_id: str, s: Session = Depends(get_session)):
|
||||
a = s.get(ConnectorAccount, account_id)
|
||||
if not a:
|
||||
raise HTTPException(404, "not found")
|
||||
a.state = ConnState.disconnected
|
||||
a.token_enc = ""
|
||||
a.last_label = "연결 안 됨"
|
||||
s.add(a)
|
||||
s.add(
|
||||
ConnectorAccountLog(
|
||||
id="cl-" + uuid.uuid4().hex[:8],
|
||||
account_id=a.id,
|
||||
action="disconnected",
|
||||
detail="사용자 해제",
|
||||
)
|
||||
)
|
||||
s.commit()
|
||||
return ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain))
|
||||
|
||||
|
||||
# ── 로컬 우선: 수동 임포트(CSV / .ics / HealthKit export) ──
|
||||
@router.post("/connectors/{account_id}/import", response_model=ImportResultOut)
|
||||
async def import_file(
|
||||
account_id: str, file: UploadFile = File(...), s: Session = Depends(get_session)
|
||||
):
|
||||
a = s.get(ConnectorAccount, account_id)
|
||||
if not a:
|
||||
raise HTTPException(404, "not found")
|
||||
conn = ConnectorRegistry.get(s, a)
|
||||
if not hasattr(conn, "import_bytes"):
|
||||
raise HTTPException(400, "이 커넥터는 파일 임포트를 지원하지 않아요")
|
||||
content = await file.read()
|
||||
res = conn.import_bytes(s, content, filename=file.filename or "")
|
||||
return ImportResultOut(**res)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue