Compare commits

..

20 Commits

Author SHA1 Message Date
hyungi 7b8524192d fix(ui): 인스펙터 md상태 칩 enum 버그 (success 항상 노랑) + article suppress
documents/+page.svelte 인스펙터의 md상태 칩이 doc.md_status==='completed'
비교였는데 실제 enum은 success/partial/skipped/failed/pending 이라 'completed'가
존재하지 않음 → success 여도 항상 text-warning(노랑)으로 표시되던 라이브 버그.

- documents/+page.svelte: 깨진 삼항을 MarkdownStatusBadge 재사용으로 교체.
  success→success(초록) 자동, pending/null→null 이라 article(news) 칩 자동 suppress.
  표시 조건을 badge 가 렌더하는 5상태로 명시(빈 라벨 행 방지).
- MarkdownStatusBadge: partial case 추가(tone warning 'Markdown 일부') →
  대형 split 일부 실패 문서도 칩 노출 + md_status 표시 어휘를 단일 컴포넌트에 완결.

FE only, 백엔드/스키마 무변. vite build + lint:tokens(신규 위반 0) PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:35:05 +09:00
hyungi 279124d953 feat(ui): 학습 진단(이드 코치) 허브 진입점 + /study/diagnosis 전용 라우트
diagnosis는 cross-topic(사용자 단위) 코칭 표면인데 기존엔 /study/topics 상단에만
노출돼 발견성이 낮았다. 허브(/study)에 '학습 진단' 카드 추가 + 전용 라우트
/study/diagnosis 신설(향후 weekly_recap·review_set_draft 코치 표면의 정식 홈).

패널은 StudyDiagnosisPanel 공유 컴포넌트로 추출 — topics·diagnosis 양쪽이 단일
청크 참조(복붙 drift 0). 백엔드 무변경(기존 POST /diagnosis/generate 재사용).

검증: vite build OK, lint:tokens 내 파일 위반 0, 새 라우트·허브 링크·공유 청크
번들 반영 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:35:35 +00:00
hyungi c8600f8046 feat(ui): 데스크탑 분류 사이드바 접기/펴기 토글
상단 nav 좌측 PanelLeft 버튼으로 좌측 분류(소스트리) 사이드바를 접고/펼침.
접으면 aside w-sidebar→w-0(+border 제거)로 콘텐츠가 넓어짐, 상태는 localStorage 기억.
확정 시안(documents-confirmed-column-browser)의 '소스트리 접기/펴기' 반영.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:14:39 +09:00
hyungi 7d06816bac fix(ops): DS compose 잉여 ollama 서비스 제거 — 매주 재부팅 outage 근본 해소
DS compose 의 ollama 서비스가 standalone ~/ollama 컨테이너와 host 127.0.0.1:11434 를
다퉈, 정기 재부팅 후 `docker compose up` 이 'port already allocated' 로 abort →
caddy·frontend 미기동 = 웹 outage(2026-06-08 internal error). standalone 이 이미
hyungi_document_server_default 망 + 동일 ollama_data 볼륨(external) 부착으로 fastapi
`ollama:11434` 임베딩을 서빙하므로 DS 서비스는 100% 잉여 → 제거(서비스+ai-gateway
depends_on). ollama_data 볼륨 def 는 standalone external 참조용으로 보존. 임베딩 무영향.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 07:15:24 +09:00
hyungi 66a906a156 feat(ui): study/topics 학습 진단(study_diagnosis) 패널 — 이드 코치 표면 UI
eid study_diagnosis 백엔드(/api/study-topics/diagnosis/generate)에 프론트 진입점 추가.
학습 주제 페이지 상단 '학습 진단' 카드: [진단 생성] → POST → 코치 응답(약점 Top-N·근거·
복습세트 초안) 마크다운 렌더. data 없으면 status=none 안내(토픽 focus 유도). LLM 호출이라
버튼 트리거. 디자인 토큰·no-emoji. 백엔드 무변(frontend-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:00:08 +09:00
hyungi 5bde1c765c fix(migrations): eid 301~305 multi-statement → 1-statement/파일 분리 (301~316)
asyncpg 러너가 exec_driver_sql 을 prepared statement(extended protocol)로 처리해
multi-statement 를 거부(cannot insert multiple commands) → fastapi init_db crash.
(001 등 초기 multi-stmt 는 postgres initdb=psql simple protocol 로 적용됐던 것 — 작성자 가정 오류.)
301~305(각 2~4 stmt)를 내용 불변으로 16개 single-statement 파일(301~316)로 분리:
 eid_study_weakness(table/rule2/idx)·eid_review_set_draft(동)·eid_weekly_recap(동)
 ·approval_requests(table/idx)·eid_schedule_views(view2). 원순서·FK 의존성 보존.
프로덕션 pkm DB 대상 트랜잭션 dry-run(ROLLBACK) 16/16 무오류 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:42:32 +09:00
hyungi e817a0abfc Merge pull request 'Feat/ui sage all' (#27) from feat/ui-sage-all into main
Reviewed-on: #27
2026-06-07 20:26:37 +09:00
hyungi a1a46f2a2b fix(ui): 배포 전 적대 리뷰 반영 — 대시보드/문서/뉴스
15-에이전트 적대 리뷰의 확정 결함 수정:
- dashboard: digest 헤드라인 날짜 d.date→d.digest_date ("undefined 브리핑" 버그/HIGH)
  + 빠른캡처 후 refresh() + 스탯띠 nowrap(줄바꿈 구분선 제거) + formatTime Invalid 가드 + chevron :global
- documents: bulkAddTag 검색모드 데이터손실 방지(태그 미확인 시 풀문서 머지/HIGH)
  + selectDoc 풀 하이드레이션(인스펙터 메타 보강) + 검색모드 클라정렬 비활성 + 죽은 handleDocDelete 제거
- news: 인용 출처 국가 색칩 추가(+빈 국가 가드) + 읽음 스탬프(시안 충실)
digest/memos = 확정 결함 0(무변). vite build PASS·토큰 청결.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:12:00 +09:00
hyungi 126f633d32 feat(ui): /memos 노트 피드(d1) 세이지 하모나이즈 + 상단 고정 캡처
확정 컨셉=노트 피드(d1, 5안 권장 1순위). 현재 페이지가 이미 단일 컬럼 카드
피드 패러다임이라 focused 업데이트:
- 빠른 캡처 컴포저 상단 고정(sticky) — d1 핵심
- 비-세이지 팔레트(indigo/blue/emerald/rose/amber) → 디자인 토큰 하모나이즈
  (AI 분류 배지·음성 배지·승급 버튼·promoted 링크). 기능 회귀 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:43:58 +09:00
hyungi 058183d3ff feat(ui): /digest 웜 클레이 → 세이지 재톤 (앱 톤 통일)
편집형 digest 가 자체 웜 클레이 팔레트라 세이지 앱 속 '웜 섬'이었던 것을
세이지로 통일. 스코프 <style> 의 warm hex 14종 + clay rgba 틴트 2종을
세이지 등가로 치환(구조·기능 무변, 색만). 토큰 청결.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:41:21 +09:00
hyungi 73d7683eda feat(ui): 모닝브리핑 /news 편집 신문 1면 재작성 (국가 색칩·이모지 제거)
확정 시안 morning-briefing-final 의 '편집 신문 1면'으로 재구조화.
- 마스트헤드(제호·날짜선택·에디션메타·오늘의 한 줄 deck·통계·상태 가드 배너)
- 리드 토픽 전체너비(관점 2열) + 나머지 2열 그리드, folio/serif 헤드라인
- 국가별 관점(색칩+기사ID 링크+요약)·차이/공통 ednote·인용(serif)·지난 흐름
- 이모지 국기 → 국가 색칩(no-emoji 규칙). 읽음/별표/날짜 등 전 기능 보존.
데이터·API(/briefing)는 기존 그대로. 기존 news lint:tokens 51 위반도 해소.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:39:09 +09:00
hyungi 36c6ff8046 feat(ui): 문서 /documents DEVONthink 컬럼 브라우저 전면 재작성 (3-pane + 인스펙터)
확정 시안 documents-confirmed-column-browser 대로 세로 split → 가로 3-pane 재구조화.
- 좌: 리스트 컬럼(제목+도메인 / 형식 배지 / 수정일, 제목·수정 정렬, zebra, 선택강조)
- 중앙: 리더(DocumentViewer 재사용) + 상단 ⓘ 인스펙터 토글·모바일 뒤로가기
- 우: 인스펙터 인라인(정보 KV · 태그 · See Also · AI 분류, ⓘ 토글)
- 모바일: 흐름형(리스트 → 풀스크린 리더 → 정보 Drawer 시트)
기존 검색·모드·AI답변·필터칩·일괄작업(도메인/태그/삭제)·키보드내비·업로드·페이지네이션 전부 흡수.
See Also(벡터 유사도)는 엔드포인트 부재(코드 TODO)로 degrade — eid 세션 후 백엔드.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:15:27 +09:00
hyungi 7e5988cb20 merge(study+eid): 암기카드 학습 트랙 + 이드 persona substrate W2~W4 → main
study-memo-card-p1(복습/카드 SR·복습함·신고·검수 + 이드 substrate W2~W4) 통합.
email 트랙(feat/email-pkm-folder)은 분리 — 별도 배포 예정.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:12:28 +09:00
hyungi f24d35681f feat(ui): 홈 대시보드 데일리 홈 cockpit 재설계 (안1 골격+안2 위젯+안3 분포)
확정 시안 dashboard-sage-3 의 권장 합성(안1 데일리 홈 골격 + 안2 검토/파이프라인
위젯 + 안3 도메인 분포 한 줄)으로 콘텐츠 재구조화. F1 세이지 테마 위 레이아웃 개편.
- 인사 헤더 + 오늘 요약 띠(검토 대기 + 디제스트 톱 + 스탯 띠)
- 2열: 좌(빠른 캡처·활동 타임라인) / 우(학습·도메인 분포+파이프라인 칩·고정)
- digest/도메인 분포는 기존 엔드포인트 wiring(백엔드 변경 0), 학습 streak는 링크형 degrade

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:57:34 +09:00
hyungi 547a533e8b fix(study): 복습함 탭 전환 시 선택 초기화 (탭별 독립 선택)
검토 지적: 탭 바꿔도 selected 잔존 → 탭별 독립 선택으로 setTab 에서 selected={} 리셋. (선택 복습은 이미 현재 탭 shown 기준이라 데이터 오염은 없었고 UX 정합 개선.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:22:34 +09:00
hyungi 2c8b6808b9 feat(study): 복습함(B4 v1) — 오늘 할 일/미확인 2탭 + 멀티셀렉트 선택 복습
/study/review-box: GET /study-cards/due(review_stage) 를 2탭 분리(오늘 할 일=review_stage 보유 / 미확인=review_stage null 신규). 카드 멀티셀렉트 → pendingReviewCards store 로 cards-study 복습 세션에 선택분 전달(백엔드 세션 X = eid contention 중 fastapi 무재빌드). '이 탭 전체 복습'도. 완료 탭은 졸업카드 엔드포인트 필요라 비활성('추후'). 허브에 복습함 진입 카드.
- 신규 store /stores/studySession.ts(pendingReviewCards). cards-study startReview 가 consume. 전부 frontend-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:17:31 +09:00
hyungi 1eda37ba16 polish(study): 암기카드 학습 문구 다듬기 + '이 카드 이상해요' 버튼 강조
시안 합의본 문구 실제 반영: 탭하면 정답이 보여요 / 봤어요·다음 / 오늘 복습을 마쳤어요 / 애매하거나 몰랐던 카드는 내일 다시 만나요 / 공부로 돌아가기 / 앞—떠올리기 / 평가 sublabel 내일 다시·N일 뒤. 키보드 힌트(Space·Enter)는 sm:inline(데스크탑만). 플래그 버튼=흐린 텍스트→테두리 칩(hover 경고색).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:06:53 +09:00
hyungi 6323ad7f08 fix(study): 검수함 카드 마크다운+수식 렌더 — 근거/앞면/정답
cards-review view 모드가 cue/cloze/fact/근거를 평문으로 뿌려 표·**굵게**·수식이 raw 노출. cards-study와 동일하게 renderMathMarkdown(근거 블록)·renderMathMarkdownInline(앞면·정답) 적용. 편집모드 textarea는 raw 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:43:00 +09:00
hyungi 48de08da39 fix(study): 검수함 each_key_duplicate 크래시 — 자료(수동) 그룹 null 키 중복 해소
manual 카드 그룹은 source_question_id=null 이라 자료가 2개+ 면 {#each ... (g.source_question_id)} 키 중복 → Svelte each_key_duplicate 크래시. 키를 (source_question_id ?? question_text) 고유값으로 변경. 추가로 자료 그룹은 approve-batch 가 source_question_id:int 필수라 422 → 일괄승인 버튼을 question 그룹에만 노출. 개별 승인/수정/삭제는 cardId 기반이라 자료도 정상.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:38:48 +09:00
hyungi 16313f8f35 fix(ds-app): DSBaseURL.tailscale placeholder를 GPU canonical Tailscale IP로 정정
ds-gpu.tailnet-name.ts.net(실재하지 않는 placeholder) → http://100.110.63.63:8000/api.
contract/CONTRACT.md·CompositionTests 의 기존 값과 일치. DS 본체 = GPU 서버 유지
확정(2026-06-07)에 따른 앱 연결 타깃 정합. swift test 72 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:05:56 +09:00
32 changed files with 299 additions and 292 deletions
-95
View File
@@ -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():
+18 -22
View File
@@ -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),
))
+4 -4
View File
@@ -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
View File
@@ -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>
+16 -2
View File
@@ -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}
+2 -1
View File
@@ -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>
+12 -1
View File
@@ -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()}
-40
View File
@@ -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()
);
-26
View File
@@ -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;
-27
View File
@@ -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';
-24
View File
@@ -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()
);
-33
View File
@@ -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);
+15
View File
@@ -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;
+4
View File
@@ -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()
);
+3
View File
@@ -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;