Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b8524192d | |||
| 279124d953 | |||
| c8600f8046 | |||
| 7d06816bac | |||
| 66a906a156 | |||
| 5bde1c765c | |||
| e817a0abfc | |||
| a1a46f2a2b | |||
| 126f633d32 | |||
| 058183d3ff | |||
| 73d7683eda | |||
| 36c6ff8046 | |||
| 7e5988cb20 | |||
| f24d35681f | |||
| 547a533e8b | |||
| 2c8b6808b9 | |||
| 1eda37ba16 | |||
| 6323ad7f08 | |||
| 48de08da39 | |||
| 16313f8f35 |
@@ -1,8 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
/// Injectable base URL. Public TLS by default; Tailscale alternative uses a MagicDNS hostname
|
||||
/// (NOT a hardcoded 100.x IP, which changes on node re-registration). Scaffold never makes a live
|
||||
/// call, so the Tailscale host is a placeholder until FU-A.
|
||||
/// Injectable base URL. Public TLS by default; Tailscale alternative = GPU 서버 canonical
|
||||
/// Tailscale IP (infra_inventory.md 단일 진실, 2026-06-07 사용자 확정 — DS 본체 = GPU 서버 유지,
|
||||
/// contract/CONTRACT.md·CompositionTests 와 동일 값).
|
||||
public enum DSBaseURL: Sendable {
|
||||
case publicTLS
|
||||
case tailscale
|
||||
@@ -11,7 +11,7 @@ public enum DSBaseURL: Sendable {
|
||||
public var url: URL {
|
||||
switch self {
|
||||
case .publicTLS: return URL(string: "https://document.hyungi.net/api")!
|
||||
case .tailscale: return URL(string: "http://ds-gpu.tailnet-name.ts.net:8000/api")!
|
||||
case .tailscale: return URL(string: "http://100.110.63.63:8000/api")!
|
||||
case .custom(let u): return u
|
||||
}
|
||||
}
|
||||
|
||||
+10
-16
@@ -114,20 +114,14 @@ services:
|
||||
start_period: 300s
|
||||
restart: unless-stopped
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
ports:
|
||||
- "127.0.0.1:11434:11434"
|
||||
restart: unless-stopped
|
||||
# ── ollama 서비스 제거 (2026-06-08) ──
|
||||
# 정본 ollama = standalone `~/ollama/docker-compose.yml`(container_name: ollama).
|
||||
# 그 컨테이너가 hyungi_document_server_default 망(external) + 동일 볼륨
|
||||
# hyungi_document_server_ollama_data(external, bge-m3) 부착으로 fastapi 의 `ollama:11434`
|
||||
# 임베딩을 이미 서빙(재부팅에도 durable). 본 중복 서비스는 같은 host 127.0.0.1:11434 를
|
||||
# 점유 다퉈, 재부팅 후 `docker compose up` 을 'port already allocated' 로 abort →
|
||||
# 뒤 의존서비스(caddy·frontend) 미기동 = 웹 outage 유발 → 제거. (ollama_data 볼륨 def 는
|
||||
# standalone 이 external 로 참조하므로 아래 volumes: 에 보존.)
|
||||
|
||||
# Phase 1.3: bge-reranker-v2-m3 (TEI) — internal only, fastapi에서 reranker:80으로 호출
|
||||
# fastapi가 depends_on 안 함 → 단독 시작 가능, 없어도 fastapi 동작 (rerank=false fallback)
|
||||
@@ -173,8 +167,8 @@ services:
|
||||
- FALLBACK_ENDPOINT=http://ollama:11434/v1/chat/completions
|
||||
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
|
||||
- DAILY_BUDGET_USD=${DAILY_BUDGET_USD:-5.00}
|
||||
depends_on:
|
||||
- ollama
|
||||
# depends_on: ollama 제거 (2026-06-08) — ollama 서비스가 standalone 으로 이관됨.
|
||||
# FALLBACK_ENDPOINT 의 ollama:11434 는 standalone(동일 hostname, DS 망 부착)으로 해소.
|
||||
restart: unless-stopped
|
||||
|
||||
fastapi:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* 정책 (사용자 결정):
|
||||
* - pending 은 표시 안 함 (legacy 9792 건에 모두 노출되는 시각적 노이즈 회피).
|
||||
* - processing/success/skipped/failed 4 상태 표시.
|
||||
* - processing/success/partial/skipped/failed 5 상태 표시 (partial = 대형 split 일부 실패).
|
||||
* - success 도 작은 chip 으로 노출 — 1D pilot 에서 markdown 화면 식별용.
|
||||
* - skipped/failed 는 tooltip 으로 reason/error 보조 표시.
|
||||
*
|
||||
@@ -82,6 +82,12 @@
|
||||
label: 'Markdown',
|
||||
tooltip: qualitySummary(mdExtractionQuality),
|
||||
};
|
||||
case 'partial':
|
||||
return {
|
||||
tone: 'warning',
|
||||
label: 'Markdown 일부',
|
||||
tooltip: qualitySummary(mdExtractionQuality) ?? mdExtractionError ?? null,
|
||||
};
|
||||
case 'skipped':
|
||||
return {
|
||||
tone: 'neutral',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script>
|
||||
/**
|
||||
* 학습 진단 패널 (study_diagnosis surface) — 이드 코치 표면.
|
||||
*
|
||||
* 워커(study_weakness)가 산출한 최신 약점 스냅샷을 코치 언어로 번역. 데이터 없으면 status='none'.
|
||||
* LLM 호출이라 버튼 트리거(자동 호출 X). /study/diagnosis 와 /study/topics 양쪽에서 재사용.
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { Activity } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { renderMathMarkdown } from '$lib/utils/mathMarkdown';
|
||||
|
||||
let { class: className = '' } = $props();
|
||||
|
||||
let diag = $state(null); // StudyDiagnosisResponse | null
|
||||
let diagLoading = $state(false);
|
||||
async function generateDiagnosis() {
|
||||
if (diagLoading) return;
|
||||
diagLoading = true;
|
||||
try {
|
||||
diag = await api('/study-topics/diagnosis/generate', { method: 'POST' });
|
||||
} catch {
|
||||
addToast('error', '진단 생성 실패');
|
||||
} finally {
|
||||
diagLoading = false;
|
||||
}
|
||||
}
|
||||
function fmtDiagTime(s) {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card class={className}>
|
||||
{#snippet children()}
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Activity size={16} class="text-accent shrink-0" />
|
||||
<span class="text-sm font-semibold text-text">학습 진단</span>
|
||||
<span class="text-[11px] text-faint truncate hidden sm:inline">누적 풀이 약점·학습 태도 코치</span>
|
||||
</div>
|
||||
<Button onclick={generateDiagnosis} size="sm" variant={diag ? 'ghost' : 'primary'} loading={diagLoading}>
|
||||
{diag ? '새로고침' : '진단 생성'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if diagLoading}
|
||||
<div class="mt-3 space-y-2">
|
||||
<Skeleton w="w-full" h="h-4" /><Skeleton w="w-5/6" h="h-4" /><Skeleton w="w-2/3" h="h-4" />
|
||||
</div>
|
||||
{:else if diag && diag.status === 'ready'}
|
||||
<div class="markdown-body math-area mt-3 text-sm leading-relaxed text-text">{@html renderMathMarkdown(diag.content)}</div>
|
||||
{#if diag.review_set_draft_id}
|
||||
<div class="mt-2.5 inline-block text-xs text-accent-hover bg-accent/10 rounded-md px-2.5 py-1.5">
|
||||
권장 복습세트 초안 #{diag.review_set_draft_id} — 복습함에서 1클릭 확인 후 편성
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-2 text-[11px] text-faint">
|
||||
{#if diag.snapshot_at}스냅샷 {fmtDiagTime(diag.snapshot_at)}{/if}{#if diag.generated_at} · 생성 {fmtDiagTime(diag.generated_at)}{/if}{#if diag.model} · {diag.model}{/if}
|
||||
</div>
|
||||
{:else if diag && diag.status === 'none'}
|
||||
<p class="mt-3 text-xs text-dim leading-relaxed">
|
||||
아직 진단할 약점 데이터가 없습니다. 학습 주제를 <b class="text-text">공부중</b>으로 표시하면 매일 새벽 누적 풀이에서 약점·태도 스냅샷이 만들어지고, 여기서 진단 코치를 받을 수 있습니다.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-3 text-xs text-dim leading-relaxed">
|
||||
누적 학습 이력을 근거로 약점 토픽과 학습 태도를 진단합니다. <span class="text-text font-medium">진단 생성</span>을 눌러보세요.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Card>
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 카드 학습 세션 전달용 store.
|
||||
*
|
||||
* 복습함(/study/review-box)에서 선택한 카드들을 cards-study 복습 세션으로 넘긴다.
|
||||
* 백엔드 '세션 by card_ids' 엔드포인트 없이(= eid contention 중 fastapi 무재빌드) 동작하도록
|
||||
* 선택 카드 객체 배열을 그대로 전달. cards-study 가 startReview 에서 consume(읽고 비움).
|
||||
*
|
||||
* 모듈 레벨 store 라 SPA 네비게이션 동안 유지되고, 새로고침 시 사라진다(그땐 복습함에서 다시 선택).
|
||||
*/
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// CardItem[] | null — 복습함에서 '선택 복습' 시 set, cards-study 가 소비 후 null.
|
||||
export const pendingReviewCards = writable(null);
|
||||
@@ -3,7 +3,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Menu, EllipsisVertical, ChevronDown, FileText, Newspaper, HelpCircle, StickyNote, Inbox } from 'lucide-svelte';
|
||||
import { Menu, EllipsisVertical, ChevronDown, FileText, Newspaper, HelpCircle, StickyNote, Inbox, PanelLeft } from 'lucide-svelte';
|
||||
import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth';
|
||||
import { toasts, removeToast } from '$lib/stores/toast';
|
||||
import { refresh as refreshPublicConfig } from '$lib/stores/config';
|
||||
@@ -32,6 +32,15 @@
|
||||
let menuOpen = $state(false); // ⋮ 설정 메뉴
|
||||
let navMenu = $state(''); // '' | 'docs' | 'news' — 상단 드롭다운
|
||||
|
||||
// 데스크탑 분류(소스트리) 사이드바 접기/펴기 — localStorage 기억. 접으면 콘텐츠가 넓어짐.
|
||||
let sidebarCollapsed = $state(
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('sidebarCollapsed') === 'true' : false
|
||||
);
|
||||
function toggleSidebarCollapse() {
|
||||
sidebarCollapsed = !sidebarCollapsed;
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed));
|
||||
}
|
||||
|
||||
function isActive(path) {
|
||||
return $page.url.pathname.startsWith(path);
|
||||
}
|
||||
@@ -85,6 +94,11 @@
|
||||
<div class="lg:hidden">
|
||||
<IconButton icon={Menu} size="sm" aria-label="사이드바" onclick={() => ui.openDrawer('sidebar')} />
|
||||
</div>
|
||||
<div class="hidden lg:block">
|
||||
<IconButton icon={PanelLeft} size="sm" aria-label={sidebarCollapsed ? '사이드바 펴기' : '사이드바 접기'}
|
||||
aria-pressed={!sidebarCollapsed} title={sidebarCollapsed ? '사이드바 펴기' : '사이드바 접기'}
|
||||
onclick={toggleSidebarCollapse} />
|
||||
</div>
|
||||
{/if}
|
||||
<a href="/" class="flex items-center gap-2 shrink-0">
|
||||
<span class="w-7 h-7 rounded-md bg-accent text-white grid place-items-center text-[10px] font-extrabold tracking-wide">DS</span>
|
||||
@@ -150,7 +164,7 @@
|
||||
<!-- 메인: 데스크탑 상시 사이드바 + 콘텐츠 -->
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
{#if showSidebar}
|
||||
<aside class="hidden lg:block w-sidebar shrink-0 overflow-hidden border-r border-default">
|
||||
<aside class="hidden lg:block shrink-0 overflow-hidden transition-[width] duration-200 ease-out {sidebarCollapsed ? 'w-0 border-r-0' : 'w-sidebar border-r border-default'}">
|
||||
<Sidebar />
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
+316
-400
@@ -1,76 +1,124 @@
|
||||
<script lang="ts">
|
||||
// 대시보드 — 상황판. 사용자 지시서 기반 재설계.
|
||||
// 정보 위계: 헤더 → 핀 메모 → 카드 4개 → 최근 활동 → 파이프라인.
|
||||
// 단일 흐름 레이아웃, 모바일 우선, 행동 유도는 승인 대기에만.
|
||||
// 홈 대시보드 — 데일리 홈 cockpit (확정 시안 dashboard-sage-3 안1 골격 + 안2 검토/파이프라인 위젯 + 안3 도메인 분포 한 줄).
|
||||
// 정보 흐름: 인사 → 오늘 요약 띠(검토 대기 + 디제스트 + 스탯) → 2열(좌: 빠른 캡처·활동 / 우: 학습·도메인 분포·고정).
|
||||
// 데이터는 전부 기존 엔드포인트 wiring(백엔드 변경 0). 학습 streak/복습 마감은 전용 엔드포인트 부재라 링크형으로 degrade.
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
dashboardSummary,
|
||||
refresh,
|
||||
type DashboardSummary,
|
||||
type PipelineStatus,
|
||||
type QueueLag,
|
||||
} from '$lib/stores/system';
|
||||
import { domainBgClass, domainLabel } from '$lib/utils/domainSlug';
|
||||
import { user } from '$lib/stores/auth';
|
||||
import { api } from '$lib/api';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import {
|
||||
Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin, ChevronRight, Pencil,
|
||||
Library, Mic, Video, Sparkles,
|
||||
Scale, FileText, Pin, ChevronRight, GraduationCap, Upload, Newspaper,
|
||||
} from 'lucide-svelte';
|
||||
import { renderMemoHtml, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
|
||||
let summary = $derived<DashboardSummary | null>($dashboardSummary);
|
||||
let loading = $derived(summary === null);
|
||||
|
||||
// ─── 핀 고정 메모 ───
|
||||
let pinnedMemos = $state<any[]>([]);
|
||||
// 메모별 "완료 항목 펼침" 토글 — key: memo.id, value: true 면 숨겨진 체크 항목 노출
|
||||
let showHiddenByMemo = $state<Record<number, boolean>>({});
|
||||
// 자동 숨김 tick. 1초 해상도로 충분 (hideAfter 10초라 오차 수용).
|
||||
let nowTick = $state(new Date());
|
||||
// ─── 인사 헤더 ───
|
||||
const greetingName = $derived($user?.username ?? 'hyungi');
|
||||
const todayLabel = new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric', month: 'long', day: 'numeric', weekday: 'long',
|
||||
}).format(new Date());
|
||||
|
||||
$effect(() => {
|
||||
const id = setInterval(() => { nowTick = new Date(); }, 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
// ─── 디제스트 헤드라인 (best-effort, 기존 /digest) ───
|
||||
interface DigestLead {
|
||||
topic_label: string;
|
||||
article_count: number;
|
||||
importance_score: number;
|
||||
country: string;
|
||||
date: string;
|
||||
}
|
||||
let digestLead = $state<DigestLead | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const COUNTRY_KO: Record<string, string> = {
|
||||
KR: '한국', JP: '일본', US: '미국', CN: '중국', DE: '독일',
|
||||
FR: '프랑스', GB: '영국', TW: '대만',
|
||||
};
|
||||
function countryKo(c: string): string {
|
||||
return COUNTRY_KO[c?.toUpperCase?.()] ?? c ?? '';
|
||||
}
|
||||
|
||||
// ─── 도메인 분포 (best-effort, 기존 /documents/tree) ───
|
||||
interface DomainDist { name: string; count: number; }
|
||||
let domainDist = $state<DomainDist[]>([]);
|
||||
let domainTotal = $derived(domainDist.reduce((s, d) => s + d.count, 0));
|
||||
function domainCount(slugLike: string): number {
|
||||
// domainBgClass 와 동일 매핑 기준으로 특정 도메인 건수 추출 (스탯 띠용)
|
||||
const target = domainBgClass(slugLike);
|
||||
return domainDist.find((d) => domainBgClass(d.name) === target)?.count ?? 0;
|
||||
}
|
||||
|
||||
// ─── 빠른 캡처 (기존 POST /memos) ───
|
||||
let captureText = $state('');
|
||||
let capturing = $state(false);
|
||||
async function quickCapture() {
|
||||
const content = captureText.trim();
|
||||
if (!content || capturing) return;
|
||||
capturing = true;
|
||||
try {
|
||||
const res = await api<any>('/memos/?pinned=true&page_size=3&archived=false');
|
||||
pinnedMemos = res.items || [];
|
||||
} catch { /* 실패 시 빈 배열 유지 */ }
|
||||
});
|
||||
|
||||
// ─── 핀 메모 체크박스 토글 ───
|
||||
async function handlePinCheckbox(e: MouseEvent, memo: any) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName !== 'INPUT' || (target as HTMLInputElement).type !== 'checkbox') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // details 토글 충돌 방지
|
||||
const input = target as HTMLInputElement;
|
||||
const taskIndex = parseInt(input.dataset.taskIndex || '', 10);
|
||||
if (isNaN(taskIndex)) return;
|
||||
|
||||
const checked = input.checked;
|
||||
try {
|
||||
const updated = await api<any>(`/memos/${memo.id}/tasks/${taskIndex}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ checked }),
|
||||
});
|
||||
pinnedMemos = pinnedMemos.map((m) => (m.id === memo.id ? updated : m));
|
||||
await api('/memos/', { method: 'POST', body: JSON.stringify({ content }) });
|
||||
captureText = '';
|
||||
addToast('success', '메모 저장됨');
|
||||
void refresh(); // 메모 수 등 요약 즉시 갱신(60s 폴 기다리지 않음)
|
||||
} catch {
|
||||
input.checked = !checked; // 롤백
|
||||
addToast('error', '체크박스 변경 실패');
|
||||
addToast('error', '메모 저장 실패');
|
||||
} finally {
|
||||
capturing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowHidden(memoId: number) {
|
||||
showHiddenByMemo = { ...showHiddenByMemo, [memoId]: !showHiddenByMemo[memoId] };
|
||||
function onCaptureKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void quickCapture(); }
|
||||
}
|
||||
|
||||
// ─── 파이프라인 ───
|
||||
// ─── 핀 고정 메모 (기존 /memos?pinned) ───
|
||||
let pinnedMemos = $state<any[]>([]);
|
||||
function pinTitle(memo: any): string {
|
||||
const firstLine = memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').trim();
|
||||
return memo.title || firstLine || '메모';
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// 핀 메모
|
||||
try {
|
||||
const res = await api<any>('/memos/?pinned=true&page_size=4&archived=false');
|
||||
pinnedMemos = res.items || [];
|
||||
} catch { /* 빈 배열 유지 */ }
|
||||
// 디제스트 최신 — countries→topics flatten 후 중요도 desc(동률 시 기사수 desc) top
|
||||
try {
|
||||
const d = await api<any>('/digest');
|
||||
const topics = (d.countries || []).flatMap((c: any) =>
|
||||
(c.topics || []).map((t: any) => ({ ...t, country: c.country })));
|
||||
topics.sort((a: any, b: any) =>
|
||||
(b.importance_score - a.importance_score) || (b.article_count - a.article_count));
|
||||
if (topics[0]) {
|
||||
digestLead = {
|
||||
topic_label: topics[0].topic_label,
|
||||
article_count: topics[0].article_count,
|
||||
importance_score: topics[0].importance_score,
|
||||
country: topics[0].country,
|
||||
date: d.digest_date,
|
||||
};
|
||||
}
|
||||
} catch { /* 디제스트 없으면 블록 자동 생략 */ }
|
||||
// 도메인 분포 — 트리 top-level 노드 건수
|
||||
try {
|
||||
const tree = await api<any[]>('/documents/tree');
|
||||
domainDist = (tree || [])
|
||||
.map((n) => ({ name: n.name as string, count: n.count as number }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
} catch { /* 분포 없으면 카드 자동 생략 */ }
|
||||
});
|
||||
|
||||
// ─── 파이프라인 (기존 로직 재사용, 칩 요약 + 상세 접힘) ───
|
||||
const STAGE_ORDER = ['extract', 'stt', 'classify', 'embed', 'preview', 'thumbnail'] as const;
|
||||
const STAGE_LABEL: Record<string, string> = {
|
||||
extract: '추출', stt: '전사', classify: '분류', embed: '임베딩',
|
||||
@@ -80,13 +128,10 @@
|
||||
interface PipelineRow {
|
||||
stage: string; label: string;
|
||||
pending: number; processing: number; failed: number; total: number;
|
||||
// §4 — queue_lag 의 oldest_pending_age_sec (적체 신호용)
|
||||
oldestPendingAgeSec: number | null;
|
||||
}
|
||||
|
||||
function buildPipelineRows(items: PipelineStatus[], lag: QueueLag[]): PipelineRow[] {
|
||||
// §4 — 24h 누적 (pipeline_status) + 현재 시점 lag (queue_lag) 두 소스 머지.
|
||||
// queue_lag 가 있으면 stage 별 pending/processing/failed 는 그쪽 (정확) 사용.
|
||||
const lagMap = new Map(lag.map((l) => [l.stage, l]));
|
||||
const grouped = new Map<string, { pending: number; processing: number; failed: number; ageSec: number | null }>();
|
||||
for (const it of items) {
|
||||
@@ -96,14 +141,12 @@
|
||||
else if (it.status === 'failed') cur.failed += it.count;
|
||||
grouped.set(it.stage, cur);
|
||||
}
|
||||
// queue_lag 로 덮어쓰기 (현재 시점 신호가 우선)
|
||||
for (const l of lag) {
|
||||
grouped.set(l.stage, {
|
||||
pending: l.pending, processing: l.processing, failed: l.failed,
|
||||
ageSec: l.oldest_pending_age_sec,
|
||||
});
|
||||
}
|
||||
// queue_lag 만 있는 stage 도 전부 포함
|
||||
const allStages = new Set([...grouped.keys(), ...lagMap.keys()]);
|
||||
const orderedStages = [
|
||||
...STAGE_ORDER.filter((s) => allStages.has(s)),
|
||||
@@ -126,13 +169,10 @@
|
||||
let pipelineMax = $derived(Math.max(1, ...pipelineRows.map((r) => r.total)));
|
||||
let totalFailed = $derived(summary?.failed_count ?? 0);
|
||||
let totalPending = $derived(pipelineRows.reduce((s, r) => s + r.pending, 0));
|
||||
let totalProcessing = $derived(pipelineRows.reduce((s, r) => s + r.processing, 0));
|
||||
|
||||
// §4 — 카테고리 mini-card 데이터
|
||||
const CATEGORY_CARDS: { key: string; label: string; href: string; icon: any }[] = [
|
||||
{ key: 'library', label: '자료실', href: '/library', icon: Library },
|
||||
{ key: 'audio', label: '오디오', href: '/audio', icon: Mic },
|
||||
{ key: 'video', label: '비디오', href: '/video', icon: Video },
|
||||
];
|
||||
let pipelineManualClosed = $state(false);
|
||||
let pipelineOpen = $derived(pipelineManualClosed ? false : totalFailed > 0);
|
||||
|
||||
function formatAge(sec: number | null): string {
|
||||
if (sec == null || sec <= 0) return '';
|
||||
@@ -142,23 +182,9 @@
|
||||
return `${Math.floor(sec / 86400)}일 전`;
|
||||
}
|
||||
|
||||
// 파이프라인 접힘 상태
|
||||
let pipelineManualClosed = $state(false);
|
||||
let pipelineOpen = $derived(pipelineManualClosed ? false : totalFailed > 0);
|
||||
|
||||
// ─── 시스템 상태 ───
|
||||
function pickSystemTone(s: DashboardSummary) {
|
||||
if (s.failed_count > 0) return { label: `실패 ${s.failed_count}`, tone: 'error' as const };
|
||||
const backlog = s.pipeline_status.some((p) => p.status === 'pending' && p.count > 10);
|
||||
if (backlog) return { label: '대기열 적체', tone: 'warning' as const };
|
||||
return { label: '정상', tone: 'success' as const };
|
||||
}
|
||||
const TONE_DOT: Record<string, string> = { success: 'bg-success', warning: 'bg-warning', error: 'bg-error' };
|
||||
const TONE_TEXT: Record<string, string> = { success: 'text-success', warning: 'text-warning', error: 'text-error' };
|
||||
let systemView = $derived(summary ? pickSystemTone(summary) : null);
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return ''; // 빈 문자열/유효하지 않은 created_at → 'Invalid Date' 회피
|
||||
const diff = Date.now() - d.getTime();
|
||||
if (diff < 60000) return '방금';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}분 전`;
|
||||
@@ -168,351 +194,251 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 lg:p-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="p-4 lg:p-8">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
|
||||
<!-- ═══ 1. 헤더 + 시스템 상태 ═══ -->
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-xl font-bold text-text">대시보드</h2>
|
||||
{#if systemView}
|
||||
<span class="text-xs text-dim flex items-center gap-1.5 select-none">
|
||||
{systemView.label}
|
||||
<span class="w-2 h-2 rounded-full {TONE_DOT[systemView.tone]}"></span>
|
||||
</span>
|
||||
{/if}
|
||||
<!-- ═══ 인사 헤더 ═══ -->
|
||||
<div class="flex items-baseline gap-2.5 flex-wrap">
|
||||
<h1 class="text-2xl font-bold text-text tracking-tight">안녕하세요, {greetingName}</h1>
|
||||
<span class="text-sm text-dim">오늘도 지식 쌓는 날.</span>
|
||||
</div>
|
||||
<div class="text-xs text-faint mt-1 mb-6 tracking-wide">{todayLabel}</div>
|
||||
|
||||
{#if loading}
|
||||
<!-- 스켈레톤 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
|
||||
{#each Array(4) as _}
|
||||
<Card><Skeleton w="w-20" h="h-3" /><Skeleton w="w-16" h="h-8" class="mt-3" /><Skeleton w="w-24" h="h-3" class="mt-2" /></Card>
|
||||
{/each}
|
||||
<div class="bg-surface border border-default rounded-card p-5 mb-5">
|
||||
<Skeleton w="w-40" h="h-10" />
|
||||
<Skeleton w="w-full" h="h-4" class="mt-4" />
|
||||
<Skeleton w="w-2/3" h="h-4" class="mt-2" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-5">
|
||||
<div class="space-y-5">
|
||||
<div class="bg-surface border border-default rounded-card p-5"><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-10" class="mt-3" /></div>
|
||||
<div class="bg-surface border border-default rounded-card p-5"><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-40" class="mt-3" /></div>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<div class="bg-surface border border-default rounded-card p-5"><Skeleton w="w-full" h="h-24" /></div>
|
||||
<div class="bg-surface border border-default rounded-card p-5"><Skeleton w="w-full" h="h-32" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<Card class="mb-4"><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-40" class="mt-3" /></Card>
|
||||
<Card><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-20" class="mt-3" /></Card>
|
||||
|
||||
{:else if summary}
|
||||
|
||||
<!-- ═══ 2. 핀 고정 메모 (조건부, 펼침/접힘) ═══ -->
|
||||
{#if pinnedMemos.length > 0}
|
||||
<div class="mb-5 space-y-1.5">
|
||||
{#each pinnedMemos as memo (memo.id)}
|
||||
<details class="group/pin">
|
||||
<summary class="flex items-center gap-2.5 px-3 py-2 bg-surface border border-default/50 rounded-lg
|
||||
hover:bg-surface-hover transition-colors text-sm cursor-pointer select-none list-none">
|
||||
<Pin size={13} class="text-accent shrink-0" />
|
||||
<span class="text-text truncate flex-1">
|
||||
{memo.title && memo.title !== memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').slice(0, 80)
|
||||
? memo.title
|
||||
: memo.content?.split('\n')[0] || '메모'}
|
||||
</span>
|
||||
<ChevronRight size={13} class="text-dim shrink-0 transition-transform group-open/pin:rotate-90" />
|
||||
</summary>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mt-1 px-3 py-2.5 bg-surface/50 border border-default/30 rounded-lg text-sm text-text"
|
||||
onclick={(e) => handlePinCheckbox(e, memo)}
|
||||
>
|
||||
<div
|
||||
class="prose prose-sm max-w-none memo-content-pin"
|
||||
class:show-hidden={showHiddenByMemo[memo.id]}
|
||||
>
|
||||
{@html renderMemoHtml(memo.content || '', {
|
||||
compact: true,
|
||||
interactive: true,
|
||||
taskStates: memo.memo_task_state ?? {},
|
||||
now: nowTick,
|
||||
})}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
{#if countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS) > 0 || showHiddenByMemo[memo.id]}
|
||||
<button
|
||||
type="button"
|
||||
class="text-[11px] text-dim hover:text-text underline-offset-2 hover:underline"
|
||||
onclick={(e) => { e.stopPropagation(); toggleShowHidden(memo.id); }}
|
||||
>
|
||||
{#if showHiddenByMemo[memo.id]}
|
||||
완료 항목 숨기기
|
||||
{:else}
|
||||
완료 {countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS)}개 보기
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<a href="/memos" class="text-[11px] text-accent hover:underline">메모함에서 보기 →</a>
|
||||
</div>
|
||||
<!-- ═══ 오늘 요약 띠 ═══ -->
|
||||
<div class="bg-surface border border-default rounded-card p-5 lg:p-6 mb-5">
|
||||
<!-- 검토 대기 + 디제스트 -->
|
||||
<div class="flex flex-col sm:flex-row items-stretch gap-5">
|
||||
<!-- 검토 대기 강조 -->
|
||||
<div class="flex flex-col justify-center sm:pr-6 sm:border-r border-default sm:min-w-[150px]">
|
||||
<span class="text-4xl font-extrabold tracking-tight leading-none {summary.inbox_count > 0 ? 'text-warning' : 'text-success'}">
|
||||
{summary.inbox_count.toLocaleString()}
|
||||
</span>
|
||||
<span class="text-[11px] text-dim mt-1.5 uppercase tracking-wide">검토 대기 문서</span>
|
||||
{#if summary.inbox_count > 0}
|
||||
<a href="/inbox" class="text-[11px] text-accent font-semibold mt-2 hover:underline">검토 시작 →</a>
|
||||
{:else}
|
||||
<span class="text-[11px] text-dim mt-2">미분류 없음</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 디제스트 톱 (best-effort) -->
|
||||
{#if digestLead}
|
||||
<a href="/digest" class="flex-1 flex flex-col justify-center gap-1.5 group">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-bold text-error bg-error/10 rounded px-1.5 py-0.5 uppercase tracking-wide">속보</span>
|
||||
<span class="text-[11px] text-faint">{digestLead.date} 브리핑</span>
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
{#if pinnedMemos.length >= 3}
|
||||
<a href="/memos" class="text-[11px] text-accent hover:underline pl-8">더보기 →</a>
|
||||
<div class="text-[15px] font-semibold text-text leading-snug group-hover:text-accent transition-colors">
|
||||
{digestLead.topic_label}
|
||||
</div>
|
||||
<div class="text-[11px] text-dim">
|
||||
관련 기사 <strong class="text-text">{digestLead.article_count}건</strong>
|
||||
· 중요도 {digestLead.importance_score.toFixed(2)}
|
||||
· {countryKo(digestLead.country)}
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<a href="/news" class="flex-1 flex items-center gap-2 text-sm text-dim hover:text-accent transition-colors">
|
||||
<Newspaper size={16} /> 오늘의 뉴스 브리핑 보기 →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ 3. 핵심 카드 4개 ═══ -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
|
||||
<!-- 문서함 -->
|
||||
<a href="/documents" class="block">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">문서함</p>
|
||||
<FileText size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-3xl font-bold mt-2 text-text">{(summary.documents_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-xs text-dim mt-1">
|
||||
{#if summary.today_added > 0}
|
||||
<span class="text-accent">+{summary.today_added} 오늘</span>
|
||||
{:else}
|
||||
일반 문서
|
||||
{/if}
|
||||
</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<!-- 메모 -->
|
||||
<a href="/memos" class="block">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">메모</p>
|
||||
<StickyNote size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-3xl font-bold mt-2 text-text">{(summary.memos_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-xs text-dim mt-1">직접 작성</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<!-- 뉴스 -->
|
||||
<a href="/news" class="block">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">뉴스</p>
|
||||
<Newspaper size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-3xl font-bold mt-2 text-text">{(summary.news_count ?? 0).toLocaleString()}</p>
|
||||
<p class="text-xs text-dim mt-1">수집 기사</p>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<!-- 승인 대기 (액션형) -->
|
||||
<a href="/inbox" class="block">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">승인 대기</p>
|
||||
<Inbox size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-3xl font-bold mt-2 {summary.inbox_count > 0 ? 'text-warning' : 'text-success'}">
|
||||
{summary.inbox_count}
|
||||
</p>
|
||||
{#if summary.inbox_count > 0}
|
||||
<p class="text-xs text-accent mt-1">검토하기 →</p>
|
||||
{:else}
|
||||
<p class="text-xs text-dim mt-1">미분류 없음</p>
|
||||
{/if}
|
||||
</Card>
|
||||
</a>
|
||||
<!-- 스탯 띠 -->
|
||||
<div class="flex flex-nowrap overflow-x-auto border-t border-default mt-4 pt-4">
|
||||
{@render stat((summary.documents_count ?? 0).toLocaleString(), '문서', 'text-accent')}
|
||||
{@render stat((summary.news_count ?? 0).toLocaleString(), '뉴스')}
|
||||
{#if domainTotal > 0}
|
||||
{@render stat(domainCount('Industrial_Safety').toLocaleString(), '산업안전', 'text-domain-safety')}
|
||||
{@render stat(domainCount('Engineering').toLocaleString(), '엔지니어링', 'text-domain-engineering')}
|
||||
{/if}
|
||||
{#if summary.category_counts?.library}
|
||||
{@render stat(summary.category_counts.library.toLocaleString(), '자료실')}
|
||||
{/if}
|
||||
{@render stat((summary.memos_count ?? 0).toLocaleString(), '메모')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 3.5. 카테고리 + 자료실 제안 (§4) ═══ -->
|
||||
{#if summary.category_counts && (Object.keys(summary.category_counts).length > 0 || summary.library_pending_suggestions > 0)}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
|
||||
{#each CATEGORY_CARDS as cat}
|
||||
{@const count = summary.category_counts?.[cat.key] ?? 0}
|
||||
<a href={cat.href} class="block">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">{cat.label}</p>
|
||||
<cat.icon size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-2 text-text">{count.toLocaleString()}</p>
|
||||
<p class="text-xs text-dim mt-1">카테고리</p>
|
||||
</Card>
|
||||
</a>
|
||||
{/each}
|
||||
<!-- ═══ 2열 본문 ═══ -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-5 items-start">
|
||||
|
||||
<!-- 자료실 제안 (action card) -->
|
||||
<a href="/library" class="block">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">자료실 제안</p>
|
||||
<Sparkles size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-2 {summary.library_pending_suggestions > 0 ? 'text-warning' : 'text-success'}">
|
||||
{summary.library_pending_suggestions}
|
||||
</p>
|
||||
{#if summary.library_pending_suggestions > 0}
|
||||
<p class="text-xs text-accent mt-1">검토하기 →</p>
|
||||
{:else}
|
||||
<p class="text-xs text-dim mt-1">대기 없음</p>
|
||||
{/if}
|
||||
</Card>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- ─── 왼쪽 ─── -->
|
||||
<div class="space-y-5">
|
||||
|
||||
<!-- ═══ 3.6. tier 관측성 3종 카드 (B-3) ═══ -->
|
||||
{#if summary.tier_health && summary.tier_health.triage_total > 0}
|
||||
{@const th = summary.tier_health}
|
||||
{@const esc_rate = th.triage_total > 0 ? th.escalated_total / th.triage_total : 0}
|
||||
{@const json_rate = th.triage_total > 0 ? th.triage_json_invalid / th.triage_total : 0}
|
||||
{@const sup_rate = th.triage_total > 0 ? th.suppressed_total / th.triage_total : 0}
|
||||
{@const deep_total = th.deep_total ?? 0}
|
||||
{@const deep_err_rate = deep_total > 0 ? (th.deep_err_total ?? 0) / deep_total : 0}
|
||||
<!-- Day 4 튜닝 (2026-04-27): 운영 패턴 실측 후 임계치 재조정.
|
||||
3일 telemetry 기준 escalate 97% 가 정상 (safety 정책 의도) → <80% 가 진짜 신호. -->
|
||||
{@const esc_tone = esc_rate < 0.80 ? 'text-error' : 'text-text'}
|
||||
{@const json_tone = json_rate > 0.05 ? 'text-error' : 'text-text'}
|
||||
{@const sup_tone = sup_rate > 0.10 ? 'text-warning' : 'text-text'}
|
||||
{@const deep_tone = deep_err_rate > 0.05 ? 'text-error' : 'text-text'}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-5">
|
||||
<!-- 에스컬레이션 비율 -->
|
||||
<Card class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">에스컬레이션 비율 (24h)</p>
|
||||
<Sparkles size={18} class="text-faint" />
|
||||
<!-- 빠른 캡처 -->
|
||||
<div class="bg-surface border border-default rounded-card p-5">
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wider mb-3">빠른 캡처</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
class="flex-1 h-9 border border-default rounded-md bg-bg text-text text-sm px-3.5 outline-none focus:border-accent transition-colors placeholder:text-faint"
|
||||
type="text"
|
||||
placeholder="메모 한 줄 남기기…"
|
||||
bind:value={captureText}
|
||||
onkeydown={onCaptureKeydown}
|
||||
disabled={capturing}
|
||||
/>
|
||||
<button
|
||||
class="h-9 px-4 rounded-md bg-accent text-white text-xs font-semibold hover:bg-accent-hover transition-colors disabled:opacity-50 shrink-0"
|
||||
onclick={quickCapture}
|
||||
disabled={capturing || !captureText.trim()}
|
||||
>저장</button>
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-2 {esc_tone}">
|
||||
{(esc_rate * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p class="text-xs text-dim mt-1">
|
||||
{th.escalated_total} / {th.triage_total}
|
||||
{#if esc_rate < 0.80}<span class="text-error ml-1">(매칭 실패 증가)</span>{/if}
|
||||
</p>
|
||||
<p class="text-[10px] text-faint mt-1">safety 정책상 95~100% 가 정상</p>
|
||||
{#if Object.keys(th.escalation_by_reason).length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each Object.entries(th.escalation_by_reason).slice(0, 4) as [reason, n]}
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-surface-muted text-dim">
|
||||
{reason} {n}
|
||||
</span>
|
||||
<div class="flex gap-2 mt-2.5">
|
||||
<a href="/documents" class="inline-flex items-center gap-1.5 text-[11px] text-accent-hover bg-accent/10 rounded-md px-2.5 py-1 hover:bg-accent/20 transition-colors">
|
||||
<Upload size={11} /> 파일 업로드
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 활동 타임라인 -->
|
||||
<div class="bg-surface border border-default rounded-card p-5">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<span class="text-[11px] font-bold text-dim uppercase tracking-wider">최근 활동</span>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if summary.law_alerts > 0}
|
||||
<a href="/documents?source=law_monitor"
|
||||
class="text-[11px] flex items-center gap-1 px-2.5 py-1 rounded-full bg-warning/10 text-warning border border-warning/20 hover:bg-warning/20 transition-colors">
|
||||
<Scale size={11} /> 법령 {summary.law_alerts}
|
||||
</a>
|
||||
{/if}
|
||||
<a href="/documents" class="text-[11px] text-accent hover:underline">전체 보기 →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if summary.recent_documents.length > 0}
|
||||
<div class="flex flex-col">
|
||||
{#each summary.recent_documents as doc, i (doc.id)}
|
||||
<a href="/documents/{doc.id}"
|
||||
class="grid grid-cols-[auto_14px_1fr] gap-x-3 py-2.5 {i > 0 ? 'border-t border-default' : ''} group">
|
||||
<div class="text-[10px] text-faint text-right pt-1 whitespace-nowrap tabular-nums w-14">{formatTime(doc.created_at)}</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="w-2 h-2 rounded-full mt-1.5 shrink-0 {domainBgClass(doc.ai_domain)}"></span>
|
||||
{#if i < summary.recent_documents.length - 1}<span class="flex-1 w-px bg-default mt-1"></span>{/if}
|
||||
</div>
|
||||
<div class="pb-1">
|
||||
<div class="text-[10px] font-bold uppercase tracking-wide text-dim mb-0.5">{domainLabel(doc.ai_domain)}</div>
|
||||
<div class="text-[13px] text-text leading-snug group-hover:text-accent transition-colors truncate">{doc.title || '제목 없음'}</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- triage JSON 건강도 -->
|
||||
<Card class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">triage JSON 건강도 (24h)</p>
|
||||
<Sparkles size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-2 {json_tone}">
|
||||
{(json_rate * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p class="text-xs text-dim mt-1">
|
||||
깨짐 {th.triage_json_invalid} 건
|
||||
{#if json_rate > 0.05}<span class="text-error ml-1">(프롬프트 이슈 의심)</span>{/if}
|
||||
</p>
|
||||
<p class="text-[10px] text-faint mt-1">5% 초과 시 4B 프롬프트·모델 재검토</p>
|
||||
</Card>
|
||||
|
||||
<!-- Backlog Suppression -->
|
||||
<Card class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">Backlog Suppression (24h)</p>
|
||||
<Sparkles size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-2 {sup_tone}">
|
||||
{(sup_rate * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p class="text-xs text-dim mt-1">
|
||||
억제 {th.suppressed_total} 건
|
||||
{#if sup_rate > 0.10}<span class="text-warning ml-1">(임계치 재조정 신호)</span>{/if}
|
||||
</p>
|
||||
<p class="text-[10px] text-faint mt-1">10% 초과 시 ratio/pending threshold 조정</p>
|
||||
</Card>
|
||||
|
||||
<!-- Deep summary 안정성 (Day 4 신규) -->
|
||||
<Card class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">Deep summary 안정성 (24h)</p>
|
||||
<Sparkles size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-2 {deep_tone}">
|
||||
{(deep_err_rate * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p class="text-xs text-dim mt-1">
|
||||
실패 {th.deep_err_total ?? 0} / {deep_total}
|
||||
{#if deep_err_rate > 0.05}<span class="text-error ml-1">(MLX 안정성 점검)</span>{/if}
|
||||
</p>
|
||||
<p class="text-[10px] text-faint mt-1">call_failed / parse:* 합계, 5% 초과 시 점검</p>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ 4. 최근 활동 ═══ -->
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-text">최근 활동</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if summary.law_alerts > 0}
|
||||
<a
|
||||
href="/documents?source=law_monitor"
|
||||
class="text-[11px] flex items-center gap-1 px-2.5 py-1 rounded-full
|
||||
bg-warning/10 text-warning border border-warning/20
|
||||
hover:bg-warning/20 transition-colors"
|
||||
>
|
||||
<Scale size={11} /> 법령 {summary.law_alerts}
|
||||
</a>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="아직 문서가 없습니다"
|
||||
description="NAS PKM 폴더에 파일을 추가하면 자동으로 인덱싱됩니다."
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if summary.recent_documents.length > 0}
|
||||
<div class="divide-y divide-default/50">
|
||||
{#each summary.recent_documents as doc (doc.id)}
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="block py-2.5 first:pt-0 last:pb-0 hover:bg-surface-hover -mx-5 px-5 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-text truncate">{doc.title || '제목 없음'}</span>
|
||||
<span class="text-[11px] text-dim shrink-0">{formatTime(doc.created_at)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 mt-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0 {domainBgClass(doc.ai_domain)}"></span>
|
||||
<span class="text-[11px] text-dim truncate">{domainLabel(doc.ai_domain)}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="아직 문서가 없습니다"
|
||||
description="NAS PKM 폴더에 파일을 추가하면 자동으로 인덱싱됩니다."
|
||||
/>
|
||||
{/if}
|
||||
</Card>
|
||||
<!-- ─── 오른쪽 ─── -->
|
||||
<div class="space-y-5">
|
||||
|
||||
<!-- ═══ 5. 파이프라인 (접힘) ═══ -->
|
||||
<!-- 학습 (streak/복습 마감은 백엔드 부재로 링크형 degrade) -->
|
||||
<a href="/study" class="block bg-surface border border-default rounded-card p-5 hover:bg-surface-hover transition-colors group">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] font-bold text-dim uppercase tracking-wider">학습</span>
|
||||
<GraduationCap size={16} class="text-faint" />
|
||||
</div>
|
||||
<div class="text-[15px] font-semibold text-text mt-3 group-hover:text-accent transition-colors">암기 노트 학습 시작 →</div>
|
||||
<div class="text-[11px] text-dim mt-1">검수함 · 복습함 · 암기카드</div>
|
||||
</a>
|
||||
|
||||
<!-- 도메인 분포 + 파이프라인 -->
|
||||
{#if domainDist.length > 0}
|
||||
<div class="bg-surface border border-default rounded-card p-5">
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wider mb-2">도메인 분포</div>
|
||||
<div class="text-[11px] text-faint mb-3">전체 <strong class="text-base font-bold text-text tracking-tight align-baseline">{domainTotal.toLocaleString()}</strong>건</div>
|
||||
|
||||
<!-- 분포 막대 -->
|
||||
<div class="flex gap-0.5 h-2 rounded mb-4 overflow-hidden">
|
||||
{#each domainDist as d (d.name)}
|
||||
<div class="h-full rounded-sm {domainBgClass(d.name)}" style="width:{domainTotal > 0 ? (d.count / domainTotal) * 100 : 0}%"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each domainDist.slice(0, 6) as d (d.name)}
|
||||
<a href="/documents?domain={encodeURIComponent(d.name)}" class="flex items-center gap-2 text-xs hover:text-accent transition-colors group">
|
||||
<span class="w-2.5 h-2.5 rounded-sm shrink-0 {domainBgClass(d.name)}"></span>
|
||||
<span class="flex-1 text-text truncate group-hover:text-accent">{domainLabel(d.name)}</span>
|
||||
<span class="font-semibold text-dim tabular-nums">{d.count.toLocaleString()}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 파이프라인 칩 (안2 흡수) -->
|
||||
<div class="flex items-center gap-1.5 flex-wrap mt-4 pt-3.5 border-t border-default">
|
||||
<span class="text-[10px] text-faint uppercase tracking-wide mr-1">파이프라인</span>
|
||||
{#if totalFailed > 0}<span class="text-[10px] font-bold rounded px-2 py-0.5 text-error bg-error/10">실패 {totalFailed}</span>{/if}
|
||||
{#if totalPending > 0}<span class="text-[10px] font-bold rounded px-2 py-0.5 text-warning bg-warning/10">대기 {totalPending}</span>{/if}
|
||||
{#if totalProcessing > 0}<span class="text-[10px] font-bold rounded px-2 py-0.5 text-success bg-success/10">처리중 {totalProcessing}</span>{/if}
|
||||
{#if totalFailed === 0 && totalPending === 0 && totalProcessing === 0}<span class="text-[10px] font-bold rounded px-2 py-0.5 text-success bg-success/10">정상</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 고정 항목 -->
|
||||
{#if pinnedMemos.length > 0}
|
||||
<div class="bg-surface border border-default rounded-card p-5">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<span class="text-[11px] font-bold text-dim uppercase tracking-wider">고정 항목</span>
|
||||
<a href="/memos" class="text-[11px] text-accent hover:underline">관리 →</a>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pinnedMemos as memo (memo.id)}
|
||||
<a href="/memos" class="flex items-start gap-2.5 px-3 py-2.5 rounded-lg bg-bg hover:bg-surface-hover transition-colors">
|
||||
<span class="text-[9px] font-bold rounded px-1.5 py-0.5 uppercase tracking-wide shrink-0 mt-0.5 text-accent-hover bg-accent/10">메모</span>
|
||||
<span class="text-xs text-text leading-snug flex-1">{pinTitle(memo)}</span>
|
||||
<Pin size={11} class="text-faint shrink-0 mt-0.5" />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 파이프라인 상세 (실패 있을 때 자동 펼침) ═══ -->
|
||||
<details
|
||||
class="mt-5"
|
||||
open={pipelineOpen}
|
||||
ontoggle={(e) => { if (!e.currentTarget.open) pipelineManualClosed = true; }}
|
||||
>
|
||||
<summary
|
||||
class="flex items-center justify-between px-5 py-3.5 bg-surface border border-default rounded-lg
|
||||
cursor-pointer hover:bg-surface-hover transition-colors select-none list-none"
|
||||
>
|
||||
<summary class="flex items-center justify-between px-5 py-3.5 bg-surface border border-default rounded-card cursor-pointer hover:bg-surface-hover transition-colors select-none list-none">
|
||||
<span class="text-sm font-semibold text-text flex items-center gap-2">
|
||||
<ChevronRight size={14} class="transition-transform details-chevron" />
|
||||
파이프라인
|
||||
파이프라인 상세
|
||||
</span>
|
||||
<span class="text-xs text-dim flex items-center gap-2.5">
|
||||
{#if totalFailed > 0}
|
||||
<span class="text-error font-medium">실패 {totalFailed}</span>
|
||||
{/if}
|
||||
{#if totalPending > 0}
|
||||
<span>대기 {totalPending}</span>
|
||||
{/if}
|
||||
{#if totalFailed === 0 && totalPending === 0}
|
||||
<span>처리 완료</span>
|
||||
{/if}
|
||||
{#if totalFailed > 0}<span class="text-error font-medium">실패 {totalFailed}</span>{/if}
|
||||
{#if totalPending > 0}<span>대기 {totalPending}</span>{/if}
|
||||
{#if totalFailed === 0 && totalPending === 0}<span>처리 완료</span>{/if}
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div class="mt-2 px-5 py-4 bg-surface border border-default rounded-lg">
|
||||
<div class="mt-2 px-5 py-4 bg-surface border border-default rounded-card">
|
||||
<p class="text-xs text-dim mb-3">최근 24시간</p>
|
||||
{#if pipelineRows.length > 0}
|
||||
<div class="space-y-3">
|
||||
@@ -522,9 +448,7 @@
|
||||
<span class="text-dim">
|
||||
{row.label}
|
||||
{#if row.oldestPendingAgeSec && row.oldestPendingAgeSec > 600}
|
||||
<span class="ml-1 text-warning" title="가장 오래된 pending 의 경과 시간">
|
||||
({formatAge(row.oldestPendingAgeSec)})
|
||||
</span>
|
||||
<span class="ml-1 text-warning" title="가장 오래된 pending 의 경과 시간">({formatAge(row.oldestPendingAgeSec)})</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-dim tabular-nums">
|
||||
@@ -551,22 +475,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet stat(value: string, label: string, colorClass = 'text-text')}
|
||||
<div class="flex flex-col items-start px-4 first:pl-0 border-l border-default first:border-l-0 min-w-[64px]">
|
||||
<span class="text-xl font-bold tracking-tight leading-none {colorClass}">{value}</span>
|
||||
<span class="text-[10px] text-faint mt-1 uppercase tracking-wide">{label}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
details[open] .details-chevron { transform: rotate(90deg); }
|
||||
details[open] :global(.details-chevron) { transform: rotate(90deg); }
|
||||
details summary::-webkit-details-marker { display: none; }
|
||||
.memo-content-pin :global(p) { margin: 0.2em 0; }
|
||||
.memo-content-pin :global(ul), .memo-content-pin :global(ol) { margin: 0.2em 0; padding-left: 1.5em; }
|
||||
.memo-content-pin :global(code) { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
||||
.memo-content-pin :global(a) { color: var(--accent); }
|
||||
.memo-content-pin :global(.memo-checkbox) { cursor: pointer; width: 14px; height: 14px; accent-color: var(--accent); vertical-align: middle; margin-right: 3px; }
|
||||
.memo-content-pin :global(li:has(.memo-checkbox)) { list-style: none; margin-left: -1.5em; }
|
||||
.memo-content-pin :global(.memo-task-done) { opacity: 0.5; text-decoration: line-through; }
|
||||
/* 체크 후 10초 경과 항목 자동 숨김 (`show-hidden` 클래스로 토글 해제) */
|
||||
.memo-content-pin :global(.memo-task-hidden) { display: none; }
|
||||
.memo-content-pin.show-hidden :global(.memo-task-hidden) { display: list-item; }
|
||||
.memo-content-pin :global(.due-badge) { display: inline-block; font-size: 10px; padding: 1px 5px; border-radius: 8px; margin-left: 3px; }
|
||||
.memo-content-pin :global(.due-overdue) { background: rgba(245, 86, 78, 0.15); color: var(--error); }
|
||||
.memo-content-pin :global(.due-soon) { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
|
||||
.memo-content-pin :global(.due-normal) { background: var(--surface); color: var(--text-dim); }
|
||||
.memo-content-pin :global(.due-done) { background: var(--surface); color: var(--text-dim); opacity: 0.6; }
|
||||
</style>
|
||||
|
||||
@@ -410,20 +410,20 @@
|
||||
</div><!-- /.digest-page -->
|
||||
|
||||
<style>
|
||||
/* ── 웜 팔레트 로컬 재정의 ──
|
||||
/* ── 세이지 팔레트 로컬 재정의 ──
|
||||
앱 :root 다크 토큰(--surface:#1a1d27, --accent:파랑 등)이 하위 var() 로 새지 않도록
|
||||
이 subtree 에서 웜값으로 덮어쓴다. 하위 모든 var(--surface/--card/--line/--brand …)는
|
||||
이 subtree 에서 세이지값으로 덮어쓴다. 하위 모든 var(--surface/--card/--line/--brand …)는
|
||||
여기서 해석된다. 검정(#000/#1f2024) 미사용. */
|
||||
.digest-page {
|
||||
--brand: #d97757;
|
||||
--brand-d: #c2603f;
|
||||
--surface: #f0eee6;
|
||||
--brand: #4f8a6b;
|
||||
--brand-d: #3d7256;
|
||||
--surface: #ecf0e8;
|
||||
--card: #fff;
|
||||
--ink: #2e2420;
|
||||
--muted: #6b6f76;
|
||||
--line: #e3e0d6;
|
||||
--ink: #23291f;
|
||||
--muted: #697061;
|
||||
--line: #dde3d6;
|
||||
font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', system-ui, sans-serif;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
}
|
||||
|
||||
/* ── App shell ── */
|
||||
@@ -437,14 +437,14 @@
|
||||
|
||||
/* ── Masthead ── */
|
||||
header.bar {
|
||||
background: #faf7f1;
|
||||
background: #f4f7f1;
|
||||
border-bottom: 3px solid var(--brand);
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: 56px;
|
||||
box-shadow: 0 1px 0 #e3e0d6;
|
||||
box-shadow: 0 1px 0 #dde3d6;
|
||||
}
|
||||
header.bar .mark {
|
||||
display: flex;
|
||||
@@ -467,7 +467,7 @@
|
||||
header.bar h1 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
@@ -493,11 +493,11 @@
|
||||
header.bar .stat-val {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
}
|
||||
header.bar .stat-lbl {
|
||||
font-size: 10px;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@@ -508,20 +508,20 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.date-btn {
|
||||
background: #f0eee6;
|
||||
border: 1px solid #d8d3c8;
|
||||
background: #ecf0e8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-radius: 4px;
|
||||
width: 26px;
|
||||
height: 28px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.date-btn:hover:not(:disabled) {
|
||||
background: #e7e2d6;
|
||||
background: #e3ebdf;
|
||||
color: var(--brand-d);
|
||||
}
|
||||
.date-btn:disabled {
|
||||
@@ -529,13 +529,13 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.date-select {
|
||||
background: #f0eee6;
|
||||
border: 1px solid #d8d3c8;
|
||||
background: #ecf0e8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
letter-spacing: 0.02em;
|
||||
max-width: 220px;
|
||||
cursor: pointer;
|
||||
@@ -561,7 +561,7 @@
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
@@ -571,8 +571,8 @@
|
||||
font-family: inherit;
|
||||
}
|
||||
.country-nav .nav-item:hover {
|
||||
color: #3a322a;
|
||||
border-bottom-color: #d8d3c8;
|
||||
color: #333a2d;
|
||||
border-bottom-color: #cfd7c6;
|
||||
}
|
||||
.country-nav .nav-item.active {
|
||||
color: var(--brand);
|
||||
@@ -586,16 +586,16 @@
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--surface);
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
}
|
||||
.country-nav .nav-item.active .cc-chip {
|
||||
background: rgba(217, 119, 87, 0.15);
|
||||
background: rgba(79, 138, 107, 0.15);
|
||||
color: var(--brand);
|
||||
}
|
||||
.country-nav .topic-count {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
@@ -626,7 +626,7 @@
|
||||
.edition-line .edition-date {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.edition-line .edition-sep {
|
||||
@@ -636,13 +636,13 @@
|
||||
}
|
||||
.edition-line .edition-sub {
|
||||
font-size: 11px;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
}
|
||||
|
||||
/* ── Lead story block ── */
|
||||
.lead-block {
|
||||
background: var(--card);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-top: 4px solid var(--brand);
|
||||
border-radius: 4px;
|
||||
padding: 28px 32px 24px;
|
||||
@@ -657,7 +657,7 @@
|
||||
right: 0;
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
background: linear-gradient(to left, rgba(217, 119, 87, 0.05), transparent);
|
||||
background: linear-gradient(to left, rgba(79, 138, 107, 0.05), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.lead-meta {
|
||||
@@ -683,9 +683,9 @@
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
background: var(--surface);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -700,27 +700,27 @@
|
||||
}
|
||||
.lead-meta .cnt-badge {
|
||||
font-size: 11px;
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.lead-meta .cnt-badge strong {
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lead-headline {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
color: #2e2420;
|
||||
color: #23291f;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.lead-summary {
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
margin: 0 0 20px;
|
||||
max-width: 680px;
|
||||
}
|
||||
@@ -747,7 +747,7 @@
|
||||
.lead-articles a {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
text-decoration: none;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -766,7 +766,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lead-imp-bar .bar-track {
|
||||
@@ -800,7 +800,7 @@
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.section-head .sh-line {
|
||||
@@ -820,7 +820,7 @@
|
||||
/* ── Story card ── */
|
||||
.story-card {
|
||||
background: var(--card);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-radius: 4px;
|
||||
padding: 18px 20px 16px;
|
||||
display: flex;
|
||||
@@ -829,7 +829,7 @@
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.story-card:hover {
|
||||
box-shadow: 0 2px 12px rgba(90, 70, 55, 0.09);
|
||||
box-shadow: 0 2px 12px rgba(74, 81, 66, 0.09);
|
||||
}
|
||||
.story-card.featured {
|
||||
border-top: 3px solid var(--brand-d);
|
||||
@@ -846,9 +846,9 @@
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
background: var(--surface);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -863,21 +863,21 @@
|
||||
}
|
||||
.card-meta .cnt-tag {
|
||||
font-size: 10px;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
margin-left: auto;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #2e2420;
|
||||
color: #23291f;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
.card-summary {
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
@@ -891,7 +891,7 @@
|
||||
.card-articles a {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
@@ -904,7 +904,7 @@
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: #b8a898;
|
||||
background: #9aa090;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
@@ -923,7 +923,7 @@
|
||||
.card-imp .imp-val {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
}
|
||||
|
||||
/* ── Sidebar stack ── */
|
||||
@@ -934,7 +934,7 @@
|
||||
}
|
||||
.sidebar-card {
|
||||
background: var(--card);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
@@ -958,24 +958,24 @@
|
||||
.sidebar-card .cc-name {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
}
|
||||
.sidebar-card .sc-cnt {
|
||||
font-size: 10px;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
margin-left: auto;
|
||||
}
|
||||
.sidebar-card .s-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #2e2420;
|
||||
color: #23291f;
|
||||
margin: 0;
|
||||
}
|
||||
.sidebar-card .s-summary {
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
margin: 0;
|
||||
}
|
||||
.sidebar-card .s-link {
|
||||
@@ -995,7 +995,7 @@
|
||||
}
|
||||
.compact-card {
|
||||
background: var(--card);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
@@ -1019,24 +1019,24 @@
|
||||
.compact-card .c-ko {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
}
|
||||
.compact-card .c-cnt {
|
||||
font-size: 10px;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
margin-left: auto;
|
||||
}
|
||||
.compact-card .c-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #2e2420;
|
||||
color: #23291f;
|
||||
margin: 0;
|
||||
}
|
||||
.compact-card .c-summary {
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1056,11 +1056,11 @@
|
||||
.compact-card .c-imp-fill {
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: rgba(217, 119, 87, 0.6);
|
||||
background: rgba(79, 138, 107, 0.6);
|
||||
}
|
||||
|
||||
/* ── Importance swatches ── */
|
||||
.imp-high { background: rgba(217, 119, 87, 0.85); }
|
||||
.imp-high { background: rgba(79, 138, 107, 0.85); }
|
||||
|
||||
/* 극단적 긴 무공백 토큰(연속 CJK·URL) 가로 오버플로 방어 */
|
||||
.lead-headline, .lead-summary, .card-title, .card-summary,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -292,10 +292,10 @@
|
||||
};
|
||||
const KIND_BADGE_CLASS = {
|
||||
note: 'bg-surface text-dim',
|
||||
task: 'bg-indigo-100 text-indigo-700',
|
||||
calendar_event: 'bg-blue-100 text-blue-700',
|
||||
activity_log: 'bg-emerald-100 text-emerald-700',
|
||||
reference: 'bg-amber-100 text-amber-700',
|
||||
task: 'bg-accent/15 text-accent-hover',
|
||||
calendar_event: 'bg-domain-engineering/15 text-domain-engineering',
|
||||
activity_log: 'bg-success/15 text-success',
|
||||
reference: 'bg-domain-reference/15 text-domain-reference',
|
||||
};
|
||||
|
||||
async function handleCheckboxClick(e, memo) {
|
||||
@@ -400,9 +400,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ 빠른 입력 ═══ -->
|
||||
<!-- ═══ 빠른 입력 (상단 고정) ═══ -->
|
||||
{#if !showArchived}
|
||||
<Card class="mb-5">
|
||||
<Card class="mb-5 sticky top-0 z-10 shadow-sm">
|
||||
<!-- 선택적 제목 -->
|
||||
{#if showTitle}
|
||||
<input
|
||||
@@ -526,7 +526,7 @@
|
||||
{#if memo.source_channel === 'voice' || memo.ai_event_kind || memo._last_promoted}
|
||||
<div class="flex flex-wrap items-center gap-1.5 mb-1.5">
|
||||
{#if memo.source_channel === 'voice'}
|
||||
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-rose-100 text-rose-700" title="음성 메모">
|
||||
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-domain-philosophy/15 text-domain-philosophy" title="음성 메모">
|
||||
<Mic size={10} /> 음성
|
||||
</span>
|
||||
{/if}
|
||||
@@ -536,7 +536,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
{#if memo._last_promoted}
|
||||
<a href={`/events/${memo._last_promoted.event_id}`} class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-emerald-100 text-emerald-700 hover:bg-emerald-200">
|
||||
<a href={`/events/${memo._last_promoted.event_id}`} class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-success/15 text-success hover:bg-success/25">
|
||||
<ArrowRight size={10} /> events #{memo._last_promoted.event_id}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -586,13 +586,13 @@
|
||||
<!-- PR-2B: AI triage 결과 → 1-click promote 버튼 (분류 결과 있고 dismissed 아닌 메모) -->
|
||||
{#if editingId !== memo.id && memo.ai_event_kind && memo.ai_event_kind !== 'note' && !memo._last_promoted && !showArchived}
|
||||
<div class="flex flex-wrap gap-1 mt-2 pt-2 border-t border-default/30">
|
||||
<button onclick={() => promoteMemo(memo.id, 'task')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'task' ? 'bg-indigo-500 text-white hover:bg-indigo-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<button onclick={() => promoteMemo(memo.id, 'task')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'task' ? 'bg-accent text-white hover:bg-accent-hover' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<FileText size={11} /> 할 일로
|
||||
</button>
|
||||
<button onclick={() => promoteMemo(memo.id, 'calendar_event')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'calendar_event' ? 'bg-blue-500 text-white hover:bg-blue-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<button onclick={() => promoteMemo(memo.id, 'calendar_event')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'calendar_event' ? 'bg-domain-engineering text-white hover:opacity-90' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<Calendar size={11} /> 일정으로
|
||||
</button>
|
||||
<button onclick={() => promoteMemo(memo.id, 'activity_log')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'activity_log' ? 'bg-emerald-500 text-white hover:bg-emerald-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<button onclick={() => promoteMemo(memo.id, 'activity_log')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'activity_log' ? 'bg-success text-white hover:opacity-90' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<Activity size={11} /> 활동으로
|
||||
</button>
|
||||
<button onclick={() => dismissEventSuggestion(memo.id)} class="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] bg-surface text-dim hover:bg-surface-hover hover:text-text transition-colors">
|
||||
|
||||
@@ -1,101 +1,59 @@
|
||||
<script lang="ts">
|
||||
// 야간 수집 뉴스 브리핑 (Morning Briefing) — 매일 KST 05:10 cron 으로 만들어진
|
||||
// topic×country 비교 분석 1페이지 카드. 기존 article list / source tree /
|
||||
// 북마크 / 노트 / 필터 UI 는 폐기 (PR-MorningBriefing-2 swap).
|
||||
// 모닝브리핑 /news — 확정 시안 '편집 신문 1면'. 야간(KST 0~5h) 수집 뉴스를
|
||||
// topic×country 비교 분석. 전 기능 보존(국가 관점·기사ID·차이/공통·인용·지난흐름·읽음/별표·날짜).
|
||||
// 이모지 국기 → 국가 색칩(no-emoji 규칙). 데이터·API 는 기존 /briefing 그대로.
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type ApiError } from '$lib/api';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
|
||||
type CountryPerspective = {
|
||||
country: string;
|
||||
summary: string;
|
||||
article_ids: number[];
|
||||
};
|
||||
|
||||
type KeyQuote = {
|
||||
country: string;
|
||||
source: string;
|
||||
quote: string;
|
||||
};
|
||||
|
||||
type CountryPerspective = { country: string; summary: string; article_ids: number[] };
|
||||
type KeyQuote = { country: string; source: string; quote: string };
|
||||
type BriefingTopic = {
|
||||
id: number;
|
||||
topic_rank: number;
|
||||
topic_label: string;
|
||||
headline: string;
|
||||
country_perspectives: CountryPerspective[];
|
||||
divergences: string[];
|
||||
convergences: string[];
|
||||
key_quotes: KeyQuote[];
|
||||
historical_context: string | null;
|
||||
cluster_members: number[];
|
||||
article_count: number;
|
||||
country_count: number;
|
||||
importance_score: number;
|
||||
llm_fallback_used: boolean;
|
||||
is_read: boolean;
|
||||
read_at: string | null;
|
||||
highlighted: boolean;
|
||||
highlighted_at: string | null;
|
||||
id: number; topic_rank: number; topic_label: string; headline: string;
|
||||
country_perspectives: CountryPerspective[]; divergences: string[]; convergences: string[];
|
||||
key_quotes: KeyQuote[]; historical_context: string | null; cluster_members: number[];
|
||||
article_count: number; country_count: number; importance_score: number; llm_fallback_used: boolean;
|
||||
is_read: boolean; read_at: string | null; highlighted: boolean; highlighted_at: string | null;
|
||||
};
|
||||
|
||||
type BriefingDateSummary = {
|
||||
briefing_date: string;
|
||||
total_topics: number;
|
||||
total_articles: number;
|
||||
status: string;
|
||||
read_count: number;
|
||||
highlighted_count: number;
|
||||
briefing_date: string; total_topics: number; total_articles: number;
|
||||
status: string; read_count: number; highlighted_count: number;
|
||||
};
|
||||
|
||||
type Briefing = {
|
||||
briefing_date: string;
|
||||
window_start: string;
|
||||
window_end: string;
|
||||
total_articles: number;
|
||||
total_countries: number;
|
||||
total_topics: number;
|
||||
llm_calls: number;
|
||||
llm_failures: number;
|
||||
briefing_date: string; window_start: string; window_end: string;
|
||||
total_articles: number; total_countries: number; total_topics: number;
|
||||
llm_calls: number; llm_failures: number;
|
||||
status: 'success' | 'partial' | 'failed' | 'empty';
|
||||
headline_oneliner: string | null;
|
||||
topics: BriefingTopic[];
|
||||
headline_oneliner: string | null; topics: BriefingTopic[];
|
||||
};
|
||||
|
||||
const COUNTRY_META: Record<string, { flag: string; label: string }> = {
|
||||
KR: { flag: '🇰🇷', label: '한국' },
|
||||
US: { flag: '🇺🇸', label: '미국' },
|
||||
JP: { flag: '🇯🇵', label: '일본' },
|
||||
CN: { flag: '🇨🇳', label: '중국' },
|
||||
HK: { flag: '🇭🇰', label: '홍콩' },
|
||||
TW: { flag: '🇹🇼', label: '대만' },
|
||||
DE: { flag: '🇩🇪', label: '독일' },
|
||||
FR: { flag: '🇫🇷', label: '프랑스' },
|
||||
GB: { flag: '🇬🇧', label: '영국' },
|
||||
UK: { flag: '🇬🇧', label: '영국' },
|
||||
IN: { flag: '🇮🇳', label: '인도' },
|
||||
RU: { flag: '🇷🇺', label: '러시아' },
|
||||
IR: { flag: '🇮🇷', label: '이란' },
|
||||
IL: { flag: '🇮🇱', label: '이스라엘' },
|
||||
PH: { flag: '🇵🇭', label: '필리핀' },
|
||||
AU: { flag: '🇦🇺', label: '호주' },
|
||||
NL: { flag: '🇳🇱', label: '네덜란드' },
|
||||
// 국가 라벨(한국어, 이모지 없음) + 색칩 토큰
|
||||
const COUNTRY_LABEL: Record<string, string> = {
|
||||
KR: '한국', US: '미국', JP: '일본', CN: '중국', HK: '홍콩', TW: '대만',
|
||||
DE: '독일', FR: '프랑스', GB: '영국', UK: '영국', IN: '인도', RU: '러시아',
|
||||
IR: '이란', IL: '이스라엘', PH: '필리핀', AU: '호주', NL: '네덜란드',
|
||||
};
|
||||
const COUNTRY_CHIP: Record<string, string> = {
|
||||
KR: 'bg-warning', US: 'bg-domain-engineering', JP: 'bg-domain-reference',
|
||||
DE: 'bg-accent-hover', HK: 'bg-domain-philosophy', CN: 'bg-error',
|
||||
TW: 'bg-domain-general', GB: 'bg-domain-engineering', UK: 'bg-domain-engineering',
|
||||
FR: 'bg-domain-philosophy', IN: 'bg-domain-reference', RU: 'bg-error',
|
||||
IL: 'bg-accent', IR: 'bg-warning',
|
||||
};
|
||||
function countryLabel(code: string): string {
|
||||
const meta = COUNTRY_META[code?.toUpperCase()];
|
||||
return meta ? `${meta.flag} ${meta.label}` : code;
|
||||
return COUNTRY_LABEL[code?.toUpperCase?.()] ?? code;
|
||||
}
|
||||
function countryChip(code: string): string {
|
||||
return COUNTRY_CHIP[code?.toUpperCase?.()] ?? 'bg-dim';
|
||||
}
|
||||
|
||||
let briefing = $state<Briefing | null>(null);
|
||||
let loading = $state(true);
|
||||
let errorMsg = $state<string | null>(null);
|
||||
// 2026-05-13 추가 — 날짜 선택 + 카드 액션
|
||||
let availableDates = $state<BriefingDateSummary[]>([]);
|
||||
let selectedDate = $state<string>(''); // YYYY-MM-DD ('' = 최신)
|
||||
let selectedDate = $state<string>('');
|
||||
|
||||
async function loadBriefing(dateStr: string) {
|
||||
loading = true;
|
||||
errorMsg = null;
|
||||
loading = true; errorMsg = null;
|
||||
try {
|
||||
const path = dateStr ? `/briefing?date=${dateStr}` : '/briefing/latest';
|
||||
briefing = await api<Briefing>(path);
|
||||
@@ -109,216 +67,238 @@
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDates() {
|
||||
try {
|
||||
availableDates = await api<BriefingDateSummary[]>('/briefing/dates');
|
||||
} catch {
|
||||
availableDates = [];
|
||||
}
|
||||
}
|
||||
|
||||
function onDateChange() {
|
||||
loadBriefing(selectedDate);
|
||||
try { availableDates = await api<BriefingDateSummary[]>('/briefing/dates'); }
|
||||
catch { availableDates = []; }
|
||||
}
|
||||
function onDateChange() { loadBriefing(selectedDate); }
|
||||
|
||||
async function toggleRead(topic: BriefingTopic) {
|
||||
if (!briefing) return;
|
||||
const next = !topic.is_read;
|
||||
try {
|
||||
const r = await api<{ id: number; is_read: boolean; read_at: string | null; highlighted: boolean; highlighted_at: string | null }>(
|
||||
`/briefing/topics/${topic.id}/read`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ value: next }) }
|
||||
);
|
||||
topic.is_read = r.is_read;
|
||||
topic.read_at = r.read_at;
|
||||
} catch (e) {
|
||||
console.error('toggleRead failed', e);
|
||||
}
|
||||
const r = await api<{ is_read: boolean; read_at: string | null }>(
|
||||
`/briefing/topics/${topic.id}/read`, { method: 'PATCH', body: JSON.stringify({ value: next }) });
|
||||
topic.is_read = r.is_read; topic.read_at = r.read_at;
|
||||
} catch (e) { console.error('toggleRead failed', e); }
|
||||
}
|
||||
|
||||
async function toggleHighlight(topic: BriefingTopic) {
|
||||
if (!briefing) return;
|
||||
const next = !topic.highlighted;
|
||||
try {
|
||||
const r = await api<{ id: number; is_read: boolean; read_at: string | null; highlighted: boolean; highlighted_at: string | null }>(
|
||||
`/briefing/topics/${topic.id}/highlight`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ value: next }) }
|
||||
);
|
||||
topic.highlighted = r.highlighted;
|
||||
topic.highlighted_at = r.highlighted_at;
|
||||
} catch (e) {
|
||||
console.error('toggleHighlight failed', e);
|
||||
}
|
||||
const r = await api<{ highlighted: boolean; highlighted_at: string | null }>(
|
||||
`/briefing/topics/${topic.id}/highlight`, { method: 'PATCH', body: JSON.stringify({ value: next }) });
|
||||
topic.highlighted = r.highlighted; topic.highlighted_at = r.highlighted_at;
|
||||
} catch (e) { console.error('toggleHighlight failed', e); }
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadDates(), loadBriefing('')]);
|
||||
});
|
||||
onMount(async () => { await Promise.all([loadDates(), loadBriefing('')]); });
|
||||
|
||||
const fallbackPct = $derived(
|
||||
briefing && briefing.llm_calls > 0
|
||||
? Math.round((briefing.llm_failures / briefing.llm_calls) * 100)
|
||||
: 0
|
||||
briefing && briefing.llm_calls > 0 ? Math.round((briefing.llm_failures / briefing.llm_calls) * 100) : 0
|
||||
);
|
||||
const highlightedCount = $derived(briefing ? briefing.topics.filter((t) => t.highlighted).length : 0);
|
||||
const leadTopic = $derived(briefing && briefing.topics.length > 0 ? briefing.topics[0] : null);
|
||||
const restTopics = $derived(briefing ? briefing.topics.slice(1) : []);
|
||||
function folio(rank: number) { return String(rank).padStart(2, '0'); }
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-6 space-y-4">
|
||||
<header class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<h1 class="text-xl font-semibold">야간 뉴스 브리핑</h1>
|
||||
{#if availableDates.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="briefing-date" class="text-xs text-dim">날짜</label>
|
||||
<select
|
||||
id="briefing-date"
|
||||
bind:value={selectedDate}
|
||||
onchange={onDateChange}
|
||||
class="text-sm border border-default rounded-md px-2 py-1 bg-surface"
|
||||
>
|
||||
<option value="">최신</option>
|
||||
{#each availableDates as d}
|
||||
<option value={d.briefing_date}>
|
||||
{d.briefing_date} · {d.total_topics}토픽
|
||||
{#if d.highlighted_count > 0}⭐{d.highlighted_count}{/if}
|
||||
</option>
|
||||
<div class="nws bg-bg min-h-full p-4 lg:p-6">
|
||||
<div class="max-w-[1240px] mx-auto">
|
||||
|
||||
<!-- ═══ 마스트헤드 ═══ -->
|
||||
<header class="bg-surface border border-default rounded-lg relative overflow-hidden px-5 lg:px-7 pt-5 pb-4">
|
||||
<span class="absolute left-0 top-0 bottom-0 w-[5px] bg-accent"></span>
|
||||
<div class="flex justify-between items-end flex-wrap gap-3 border-b-2 border-text pb-2.5 mb-3">
|
||||
<div class="nws-serif font-extrabold tracking-tight text-text text-3xl lg:text-4xl leading-none">모닝브리핑</div>
|
||||
<div class="flex items-center gap-2.5 flex-wrap text-xs text-dim font-mono">
|
||||
{#if availableDates.length > 0}
|
||||
<select
|
||||
bind:value={selectedDate}
|
||||
onchange={onDateChange}
|
||||
class="bg-bg border border-default rounded-md px-2 py-1 text-xs text-text"
|
||||
aria-label="브리핑 날짜"
|
||||
>
|
||||
<option value="">최신</option>
|
||||
{#each availableDates as d}
|
||||
<option value={d.briefing_date}>{d.briefing_date} · {d.total_topics}토픽{#if d.highlighted_count > 0} · ★{d.highlighted_count}{/if}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if briefing}
|
||||
<span class="font-bold text-text">{briefing.briefing_date}</span>
|
||||
{/if}
|
||||
{#if briefing}
|
||||
<span>{briefing.total_topics}토픽{#if highlightedCount > 0} · 별표 <span class="text-warning font-bold">{highlightedCount}</span>{/if}</span>
|
||||
<span>새벽 수집</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if briefing?.headline_oneliner}
|
||||
<div class="nws-serif text-text font-semibold text-lg lg:text-[22px] leading-snug tracking-tight mb-3.5">
|
||||
<span class="block font-mono text-xs font-bold text-accent-hover uppercase tracking-wider mb-1">오늘의 한 줄</span>
|
||||
{briefing.headline_oneliner}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if briefing}
|
||||
<div class="flex flex-wrap border-t border-default pt-3">
|
||||
<div class="flex flex-col gap-0.5 pr-6 border-r border-default">
|
||||
<span class="nws-serif text-2xl font-extrabold text-text leading-none">{briefing.total_articles}</span>
|
||||
<span class="text-[11px] text-dim uppercase tracking-wide">총 기사</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-6 border-r border-default">
|
||||
<span class="nws-serif text-2xl font-extrabold text-text leading-none">{briefing.total_countries}</span>
|
||||
<span class="text-[11px] text-dim uppercase tracking-wide">개국</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-6">
|
||||
<span class="nws-serif text-2xl font-extrabold text-text leading-none">{briefing.total_topics}</span>
|
||||
<span class="text-[11px] text-dim uppercase tracking-wide">토픽</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if briefing && (briefing.status === 'partial' || briefing.status === 'failed')}
|
||||
<div class="flex items-center gap-2.5 mt-3.5 px-3.5 py-2 rounded-md text-[13px]
|
||||
{briefing.status === 'failed' ? 'bg-error/10 border border-error/30 text-error' : 'bg-warning/10 border border-warning/30 text-warning'}">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {briefing.status === 'failed' ? 'bg-error' : 'bg-warning'}"></span>
|
||||
{#if briefing.status === 'failed'}
|
||||
LLM 분석 실패율이 높습니다 ({briefing.llm_failures}/{briefing.llm_calls}, {fallbackPct}%). 일부 토픽이 원문 묶음으로 표시됩니다.
|
||||
{:else}
|
||||
일부 토픽 LLM 실패 ({briefing.llm_failures}/{briefing.llm_calls}). 다른 토픽은 정상 분석되었습니다.
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- ═══ 본문 ═══ -->
|
||||
{#if loading}
|
||||
<div class="bg-surface border border-default rounded-lg p-5 mt-4 text-sm text-dim">불러오는 중…</div>
|
||||
{:else if errorMsg}
|
||||
<div class="bg-surface border border-default rounded-lg p-5 mt-4 text-sm text-text">{errorMsg}</div>
|
||||
{:else if briefing}
|
||||
{#if briefing.status === 'empty'}
|
||||
<div class="bg-surface border border-default rounded-lg p-5 mt-4">
|
||||
<p class="text-sm text-text">오늘 새벽({briefing.briefing_date}) 다국 비교 가능한 토픽이 없습니다.</p>
|
||||
<p class="mt-2 text-xs text-dim">(수집 뉴스 0건 또는 2개국 이상 다룬 주제 없음)</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 리드 토픽 (전체 너비, 관점 2열) -->
|
||||
{#if leadTopic}
|
||||
{@render topicCard(leadTopic, true)}
|
||||
{/if}
|
||||
<!-- 나머지 토픽 (2열 그리드) -->
|
||||
{#if restTopics.length > 0}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
||||
{#each restTopics as topic (topic.id)}
|
||||
{@render topicCard(topic, false)}
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet topicCard(topic, isLead)}
|
||||
<article class="bg-surface border rounded-lg overflow-hidden relative transition-opacity
|
||||
{isLead ? 'mt-4' : ''}
|
||||
{topic.highlighted ? 'border-accent ring-2 ring-accent/25' : 'border-default'}
|
||||
{topic.is_read ? 'opacity-50 hover:opacity-80' : ''}">
|
||||
{#if topic.is_read}
|
||||
<span class="absolute top-3 right-[88px] text-[10px] font-mono font-bold tracking-widest text-error border border-error rounded px-1.5 py-0.5 -rotate-6 opacity-70 pointer-events-none uppercase select-none">읽음</span>
|
||||
{/if}
|
||||
<!-- head -->
|
||||
<div class="flex items-start gap-3.5 px-5 pt-4 pb-3.5 border-b border-default">
|
||||
<div class="nws-serif font-extrabold leading-none text-text shrink-0 text-center pt-0.5 min-w-[42px]
|
||||
{topic.highlighted ? 'text-white bg-accent rounded-md px-1 py-1.5' : ''} {isLead ? 'text-3xl' : 'text-2xl'}">{folio(topic.topic_rank)}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-mono text-[11px] tracking-wide uppercase text-accent-hover font-bold mb-1">
|
||||
{topic.topic_label}{#if topic.llm_fallback_used}<span class="text-dim ml-1 normal-case">(원문 묶음)</span>{/if}
|
||||
</div>
|
||||
<div class="nws-serif font-bold leading-tight text-text tracking-tight {isLead ? 'text-[23px]' : 'text-[19px]'}">{topic.headline}</div>
|
||||
<div class="inline-flex items-center gap-1.5 mt-2 text-xs text-dim font-mono">
|
||||
<span>{topic.country_count}개국</span><span class="w-1 h-1 rounded-full bg-faint"></span><span>{topic.article_count}건</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1.5 shrink-0">
|
||||
<button type="button" onclick={() => toggleHighlight(topic)} aria-label="별표 토글" title={topic.highlighted ? '별표 해제' : '별표'}
|
||||
class="w-[34px] h-[30px] rounded-md border flex items-center justify-center transition-colors
|
||||
{topic.highlighted ? 'bg-accent border-accent text-white' : 'bg-bg border-default text-dim hover:text-text hover:bg-surface-hover'}">★</button>
|
||||
<button type="button" onclick={() => toggleRead(topic)} aria-label="읽음 토글" title={topic.is_read ? '읽지 않음으로' : '읽음 처리'}
|
||||
class="w-[34px] h-[30px] rounded-md border flex items-center justify-center text-xs transition-colors
|
||||
{topic.is_read ? 'bg-accent/15 border-accent text-accent-hover' : 'bg-bg border-default text-dim hover:text-text hover:bg-surface-hover'}">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- body -->
|
||||
<div class="px-5 pt-4 pb-4.5">
|
||||
{#if topic.country_perspectives.length > 0}
|
||||
<div class="nws-rule font-mono text-[10px] tracking-wider uppercase text-faint flex items-center gap-2 mb-2">국가별 관점</div>
|
||||
<div class="grid gap-2.5 {isLead ? 'lg:grid-cols-2' : 'grid-cols-1'}">
|
||||
{#each topic.country_perspectives as cp}
|
||||
<div class="border-l-[3px] border-border-strong pl-3 py-0.5">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span class="font-mono text-[10.5px] font-extrabold tracking-wide text-white rounded px-1.5 py-0.5 {countryChip(cp.country)}">{countryLabel(cp.country)}</span>
|
||||
{#if cp.article_ids.length > 0}
|
||||
<span class="inline-flex gap-1.5 flex-wrap">
|
||||
{#each cp.article_ids as id}
|
||||
<a href={`/documents/${id}`} class="font-mono text-[11px] text-accent-hover bg-accent/12 rounded px-1.5 py-px border border-transparent hover:border-accent transition-colors">#{id}</a>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-[13.5px] text-text leading-relaxed">{cp.summary}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if topic.divergences.length > 0 || topic.convergences.length > 0}
|
||||
<div class="grid gap-2.5 mt-3.5 {isLead && topic.divergences.length > 0 && topic.convergences.length > 0 ? 'lg:grid-cols-2' : 'grid-cols-1'}">
|
||||
{#if topic.divergences.length > 0}
|
||||
<div class="rounded-lg px-3.5 py-3 text-[13px] leading-relaxed bg-error/[0.06] border border-error/20">
|
||||
<span class="block font-mono text-[10px] font-bold tracking-wide uppercase mb-1.5 text-error">차이</span>
|
||||
<span class="text-text">{topic.divergences.join(' · ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if topic.convergences.length > 0}
|
||||
<div class="rounded-lg px-3.5 py-3 text-[13px] leading-relaxed bg-accent/12 border border-accent/25">
|
||||
<span class="block font-mono text-[10px] font-bold tracking-wide uppercase mb-1.5 text-accent-hover">공통</span>
|
||||
<span class="text-text">{topic.convergences.join(' · ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if topic.key_quotes.length > 0}
|
||||
<div class="mt-3.5 flex flex-col gap-2.5">
|
||||
{#each topic.key_quotes as q}
|
||||
<div class="nws-quote relative pl-6">
|
||||
<div class="nws-serif italic text-[15px] leading-snug text-text">{q.quote}</div>
|
||||
<div class="text-[11px] text-dim font-mono mt-1 flex items-center gap-1.5 flex-wrap">
|
||||
{#if q.country}<span class="text-[9.5px] font-extrabold tracking-wide text-white rounded px-1.5 py-0.5 {countryChip(q.country)}">{countryLabel(q.country)}</span>{/if}
|
||||
<span class="font-bold text-text">{q.source}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if topic.historical_context}
|
||||
<div class="mt-3.5 px-3 py-2.5 rounded-md bg-bg border border-default text-[12.5px] text-dim leading-relaxed">
|
||||
<span class="font-mono text-[10px] font-bold tracking-wide uppercase text-faint mr-1.5">지난 흐름</span>{topic.historical_context}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-dim">
|
||||
{#if briefing}
|
||||
{briefing.briefing_date} 새벽 수집 · 총 {briefing.total_articles}건 / {briefing.total_countries}개국 / {briefing.total_topics}개 토픽
|
||||
{:else}
|
||||
매일 KST 자정~05:00 누적 뉴스를 주제별로 다국 비교 분석합니다.
|
||||
{/if}
|
||||
</p>
|
||||
</header>
|
||||
</article>
|
||||
{/snippet}
|
||||
|
||||
{#if loading}
|
||||
<Card>
|
||||
<p class="text-sm text-dim">불러오는 중…</p>
|
||||
</Card>
|
||||
{:else if errorMsg}
|
||||
<Card>
|
||||
<p class="text-sm">{errorMsg}</p>
|
||||
</Card>
|
||||
{:else if briefing}
|
||||
{#if briefing.status === 'empty'}
|
||||
<Card>
|
||||
<p class="text-sm">
|
||||
오늘 새벽({briefing.briefing_date}) 다국 비교 가능한 토픽이 없습니다.
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-dim">
|
||||
(수집 뉴스 0건 또는 2개국 이상 다룬 주제 없음)
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
{#if briefing.status === 'failed'}
|
||||
<div class="border border-error/40 bg-error/10 text-sm rounded-md px-4 py-3">
|
||||
⚠ LLM 분석 실패율이 높습니다 ({briefing.llm_failures}/{briefing.llm_calls}, {fallbackPct}%). 일부 토픽이 원문 묶음으로 표시됩니다.
|
||||
</div>
|
||||
{:else if briefing.status === 'partial'}
|
||||
<div class="border border-warning/40 bg-warning/10 text-sm rounded-md px-4 py-3">
|
||||
일부 토픽 LLM 실패 ({briefing.llm_failures}/{briefing.llm_calls}). 다른 토픽은 정상 분석되었습니다.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each briefing.topics as topic (topic.id)}
|
||||
<div class:opacity-60={topic.is_read}>
|
||||
<Card class={topic.highlighted ? "ring-2 ring-yellow-400" : ""}>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-xs text-faint shrink-0 pt-1">#{topic.topic_rank}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-base font-semibold leading-snug">
|
||||
{topic.topic_label}
|
||||
{#if topic.llm_fallback_used}
|
||||
<span class="ml-1 text-xs text-dim">(원문 묶음)</span>
|
||||
{/if}
|
||||
</h2>
|
||||
<p class="text-sm text-dim mt-1">{topic.headline}</p>
|
||||
<p class="text-xs text-faint mt-1">
|
||||
{topic.country_count}개국 · {topic.article_count}건
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleHighlight(topic)}
|
||||
class="text-base leading-none px-1.5 py-0.5 rounded hover:bg-surface"
|
||||
class:text-yellow-500={topic.highlighted}
|
||||
class:text-faint={!topic.highlighted}
|
||||
title={topic.highlighted ? '하이라이트 해제' : '하이라이트'}
|
||||
aria-label="하이라이트 토글"
|
||||
>★</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleRead(topic)}
|
||||
class="text-xs px-1.5 py-0.5 rounded border border-default hover:bg-surface"
|
||||
title={topic.is_read ? '읽지 않음으로' : '읽음 처리'}
|
||||
aria-label="읽음 토글"
|
||||
>{topic.is_read ? '✓읽음' : '읽음'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if topic.country_perspectives.length > 0}
|
||||
<div class="space-y-1.5">
|
||||
{#each topic.country_perspectives as cp}
|
||||
<div class="text-sm leading-relaxed">
|
||||
<span class="font-medium">{countryLabel(cp.country)}</span>
|
||||
<span class="text-dim mx-1">·</span>
|
||||
<span>{cp.summary}</span>
|
||||
{#if cp.article_ids.length > 0}
|
||||
<span class="ml-1 text-xs text-faint">
|
||||
{#each cp.article_ids as id, i}
|
||||
{#if i > 0}<span class="mx-0.5">·</span>{/if}<a
|
||||
href={`/documents/${id}`}
|
||||
class="hover:text-accent"
|
||||
>#{id}</a>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if topic.divergences.length > 0}
|
||||
<div class="text-xs">
|
||||
<span class="text-dim">차이 </span>
|
||||
<span class="text-text">{topic.divergences.join(' · ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if topic.convergences.length > 0}
|
||||
<div class="text-xs">
|
||||
<span class="text-dim">공통 </span>
|
||||
<span class="text-text">{topic.convergences.join(' · ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if topic.key_quotes.length > 0}
|
||||
<ul class="text-xs space-y-1 border-l-2 border-default pl-3">
|
||||
{#each topic.key_quotes as q}
|
||||
<li>
|
||||
<span class="text-dim">{countryLabel(q.country)} · {q.source}</span>
|
||||
<span class="text-text">"{q.quote}"</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if topic.historical_context}
|
||||
<p class="text-xs text-faint italic">
|
||||
↩ 지난 흐름 · {topic.historical_context}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<style>
|
||||
.nws-serif { font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif; }
|
||||
.nws-rule::after { content: ""; flex: 1; height: 1px; background: var(--border); }
|
||||
.nws-quote::before {
|
||||
content: "\201C"; font-family: Georgia, serif; font-size: 36px; line-height: 0;
|
||||
color: var(--accent); position: absolute; left: 0; top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag } from 'lucide-svelte';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity } from 'lucide-svelte';
|
||||
|
||||
let cardReviewCount = $state(0);
|
||||
let questionFlagCount = $state(0);
|
||||
@@ -38,6 +38,17 @@
|
||||
<p class="text-xs text-dim">"가스기사" 같은 학습 주제 아래에 필기 세션과 자료를 함께 묶어 본다. 한 주제 안에서 필기·자료를 한눈에.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/study/diagnosis"
|
||||
class="block mb-3 p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Activity size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">학습 진단</h2>
|
||||
</div>
|
||||
<p class="text-xs text-dim">누적 풀이 이력에서 약점 토픽과 학습 태도를 코치(이드)가 진단합니다. 매일 새벽 약점 스냅샷을 만들고, 권장 복습세트 초안까지 제안.</p>
|
||||
</a>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<a
|
||||
href="/study/sources"
|
||||
@@ -86,6 +97,17 @@
|
||||
<p class="text-xs text-dim">검수한 암기카드를 모바일에서 학습. <b>복습(간격반복 1·3·7·14일)</b>으로 자기평가하거나, <b>그냥 공부</b>로 덜 본 카드를 가볍게 훑어봅니다.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/study/review-box"
|
||||
class="block p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Inbox size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">복습함</h2>
|
||||
</div>
|
||||
<p class="text-xs text-dim">오늘 복습할 카드와 미확인 카드를 한눈에 보고, <b>골라서</b> 복습합니다.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/study/questions-review"
|
||||
class="block p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
|
||||
|
||||
let loading = $state(true);
|
||||
let groups = $state([]); // [{ source_question_id, question_text, correct_choice, cards: [...] }]
|
||||
@@ -152,7 +153,7 @@
|
||||
<EmptyState title="검수할 카드가 없습니다" description="새 문제를 풀면 AI가 암기카드를 추출해 여기에 쌓입니다." icon={CheckCheck} />
|
||||
{:else}
|
||||
<div class="space-y-5">
|
||||
{#each shownGroups as g (g.source_question_id)}
|
||||
{#each shownGroups as g (g.source_question_id ?? g.question_text)}
|
||||
<div class="rounded-card border border-default bg-bg/40 p-3">
|
||||
<!-- 출처 문제 -->
|
||||
<div class="mb-3 flex items-start gap-2 rounded-lg border border-default bg-surface px-3 py-2">
|
||||
@@ -162,7 +163,7 @@
|
||||
<div class="text-sm text-text">{g.question_text}</div>
|
||||
{#if g.correct_choice}<div class="mt-0.5 text-xs text-accent">사용자 정답: {g.correct_choice}번</div>{/if}
|
||||
</div>
|
||||
{#if g.cards.length > 1}
|
||||
{#if g.cards.length > 1 && g.source_question_id != null}
|
||||
<Button variant="secondary" size="sm" icon={CheckCheck} onclick={() => approveGroup(g.source_question_id, g.cards.length)}>{g.cards.length}장 승인</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -200,18 +201,22 @@
|
||||
{:else}
|
||||
<!-- 보기 모드 -->
|
||||
<div class="rounded-md border border-default bg-surface-active px-3 py-2 text-sm">
|
||||
<div class="text-[10px] font-bold uppercase tracking-wide text-faint">앞</div>{c.cue}
|
||||
<div class="text-[10px] font-bold uppercase tracking-wide text-faint">앞</div>
|
||||
<div class="math-area break-words">{@html renderMathMarkdownInline(c.cue)}</div>
|
||||
</div>
|
||||
<div class="mt-1.5 rounded-md border border-accent-ring bg-bg px-3 py-2 text-sm">
|
||||
{#if c.format === 'cloze' && c.cloze_text}
|
||||
{c.cloze_text}
|
||||
<div class="mt-1 text-xs text-accent">정답: <b>{c.fact}</b></div>
|
||||
<span class="math-area break-words">{@html renderMathMarkdownInline(c.cloze_text)}</span>
|
||||
<div class="mt-1 text-xs text-accent">정답: <b class="math-area break-words">{@html renderMathMarkdownInline(c.fact)}</b></div>
|
||||
{:else}
|
||||
<b class="text-accent">{c.fact}</b>
|
||||
<b class="math-area break-words text-accent">{@html renderMathMarkdownInline(c.fact)}</b>
|
||||
{/if}
|
||||
</div>
|
||||
{#if c.evidence?.length}
|
||||
<div class="mt-2 text-[11px] text-dim">근거: {c.evidence[0].snippet}</div>
|
||||
<div class="mt-2">
|
||||
<span class="text-[10px] font-bold uppercase tracking-wide text-faint">근거</span>
|
||||
<div class="markdown-body math-area mt-1 overflow-x-auto text-[11px] leading-relaxed text-dim">{@html renderMathMarkdown(c.evidence[0].snippet)}</div>
|
||||
</div>
|
||||
{:else if c.source_kind === 'manual'}
|
||||
<div class="mt-2 text-[11px] text-faint">출처: 직접 추가 자료</div>
|
||||
{:else}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
|
||||
import { get } from 'svelte/store';
|
||||
import { pendingReviewCards } from '$lib/stores/studySession';
|
||||
|
||||
// sr_schedule.py 단일 source 미러 — '암'(correct) 다음 복습일 라벨 전용(실제 스케줄은 백엔드).
|
||||
// stage===null = 신규 카드(progress 없음): '암'이면 백엔드가 due 안 박음(외움→큐 제외)이라 '안 나옴'.
|
||||
@@ -29,7 +31,7 @@
|
||||
if (stage === null || stage === undefined) return '안 나옴';
|
||||
const ns = stage + 1;
|
||||
if (ns >= 4) return '졸업';
|
||||
return `+${REVIEW_INTERVAL_DAYS[ns]}일`;
|
||||
return `${REVIEW_INTERVAL_DAYS[ns]}일 뒤`;
|
||||
}
|
||||
|
||||
let mode = $state('landing'); // 'landing' | 'review' | 'cram'
|
||||
@@ -82,6 +84,14 @@
|
||||
revealed = false;
|
||||
tally = { correct: 0, unsure: 0, wrong: 0 };
|
||||
marks = [];
|
||||
// 복습함(/study/review-box)에서 선택해 넘긴 카드가 있으면 그걸로 세션 구성.
|
||||
const preset = get(pendingReviewCards);
|
||||
if (preset && preset.length) {
|
||||
pendingReviewCards.set(null); // 소비
|
||||
cards = preset;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
cards = _dueCache ?? (await fetchDue());
|
||||
_dueCache = null; // 소비
|
||||
@@ -279,10 +289,10 @@
|
||||
>
|
||||
<div class="mb-1.5 flex items-center gap-2">
|
||||
<Layers size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">그냥 공부 (휙휙)</h2>
|
||||
<h2 class="text-base font-semibold text-text">그냥 공부</h2>
|
||||
</div>
|
||||
<p class="text-xs text-dim">
|
||||
덜 본 카드부터 빠르게 넘겨보며 <b class="text-text">봤다</b>만 기록합니다. 간격반복(SR)과 무관 — 가볍게 훑을 때.
|
||||
아직 덜 본 카드부터 가볍게 넘겨보며 <b class="text-text">봤어요</b>만 기록해요. 복습 일정과는 무관해요.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
@@ -294,21 +304,21 @@
|
||||
<!-- 결과 화면 -->
|
||||
<div class="flex flex-1 flex-col items-center justify-center text-center">
|
||||
{#if mode === 'review'}
|
||||
<div class="text-lg font-bold text-text">오늘 카드 복습 완료</div>
|
||||
<div class="text-lg font-bold text-text">오늘 복습을 마쳤어요</div>
|
||||
<div class="my-6 flex gap-9">
|
||||
<div><div class="text-3xl font-extrabold text-success">{tally.correct}</div><div class="text-xs text-dim">암</div></div>
|
||||
<div><div class="text-3xl font-extrabold text-warning">{tally.unsure}</div><div class="text-xs text-dim">애매</div></div>
|
||||
<div><div class="text-3xl font-extrabold text-error">{tally.wrong}</div><div class="text-xs text-dim">모름</div></div>
|
||||
</div>
|
||||
<p class="text-xs text-dim">애매·모름 카드는 내일 복습 큐에 다시 올라옵니다. 암 카드는 간격만큼 쉬어요.</p>
|
||||
<p class="text-xs text-dim">애매하거나 몰랐던 카드는 내일 다시 만나요. 외운 카드는 간격만큼 쉬어요.</p>
|
||||
{:else}
|
||||
<div class="text-lg font-bold text-text">훑어보기 완료</div>
|
||||
<div class="my-6 text-3xl font-extrabold text-accent">{seen}<span class="ml-1 text-sm font-medium text-dim">장</span></div>
|
||||
<p class="text-xs text-dim">'봤다'로 기록한 카드는 다음에 덜 본 순서에서 뒤로 갑니다.</p>
|
||||
<p class="text-xs text-dim">'봤어요'로 표시한 카드는 다음엔 덜 본 순서 뒤로 가요.</p>
|
||||
{/if}
|
||||
<div class="mt-7 flex gap-2">
|
||||
<Button variant="secondary" onclick={backToLanding}>다시 고르기</Button>
|
||||
<Button variant="primary" onclick={() => goto('/study')}>공부 허브로</Button>
|
||||
<Button variant="primary" onclick={() => goto('/study')}>공부로 돌아가기</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -359,7 +369,7 @@
|
||||
type="button"
|
||||
onclick={flagCard}
|
||||
disabled={flagBusy || busy}
|
||||
class="flex items-center gap-1 text-[11px] text-faint transition-colors hover:text-warning disabled:opacity-50"
|
||||
class="flex items-center gap-1 rounded-full border border-default px-2.5 py-1 text-[11px] font-medium text-dim transition-colors hover:border-warning hover:bg-warning/10 hover:text-warning disabled:opacity-50"
|
||||
title="카드 내용이 이상하면 검수함으로 보냅니다"
|
||||
>
|
||||
<Flag size={12} /> 이 카드 이상해요
|
||||
@@ -367,7 +377,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-[10px] font-bold uppercase tracking-wide text-faint">
|
||||
앞 — {current.format === 'qa' ? '질문' : '회상'}
|
||||
앞 — {current.format === 'qa' ? '질문' : '떠올리기'}
|
||||
</div>
|
||||
<div class="math-area mt-1 break-words text-lg font-semibold leading-relaxed text-text md:mt-2 md:text-2xl">{@html renderMathMarkdownInline(frontText(current))}</div>
|
||||
|
||||
@@ -389,7 +399,7 @@
|
||||
onclick={() => (revealed = true)}
|
||||
class="mt-auto flex items-center justify-center gap-2 rounded-md border border-dashed border-accent-ring bg-surface-hover py-3 text-sm font-medium text-accent transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<Eye size={16} /> 탭하여 정답 보기 <span class="text-faint">(Space)</span>
|
||||
<Eye size={16} /> 탭하면 정답이 보여요 <span class="hidden text-faint sm:inline">· Space</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -402,12 +412,12 @@
|
||||
onclick={() => rate('모름')}
|
||||
disabled={busy}
|
||||
class="flex flex-col items-center rounded-lg bg-error py-3.5 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>모름<span class="mt-0.5 text-[10px] font-medium opacity-85">내일</span></button>
|
||||
>모름<span class="mt-0.5 text-[10px] font-medium opacity-85">내일 다시</span></button>
|
||||
<button
|
||||
onclick={() => rate('애매')}
|
||||
disabled={busy}
|
||||
class="flex flex-col items-center rounded-lg bg-warning py-3.5 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>애매<span class="mt-0.5 text-[10px] font-medium opacity-85">내일</span></button>
|
||||
>애매<span class="mt-0.5 text-[10px] font-medium opacity-85">내일 다시</span></button>
|
||||
<button
|
||||
onclick={() => rate('암')}
|
||||
disabled={busy}
|
||||
@@ -420,7 +430,7 @@
|
||||
onclick={markSeen}
|
||||
disabled={busy}
|
||||
class="mt-3 w-full rounded-lg bg-accent py-3.5 text-sm font-bold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
>봤다 — 다음 <span class="text-xs font-medium opacity-85">(Enter)</span></button>
|
||||
>봤어요 · 다음 <span class="hidden text-xs font-medium opacity-85 sm:inline">Enter</span></button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
/**
|
||||
* /study/diagnosis — 학습 진단(이드 코치) 전용 페이지.
|
||||
*
|
||||
* 누적 풀이 약점·학습 태도를 코치 언어로 진단하는 cross-topic 표면. 허브(/study)에서 진입.
|
||||
* 패널 본체는 공유 컴포넌트 StudyDiagnosisPanel (/study/topics 상단에도 동일 노출).
|
||||
*/
|
||||
import { ArrowLeft, Activity } from 'lucide-svelte';
|
||||
import StudyDiagnosisPanel from '$lib/components/StudyDiagnosisPanel.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>학습 진단 — 공부</title></svelte:head>
|
||||
|
||||
<div class="p-4 md:p-6 max-w-5xl mx-auto">
|
||||
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
|
||||
<a href="/study" class="text-dim hover:text-text flex items-center gap-1">
|
||||
<ArrowLeft size={14} /> 공부
|
||||
</a>
|
||||
<span class="text-faint">/</span>
|
||||
<span class="text-text font-medium flex items-center gap-1.5">
|
||||
<Activity size={14} class="text-accent" /> 학습 진단
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<header class="mb-4">
|
||||
<h1 class="text-lg font-semibold text-text">학습 진단</h1>
|
||||
<p class="text-xs text-dim mt-1">누적 풀이 이력을 근거로 약점 토픽과 학습 태도를 코치가 진단합니다. 약점·수치는 매일 새벽 약점 스냅샷에서만 인용되며, 스냅샷에 없는 토픽은 만들지 않습니다.</p>
|
||||
</header>
|
||||
|
||||
<StudyDiagnosisPanel />
|
||||
</div>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script>
|
||||
/**
|
||||
* /study/review-box — 복습함 (카드 SR 복습 현황 + 선택 학습, B4).
|
||||
*
|
||||
* GET /study-cards/due (review_stage 포함) 로 오늘의 복습 큐를 받아 2탭으로 분리:
|
||||
* - 오늘 할 일: review_stage != null (예전에 평가돼 복습일이 도래한 카드)
|
||||
* - 미확인 : review_stage == null (검수 통과했지만 아직 한 번도 회상 안 한 새 카드)
|
||||
* - 완료 : 졸업 카드 — 백엔드 엔드포인트 필요(현재 미배포 = eid contention 중 fastapi 무재빌드)라 추후.
|
||||
*
|
||||
* 멀티셀렉트 → 선택 카드를 pendingReviewCards store 로 cards-study 복습 세션에 전달(백엔드 세션 X).
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { pendingReviewCards } from '$lib/stores/studySession';
|
||||
import { ArrowLeft, Repeat, GraduationCap, CheckCheck, Play } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
let cards = $state([]); // /due 결과 (CardItem[], review_stage 포함)
|
||||
let tab = $state('today'); // 'today' | 'new' | 'done'
|
||||
let selected = $state({}); // card.id -> true
|
||||
|
||||
let newCards = $derived(cards.filter((c) => c.review_stage === null || c.review_stage === undefined));
|
||||
let dueCards = $derived(cards.filter((c) => c.review_stage !== null && c.review_stage !== undefined));
|
||||
let shown = $derived(tab === 'today' ? dueCards : tab === 'new' ? newCards : []);
|
||||
let selectedCount = $derived(shown.filter((c) => selected[c.id]).length);
|
||||
let allShownSelected = $derived(shown.length > 0 && shown.every((c) => selected[c.id]));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
cards = (await api('/study-cards/due?limit=200')) ?? [];
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '복습 카드 조회 실패');
|
||||
cards = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function frontText(c) {
|
||||
const t = (c.format === 'cloze' && c.cloze_text ? c.cloze_text : c.cue) ?? '';
|
||||
return t.length > 60 ? t.slice(0, 60) + '…' : t;
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
selected = { ...selected, [id]: !selected[id] };
|
||||
}
|
||||
function selectAllShown() {
|
||||
const next = { ...selected };
|
||||
shown.forEach((c) => { next[c.id] = !allShownSelected; });
|
||||
selected = next;
|
||||
}
|
||||
|
||||
function startCards(list) {
|
||||
if (!list.length) return;
|
||||
pendingReviewCards.set(list);
|
||||
goto('/study/cards-study?mode=review');
|
||||
}
|
||||
function startSelected() {
|
||||
startCards(shown.filter((c) => selected[c.id]));
|
||||
}
|
||||
function startTab() {
|
||||
startCards(shown);
|
||||
}
|
||||
|
||||
function setTab(t) {
|
||||
if (t === 'done' || t === tab) return; // 완료 탭은 백엔드 준비 전 비활성
|
||||
selected = {}; // 탭 전환 시 선택 초기화 — 탭별 독립 선택(선택 복습은 현재 탭 기준)
|
||||
tab = t;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>복습함</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" icon={ArrowLeft} onclick={() => goto('/study')}>공부</Button>
|
||||
<h1 class="text-xl font-bold text-text">복습함</h1>
|
||||
</div>
|
||||
<p class="mb-4 text-sm text-dim">
|
||||
검수 통과한 암기카드의 복습 현황입니다. 탭에서 카드를 골라 <b class="text-text">선택 복습</b>하거나, 탭 전체를 한 번에 복습할 수 있어요.
|
||||
</p>
|
||||
|
||||
<!-- 탭 -->
|
||||
<div class="mb-4 flex gap-1.5">
|
||||
{#each [['today', '오늘 할 일', dueCards.length], ['new', '미확인', newCards.length], ['done', '완료', null]] as [val, label, n] (val)}
|
||||
<button
|
||||
onclick={() => setTab(val)}
|
||||
disabled={val === 'done'}
|
||||
class="flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-semibold transition-colors
|
||||
{tab === val ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim hover:text-text'}
|
||||
{val === 'done' ? 'cursor-not-allowed opacity-50' : ''}"
|
||||
>
|
||||
{label}
|
||||
{#if n !== null}<span class="rounded-full px-1.5 text-[10px] {tab === val ? 'bg-white/25' : 'bg-default'}">{n}</span>{/if}
|
||||
{#if val === 'done'}<span class="text-[10px]">추후</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-2">{#each Array(5).fill(0) as _, i (i)}<Skeleton class="h-12 w-full" />{/each}</div>
|
||||
{:else if tab === 'done'}
|
||||
<EmptyState title="완료 탭은 준비 중" description="졸업(완료)한 카드 목록은 백엔드 엔드포인트가 준비되면 추가됩니다." icon={GraduationCap} />
|
||||
{:else if shown.length === 0}
|
||||
<EmptyState
|
||||
title={tab === 'today' ? '오늘 복습할 카드가 없습니다' : '미확인 카드가 없습니다'}
|
||||
description={tab === 'today' ? '애매·모름으로 평가한 카드의 복습일이 되면 여기에 나타납니다.' : '검수 통과한 새 카드가 여기에 모입니다. 지금은 모두 한 번씩 본 상태예요.'}
|
||||
icon={Repeat}
|
||||
/>
|
||||
{:else}
|
||||
<!-- 선택 바 -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<button onclick={selectAllShown} class="rounded-md border border-default px-2.5 py-1 text-xs font-medium text-dim transition-colors hover:text-text">
|
||||
{allShownSelected ? '선택 해제' : '전체 선택'}
|
||||
</button>
|
||||
<span class="text-xs text-dim">{selectedCount > 0 ? `${selectedCount}장 선택됨` : `${shown.length}장`}</span>
|
||||
<div class="ml-auto flex gap-2">
|
||||
{#if selectedCount > 0}
|
||||
<Button variant="secondary" size="sm" icon={Play} onclick={startSelected}>선택 {selectedCount}장 복습</Button>
|
||||
{/if}
|
||||
<Button variant="primary" size="sm" icon={CheckCheck} onclick={startTab}>이 탭 전체 복습</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 카드 목록 -->
|
||||
<div class="space-y-1.5">
|
||||
{#each shown as c (c.id)}
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-default bg-surface px-3 py-2.5 transition-colors hover:border-accent">
|
||||
<input type="checkbox" checked={!!selected[c.id]} onchange={() => toggle(c.id)} class="size-4 shrink-0 accent-accent" />
|
||||
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold text-white {c.format === 'cloze' ? 'bg-accent' : 'bg-domain-engineering'}">{c.format}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-text">{frontText(c)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -21,6 +21,7 @@
|
||||
import TextInput from '$lib/components/ui/TextInput.svelte';
|
||||
import Select from '$lib/components/ui/Select.svelte';
|
||||
import Textarea from '$lib/components/ui/Textarea.svelte';
|
||||
import StudyDiagnosisPanel from '$lib/components/StudyDiagnosisPanel.svelte';
|
||||
|
||||
const STUDY_TYPE_OPTIONS = [
|
||||
{ value: '', label: '미지정' },
|
||||
@@ -205,6 +206,9 @@
|
||||
<p class="text-xs text-dim mt-1">한 주제 아래에 필기 세션과 자료를 묶어 보고 진도 관리. 향후 단어장·오디오·문제세트도 같은 묶음으로 연결됩니다.</p>
|
||||
</header>
|
||||
|
||||
<!-- 이드 학습 진단 (공유 컴포넌트 — /study/diagnosis 와 동일 패널) -->
|
||||
<StudyDiagnosisPanel class="mb-4" />
|
||||
|
||||
<!-- 새 주제 -->
|
||||
<Card class="mb-4">
|
||||
{#snippet children()}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
-- 301_eid_study_weakness.sql
|
||||
-- 이드 학습 약점 스냅샷 (append-only derived-fact). eid_study_weakness 워커가 study_question_progress
|
||||
-- + study_quiz_sessions 집계로 산출(LLM 0). study_diagnosis 표면이 최신 행을 읽어 코치 발화.
|
||||
--
|
||||
-- ★ append-only 구조강제 (project_eid_persona_substrate 불변식 #8) — 2중:
|
||||
-- (1) INSERT 스탬프 누락 거부: actor·source_generated_at = NOT NULL·DEFAULT 없음
|
||||
-- → 스탬프 없는 INSERT 를 DB 가 거부. NOT NULL 은 owner 포함 모든 role 에 적용(role 독립).
|
||||
-- (2) UPDATE/DELETE 차단: CREATE RULE ... DO INSTEAD NOTHING → 행 불변(owner·superuser 독립).
|
||||
--
|
||||
-- ★ 설계 원안 'REVOKE UPDATE,DELETE' 정정(load-bearing): 단일 DB role `pkm` 이 테이블 OWNER 라
|
||||
-- REVOKE 가 무효(owner 는 GRANT/REVOKE 우회). plpgsql trigger(RAISE)는 migration 검증기가
|
||||
-- 본문의 BEGIN 키워드를 거부(_validate_sql_content)해 불가. → RULE 이 owner 독립 + 검증기 통과하는
|
||||
-- 유일한 구조 enforcement(silent no-op, 행은 구조적으로 불변). 별도 read-only role 미존재.
|
||||
--
|
||||
-- ★ '현재' 스냅샷 = 최신 created_at 행(WHERE status='active'). 상태전이 UPDATE 없음(append-only).
|
||||
-- dispute = status='disputed' + supersedes_id 로 특정 스냅샷 무효화(새 INSERT). 표면이 disputed 제외.
|
||||
--
|
||||
-- runner = exec_driver_sql(simple protocol) → multi-statement 처리(001_initial_schema 선례, 18 stmt).
|
||||
-- BEGIN/COMMIT/ROLLBACK 없음(검증기 통과). CREATE RULE 은 IF NOT EXISTS 미지원 → OR REPLACE 로 idempotent.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS eid_study_weakness (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
weaknesses JSONB NOT NULL, -- [{topic_id,topic,chronic,relapsed,leech,coverage_gap,unsure,overdue,trend,tier}]
|
||||
habit_signals JSONB NOT NULL, -- {avoidance_topics,session_abandon_rate,stale_due_count,skew_topics}
|
||||
trend_label VARCHAR(20) NOT NULL, -- overall 개선|정체|악화 (코드 산출)
|
||||
sample_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
is_shallow_sample BOOLEAN NOT NULL DEFAULT false,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 'active' | 'disputed'(dispute marker)
|
||||
supersedes_id BIGINT REFERENCES eid_study_weakness(id) ON DELETE SET NULL,
|
||||
actor VARCHAR(20) NOT NULL, -- 스탬프(no default) = 'eid'
|
||||
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프(no default) = 집계 시점
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE OR REPLACE RULE eid_study_weakness_no_update AS ON UPDATE TO eid_study_weakness DO INSTEAD NOTHING;
|
||||
CREATE OR REPLACE RULE eid_study_weakness_no_delete AS ON DELETE TO eid_study_weakness DO INSTEAD NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_eid_weakness_user_current
|
||||
ON eid_study_weakness (user_id, created_at DESC) WHERE status = 'active';
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 301_eid_study_weakness_table.sql — 301_eid_study_weakness.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS eid_study_weakness (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
weaknesses JSONB NOT NULL, -- [{topic_id,topic,chronic,relapsed,leech,coverage_gap,unsure,overdue,trend,tier}]
|
||||
habit_signals JSONB NOT NULL, -- {avoidance_topics,session_abandon_rate,stale_due_count,skew_topics}
|
||||
trend_label VARCHAR(20) NOT NULL, -- overall 개선|정체|악화 (코드 산출)
|
||||
sample_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
is_shallow_sample BOOLEAN NOT NULL DEFAULT false,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 'active' | 'disputed'(dispute marker)
|
||||
supersedes_id BIGINT REFERENCES eid_study_weakness(id) ON DELETE SET NULL,
|
||||
actor VARCHAR(20) NOT NULL, -- 스탬프(no default) = 'eid'
|
||||
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프(no default) = 집계 시점
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -1,26 +0,0 @@
|
||||
-- 302_eid_review_set_draft.sql
|
||||
-- 이드 복습세트 초안 (append-only derived-fact). 워커가 약점 스냅샷에서 권장 복습세트를 '제안'만 한다.
|
||||
-- study overlay 항목6: "복습세트를 실제 복습 큐에 편성은 자율로 못 한다 — 초안만 제시, 사용자 1클릭".
|
||||
-- 실제 편성(study_question_progress.due_at 편집)은 별도 T2 액션 — 이 draft 는 불변 제안 기록.
|
||||
--
|
||||
-- append-only 구조강제(=301 동일): actor·source_generated_at NOT NULL no-default(스탬프) + RULE(불변).
|
||||
-- 상태전이 없음 — '현재 제안' = 최신 created_at. 새 제안은 supersedes_id 로 이전 것 가리킴(새 INSERT).
|
||||
-- question_ids = ordered list[int] snapshot (study_quiz_sessions.question_ids 패턴, junction 안 씀).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS eid_review_set_draft (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
study_topic_id BIGINT REFERENCES study_topics(id) ON DELETE CASCADE, -- nullable = cross-topic 세트
|
||||
question_ids JSONB NOT NULL, -- ordered list[int]
|
||||
reason VARCHAR(40) NOT NULL, -- chronic | relapse | coverage | overdue
|
||||
actor VARCHAR(20) NOT NULL, -- 스탬프 = 'eid'
|
||||
source_weakness_id BIGINT REFERENCES eid_study_weakness(id) ON DELETE SET NULL,
|
||||
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프
|
||||
supersedes_id BIGINT REFERENCES eid_review_set_draft(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE OR REPLACE RULE eid_review_set_draft_no_update AS ON UPDATE TO eid_review_set_draft DO INSTEAD NOTHING;
|
||||
CREATE OR REPLACE RULE eid_review_set_draft_no_delete AS ON DELETE TO eid_review_set_draft DO INSTEAD NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_eid_review_draft_user ON eid_review_set_draft (user_id, created_at DESC);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 302_eid_study_weakness_no_update.sql — 301_eid_study_weakness.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE OR REPLACE RULE eid_study_weakness_no_update AS ON UPDATE TO eid_study_weakness DO INSTEAD NOTHING;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 303_eid_study_weakness_no_delete.sql — 301_eid_study_weakness.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE OR REPLACE RULE eid_study_weakness_no_delete AS ON DELETE TO eid_study_weakness DO INSTEAD NOTHING;
|
||||
@@ -1,27 +0,0 @@
|
||||
-- 303_eid_weekly_recap.sql
|
||||
-- 이드 주간 회고 카드 (append-only derived-fact). 회고 워커(scaffold, 미배선 — W4/Phase2)가 산출.
|
||||
-- recap overlay: 'T1 write 자율 eid_weekly_recap(append-only)'. 미결 액션아이템 open/done UPDATE 는
|
||||
-- events 측(가변)이지 이 카드가 아님 — 카드 자체는 불변 스냅샷.
|
||||
-- 현재는 통합 migration 의 scaffold 테이블(dispatch enum WRITE_WEEKLY_RECAP 의 write target 예약).
|
||||
--
|
||||
-- append-only 구조강제(=301 동일): 스탬프 NOT NULL no-default + RULE(불변). '현재' = 최신 created_at.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS eid_weekly_recap (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
period_start DATE NOT NULL,
|
||||
period_end DATE NOT NULL,
|
||||
recap JSONB NOT NULL, -- {activity_summary, open_action_items:[event_id], recurring_topics}
|
||||
trend_label VARCHAR(20),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 'active' | 'disputed'
|
||||
supersedes_id BIGINT REFERENCES eid_weekly_recap(id) ON DELETE SET NULL,
|
||||
actor VARCHAR(20) NOT NULL, -- 스탬프 = 'eid'
|
||||
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE OR REPLACE RULE eid_weekly_recap_no_update AS ON UPDATE TO eid_weekly_recap DO INSTEAD NOTHING;
|
||||
CREATE OR REPLACE RULE eid_weekly_recap_no_delete AS ON DELETE TO eid_weekly_recap DO INSTEAD NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_eid_recap_user_current
|
||||
ON eid_weekly_recap (user_id, created_at DESC) WHERE status = 'active';
|
||||
@@ -1,24 +0,0 @@
|
||||
-- 304_approval_requests.sql
|
||||
-- 외부 전송 승인 큐 (★ 가변 workflow queue — append-only 아님). 설계 3-4 명시 카브아웃:
|
||||
-- "approval_requests 는 status 를 pending→approved 로 바꾸는 가변 state 라 eid_* 불변 REVOKE/RULE 대상 아님".
|
||||
-- → 여기엔 RULE(append-only) 안 건다. status 전이(UPDATE) 허용.
|
||||
--
|
||||
-- ★ Phase1 현재: app/eid/tools/dispatch.py 의 request_external_approval = 즉시 거부(INSERT 0).
|
||||
-- dispatcher 워커(유일 egress 집행)는 Phase3. 이 테이블은 그때까지 scaffold(빈 상태).
|
||||
-- ★ payload 는 고정 템플릿 슬롯만(free-form 금지) — app 층이 request_type 별 화이트리스트 검증.
|
||||
-- 승인 UI 는 전송 body 전문 diff 노출. 불변 결정 원장이 필요하면 별도 append-only approval_events(Phase3).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
request_type VARCHAR(40) NOT NULL, -- 고정 템플릿 슬롯 타입(app 화이트리스트)
|
||||
payload JSONB NOT NULL, -- 고정 템플릿 슬롯만
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending | approved | rejected (전이 허용)
|
||||
requester VARCHAR(20) NOT NULL, -- 'eid'
|
||||
decided_by VARCHAR(40),
|
||||
decided_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_requests_status ON approval_requests (status, created_at);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 304_eid_study_weakness_idx.sql — 301_eid_study_weakness.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_eid_weakness_user_current
|
||||
ON eid_study_weakness (user_id, created_at DESC) WHERE status = 'active';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 305_eid_review_set_draft_table.sql — 302_eid_review_set_draft.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS eid_review_set_draft (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
study_topic_id BIGINT REFERENCES study_topics(id) ON DELETE CASCADE, -- nullable = cross-topic 세트
|
||||
question_ids JSONB NOT NULL, -- ordered list[int]
|
||||
reason VARCHAR(40) NOT NULL, -- chronic | relapse | coverage | overdue
|
||||
actor VARCHAR(20) NOT NULL, -- 스탬프 = 'eid'
|
||||
source_weakness_id BIGINT REFERENCES eid_study_weakness(id) ON DELETE SET NULL,
|
||||
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프
|
||||
supersedes_id BIGINT REFERENCES eid_review_set_draft(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -1,33 +0,0 @@
|
||||
-- 305_eid_schedule_views.sql
|
||||
-- 이드 일정(schedule_brief, 미래 surface) 파생뷰 2. 신규 schedule 테이블 0 — events/events_history 재활용.
|
||||
-- quadrant(중요×긴급)·D-N 정렬은 app 층(schedule overlay). 뷰는 raw 입력 필드 + today/defer 집계만.
|
||||
-- CREATE VIEW 선례 = 010_soft_delete / 283_corpus_chunks. BEGIN/COMMIT 없음.
|
||||
--
|
||||
-- v_schedule_today: 오늘(Asia/Seoul local day) 활성 일정. active 필터 = events.py:list_today reference.
|
||||
-- today 경계 = Seoul 자정→UTC 변환(date_trunc ... AT TIME ZONE 왕복). LATERAL 로 1회 계산.
|
||||
-- v_schedule_defer_pattern: events_history change_kind IN(defer,reschedule) 를 event_id 별 COUNT.
|
||||
-- '반복 미룸' 임계 3회+ (schedule overlay 판단근거 #5). reactivate 는 제외.
|
||||
|
||||
CREATE OR REPLACE VIEW v_schedule_today AS
|
||||
SELECT e.id, e.user_id, e.title, e.kind, e.status, e.priority,
|
||||
e.due_at, e.start_at, e.end_at, e.started_at, e.defer_until, e.project_tag
|
||||
FROM events e
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT (date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul') AS lo
|
||||
) b
|
||||
WHERE (e.status IN ('inbox','next','scheduled','in_progress')
|
||||
OR (e.status = 'deferred' AND e.defer_until IS NOT NULL AND e.defer_until <= now()))
|
||||
AND (
|
||||
(e.kind = 'task' AND e.due_at >= b.lo AND e.due_at < b.lo + interval '1 day')
|
||||
OR (e.kind = 'calendar_event' AND e.start_at >= b.lo AND e.start_at < b.lo + interval '1 day')
|
||||
OR (e.kind = 'activity_log' AND e.started_at >= b.lo AND e.started_at < b.lo + interval '1 day')
|
||||
);
|
||||
|
||||
CREATE OR REPLACE VIEW v_schedule_defer_pattern AS
|
||||
SELECT eh.event_id,
|
||||
COUNT(*)::int AS defer_reschedule_count,
|
||||
MAX(eh.changed_at) AS last_changed_at,
|
||||
(COUNT(*) >= 3) AS is_repeat_defer
|
||||
FROM events_history eh
|
||||
WHERE eh.change_kind IN ('defer','reschedule')
|
||||
GROUP BY eh.event_id;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 306_eid_review_set_draft_no_update.sql — 302_eid_review_set_draft.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE OR REPLACE RULE eid_review_set_draft_no_update AS ON UPDATE TO eid_review_set_draft DO INSTEAD NOTHING;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 307_eid_review_set_draft_no_delete.sql — 302_eid_review_set_draft.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE OR REPLACE RULE eid_review_set_draft_no_delete AS ON DELETE TO eid_review_set_draft DO INSTEAD NOTHING;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 308_eid_review_set_draft_idx.sql — 302_eid_review_set_draft.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_eid_review_draft_user ON eid_review_set_draft (user_id, created_at DESC);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 309_eid_weekly_recap_table.sql — 303_eid_weekly_recap.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS eid_weekly_recap (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
period_start DATE NOT NULL,
|
||||
period_end DATE NOT NULL,
|
||||
recap JSONB NOT NULL, -- {activity_summary, open_action_items:[event_id], recurring_topics}
|
||||
trend_label VARCHAR(20),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 'active' | 'disputed'
|
||||
supersedes_id BIGINT REFERENCES eid_weekly_recap(id) ON DELETE SET NULL,
|
||||
actor VARCHAR(20) NOT NULL, -- 스탬프 = 'eid'
|
||||
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 310_eid_weekly_recap_no_update.sql — 303_eid_weekly_recap.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE OR REPLACE RULE eid_weekly_recap_no_update AS ON UPDATE TO eid_weekly_recap DO INSTEAD NOTHING;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 311_eid_weekly_recap_no_delete.sql — 303_eid_weekly_recap.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE OR REPLACE RULE eid_weekly_recap_no_delete AS ON DELETE TO eid_weekly_recap DO INSTEAD NOTHING;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 312_eid_weekly_recap_idx.sql — 303_eid_weekly_recap.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_eid_recap_user_current
|
||||
ON eid_weekly_recap (user_id, created_at DESC) WHERE status = 'active';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 313_approval_requests_table.sql — 304_approval_requests.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
request_type VARCHAR(40) NOT NULL, -- 고정 템플릿 슬롯 타입(app 화이트리스트)
|
||||
payload JSONB NOT NULL, -- 고정 템플릿 슬롯만
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending | approved | rejected (전이 허용)
|
||||
requester VARCHAR(20) NOT NULL, -- 'eid'
|
||||
decided_by VARCHAR(40),
|
||||
decided_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 314_approval_requests_idx.sql — 304_approval_requests.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_requests_status ON approval_requests (status, created_at);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 315_eid_schedule_views_v_schedule_today.sql — 305_eid_schedule_views.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE OR REPLACE VIEW v_schedule_today AS
|
||||
SELECT e.id, e.user_id, e.title, e.kind, e.status, e.priority,
|
||||
e.due_at, e.start_at, e.end_at, e.started_at, e.defer_until, e.project_tag
|
||||
FROM events e
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT (date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul') AS lo
|
||||
) b
|
||||
WHERE (e.status IN ('inbox','next','scheduled','in_progress')
|
||||
OR (e.status = 'deferred' AND e.defer_until IS NOT NULL AND e.defer_until <= now()))
|
||||
AND (
|
||||
(e.kind = 'task' AND e.due_at >= b.lo AND e.due_at < b.lo + interval '1 day')
|
||||
OR (e.kind = 'calendar_event' AND e.start_at >= b.lo AND e.start_at < b.lo + interval '1 day')
|
||||
OR (e.kind = 'activity_log' AND e.started_at >= b.lo AND e.started_at < b.lo + interval '1 day')
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 316_eid_schedule_views_v_schedule_defer_pattern.sql — 305_eid_schedule_views.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
|
||||
|
||||
CREATE OR REPLACE VIEW v_schedule_defer_pattern AS
|
||||
SELECT eh.event_id,
|
||||
COUNT(*)::int AS defer_reschedule_count,
|
||||
MAX(eh.changed_at) AS last_changed_at,
|
||||
(COUNT(*) >= 3) AS is_repeat_defer
|
||||
FROM events_history eh
|
||||
WHERE eh.change_kind IN ('defer','reschedule')
|
||||
GROUP BY eh.event_id;
|
||||
Reference in New Issue
Block a user