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,11 +1,9 @@
|
||||
"""텍스트 추출 워커 — kordoc / PyMuPDF / Surya OCR / LibreOffice / 직접 읽기 / 웹 HTML"""
|
||||
|
||||
import email
|
||||
import hashlib
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from email.header import decode_header
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -25,8 +23,6 @@ TEXT_FORMATS = {"md", "txt", "csv", "json", "xml", "html"}
|
||||
OFFICE_FORMATS = {"xlsx", "xls", "docx", "doc", "pptx", "ppt", "odt", "ods", "odp", "odoc", "osheet"}
|
||||
# OCR 대상 이미지 포맷
|
||||
IMAGE_FORMATS = {"jpg", "jpeg", "png", "tiff", "tif", "bmp", "gif", "webp"}
|
||||
# 이메일 (선별 PKM 폴더 수집 → 헤더+본문 추출)
|
||||
EML_FORMATS = {"eml"}
|
||||
|
||||
EXTRACTOR_VERSION = "kordoc@1.7"
|
||||
PYMUPDF_VERSION = "pymupdf"
|
||||
@@ -237,90 +233,6 @@ async def _extract_web_html(doc: Document, html_path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ─── 이메일(.eml) 추출 ───
|
||||
|
||||
def _decode_eml_header(raw: str) -> str:
|
||||
"""MIME 인코딩 헤더 디코딩."""
|
||||
if not raw:
|
||||
return ""
|
||||
out = []
|
||||
for data, charset in decode_header(raw):
|
||||
if isinstance(data, bytes):
|
||||
out.append(data.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
out.append(data)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
async def _extract_eml(doc: Document, eml_path: Path) -> None:
|
||||
"""이메일(.eml) 본문 추출 — From/To/Date/Subject 헤더 블록 + 본문.
|
||||
|
||||
본문은 text/plain 우선, 없으면 text/html → bs4 평문(_extract_web_with_bs4 재사용).
|
||||
헤더를 본문 머리에 prepend 해 검색·요약이 발신자/제목 맥락을 갖게 함.
|
||||
첨부는 extract_meta['email_attachments'] 에 인벤토리만 (본문 추출은 후속 — scaffold).
|
||||
"""
|
||||
raw = eml_path.read_bytes()
|
||||
msg = email.message_from_bytes(raw)
|
||||
|
||||
hdr_lines = []
|
||||
for label in ("From", "To", "Date", "Subject"):
|
||||
val = _decode_eml_header(msg.get(label, ""))
|
||||
if val:
|
||||
hdr_lines.append(f"{label}: {val}")
|
||||
|
||||
body = ""
|
||||
html_body = ""
|
||||
attachments = []
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ctype = part.get_content_type()
|
||||
disp = (part.get("Content-Disposition") or "").lower()
|
||||
if "attachment" in disp:
|
||||
payload = part.get_payload(decode=True)
|
||||
attachments.append({
|
||||
"filename": _decode_eml_header(part.get_filename() or ""),
|
||||
"content_type": ctype,
|
||||
"size": len(payload) if payload else 0,
|
||||
})
|
||||
continue
|
||||
if ctype == "text/plain" and not body:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload is not None:
|
||||
body = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||
elif ctype == "text/html" and not html_body:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload is not None:
|
||||
html_body = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload is not None:
|
||||
decoded = payload.decode(msg.get_content_charset() or "utf-8", errors="replace")
|
||||
if msg.get_content_type() == "text/html":
|
||||
html_body = decoded
|
||||
else:
|
||||
body = decoded
|
||||
|
||||
# text/plain 없으면 html → bs4 평문 (devonagent 최종 fallback 재사용, 신규 의존성 0)
|
||||
if not body and html_body:
|
||||
body, _ = _extract_web_with_bs4(html_body)
|
||||
|
||||
if attachments:
|
||||
names = ", ".join(a["filename"] for a in attachments if a["filename"])
|
||||
hdr_lines.append(f"Attachments: {len(attachments)}개" + (f" ({names})" if names else ""))
|
||||
|
||||
header_block = "\n".join(hdr_lines)
|
||||
full_text = (header_block + "\n\n" + (body or "")).replace("\x00", "").strip()
|
||||
|
||||
doc.extracted_text = full_text
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = "eml@stdlib"
|
||||
if attachments:
|
||||
meta = dict(doc.extract_meta or {})
|
||||
meta["email_attachments"] = attachments
|
||||
doc.extract_meta = meta
|
||||
logger.info(f"[eml] {doc.file_path} ({len(full_text)}자, 첨부 {len(attachments)})")
|
||||
|
||||
|
||||
# ─── 메인 처리 ───
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
@@ -345,13 +257,6 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
await _extract_web_html(doc, full_path)
|
||||
return
|
||||
|
||||
# ─── 이메일 (.eml) — 헤더+본문 추출 (선별 PKM 폴더 수집) ───
|
||||
if fmt in EML_FORMATS:
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError(f"파일 없음: {full_path}")
|
||||
await _extract_eml(doc, full_path)
|
||||
return
|
||||
|
||||
# ─── 텍스트 파일 — 직접 읽기 ───
|
||||
if fmt in TEXT_FORMATS:
|
||||
if not full_path.exists():
|
||||
|
||||
@@ -55,20 +55,13 @@ def _detect_origin(subject: str, body: str) -> str:
|
||||
return "external"
|
||||
|
||||
|
||||
def _fetch_emails_sync(host: str, port: int, user: str, password: str, last_uid: int | None, folder: str):
|
||||
"""동기 IMAP 메일 가져오기 (asyncio.to_thread에서 실행).
|
||||
|
||||
선별 폴더(MAILPLUS_FOLDER, 기본 'PKM')만 수집 — INBOX 전체 X.
|
||||
폴더 부재 시 no-op (사용자가 MailPlus 규칙으로 폴더 생성 전까진 안전하게 0건).
|
||||
"""
|
||||
def _fetch_emails_sync(host: str, port: int, user: str, password: str, last_uid: int | None):
|
||||
"""동기 IMAP 메일 가져오기 (asyncio.to_thread에서 실행)"""
|
||||
results = []
|
||||
conn = imaplib.IMAP4_SSL(host, port, timeout=30)
|
||||
try:
|
||||
conn.login(user, password)
|
||||
typ, _ = conn.select(folder)
|
||||
if typ != "OK":
|
||||
logger.info(f"[메일] 폴더 '{folder}' 없음/접근불가 — 수집 건너뜀 (no-op)")
|
||||
return results
|
||||
conn.select("INBOX")
|
||||
|
||||
if last_uid:
|
||||
# 증분 동기화: last_uid 이후
|
||||
@@ -78,13 +71,14 @@ def _fetch_emails_sync(host: str, port: int, user: str, password: str, last_uid:
|
||||
since = (datetime.now() - timedelta(days=7)).strftime("%d-%b-%Y")
|
||||
_, data = conn.uid("search", None, f"SINCE {since}")
|
||||
|
||||
uids = (data[0] or b"").split()
|
||||
uids = data[0].split()
|
||||
for uid_bytes in uids:
|
||||
uid = int(uid_bytes)
|
||||
_, msg_data = conn.uid("fetch", uid_bytes, "(RFC822)")
|
||||
if msg_data[0] is None:
|
||||
continue
|
||||
results.append((uid, msg_data[0][1]))
|
||||
raw = msg_data[0][1]
|
||||
results.append((uid, raw))
|
||||
finally:
|
||||
conn.logout()
|
||||
|
||||
@@ -97,18 +91,15 @@ async def run():
|
||||
port = int(os.getenv("MAILPLUS_PORT", "993"))
|
||||
user = os.getenv("MAILPLUS_USER", "")
|
||||
password = os.getenv("MAILPLUS_PASS", "")
|
||||
folder = os.getenv("MAILPLUS_FOLDER", "PKM")
|
||||
|
||||
if not all([host, user, password]):
|
||||
logger.warning("MailPlus 인증 정보 미설정")
|
||||
return
|
||||
|
||||
job_name = f"mailplus:{folder}"
|
||||
|
||||
async with async_session() as session:
|
||||
# 마지막 UID 조회 (UID 는 폴더별 네임스페이스 → job_name 에 폴더 포함)
|
||||
# 마지막 UID 조회
|
||||
state = await session.execute(
|
||||
select(AutomationState).where(AutomationState.job_name == job_name)
|
||||
select(AutomationState).where(AutomationState.job_name == "mailplus")
|
||||
)
|
||||
state_row = state.scalar_one_or_none()
|
||||
last_uid = int(state_row.last_check_value) if state_row and state_row.last_check_value else None
|
||||
@@ -116,7 +107,7 @@ async def run():
|
||||
# IMAP 동기 호출을 비동기로 래핑
|
||||
try:
|
||||
emails = await asyncio.to_thread(
|
||||
_fetch_emails_sync, host, port, user, password, last_uid, folder,
|
||||
_fetch_emails_sync, host, port, user, password, last_uid,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"IMAP 연결 실패: {e}")
|
||||
@@ -183,10 +174,15 @@ async def run():
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
# 검색·색인 편입 (extract → classify → embed/chunk). 할일 연계 없음.
|
||||
await enqueue_stage(session, doc.id, "extract")
|
||||
safe_subj = subject.replace("\n", " ").replace("\r", " ")[:200]
|
||||
|
||||
archived.append(subject.replace("\n", " ").replace("\r", " ")[:200])
|
||||
# TODO: extract_worker가 eml 본문/첨부 파싱 지원 시 이 조건 제거
|
||||
if doc.file_format != "eml":
|
||||
await enqueue_stage(session, doc.id, "extract")
|
||||
else:
|
||||
logger.debug(f"[메일] {safe_subj} — eml extract 미지원, 큐 스킵")
|
||||
|
||||
archived.append(safe_subj)
|
||||
max_uid = max(max_uid, uid)
|
||||
|
||||
except Exception as e:
|
||||
@@ -198,7 +194,7 @@ async def run():
|
||||
state_row.last_run_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
session.add(AutomationState(
|
||||
job_name=job_name,
|
||||
job_name="mailplus",
|
||||
last_check_value=str(max_uid),
|
||||
last_run_at=datetime.now(timezone.utc),
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { Info, X, Plus, Trash2, Tag, FolderTree, Sparkles, ChevronLeft, ArrowUpDown } from 'lucide-svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
|
||||
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
@@ -678,7 +679,7 @@
|
||||
{#if doc.ai_sub_group}<div class="flex justify-between gap-2 text-xs py-1"><span class="text-dim">하위</span><span class="text-text font-medium text-right truncate">{doc.ai_sub_group}</span></div>{/if}
|
||||
<div class="flex justify-between gap-2 text-xs py-1"><span class="text-dim">수정</span><span class="text-text font-medium text-right">{shortDate(doc.updated_at || doc.created_at)}</span></div>
|
||||
{#if size}<div class="flex justify-between gap-2 text-xs py-1"><span class="text-dim">원본</span><span class="text-text font-medium text-right">{size}</span></div>{/if}
|
||||
{#if doc.md_status}<div class="flex justify-between gap-2 text-xs py-1"><span class="text-dim">md 상태</span><span class="font-medium text-right {doc.md_status === 'completed' ? 'text-success' : 'text-warning'}">{doc.md_status}</span></div>{/if}
|
||||
{#if ['processing', 'success', 'partial', 'skipped', 'failed'].includes(doc.md_status)}<div class="flex items-center justify-between gap-2 text-xs py-1"><span class="text-dim">md 상태</span><MarkdownStatusBadge mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} /></div>{/if}
|
||||
{#if doc.read_count}<div class="flex justify-between gap-2 text-xs py-1"><span class="text-dim">읽음</span><span class="text-text font-medium text-right">{doc.read_count}회</span></div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox } 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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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