Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,26 +1,66 @@
FROM python:3.11-slim
# Stage 1: Build Frontend
FROM node:22-slim AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci --fetch-timeout=600000 --fetch-retries=5
COPY frontend ./
# Pass dummy URL for build if needed
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
Comment thread
seonghobae marked this conversation as resolved.
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# Stage 2: Final Image (Python + Node.js)
FROM python:3.11-slim
WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app

# Install system dependencies if any are needed for pgvector/psycopg2
# Install system dependencies & Node.js
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev \
&& apt-get install -y --no-install-recommends gcc libpq-dev curl \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
Comment thread
seonghobae marked this conversation as resolved.
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*

# Install Backend dependencies
COPY backend/requirements.txt /app/requirements.txt
RUN PIP_ROOT_USER_ACTION=ignore PIP_DISABLE_PIP_VERSION_CHECK=1 \
pip install --no-cache-dir -r requirements.txt

# Copy Backend
COPY backend /app/

# Create a non-root user
RUN adduser --disabled-password --gecos "" appuser
# Copy Frontend runtime artifacts
COPY --from=frontend-builder /app/.next /app/frontend/.next
COPY --from=frontend-builder /app/public /app/frontend/public
COPY --from=frontend-builder /app/node_modules /app/frontend/node_modules
COPY --from=frontend-builder /app/package.json /app/frontend/package.json
COPY --from=frontend-builder /app/next.config.ts /app/frontend/next.config.ts

# Create a startup script
RUN echo '#!/bin/bash\n\
echo "Starting Naruon Backend and Frontend..."\n\
python scripts/bootstrap_db.py\n\
python scripts/start_backend.py --host 0.0.0.0 --port 8000 &\n\
BACKEND_PID=$!\n\
cd frontend && npm run start -- --hostname 0.0.0.0 --port 3000 &\n\
FRONTEND_PID=$!\n\
wait -n\n\
exit $?\n\
' > /app/start.sh && chmod +x /app/start.sh
Comment thread
seonghobae marked this conversation as resolved.

# Create non-root user
RUN useradd -m -s /bin/bash appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8000
# Environment variables for Frontend
ENV NEXT_PUBLIC_API_URL=http://localhost:8000
ENV BACKEND_INTERNAL_URL=http://127.0.0.1:8000
ENV ALLOW_DOCKER_BACKEND_INTERNAL_URL=1
Comment thread
seonghobae marked this conversation as resolved.

EXPOSE 3000 8000

CMD ["python", "scripts/start_backend.py", "--host", "0.0.0.0", "--port", "8000"]
CMD ["/app/start.sh"]
28 changes: 28 additions & 0 deletions frontend/src/components/DataLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,14 @@ export function DataLayout() {
style={{ width: `${embeddingStage?.progress_percent ?? 0}%` }}
></div>
</div>
<div className="mt-4 flex gap-2 justify-end">
<button type="button" className="rounded bg-secondary px-3 py-1.5 text-xs font-bold text-secondary-foreground hover:bg-secondary/80">
문서 업로드
</button>
<button type="button" className="rounded bg-primary/10 px-3 py-1.5 text-xs font-bold text-primary hover:bg-primary/20">
HWP 변환
</button>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>

<div className="rounded-2xl border border-border bg-card p-6 shadow-sm">
Expand Down Expand Up @@ -794,6 +802,12 @@ export function DataLayout() {
style={{ width: `${stage.progress_percent}%` }}
></div>
</div>
<div className="mt-3 flex justify-end">
<button type="button" disabled className="rounded bg-secondary px-2 py-1 text-xs font-bold text-secondary-foreground opacity-50 cursor-not-allowed flex items-center gap-1">
<RefreshCw className="h-3 w-3" />
{stage.stage_key.includes('parse') ? '재파싱 (준비 중)' : '재실행 (준비 중)'}
</button>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
))}
</div>
Expand Down Expand Up @@ -868,6 +882,12 @@ export function DataLayout() {
<dd className="mt-1 text-sm font-bold">{formatCount(collection.vector_dimensions)}</dd>
</div>
</dl>
<div className="mt-4 flex justify-end border-t border-border pt-3">
<button type="button" disabled className="rounded bg-primary/10 px-3 py-1.5 text-xs font-bold text-primary opacity-50 cursor-not-allowed flex items-center gap-1">
<RefreshCw className="h-3 w-3" />
임베딩 재생성 (준비 중)
</button>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</article>
))}
</div>
Expand All @@ -887,6 +907,14 @@ export function DataLayout() {
<span className={`mt-3 inline-flex rounded-full px-2 py-1 text-xs font-bold ${getSurfaceStatusClass(check.status_code)}`}>
{getSurfaceStatusLabel(check.status_code)}
</span>
<div className="mt-4 flex gap-2 justify-end border-t border-border pt-3">
<button type="button" className="rounded bg-secondary px-3 py-1.5 text-xs font-bold text-secondary-foreground hover:bg-secondary/80">
품질 점검
</button>
<button type="button" className="rounded bg-red-50 px-3 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100 border border-red-200">
격리
</button>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
))}
{dataSurfaceStatus === 'loading' && (
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/EmailDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,12 @@ describe("EmailDetail", () => {

const cards = Array.from(container.querySelectorAll<HTMLElement>('article[data-insight-card="true"]'));
expect(cards.map((card) => card.getAttribute("aria-label"))).toEqual(
expect.arrayContaining(["맥락 종합", "실행 항목", "답장 실행"]),
expect.arrayContaining(["맥락 종합", "실행 항목", "답장 초안"]),
);
expect(cards.find((card) => card.getAttribute("aria-label") === "답장 실행")?.querySelector('[role="heading"][aria-level="3"]')?.textContent).toContain("답장 실행");
expect(cards.find((card) => card.getAttribute("aria-label") === "답장 초안")?.querySelector('[role="heading"][aria-level="3"]')?.textContent).toContain("답장 초안");
expect(cards.find((card) => card.getAttribute("aria-label") === "맥락 종합")?.textContent).toContain("출시 메시지의 핵심 맥락입니다.");
expect(cards.find((card) => card.getAttribute("aria-label") === "실행 항목")?.textContent).toContain("캘린더에 출시 리뷰 일정을 반영");
expect(cards.find((card) => card.getAttribute("aria-label") === "답장 실행")?.querySelector('textarea[aria-label="답장 초안"]')).not.toBeNull();
expect(cards.find((card) => card.getAttribute("aria-label") === "답장 초안")?.querySelector('textarea[aria-label="답장 초안"]')).not.toBeNull();
});

it("lets users create tasks from visible execution items in the email detail", async () => {
Expand Down
45 changes: 35 additions & 10 deletions frontend/src/components/EmailDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,18 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
loading={!llmData && !llmError}
error={llmError}
provenance="AI 생성"
{/* TODO: API 연동 시 실제 llmData.confidence 값으로 대체 */}
Comment thread
seonghobae marked this conversation as resolved.
>
{llmData ? <p className="text-sm">{llmData.summary}</p> : null}
{llmData ? (
<div className="flex flex-col gap-2">
<p className="text-sm">{llmData.summary}</p>
<div className="flex justify-end">
<a href={`#msg-${email.id}`} className="text-[10px] text-primary hover:underline flex items-center gap-1 bg-primary/5 px-2 py-1 rounded">
근거 원본 보기
</a>
</div>
</div>
) : null}
</InsightCard>

<InsightCard
Expand All @@ -368,6 +378,7 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
empty={Boolean(llmData && llmData.todos.length === 0)}
emptyMessage="실행 항목이 없습니다."
provenance={`${llmData?.todos.length || 0}개 실행 항목`}
{/* TODO: API 연동 시 실제 llmData.confidence 값으로 대체 */}
footerActions={llmData && (llmData.todos.length > 0 || syncStatus || taskStatus) ? (
<>
{llmData.todos.length > 0 && (
Expand Down Expand Up @@ -423,12 +434,19 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
<Separator />

<div className="space-y-4 rounded-2xl border border-border bg-card p-4 shadow-sm">
<div className="flex items-center gap-2">
<h3 className="text-sm font-black text-foreground">대화 흐름</h3>
<Badge variant="secondary" className="text-[10px] flex items-center gap-1 border border-primary/10 bg-primary/10 text-primary">
<MessagesSquare className="w-3 h-3" />
{conversationMessages.length}개 메시지
</Badge>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold flex items-center gap-2">
<MessagesSquare className="w-4 h-4 text-primary" /> 스레드 전체
</h3>
<Badge variant="secondary" className="text-[10px] flex items-center gap-1 border border-primary/10 bg-primary/10 text-primary">
<MessagesSquare className="w-3 h-3" />
{conversationMessages.length}개 메시지
</Badge>
</div>
<Button size="sm" variant="outline" className="h-7 text-xs bg-white text-muted-foreground hover:text-foreground">
다른 스레드 병합
</Button>
</div>
<p className="text-xs text-muted-foreground">오래된 메시지부터 최신 메시지 순서로 보여줍니다. 답장은 선택된 메시지를 기준으로 작성됩니다.</p>
{threadLoading && <p role="status" aria-live="polite" className="text-sm text-muted-foreground">대화 흐름을 불러오는 중입니다...</p>}
Expand All @@ -440,10 +458,17 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
)}
<div className="space-y-4">
{conversationMessages.map((msg) => (
<div key={msg.id} className={`rounded-2xl border p-4 text-card-foreground ${msg.id === email.id ? 'border-primary/60 bg-primary/5 shadow-sm' : 'border-border bg-background/60'}`} aria-current={msg.id === email.id ? "true" : undefined}>
<div id={`msg-${msg.id}`} key={msg.id} className={`rounded-2xl border p-4 text-card-foreground ${msg.id === email.id ? 'border-primary/60 bg-primary/5 shadow-sm' : 'border-border bg-background/60'}`} aria-current={msg.id === email.id ? "true" : undefined}>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{toMailDisplayText(msg.sender, '보낸 사람')}</span>
<span className="text-xs text-muted-foreground">{formatEmailDate(msg.date)}</span>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{formatEmailDate(msg.date)}</span>
{msg.id !== conversationMessages[0]?.id && (
<Button size="sm" variant="ghost" className="h-6 px-2 text-[10px] text-muted-foreground hover:text-red-600 hover:bg-red-50">
스레드 분리
</Button>
)}
</div>
</div>
{msg.id === email.id && <Badge variant="outline" className="mb-2 border-primary/30 text-[10px] text-primary">선택된 메시지</Badge>}
<div className="text-sm leading-6 whitespace-pre-wrap">{toMailBodyText(msg.body)}</div>
Expand All @@ -454,7 +479,7 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number

<Separator />

<InsightCard title="답장 실행" provenance="사용자 확인 필요">
<InsightCard title="답장 초안" provenance="사용자 확인 필요">
<div className="flex flex-col sm:flex-row sm:items-end gap-2 justify-between">
<div className="space-y-1.5 flex-1 max-w-sm">
<label htmlFor="reply-instruction" className="sr-only">AI 답장 지시</label>
Expand Down
29 changes: 23 additions & 6 deletions frontend/src/components/InsightCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface InsightCardProps {
emptyMessage?: string;
onRetry?: () => void;
provenance?: string;
confidence?: number;
children: ReactNode;
footerActions?: ReactNode;
}
Expand All @@ -27,6 +28,7 @@ export function InsightCard({
emptyMessage = "데이터가 없습니다.",
onRetry,
provenance,
confidence,
children,
footerActions,
}: InsightCardProps) {
Expand All @@ -38,13 +40,28 @@ export function InsightCard({
{icon && <span className="text-primary">{icon}</span>}
{title}
</CardTitle>
{provenance && (
<div className="flex items-center text-[10px] text-muted-foreground bg-white/60 px-2 py-1 rounded-full border border-primary/10 shadow-sm" title="출처/사용된 모델">
<Info className="w-3 h-3 mr-1 text-primary/70" />
{provenance}
<div className="flex items-center gap-2">
{confidence !== undefined && (
<div
className={`flex items-center text-[10px] font-medium px-2 py-1 rounded-full shadow-sm border ${
confidence >= 80 ? 'bg-emerald-50 text-emerald-700 border-emerald-200' :
confidence >= 50 ? 'bg-amber-50 text-amber-700 border-amber-200' :
'bg-red-50 text-red-700 border-red-200'
}`}
title="AI 판단 확신도"
>
<span className="mr-1">신뢰도</span>
{confidence}%
</div>
)}
{provenance && (
<div className="flex items-center text-[10px] text-muted-foreground bg-white/60 px-2 py-1 rounded-full border border-primary/10 shadow-sm" title="출처/사용된 모델">
<Info className="w-3 h-3 mr-1 text-primary/70" />
{provenance}
</div>
)}
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
</CardHeader>
</CardHeader>

<CardContent className="flex-1 p-4 overflow-auto">
{loading ? (
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/TasksLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ export function TasksLayout() {
value={taskSearch}
onChange={(event) => setTaskSearch(event.target.value)}
placeholder="작업 검색..."
aria-label="작업 검색"
className="h-9 w-full rounded-md border border-border bg-background pl-9 pr-4 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary sm:w-64"
/>
</div>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/WorkspaceHome.dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ vi.mock("lucide-react", () => ({
Network: () => <svg aria-hidden="true" />,
Send: () => <svg aria-hidden="true" />,
Settings: () => <svg aria-hidden="true" />,
Sparkles: () => <svg aria-hidden="true" />,
}));

import { WorkspaceHome } from "./WorkspaceHome";
Expand Down Expand Up @@ -344,7 +345,7 @@ describe("WorkspaceHome Today dashboard", () => {
expect(linkHrefByText("메일함 열기")).toBe("/mail");
expect(linkHrefByText("보낸 메일 답변 추적")).toBe("/mail?folder=sent");
expect(linkHrefByText("일정 후보 검토")).toBe("/calendar");
expect(linkHrefByText("작업 보드")).toBe("/tasks");
expect(linkHrefByText("실행 항목 보드")).toBe("/tasks");
expect(linkHrefByText("프로젝트 의사결정")).toBe("/projects");
expect(linkHrefByText("AI 허브")).toBe("/ai-hub");
expect(linkHrefByText("데이터 품질 점검")).toBe("/data");
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/WorkspaceHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { MailFolder } from '@/components/EmailList';
import { EmailDetail } from '@/components/EmailDetail';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import dynamic from 'next/dynamic';
import { CalendarDays, CheckCircle2, Inbox, Network, Send, Settings } from 'lucide-react';
import { CalendarDays, CheckCircle2, Inbox, Network, Send, Settings, Sparkles } from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { setMobileWorkspaceView, useMobileWorkspaceView } from '@/lib/mobile-workspace';
import { toSafeReactText } from '@/lib/safe-text';
Expand Down Expand Up @@ -90,9 +90,9 @@ const dashboardQuickActions = [
{ label: '메일함 열기', href: '/mail', icon: Inbox, color: 'text-blue-500' },
{ label: '보낸 메일 답변 추적', href: '/mail?folder=sent', icon: Send, color: 'text-rose-500' },
{ label: '일정 후보 검토', href: '/calendar', icon: CalendarDays, color: 'text-blue-500' },
{ label: '작업 보드', href: '/tasks', icon: CheckCircle2, color: 'text-green-500' },
{ label: '실행 항목 보드', href: '/tasks', icon: CheckCircle2, color: 'text-green-500' },
{ label: '프로젝트 의사결정', href: '/projects', icon: Network, color: 'text-purple-500' },
{ label: 'AI 허브', href: '/ai-hub', icon: Network, color: 'text-purple-500' },
{ label: 'AI 허브', href: '/ai-hub', icon: Sparkles, color: 'text-purple-500' },
{ label: '데이터 품질 점검', href: '/data', icon: Network, color: 'text-blue-500' },
{ label: '보안 감사 로그', href: '/security', icon: CheckCircle2, color: 'text-emerald-500' },
];
Expand Down Expand Up @@ -821,7 +821,7 @@ export function WorkspaceHome({
<Network className="size-4" aria-hidden="true" />
</span>
<div>
<h3 className="font-bold text-sm text-foreground">맥락 그래프</h3>
<h3 className="font-bold text-sm text-foreground">관계 맥락</h3>
<p className="text-xs text-muted-foreground">메일과 관계의 흐름을 시각화합니다.</p>
</div>
</div>
Expand Down
Loading