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
I Luk Kim 4 months ago
parent b3d21f17d2
commit a7f830b420

@ -1,16 +1,38 @@
name: ci
on: [push, pull_request]
jobs:
backend:
backend: # 전건(데모 무손상 + 멀티유저 격리 + 인젝션 + N+1 포함)
runs-on: ubuntu-latest
env: { EMBED_PROVIDER: heuristic, AGENT_PROVIDER: scripted }
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync
working-directory: backend
- run: uv run ruff check .
- run: uv run ruff check app tests
working-directory: backend
- run: uv run pytest -q # federation/nplus1/golden 포함
- run: uv run pytest -q # federation/nplus1/golden/auth/multiuser 포함
working-directory: backend
- run: uv run pip-audit || true # 보안(경고)
working-directory: backend
auth-multiuser: # phase-15 인증 켠 상태 격리 검증
runs-on: ubuntu-latest
env: { AUTH_ENABLED: "true", EMBED_PROVIDER: heuristic, AGENT_PROVIDER: scripted }
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync
working-directory: backend
- run: AUTH_ENABLED=true uv run pytest tests/test_auth.py tests/test_multiuser_isolation.py tests/test_export.py tests/test_ready.py -q
working-directory: backend
migrations: # Alembic 왕복(전 phase 스키마)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync && uv run alembic upgrade head && uv run alembic downgrade base && uv run alembic upgrade head
working-directory: backend
frontend-unit:

@ -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)

@ -25,6 +25,55 @@ class Settings(BaseSettings):
# 테스트 리셋 게이트 (테스트 환경에서만 시드 리셋 허용)
ari_allow_test_reset: bool = False
# ---- 포스트-MVP 커넥터 (CONNECTOR_<DOMAIN>=mock|real, 기본 mock) ----
connector_calendar: str = "mock" # phase-8/13
connector_mail: str = "mock" # phase-9/13
connector_chat: str = "mock" # phase-9/13
connector_finance: str = "mock" # phase-11/13
connector_health: str = "mock" # phase-11/13
connector_knowledge: str = "mock" # phase-11/13
# ---- 포스트-MVP 에이전트 / RAG (phase-10) ----
embed_provider: str = "ollama" # EMBED_PROVIDER=ollama|heuristic
embed_model: str = "nomic-embed-text" # EMBED_MODEL (모델 비종속, env 교체)
agent_provider: str = "auto" # AGENT_PROVIDER=auto|tool|scripted
web_search_provider: str = "mock" # WEB_SEARCH_PROVIDER=mock|real
# ---- phase-13: 외부 연동 토큰 암호화 + OAuth 자격증명 + 동기화 정책 ----
# 토큰 암호화 키(real 켤 때 필수). Fernet 키 파생용.
ari_secret_key: str = "dev-insecure-change-me-please-32bytes!"
# OAuth2 자격증명(제공자별). 비어 있으면 해당 RealConnector 비활성.
google_client_id: str = "" # Gmail + Google Calendar 공용
google_client_secret: str = ""
google_redirect_uri: str = "http://localhost:31800/api/connectors/oauth/callback"
notion_client_id: str = ""
notion_client_secret: str = ""
readwise_token: str = "" # Readwise 는 단순 토큰(OAuth 아님)
# 동기화 정책
sync_interval_minutes: int = 15 # worker 주기 sync
sync_page_size: int = 50
connector_http_timeout: float = 20.0
# ---- phase-15: 인증/멀티유저 + 관측 + 운영 ----
auth_enabled: bool = False # AUTH_ENABLED 기본 off(데모 무손상)
session_secret: str = "dev-insecure-session-secret-change-me" # 세션 쿠키 서명
session_cookie_name: str = "ari_session"
session_ttl_s: int = 1209600 # 14일
cookie_secure: bool = False # prod=true (HTTPS 전용)
ari_env: str = "dev" # prod 이면 기본 비밀 거부
log_level: str = "INFO"
ollama_max_concurrency: int = 2
ollama_timeout_s: float = 12.0
demo_admin_email: str = "jiwoo@lumi.co"
demo_admin_password: str = "demo-1234"
# ---- phase-14: 멀티모달 캡처 + 능동 워커 ----
stt_provider: str = "auto" # STT_PROVIDER auto|ollama|heuristic
stt_model: str = "whisper" # STT_MODEL (모델 비종속, 주입)
vision_provider: str = "auto" # VISION_PROVIDER auto|ollama|heuristic
vision_model: str = "llava" # VISION_MODEL (placeholder, 주입)
worker_enabled: bool = False # WORKER_ENABLED 스케줄러 on/off(기본 수동 트리거)
@lru_cache
def get_settings() -> Settings:

@ -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,100 @@
# backend/app/connectors/registry.py — CONNECTOR_<DOMAIN> 로 mock|real 선택하는 단일 지점.
# 페이지/라우터는 항상 레지스트리를 통해서만 sync 커넥터를 얻는다(코드 불변의 핵심).
from sqlmodel import Session, select
from ..config import get_settings
from ..models import ConnectorAccount
from .base import BaseConnector
def _impl(domain: str, mode: str):
"""도메인 × 모드 → 구현 클래스 (lazy import 로 의존성 격리)."""
if domain == "mail":
from .mail.mock import MockMailConnector
from .mail.real_gmail import GmailConnector
from .mail.real_imap import ImapConnector
return {"mock": MockMailConnector, "real": GmailConnector, "imap": ImapConnector}.get(
mode, MockMailConnector
)
if domain == "calendar":
from .calendar.ics_import import IcsConnector
from .calendar.mock import MockCalendarConnector
from .calendar.real_google import GoogleCalendarConnector
return {
"mock": MockCalendarConnector,
"real": GoogleCalendarConnector,
"ics": IcsConnector,
}.get(mode, MockCalendarConnector)
if domain == "finance":
from .finance.csv_import import CsvFinanceConnector
from .finance.mock import MockFinanceConnector
from .finance.real_aggregator import AggregatorConnector
return {
"mock": MockFinanceConnector,
"csv": CsvFinanceConnector,
"real": AggregatorConnector,
}.get(mode, MockFinanceConnector)
if domain == "health":
from .health.healthkit_import import HealthKitConnector
from .health.mock import MockHealthConnector
from .health.real_fit import GoogleFitConnector
return {
"mock": MockHealthConnector,
"healthkit": HealthKitConnector,
"real": GoogleFitConnector,
}.get(mode, MockHealthConnector)
if domain == "knowledge":
from .knowledge.clipper import ClipperConnector
from .knowledge.mock import MockKnowledgeConnector
from .knowledge.real_notion import NotionConnector
return {
"mock": MockKnowledgeConnector,
"real": NotionConnector,
"clipper": ClipperConnector,
}.get(mode, MockKnowledgeConnector)
if domain == "chat":
from .chat.mock import MockChatConnector
from .chat.real_webhook import WebhookChatConnector
return {"mock": MockChatConnector, "real": WebhookChatConnector}.get(
mode, MockChatConnector
)
raise ValueError(f"unknown domain {domain}")
class ConnectorRegistry:
@staticmethod
def mode_for(domain) -> str:
dom = domain.value if hasattr(domain, "value") else str(domain)
return getattr(get_settings(), f"connector_{dom}", "mock")
@staticmethod
def effective_mode(account: ConnectorAccount) -> str:
"""모드 결정 정본:
- 계정 mode 가 mock 이 아니면(real/csv/healthkit/ics) 그 모드를 쓴다(연결된 계정).
- 시드 mock 계정은 env=real 이어도 mock 유지(데모 결정성).
- csv/healthkit/ics env 면 그 모드를 따른다(로컬 우선 임포트)."""
env_mode = ConnectorRegistry.mode_for(account.domain)
acct_mode = account.mode.value if hasattr(account.mode, "value") else str(account.mode)
if acct_mode != "mock":
return acct_mode
return "mock" if env_mode == "real" else env_mode
@staticmethod
def get(session: Session, account: ConnectorAccount) -> BaseConnector:
mode = ConnectorRegistry.effective_mode(account)
return _impl(
account.domain.value if hasattr(account.domain, "value") else account.domain, mode
)(account)
@staticmethod
def accounts(session: Session, domain: str | None = None) -> list[ConnectorAccount]:
q = select(ConnectorAccount)
if domain:
q = q.where(ConnectorAccount.domain == domain)
return session.exec(q.order_by(ConnectorAccount.id)).all()

@ -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 처리)

@ -5,9 +5,7 @@ from .config import get_settings
settings = get_settings()
# SQLite + FastAPI: check_same_thread=False 필요
connect_args = (
{"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
)
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, echo=False, connect_args=connect_args)

@ -10,7 +10,9 @@ RE_TIME = re.compile(
r"월요|화요|수요|목요|금요|토요|일요|내일|모레|오늘)"
)
# 행동 동사 + 기한 → task
RE_ACTION = re.compile(r"(사기|구매|하기|맡기기|알아보기|보내기|작성|정리|예약|받|비교|확인|회신|준비)") # noqa: E501
RE_ACTION = re.compile(
r"(사기|구매|하기|맡기기|알아보기|보내기|작성|정리|예약|받|비교|확인|회신|준비)"
) # noqa: E501
# work / life 키워드
RE_WORK = re.compile(r"(미팅|회의|리포트|온보딩|OKR|리뷰|배포|스프린트|기획|디자인|개발|보고|발표)")
RE_LIFE = re.compile(r"(가족|여행|선물|구독|병원|생신|자전거|집|수영장|운동|독서|엄마|아빠|티켓)")
@ -65,9 +67,17 @@ class HeuristicProvider(LLMProvider):
conf = 0.85 if has_clock or has_action else 0.6
return Classification(
type=ctype, sphere=sphere, project_id=project_id, proj_label=proj_label,
tone=tone, due_text=due_text, when_text=when_text, extra=extra,
reason=reason, confidence=conf, model="heuristic",
type=ctype,
sphere=sphere,
project_id=project_id,
proj_label=proj_label,
tone=tone,
due_text=due_text,
when_text=when_text,
extra=extra,
reason=reason,
confidence=conf,
model="heuristic",
)
def _match_project(self, text, sphere, context):

@ -3,7 +3,7 @@ import json
import httpx
from ..config import get_settings
from ..runtime_config import effective_llm
from .heuristic import HeuristicProvider
from .prompts import CLASSIFY_SYSTEM, build_classify_prompt
from .provider import Classification, LLMProvider
@ -12,11 +12,26 @@ from .provider import Classification, LLMProvider
class OllamaProvider(LLMProvider):
name = "ollama"
def __init__(self):
st = get_settings()
self.host = st.ollama_host
self.model = st.ollama_model # 비종속: env 주입값 그대로 사용
self.timeout = st.llm_timeout
def __init__(
self,
host: str | None = None,
model: str | None = None,
timeout: float | None = None,
):
# 유효 설정(런타임 오버레이 > .env). 인자가 주어지면 그 값으로 덮어쓴다(연결 테스트용).
eff = effective_llm()
self.host = host or eff.host
self.model = model or eff.model # 비종속: 주입값 그대로 사용
self.timeout = timeout if timeout is not None else eff.timeout
def list_models(self) -> list[str]:
try:
r = httpx.get(f"{self.host}/api/tags", timeout=3.0)
if r.status_code != 200:
return []
return [m.get("name", "") for m in r.json().get("models", []) if m.get("name")]
except Exception:
return []
def health(self) -> dict:
try:
@ -24,13 +39,19 @@ class OllamaProvider(LLMProvider):
ok = r.status_code == 200
tags = [m.get("name") for m in r.json().get("models", [])] if ok else []
return {
"reachable": ok, "provider": "ollama", "model": self.model,
"host": self.host, "detail": f"models={tags}",
"reachable": ok,
"provider": "ollama",
"model": self.model,
"host": self.host,
"detail": f"models={tags}",
}
except Exception as e:
return {
"reachable": False, "provider": "ollama", "model": self.model,
"host": self.host, "detail": str(e),
"reachable": False,
"provider": "ollama",
"model": self.model,
"host": self.host,
"detail": str(e),
}
def generate_json(self, prompt: str, schema: dict | None = None) -> dict:
@ -79,7 +100,8 @@ def _coerce(data: dict, raw: str, context: dict, model: str) -> Classification:
t = data.get("type") if data.get("type") in ("task", "event", "idea") else h.type
sp = data.get("sphere") if data.get("sphere") in ("work", "life") else h.sphere
return Classification(
type=t, sphere=sp,
type=t,
sphere=sp,
project_id=data.get("project_id") or h.project_id,
proj_label=data.get("proj_label") or h.proj_label,
tone=data.get("tone") or h.tone,

@ -10,9 +10,7 @@ CLASSIFY_SYSTEM = (
def build_classify_prompt(raw: str, context: dict) -> str:
projects = context.get("projects", [])
proj_lines = "\n".join(
f"- {p['id']} / {p['name']} ({p.get('folder_id')})" for p in projects
)
proj_lines = "\n".join(f"- {p['id']} / {p['name']} ({p.get('folder_id')})" for p in projects)
return (
f'메모: "{raw}"\n\n'
f"기존 프로젝트 트리:\n{proj_lines}\n\n"

@ -4,8 +4,6 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from ..config import get_settings
@dataclass
class Classification:
@ -25,6 +23,10 @@ class Classification:
class LLMProvider(ABC):
name: str = "base"
def tool_capable(self) -> bool:
"""tool-call(함수 호출) 가능한 모델인가. 기본 False → 에이전트는 scripted 폴백."""
return False
@abstractmethod
def health(self) -> dict: ...
@ -34,17 +36,26 @@ class LLMProvider(ABC):
@abstractmethod
def classify_capture(self, raw: str, context: dict) -> Classification: ...
def list_models(self) -> list[str]:
"""사용 가능한 모델 목록(설정 페이지 드롭다운용). 기본 빈 목록."""
return []
_cached: LLMProvider | None = None
def get_provider(force: str | None = None) -> LLMProvider:
"""LLM_PROVIDER=auto|ollama|heuristic. auto면 ollama health 확인 후 폴백."""
"""LLM_PROVIDER=auto|ollama|heuristic. auto면 ollama health 확인 후 폴백.
force 가 없으면 런타임 오버레이(설정 페이지 저장값) → .env 순으로 유효 provider 를 정한다.
오버레이가 비어 있으면 .env 와 동일(데모/테스트 무손상). force 는 항상 최우선(테스트 결정성).
"""
global _cached
from ..runtime_config import effective_llm
from .heuristic import HeuristicProvider
from .ollama import OllamaProvider
mode = force or get_settings().llm_provider
mode = force or effective_llm().provider
if mode == "heuristic":
return HeuristicProvider()
if mode == "ollama":

@ -5,16 +5,74 @@ from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings
from .db import init_db
from .routers import _test, dashboard, inbox, llm, people, tasks, tree
from .db import engine, init_db
from .routers import (
_test,
agents,
approvals,
automation,
calendar,
connectors,
dashboard,
inbox,
journey,
life,
llm,
mail,
notify,
ops,
people,
research,
tasks,
tree,
trip,
worker,
)
from .routers import (
auth as auth_router,
)
from .routers import (
settings as settings_router,
)
settings = get_settings()
health_router = APIRouter() # 내부 prefix 없음. /health → /api/health
def _register_event_bus() -> None:
"""전역 event_bus 에 evaluator/suggester 핸들러 등록(앱 런타임 전용).
테스트는 lifespan 을 띄우지 않으므로 전역 버스에 구독자가 없어 결정적이다."""
from sqlmodel import Session
from .automation import evaluator, suggester
from .automation.event_bus import bus
def session_factory():
return Session(engine)
evaluator.register(bus, session_factory)
suggester.register(bus, session_factory)
# phase-14: 결재 승인(approval.executed) → 승인된 심부름 이어 실행
from .worker.main import register_event_subscribers
register_event_subscribers(bus, session_factory)
@asynccontextmanager
async def lifespan(app: FastAPI):
from .observability.logging import configure_logging
configure_logging(settings.log_level)
init_db() # 개발 편의(운영은 alembic). 테이블 보장.
_register_event_bus()
# 설정 페이지의 LLM 오버레이(app_setting)를 인메모리로 적재(앱 런타임 전용).
from sqlmodel import Session
from . import runtime_config
with Session(engine) as _s:
runtime_config.load_from_db(_s)
yield
@ -26,6 +84,10 @@ app.add_middleware(
allow_headers=["*"],
allow_credentials=True,
)
# phase-15: 요청 컨텍스트(request_id) + 접근 로그 + 메트릭
from .observability.middleware import RequestContextMiddleware # noqa: E402
app.add_middleware(RequestContextMiddleware)
# health: 라우터 내부 prefix 없이 "/health" 로 정의 → prefix="/api" 등록 시 /api/health.
@ -42,5 +104,29 @@ app.include_router(tasks.router, prefix="/api", tags=["tasks"])
app.include_router(inbox.router, prefix="/api", tags=["inbox"])
app.include_router(dashboard.router, prefix="/api", tags=["dashboard"])
app.include_router(llm.router, prefix="/api", tags=["llm"])
# phase-7: 자율성 코어
app.include_router(approvals.router, prefix="/api", tags=["approvals"])
app.include_router(automation.router, prefix="/api", tags=["automation"])
# phase-8: 일정 + 회의
app.include_router(calendar.router, prefix="/api", tags=["calendar"])
# phase-9: 메일 + 알림
app.include_router(mail.router, prefix="/api", tags=["mail"])
app.include_router(notify.router, prefix="/api", tags=["notify"])
# phase-10: 리서치 + 여행
app.include_router(research.router, prefix="/api", tags=["research"])
app.include_router(trip.router, prefix="/api", tags=["trip"])
# phase-11: 라이프 케어
app.include_router(life.router, prefix="/api", tags=["life"])
# phase-12: 여정
app.include_router(journey.router, prefix="/api", tags=["journey"])
app.include_router(connectors.router, prefix="/api", tags=["connectors"])
app.include_router(agents.router, prefix="/api", tags=["agents"])
app.include_router(worker.router, prefix="/api", tags=["worker"])
app.include_router(auth_router.router, prefix="/api", tags=["auth"])
app.include_router(settings_router.router, prefix="/api", tags=["settings"])
app.include_router(ops.router, prefix="/api", tags=["ops"])
# 테스트 전용 리셋(ARI_ALLOW_TEST_RESET=1 가드, 운영 403)
app.include_router(_test.router, prefix="/api", tags=["test"])

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]

@ -19,4 +19,8 @@ def reset_seed(session: Session = Depends(get_session)):
SQLModel.metadata.create_all(engine)
run_seed(session=session, reset=True)
session.commit()
# 설정 LLM 오버레이도 초기화(같은 프로세스 내 잔여 제거 → 테스트/데모 결정성).
from app import runtime_config
runtime_config.clear()
return {"status": "reset"}

@ -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)

@ -33,7 +33,8 @@ def dashboard(s: Session = Depends(get_session)):
br = s.exec(select(Briefing)).first()
if br:
weather = WeatherOut(
temp=br.weather_temp, cond=br.weather_cond,
temp=br.weather_temp,
cond=br.weather_cond,
icon=WEATHER_ICON_MAP.get(br.weather_icon, br.weather_icon),
)
briefing = BriefingOut(
@ -42,8 +43,11 @@ def dashboard(s: Session = Depends(get_session)):
saved_today, today_routed = br.saved_today, br.today_routed
else:
briefing = BriefingOut(
today="", weather=WeatherOut(temp=0, cond="", icon="sun"),
commute="", sleep="", note="",
today="",
weather=WeatherOut(temp=0, cond="", icon="sun"),
commute="",
sleep="",
note="",
)
saved_today, today_routed = "", 0
@ -99,7 +103,14 @@ def dashboard(s: Session = Depends(get_session)):
badges = BadgesOut(appr=len(high), task=len(open_tasks), noti=6)
return DashboardOut(
user=user, briefing=briefing, saved_today=saved_today, today_routed=today_routed,
schedule=schedule, task_summary=task_summary, goals=goals,
approvals_summary=approvals_summary, inbox_recent=inbox_recent, badges=badges,
user=user,
briefing=briefing,
saved_today=saved_today,
today_routed=today_routed,
schedule=schedule,
task_summary=task_summary,
goals=goals,
approvals_summary=approvals_summary,
inbox_recent=inbox_recent,
badges=badges,
)

@ -1,26 +1,55 @@
# backend/app/routers/inbox.py
import uuid
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlmodel import Session, select
from ..auth.deps import current_user
from ..auth.scope import scoped
from ..db import get_session
from ..llm.provider import LLMProvider, get_provider
from ..models import InboxClassification, InboxItem, InboxStatus, Prio, Project, Task, TaskStatus
from ..models import (
InboxClassification,
InboxItem,
InboxStatus,
Person,
Prio,
Project,
Task,
TaskStatus,
)
from ..multimodal.factory import get_stt, get_vision
from ..routers.tasks import to_node
from ..schemas import (
CaptionOut,
CaptureRequest,
CaptureResponse,
ClassificationOut,
ConfirmResponse,
InboxItemOut,
ReclassifyRequest,
TranscribeOut,
)
from ..services.classification import classify, persist_classification
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
# ── phase-14: 멀티모달 캡처(음성→텍스트 / 이미지→캡션). 분류 경로는 phase-4 capture 재사용. ──
@router.post("/inbox/transcribe", response_model=TranscribeOut)
async def transcribe(audio: UploadFile = File(...), hint: str = Form("")):
data = await audio.read()
t = get_stt().transcribe(data, mime=audio.content_type or "audio/webm", hint=hint)
return TranscribeOut(text=t.text, seconds=t.seconds, model=t.model, confidence=t.confidence)
@router.post("/inbox/caption", response_model=CaptionOut)
async def caption(image: UploadFile = File(...), hint: str = Form("")):
data = await image.read()
c = get_vision().describe(data, mime=image.content_type or "image/jpeg", hint=hint)
return CaptionOut(text=c.text, ocr=c.ocr, model=c.model, confidence=c.confidence)
def latest_cls(s: Session, item_id: str) -> InboxClassification | None:
# id 가 TEXT PK(UUID)라 정렬 키로 부적합 → created_at 기준 최신.
rows = s.exec(
@ -35,15 +64,21 @@ def item_out(s: Session, item: InboxItem) -> InboxItemOut:
c = latest_cls(s, item.id)
cls = ClassificationOut.model_validate(c, from_attributes=True) if c else None
return InboxItemOut(
id=item.id, kind=item.kind, raw=item.raw, status=item.status,
created_at=item.created_at, materialized_task_id=item.materialized_task_id,
id=item.id,
kind=item.kind,
raw=item.raw,
status=item.status,
created_at=item.created_at,
materialized_task_id=item.materialized_task_id,
classification=cls,
)
@router.get("/inbox", response_model=list[InboxItemOut])
def list_inbox(s: Session = Depends(get_session)):
items = s.exec(select(InboxItem).order_by(InboxItem.created_at.desc())).all()
def list_inbox(s: Session = Depends(get_session), user: Person = Depends(current_user)):
items = s.exec(
scoped(select(InboxItem), InboxItem, user.id).order_by(InboxItem.created_at.desc())
).all()
return [item_out(s, it) for it in items]
@ -52,9 +87,10 @@ def capture(
body: CaptureRequest,
s: Session = Depends(get_session),
provider: LLMProvider = Depends(get_provider),
user: Person = Depends(current_user),
):
iid = "s-" + uuid.uuid4().hex[:8]
item = InboxItem(id=iid, kind=body.kind, raw=body.raw, status=InboxStatus.new)
item = InboxItem(id=iid, user_id=user.id, kind=body.kind, raw=body.raw, status=InboxStatus.new)
s.add(item)
s.commit()
s.refresh(item)
@ -113,8 +149,14 @@ def confirm(iid: str, s: Session = Depends(get_session)):
notes += " " + c.extra # 예: 비행기 티켓 → "가격 추적 알림 켜둠"
tid = "t-" + uuid.uuid4().hex[:8]
created = Task(
id=tid, title=item.raw, project_id=pid, status=TaskStatus.todo,
assignee_id="jiwoo", prio=Prio.normal, due=None, notes=notes,
id=tid,
title=item.raw,
project_id=pid,
status=TaskStatus.todo,
assignee_id="jiwoo",
prio=Prio.normal,
due=None,
notes=notes,
)
s.add(created)
s.commit()

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save