Compare commits

...

35 Commits

Author SHA1 Message Date
hyungi 5c065e6bec feat(documents): 개요 점프 결선 — anchor splice + id↔id 점프 + scroll-spy ([id])
불만② 개요→본문 점프를 deterministic 하게 결선(경로 A). 상세페이지([id], 개요 rail 보유).

- MarkdownDoc: anchorMap prop 추가 → 렌더 전 md_content 의 각 offset(내림차순)에
  <span id="sec-{chunkId}" class="md-anchor"> splice(점프 타깃). DOMPurify span+id+class 통과.
- SectionOutline: onJump(chunkId)/activeKey prop. 클릭=아코디언 toggle + onJump(점프).
  activeKey 일치 항목 좌측 accent border 강조(scroll-spy).
- [id]: anchorMap=buildAnchorMap(md_content, sections)(canShowMarkdown 시) → MarkdownDoc 전달.
  jumpToSection=#sec-id scrollIntoView. scroll-spy(window scroll, 120px 상단 통과 마지막 anchor).
  SectionOutline 양쪽(xl rail·details)에 onJump/activeKey 배선.

id↔id 직매칭이라 중복제목(표-1·Part UW 814건)·비-ATX(제N조) 정확. anchor 없는 절=점프
비활성(아코디언 폴백). node test 10/10, vite build + lint:tokens(신규0) PASS.
다음 = 3-pane(DocumentViewer) 개요 rail(commit 3, 레이아웃).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:17:07 +09:00
hyungi e1a047c2c2 feat(documents): 개요 점프 anchorMap 유틸 (forward-cursor 3중 방어)
불만② 개요→본문 점프의 deterministic anchor 좌표 산출(경로 A, FE-only).
게이트 측정상 textContent 매칭은 중복 63%·비-ATX 로 5% + silent 오점프 → md_content
에서 각 절 heading 라인 offset 을 찾아 <a id="sec-{chunk_id}"> 주입 좌표를 만든다.

★ false-early-match 방어 3중 (적대 리뷰 반영):
- 라인-시작(전체-라인) 매칭 → 본문 중간 상호참조("see Part UW")는 라인 전체가 제목과
  같지 않아 제외(forward-cursor 가 못 막던 핵심 구멍).
- 전체 매칭 + truncation(builder [:200]) 처리 → '제1조'가 '제1조의2' 오매칭 차단.
- 단조 커서 + 코드펜스 회피 → 역행/펜스 매칭 거부 = anchor 없음(점프 비활성, 오점프 금지).

window/section_split 조각·빈 제목은 skip. node test 10/10 PASS(상호참조 선행·중복 단조·
prefix·평문 제N조·펜스·window·miss·heading_path fallback). 순수 함수, vite build PASS.
다음 commit = MarkdownDoc splice + SectionOutline 점프 + DocumentViewer rail/scroll-spy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:11:00 +09:00
hyungi 2c77b3b0e7 Merge pull request 'feat(documents): 3-pane 중앙 리더 markdown-first 일원화 (DocumentViewer)' (#30) from feat/documents-viewer-unify into main
Reviewed-on: #30
2026-06-08 15:55:18 +09:00
hyungi 360871e9cf feat(documents): 3-pane 중앙 리더 markdown-first 일원화 (DocumentViewer)
메인 /documents 3-pane 의 중앙 리더(DocumentViewer)가 md_content 를 안 쓰고
PDF=raw iframe·md/txt=plain marked(extracted_text)만 렌더하던 이원화 제거.
"전부 MD화" 한 canonical markdown 이 전체보기 없이 메인에서 바로 보이게 함(불만①).

- viewerType.ts 신설: 분류 단일 source(상세페이지와 공유 예정, drift 차단).
  csv/json/xml/html→text(<pre>, 콤마 뭉침 회피), office→preview-pdf, hwp→hwp-markdown.
- DocumentViewer: 자체 getViewerType/renderMd(본문) 제거 → viewerType.ts + MarkdownDoc.
  - pdf: canShowMarkdown(isMdSuccess+md_content) 시 MarkdownDoc 기본 + [Markdown|PDF원본]
    토글 + MarkdownStatusBadge, 아니면 PDF iframe. lastDocId 가드는 fullDoc.id(prop) 키잉.
  - markdown(md/txt): MarkdownDoc(extracted_text=표시·편집 단일 필드), 편집 유지.
  - hwp-markdown/article: MarkdownDoc(앵커/KaTeX/이미지). 편집 미리보기만 plain marked 유지.
  - article/preview-pdf/image/text/cad/synology/unsupported 분기 보존(회귀 금지) + synology 신설.

API md_status='completed'(S1 validator live) 대응 = isMdSuccess. FE only, BE/스키마 무변.
vite build + lint:tokens(신규 위반 0) PASS. 후속: 개요 rail·안전점프(commit 2), [id] 정합(commit 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:44:46 +09:00
hyungi 0f37fe6492 Merge pull request 'fix(ui): md_status 'success'/'completed' 어휘 양립 (S1 API remap 대비)' (#29) from fix/md-status-completed-compat into main
Reviewed-on: #29
2026-06-08 15:27:45 +09:00
hyungi 4042d9ec61 fix(ui): md_status 'success'/'completed' 어휘 양립 (S1 API remap 대비)
S1 backend(이미 main 머지, app/api/documents.py field_validator
_db_success_to_completed)가 직렬화 시 DB 'success'를 API 'completed'로 remap한다.
그런데 프론트 3곳이 raw 'success' 만 검사 → S1 backend 배포 시 침묵 회귀:
  - documents/[id]/+page.svelte canShowMarkdown: completed PDF가 markdown-first
    대신 raw PDF로 표시
  - documents/+page.svelte 인스펙터 칩 게이트: success 문서 칩 사라짐
  - MarkdownStatusBadge: 'completed'→default→null (성공 칩 사라짐)

DB↔API enum divergence guard: 두 어휘를 모두 성공으로 취급해야 S1 배포
전(API='success')·후(API='completed') 모두 안전. 단일 source 헬퍼로 수렴.

- lib/utils/mdStatus.ts 신설: isMdSuccess / isMdStatusVisible (raw 비교 산재 금지)
- [id] canShowMarkdown → isMdSuccess()
- documents 인스펙터 게이트 → isMdStatusVisible()
- MarkdownStatusBadge: case 'completed' 를 'success' 동의어로 추가

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:48:38 +09:00
hyungi c2d2a0aa4d Merge pull request 'fix(ui): 인스펙터 md상태 칩 enum 버그 (success 항상 노랑) + article suppress' (#28) from fix/md-status-chip into main
Reviewed-on: #28
2026-06-08 14:41:31 +09:00
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 c8d8df6b2d fix(migrations): s1 dedup 287->317 renumber (main 287=study_memo_cards 충돌 회피) 2026-06-08 03:07:53 +00:00
hyungi daf6a0ade9 feat(documents): S1 dedup·office-md·storage scaffold (B/C/D/E)
plan ds-s1-backend-1 잔여 구현 (A·C-1 은 16b0fe1):
- B 중복검사: services/dedup.py (OFF-list law_monitor 공용) + 업로드 채움(B-1)
  + GET /documents/duplicates(B-2) + post-upload near-dup 비동기(B-3)
  + backfill_dedup.py(B-4) + 야간 dedup_reconcile 잡(03:30 KST 멱등 재계산)
- C MD-first: marker_worker office/hwp 분기 _process_office(C-2) + md_status
  상태머신 postcondition success|failed(C-5) + backfill_nonpdf_markdown.py(C-4)
  + requirements markitdown
- D 스토리지: services/storage ABC+Range 계약 / LocalBackend / NasApiBackend 503
  (D-1) + /file resolver 경유, 로컬 동작 불변(D-2)
- E 운영: pre-change pg_dump + rollback_287.sql + apply runbook(E-3) + 테스트(E-1)

비파괴 불변식 유지(기존 응답 shape 무변경, md_status success→completed read-time 매핑).
어드버서리얼 리뷰 확정 1건(soft-delete canonical 승격 시 stale duplicate_of) → B-1
승격 정규화 + 야간 재계산으로 정합.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 03:05:30 +00:00
hyungi 68e2d7ea04 feat(documents): S1-ADD dedup·원본명 3컬럼 + md_status success→completed 매핑 (A) + office→md PoC (C-1)
plan ds-s1-backend-1 (r5 수렴). 코드만 스테이징 — migration 미적용(restart 보류, E-2 Soft Lock 예외창).

A (앱 v1 디코딩 비파괴 최소선):
- A-1 migrations/287_documents_dedup_fields.sql: original_filename TEXT / duplicate_of BIGINT FK ON DELETE SET NULL
  / duplicate_count INTEGER NOT NULL DEFAULT 0. 단일 statement·PG16 fast-path·BEGIN/COMMIT 금지. backfill 미포함(B-4).
- A-2 app/models/document.py: 1계층 블록에 3 mapped_column (+ ForeignKey import). md_* 는 기존.
- A-3 app/api/documents.py: DocumentResponse 3필드(duplicate_count=0 non-opt) + DocumentDetailResponse
  field_validator(success→completed, mode=before) — read-time DB→API 단방향, write(ORM) 미적용.
- A-4 tests/test_s1_dedup_shape.py: success→completed 동작 + 비-success 통과 + 3필드 디폴트/roundtrip
  + ds-app contract fixture 디코드(skip-if-absent). py_compile OK. ★ backend 절반 — 전체 비파괴는 S3 render 테스트와 AND.

C-1 PoC (워커 미연결 — C-2 에서 marker_worker 분기 연결):
- app/workers/office_md.py: OOXML=markitdown(신규 dep, lazy) / hwp·hwpx=LibreOffice headless→HTML→markdownify(기존 dep).
  실패·빈출력·타임아웃·dep부재 → OfficeMdError raise (success+빈md 금지 = C-5 postcondition 의 변환기 계약).
- scripts/poc_office_md.py: 표 fidelity 측정 하니스. E-1 = prod LibreOffice 버전핀 안전컨텍스트 실행(hwpx 필터 버전 의존).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 03:05:30 +00:00
hyungi 5a19cde38c fix(documents): 도메인 트리 카운트를 문서함 list 제외와 일치
트리(/documents/tree)는 deleted 만 제외하고 뉴스/법령/메모를 다 세는데, 문서함 list 는
source_channel news/law_monitor + file_type note 를 기본 제외 → '트리는 N건인데 클릭하면
0건' 불일치(예: Philosophy/Aesthetics 5건 전부 news+note 라 클릭 시 0). 트리 쿼리에 동일
제외 적용해 카운트=실제 표시 일치. 영향: Philosophy 12→2, General 189→84 등 정상화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:57:47 +09:00
hyungi 7cc38e8a4a fix(ds-app): category-counts 계약 정정 — 합성된 shape 을 라이브 실측으로 재캡처
라이브 결선 첫 실로그인에서 decode 실패(Key 'total' not found) 진단:
서버 /documents/stats/category-counts 는 Pydantic response model 없는
raw dict 반환({counts:{category:n}, library_pending_suggestions}) — 초기
계약 추출('실 Pydantic 에서 추출')이 이 엔드포인트에선 shape 을 합성
(total/by_domain/review_pending/pipeline_failed = 실재하지 않음).

- CategoryCounts 모델 = 실측 shape + total 파생 접근자(counts 합)
- fixture 2사본(contract/fixtures + DSKit Resources) = CAPTURED_LIVE 재캡처
- DashboardView 스켈레톤 정합(카테고리 분포 + 한국어 라벨, 본격 재설계는 FU-E)
- CONTRACT.md 해당 행 정정 주석

전 엔드포인트 라이브 shape 전수 대조(토큰 생성 후 11종 curl + shape_diff):
stats 외 진짜 drift 0 — documents/tree·search·memos·digest·auth_me·detail·
content 일치. original_filename/duplicate_* 부재 = S1 미배포(optional 이라
무해, 배포 시 해소) / md_frontmatter·memo_task_state = JSONValue 오픈 shape
데이터 차이(무해) / duplicates 422 = S1 라우트 미배포(예상).

검증: swift test 82/82 + shape_diff (shape identical) + xcodebuild PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 00:55:59 +00:00
hyungi f1dc2e1a8d feat(ds-app): 본 서버(GPU DS) 라이브 결선 — 앱 기본을 오프라인 스캐폴드에서 라이브로 전환
- AppModel: AuthPhase 상태기계(checking/loggedOut/ready) + live() 팩토리
  (LiveDSClient + realRouter, ask 토큰 = TokenProvider 단일 소스) + bootstrap
  (refresh 쿠키 무로그인 복귀, single-shot, 취소 시 재시도 복원) + login(TOTP
  개행·공백 정규화) + 사용 중 세션 만료 시 loggedOut 강등 + 401 회전 후
  다운로드 ?token= 사본 재동기화(guarded 깔때기)
- LoginView 신규(기능 셸, 서버 host 표시, 서버 detail 메시지 노출)
- RootView: 인증 게이트 + errorText 하단 배너(no-silent-fallback 가시화)
- DSApp: 기본 .live(publicTLS=document.hyungi.net/api), DSAPP_FIXTURE=1 /
  DSAPP_DS_URL env 스위치(파싱 실패 = fail-loud, prod silent fallback 금지)
- LiveDSClient.currentAccessToken() — realRouter ask 토큰 closure 용
- AppFeatureTests 신규 10건(인증 상태기계·single-shot·transport 사유·totp)

검증: swift test 82/82 green + xcodebuild .app BUILD SUCCEEDED + 라이브
negative-path(/auth/login 401·/auth/refresh 401, 본 서버 양 경로 도달).
3-렌즈 어드버서리얼 리뷰 반영(재진입 가드/transport 구분/env fail-loud/토큰
사본 동기화/만료 강등). Sources/AI 무수정(시그니처 동결 준수).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 00:55:59 +00:00
hyungi 9ffbdc0c23 fix(ui): 모바일 가로 오버플로 제거 (min-w-0/minmax/flex-wrap/break)
flex/grid 자식이 truncate·긴 텍스트를 품으면서 min-w-0 부재 → 좁은 화면서 줄지 못해
페이지 좌우 스크롤·글자 화면 벗어남(대시보드 최근활동 타임라인이 대표 사례).
- dashboard: 타임라인 grid 1fr→minmax(0,1fr)+셀 min-w-0 / 도메인라벨·고정항목 flex-1 min-w-0(+break-words)
- inbox: 리스트 제목 min-w-0
- ask: 검색바 flex-wrap + 입력 min-w-0 + select min-w-0 max-w
- library: 트리노드·브레드크럼 min-w-0/truncate/flex-wrap
- events: 메타행 min-w-0 + project_tag break-all
- memos: 본문/code/링크 overflow-wrap:anywhere + table 가로스크롤 가드
감사 11p→수정 6p, 페이지별 적대 재스캔으로 잔존 antipattern까지 제거. 데스크탑 무회귀·토큰/이모지 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:41:57 +09:00
hyungi b6c5c133bc feat(ui): 데이터밀집 페이지 데스크탑 폭 채우기 (반응형 유동 ~1680/1240 캡)
데스크탑에서 콘텐츠가 ~1024~1400px로 가운데 몰려 좌우 공백이 크던 문제 해소.
밀집/격자/대시보드형은 max-w-[1680px], 단일컬럼 list형은 max-w-[1240px]로 확장(좌우 패딩 유지·구조 보존).
- dashboard: max-w-5xl→1680, 우측 레일 320→360px
- digest: .app max-width 1180→1680
- ask·library·audio·video: →1680  / inbox·events: →1240(events 반응형 패딩 보강)
읽기/폼(memos·settings·events상세·study reading)·신문형(news)·3-pane(documents)는 좁은 폭 유지.
감사 18p→수정 8p, 페이지별 적대 검증(토큰/이모지/반응형/오버플로/구조) 전부 PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:56:14 +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
86 changed files with 4065 additions and 1697 deletions
+185 -10
View File
@@ -21,8 +21,8 @@ from fastapi import (
UploadFile,
status,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, field_validator
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.requests import ClientDisconnect
@@ -30,12 +30,19 @@ from starlette.requests import ClientDisconnect
from ai.client import AIClient, _load_prompt, parse_json_response
from core.auth import get_current_user
from core.config import settings
from core.database import get_session
from core.database import async_session, get_session
from core.utils import file_hash
from models.document import Document
from models.document_image import DocumentImage
from models.queue import ProcessingQueue, enqueue_stage
from models.user import User
from services.dedup import (
DUPLICATE_GROUPS_SQL,
DEDUP_OFF_CHANNELS,
find_canonical_for_hash,
find_near_duplicates,
)
from services.storage import StorageNotConfigured, get_storage_backend
from services.document_telemetry import record_analyze_event, sanitize_source
from services.prompt_versions import ANALYZE_PROMPT_VERSION, resolve_primary_model
from services.search.llm_gate import Priority, acquire_mlx_gate
@@ -62,6 +69,53 @@ def _upload_error(status_code: int, error_code: str, message: str) -> HTTPExcept
)
async def _near_dup_scan_bg(doc_id: int) -> None:
"""B-3: post-upload near_duplicate 스캔 (BackgroundTask). 자체 세션, best-effort.
업로드 직후엔 doc.embedding 이 아직 없을 수 있어(embed stage 미완) trigram 후보만
기록되는 경우가 많다 — non-gating. 어떤 예외도 업로드 결과(201)에 영향 주지 않는다.
영속화는 보류(on-the-fly) — 현재는 로깅까지. /duplicates 의 near-dup 노출은 phase2.
"""
try:
async with async_session() as bg_session:
findings = await find_near_duplicates(bg_session, doc_id)
if findings:
top = findings[0]
logger.info(
"[dedup] near_dup_scan doc=%s candidates=%d top=%s(cosine=%s)",
doc_id, len(findings), top["doc_id"], top.get("cosine"),
)
except Exception:
logger.warning("[dedup] near_dup_scan failed doc=%s", doc_id, exc_info=True)
def _parse_byte_range(range_header: str | None, size: int) -> tuple[int | None, int | None]:
"""HTTP Range 헤더(`bytes=start-end`) 파싱 → (start, end) inclusive. 없거나 무효면 (None, None).
D-2 원격 백엔드 Range pass-through 용 (local 은 FileResponse 가 자동 처리). suffix 형식
(`bytes=-N`) 도 지원. 다중 range 는 첫 구간만.
"""
if not range_header or not range_header.startswith("bytes=") or size <= 0:
return None, None
spec = range_header[len("bytes="):].split(",")[0].strip()
if "-" not in spec:
return None, None
lo, hi = spec.split("-", 1)
try:
if lo == "": # suffix range: 마지막 N 바이트
n = int(hi)
if n <= 0:
return None, None
return max(0, size - n), size - 1
start = int(lo)
end = int(hi) if hi else size - 1
except ValueError:
return None, None
if start > end or start >= size:
return None, None
return start, min(end, size - 1)
# ─── 스키마 ───
@@ -113,6 +167,10 @@ class DocumentResponse(BaseModel):
# 회독 추적 (자료실 등) — 현재 사용자 기준. 다른 endpoint 응답에선 0/None.
read_count: int = 0
last_read_at: datetime | None = None
# S1-ADD (migration 287): 원본 파일명 + 중복검사. 앱은 옵셔널 디코딩, 없으면 폴백.
original_filename: str | None = None # 다운로드 라벨용. 없으면 file_path basename 폴백(앱 측).
duplicate_of: int | None = None # canonical doc id (자기 자신이 canonical 이면 None).
duplicate_count: int = 0 # 본인 제외 동일 판정 사본 수 (canonical 행 기준).
class Config:
from_attributes = True
@@ -140,6 +198,16 @@ class DocumentDetailResponse(DocumentResponse):
md_extraction_engine_version: str | None = None
md_generated_at: datetime | None = None
@field_validator("md_status", mode="before")
@classmethod
def _db_success_to_completed(cls, v: str | None) -> str | None:
"""DB CHECK enum 은 'success'; 계약/fixture·앱 MD-first 렌더 트리거는 'completed'.
read-time(DB→API) 단방향 매핑만 — write 경로(ORM)는 이 모델을 거치지 않아 미적용.
pending/processing/partial/failed/skipped 는 양쪽 동일하므로 'success' 만 매핑한다.
(불변식: md_status ∈ {success,partial} ⟹ md_content 非공백 = 워커 postcondition, C-5.)
"""
return "completed" if v == "success" else v
class AcceptSuggestionRequest(BaseModel):
"""§1 accept-suggestion 요청 body — stale payload / doc 수정 검출."""
@@ -192,6 +260,11 @@ async def get_document_tree(
FROM documents
WHERE ai_domain IS NOT NULL AND ai_domain != '' AND ai_domain != 'News'
AND deleted_at IS NULL
-- 문서함(list) 기본 제외와 동일하게 맞춤: 뉴스/법령 채널·메모는 문서함에 안 뜨므로
-- 트리 카운트도 제외해야 "트리 N건인데 클릭하면 0건" 불일치가 안 생긴다.
AND source_channel != 'news'
AND source_channel != 'law_monitor'
AND file_type != 'note'
GROUP BY ai_domain
ORDER BY ai_domain
""")
@@ -524,6 +597,53 @@ async def list_documents(
)
# ─── 중복검사 (dedup) — B-2 ───
# ★ 고정 path 라우트(/duplicates)는 동적 /{doc_id} 라우트보다 *위*에 등록해야 매칭 충돌이 없다.
class DuplicateGroup(BaseModel):
canonical_id: int
members: list[int]
reason: str
detail: str | None = None
class DuplicatesResponse(BaseModel):
groups: list[DuplicateGroup]
total_groups: int
total_duplicate_docs: int
@router.get("/duplicates", response_model=DuplicatesResponse)
async def list_duplicates(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""content_hash(= file_hash exact) 중복 그룹 목록.
OFF-whitelist(law_monitor) 제외 + deleted 제외. idx_documents_hash 재사용(신규 인덱스/테이블 불요).
near_duplicate(유사도 기반) 그룹은 영속화 보류 → S1 은 exact 그룹만 노출(계약 shape 동일,
detail 문구만 'file_hash' 기준). 응답 shape = ds-app contract `documents_duplicates.json`.
"""
rows = (
await session.execute(DUPLICATE_GROUPS_SQL, {"off_channels": list(DEDUP_OFF_CHANNELS)})
).all()
groups = [
DuplicateGroup(
canonical_id=r.canonical_id,
members=list(r.members),
reason="content_hash",
detail="동일 file_hash (원본 바이트 SHA-256 일치)",
)
for r in rows
]
return DuplicatesResponse(
groups=groups,
total_groups=len(groups),
# 사본 수 = 그룹별 (멤버수-1) 합 (canonical 제외) — fixture total_duplicate_docs 정의와 동일.
total_duplicate_docs=sum(len(g.members) - 1 for g in groups),
)
@router.get("/{doc_id}", response_model=DocumentDetailResponse)
async def get_document(
doc_id: int,
@@ -682,6 +802,7 @@ async def get_document_file(
session: Annotated[AsyncSession, Depends(get_session)],
token: str | None = Query(None, description="Bearer token (iframe용)"),
download: bool = Query(False, description="true면 attachment (브라우저 다운로드)"),
range_header: str | None = Header(None, alias="Range"),
user: User | None = Depends(lambda: None),
):
"""문서 원본 파일 서빙 (Bearer 헤더 또는 ?token= 쿼리 파라미터)"""
@@ -704,9 +825,10 @@ async def get_document_file(
if not doc.file_path:
raise HTTPException(status_code=404, detail="파일이 없는 문서입니다 (메모)")
file_path = Path(settings.nas_mount_path) / doc.file_path
if not file_path.exists():
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
# D-2: 물리 경로 해석을 storage 백엔드로 단일화. local=FileResponse(Range 자동) /
# 원격=ABC.stream(range). /file URL·바디 shape 불변(non-breaking). 현재 활성 백엔드는
# LocalBackend only 라 동작 변경 0.
backend = get_storage_backend()
# 미디어 타입 매핑
# HTML5 <audio>/<video> 직접 재생을 위해 audio/video mime 포함. Starlette
@@ -727,7 +849,7 @@ async def get_document_file(
# 비디오 — direct play 호환 (§3 최소판)
".mp4": "video/mp4", ".webm": "video/webm",
}
suffix = file_path.suffix.lower()
suffix = Path(doc.file_path).suffix.lower()
media_type = media_types.get(suffix, "application/octet-stream")
# Content-Disposition: download=true면 attachment (한글 filename* 호환)
@@ -739,10 +861,40 @@ async def get_document_file(
else:
disposition = "inline"
return FileResponse(
path=str(file_path),
# 로컬 백엔드: 기존과 동일하게 FileResponse (Range 자동 처리).
if backend.is_local:
local = backend.local_path(doc.file_path)
if local is None or not Path(local).exists():
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
return FileResponse(
path=str(local),
media_type=media_type,
headers={"Content-Disposition": disposition},
)
# 원격 백엔드: D-1 ABC 의 Range pass-through. 미프로비전 백엔드는 stat() 가
# StorageNotConfigured → 503 (silent fallback 금지). 현재 LocalBackend only 라 미도달.
try:
st = await backend.stat(doc.file_path)
except StorageNotConfigured as exc:
raise HTTPException(status_code=503, detail=str(exc))
if not st.exists:
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
start, end = _parse_byte_range(range_header, st.size)
headers = {"Content-Disposition": disposition, "Accept-Ranges": "bytes"}
if start is None:
headers["Content-Length"] = str(st.size)
status_code = 200
else:
headers["Content-Range"] = f"bytes {start}-{end}/{st.size}"
headers["Content-Length"] = str(end - start + 1)
status_code = 206
return StreamingResponse(
backend.stream(doc.file_path, start=start, end=end),
status_code=status_code,
media_type=media_type,
headers={"Content-Disposition": disposition},
headers=headers,
)
@@ -803,6 +955,7 @@ async def get_document_image_raw(
async def upload_document(
request: Request,
file: UploadFile,
background_tasks: BackgroundTasks,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
doc_purpose: str | None = Form(None, description="business | knowledge"),
@@ -954,6 +1107,9 @@ async def upload_document(
file_size=written,
file_type="immutable",
title=target.stem,
# B-1: 업로드 원본 파일명(다운로드 라벨용). file_path 는 충돌 시 _N 리네임되므로
# 원본명을 별도 보존. safe_name = Path(file.filename).name (경로 이탈 제거된 basename).
original_filename=safe_name,
source_channel="manual",
doc_purpose=doc_purpose,
user_tags=[library_tag] if library_tag else [],
@@ -964,6 +1120,22 @@ async def upload_document(
)
session.add(doc)
await session.flush()
# B-1: file_hash exact 중복 채움 (OFF-whitelist=law_monitor 제외). 거부(409) 아님 —
# 허용 + duplicate_of 링크 + canonical duplicate_count++ (법령 의도적 중복 보존 정책).
# 홈랩 저동시성이라 동시 동일-hash 업로드 TOCTOU 는 멱등/B-4 backfill 로 수습(락 불요).
canonical = await find_canonical_for_hash(session, fhash, exclude_id=doc.id)
if canonical is not None:
# 원래 canonical 이 soft-delete(deleted_at) 되어 former member 가 승격되면, 그 survivor 의
# stale duplicate_of 를 비워 'member 이자 counter' 모순을 막는다(B-4 불변식 유지). 문서는
# soft-delete only 라 FK ON DELETE SET NULL 이 발화하지 않아 잔여가 남기 때문(리뷰 발견).
# (삭제된 canonical 을 가리키는 다른 sibling 멤버의 잔여 포인터·overcount 는 야간
# dedup_reconcile 잡(B-4, 03:30 KST 멱등 절대 재계산)이 정리.)
if canonical.duplicate_of is not None:
canonical.duplicate_of = None
doc.duplicate_of = canonical.id
canonical.duplicate_count = (canonical.duplicate_count or 0) + 1
# document + processing_queue 는 단일 트랜잭션으로 묶어 원자적 정리
await enqueue_stage(session, doc.id, "extract")
await session.commit()
@@ -973,6 +1145,9 @@ async def upload_document(
target.unlink(missing_ok=True)
raise
# B-3: near_duplicate 스캔은 post-upload 비동기 — 201 응답을 막지 않는다(non-gating 기록).
background_tasks.add_task(_near_dup_scan_bg, doc.id)
return DocumentResponse.model_validate(doc)
+4
View File
@@ -48,6 +48,7 @@ async def lifespan(app: FastAPI):
from services.search.query_analyzer import prewarm_analyzer
from workers.briefing_worker import run as morning_briefing_run
from workers.daily_digest import run as daily_digest_run
from workers.dedup_reconcile import run as dedup_reconcile_run
from workers.digest_worker import run as global_digest_run
from workers.file_watcher import watch_inbox
from workers.law_monitor import run as law_monitor_run
@@ -120,6 +121,9 @@ async def lifespan(app: FastAPI):
# 이드 W3-2: 공부중 토픽 약점 derived 스냅샷 (nightly 04:30 KST, LLM 0). study_diagnosis 표면 source.
scheduler.add_job(study_weakness_run, CronTrigger(hour=4, minute=30, timezone=KST), id="study_weakness")
scheduler.add_job(news_collector_run, "interval", hours=6, id="news_collector")
# plan ds-s1-backend-1 B-4: dedup 컬럼(duplicate_of/duplicate_count) 야간 절대 재계산.
# soft-delete 잔여 드리프트 정리(멱등, 드리프트 없으면 no-op). cron 03:30 (다른 잡과 비충돌).
scheduler.add_job(dedup_reconcile_run, CronTrigger(hour=3, minute=30, timezone=KST), id="dedup_reconcile")
scheduler.start()
# Phase 2.1 (async 구조): QueryAnalyzer prewarm.
+14 -1
View File
@@ -3,7 +3,7 @@
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import BigInteger, Boolean, DateTime, Enum, Integer, String, Text
from sqlalchemy import BigInteger, Boolean, DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
@@ -28,6 +28,19 @@ class Document(Base):
)
import_source: Mapped[str | None] = mapped_column(Text)
# 1계층: 원본명 + 중복검사 (S1-ADD, migration 287)
# original_filename = 업로드 원본 파일명(다운로드 라벨용). file_path 는 충돌 시 _N 리네임됨.
# cf. original_format(ODF 변환용) / original_path·original_hash(007 legacy dead) 와 의미 구분.
# duplicate_of = canonical doc id (자기 자신이 canonical 이면 NULL). FK ON DELETE SET NULL.
# duplicate_count = canonical 행에 담는 '본인 제외 동일 판정 사본 수' (group_size-1). 업로드/backfill 가 갱신.
original_filename: Mapped[str | None] = mapped_column(Text)
duplicate_of: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="SET NULL")
)
duplicate_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
# 2계층: 텍스트 추출
extracted_text: Mapped[str | None] = mapped_column(Text)
extracted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+3
View File
@@ -21,3 +21,6 @@ pymupdf>=1.24.0
trafilatura>=1.12.0
readability-lxml>=0.8.1
markdownify>=0.13.1
# office OOXML(docx/xlsx/pptx) → md (plan ds-s1-backend-1 C-1). hwp 는 LibreOffice+markdownify 경로.
# 정확한 핀은 E-1 markitdown OOXML PoC(devsbx/버전핀 컨텍스트)에서 확정.
markitdown[docx,xlsx,pptx]>=0.1.0
+239
View File
@@ -0,0 +1,239 @@
"""중복검사(dedup) 공용 로직 — plan ds-s1-backend-1 B 그룹.
소비처가 공유:
- B-1 업로드 채움 (api/documents.upload_document) find_canonical_for_hash
- B-2 GET /documents/duplicates DEDUP_OFF_CHANNELS (그룹 SQL 라우터에)
- B-4 backfill (scripts/backfill_dedup.py) DEDUP_OFF_CHANNELS / canonical = min(id)
- B-3 near_duplicate find_near_duplicates
OFF-whitelist (DEDUP_OFF_CHANNELS):
law_monitor = 법령 개정본을 의도적으로 행으로 보존(개정일 추적). file_hash 같아도
collapse 하면 개정 이력이 사라지므로 dedup 비참여. (P0-2 실측: dup 18그룹/36
law_monitor 17그룹 = 의도된 개정 보존, manual 1그룹 = 진짜 content dedup.)
file_hash 이미 채널별 키를 인코딩(note=본문SHA / devonagent=URL / news=article_id)하므로
채널별 분기는 두지 않고 단일 OFF-list 데이터로 둔다(P0-2 결정).
near_duplicate (B-3):
title trigram 후보 후보에만 doc-level embedding 코사인 rerank. 전수 28.9k 임베딩 스캔 회피.
저장된 embedding read-only(검색실험 Soft Lock: 재생성 금지). 임계·결과는 전부 non-gating 기록값
(trigram-first recall gap = 본문동일·제목상이 near-dup 놓침 phase2 ivfflat 회수 대상).
영속화는 보류(on-the-fly) S1 helper + 호출부 로깅까지. duplicate_of 영속화는 exact(file_hash).
"""
from __future__ import annotations
import logging
from sqlalchemy import bindparam, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# file_hash dedup 제외 채널 (단일 OFF-whitelist). B-1/B-2/B-4 공용.
DEDUP_OFF_CHANNELS: tuple[str, ...] = ("law_monitor",)
# near_duplicate 파라미터 — 전부 기록값·non-gating (phase2 ivfflat 가 recall gap 회수).
NEAR_DUP_TRGM_THRESHOLD = 0.30 # pg_trgm title 후보 컷 (느슨 — 후보 생성용)
NEAR_DUP_COSINE_THRESHOLD = 0.95 # 후보 embedding 코사인 near-dup 판정 컷 (≈0.95~0.97)
NEAR_DUP_MAX_CANDIDATES = 50 # trigram 후보 상한 — 전수 임베딩 스캔 회피
async def find_canonical_for_hash(
session: AsyncSession, file_hash: str, *, exclude_id: int | None = None
):
"""주어진 file_hash 의 canonical 문서(가장 오래된 = min id)를 반환. 없으면 None.
OFF-whitelist 채널(law_monitor) canonical 후보에서 제외 업로드가 법령 개정본에
링크되지 않는다. exclude_id = 방금 INSERT 신규 자신 제외(B-1).
"""
from models.document import Document # 지연 import (순환 회피)
stmt = (
select(Document)
.where(
Document.file_hash == file_hash,
Document.deleted_at.is_(None),
or_(
Document.source_channel.is_(None),
Document.source_channel.notin_(DEDUP_OFF_CHANNELS),
),
)
.order_by(Document.id.asc())
)
if exclude_id is not None:
stmt = stmt.where(Document.id != exclude_id)
return (await session.execute(stmt)).scalars().first()
# B-2 /documents/duplicates 의 file_hash 그룹 SQL. 라우터가 직접 execute (Pydantic 응답은 라우터에).
# reason='content_hash' = file_hash exact 그룹(idx_documents_hash 재사용, 신규 인덱스/테이블 불요).
# canonical_id = min(id), members = id 오름차순 배열, n = 그룹 크기.
DUPLICATE_GROUPS_SQL = text(
"""
SELECT file_hash,
min(id) AS canonical_id,
array_agg(id ORDER BY id) AS members,
count(*) AS n
FROM documents
WHERE deleted_at IS NULL
AND file_hash IS NOT NULL
AND (source_channel IS NULL OR source_channel NOT IN :off_channels)
GROUP BY file_hash
HAVING count(*) > 1
ORDER BY min(id)
"""
).bindparams(bindparam("off_channels", expanding=True))
async def reconcile_dedup(
session: AsyncSession, *, apply: bool = True, chunk_size: int = 500, sample_size: int = 40
) -> dict:
"""file_hash exact 그룹의 duplicate_of/duplicate_count 를 재계산해 정합화 (B-4 코어).
멱등 목표값과 다른 행만 UPDATE. 야간 (workers.dedup_reconcile) backfill 스크립트가
공유한다. 문서는 soft-delete only(FK ON DELETE SET NULL 미발화) 비정규화 dedup 컬럼이
삭제 드리프트(멤버의 stale 포인터·canonical overcount)하므로 절대 재계산이 정합 보장.
반환 = {groups, docs, changes, applied, sample}. sample = 적용될/ 변경 미리보기(최대 sample_size).
canonical = 그룹 최古(min id): duplicate_of=NULL, duplicate_count=group_size-1. 멤버: duplicate_of=canonical, count=0.
"""
groups = (
await session.execute(
DUPLICATE_GROUPS_SQL, {"off_channels": list(DEDUP_OFF_CHANNELS)}
)
).all()
desired: dict[int, tuple[int | None, int]] = {}
for g in groups:
members = list(g.members)
canonical = g.canonical_id
desired[canonical] = (None, len(members) - 1)
for m in members:
if m != canonical:
desired[m] = (canonical, 0)
if not desired:
return {"groups": 0, "docs": 0, "changes": 0, "applied": 0, "sample": []}
ids = list(desired.keys())
current: dict[int, tuple[int | None, int]] = {}
for i in range(0, len(ids), 1000):
batch = ids[i : i + 1000]
rows = (
await session.execute(
text(
"SELECT id, duplicate_of, duplicate_count "
"FROM documents WHERE id = ANY(:ids)"
).bindparams(ids=batch)
)
).all()
for r in rows:
current[r.id] = (r.duplicate_of, int(r.duplicate_count or 0))
changes = [
(i, dof, dcnt)
for i, (dof, dcnt) in desired.items()
if current.get(i) != (dof, dcnt)
]
sample = [
{"id": i, "duplicate_of": dof, "duplicate_count": dcnt}
for (i, dof, dcnt) in changes[:sample_size]
]
applied = 0
if apply and changes:
for i in range(0, len(changes), chunk_size):
for did, dof, dcnt in changes[i : i + chunk_size]:
await session.execute(
text(
"UPDATE documents SET duplicate_of = :dof, duplicate_count = :dcnt "
"WHERE id = :id"
).bindparams(dof=dof, dcnt=dcnt, id=did)
)
await session.commit()
applied += len(changes[i : i + chunk_size])
return {
"groups": len(groups),
"docs": len(ids),
"changes": len(changes),
"applied": applied,
"sample": sample,
}
async def find_near_duplicates(
session: AsyncSession,
doc_id: int,
*,
cosine_threshold: float = NEAR_DUP_COSINE_THRESHOLD,
trgm_threshold: float = NEAR_DUP_TRGM_THRESHOLD,
max_candidates: int = NEAR_DUP_MAX_CANDIDATES,
) -> list[dict]:
"""anchor doc 의 near-duplicate 후보를 trigram→embedding 2단계로 찾는다(read-only).
반환 = [{doc_id, title, title_sim?, cosine}] (cosine 내림차순). embedding 미생성
(업로드 직후 흔함) trigram 후보만 cosine=None 으로 반환(non-gating 기록). 어떤 행도
수정/삭제하지 않으며 저장된 embedding 읽는다(Soft Lock 준수).
"""
anchor = (
await session.execute(
text(
"SELECT id, title, (embedding IS NOT NULL) AS has_emb "
"FROM documents WHERE id = :id AND deleted_at IS NULL"
).bindparams(id=doc_id)
)
).first()
if anchor is None or not anchor.title:
return []
# (1) title trigram 후보. similarity() 컷으로 후보를 max_candidates 로 줄여 전수 임베딩
# 스캔을 회피한다. (index-accelerated `%` 연산자 경로는 후보 생성이 병목이 될 때의
# phase2 최적화 — 짧은 title 28.9k seq 평가는 비동기 post-upload 에서 충분히 저렴.)
cand_rows = (
await session.execute(
text(
"""
SELECT id, title, similarity(title, :t) AS title_sim
FROM documents
WHERE id <> :id
AND deleted_at IS NULL
AND title IS NOT NULL
AND similarity(title, :t) >= :trgm
ORDER BY similarity(title, :t) DESC
LIMIT :lim
"""
).bindparams(id=doc_id, t=anchor.title, trgm=trgm_threshold, lim=max_candidates)
)
).all()
if not cand_rows:
return []
if not anchor.has_emb:
# 임베딩 미생성 — 후보만 기록(cosine rerank 는 embed stage 완료 후). non-gating.
return [
{"doc_id": r.id, "title": r.title, "title_sim": float(r.title_sim), "cosine": None}
for r in cand_rows
]
# (2) 후보에만 doc-level embedding 코사인 rerank. 저장값 read-only.
cand_ids = [r.id for r in cand_rows]
rer = (
await session.execute(
text(
"""
SELECT c.id, c.title,
(1 - (c.embedding <=> (SELECT embedding FROM documents WHERE id = :id))) AS cosine
FROM documents c
WHERE c.id = ANY(:ids) AND c.embedding IS NOT NULL
"""
).bindparams(id=doc_id, ids=cand_ids)
)
).all()
out = [
{"doc_id": r.id, "title": r.title, "cosine": float(r.cosine)}
for r in rer
if r.cosine is not None and float(r.cosine) >= cosine_threshold
]
out.sort(key=lambda x: x["cosine"], reverse=True)
return out
+39
View File
@@ -0,0 +1,39 @@
"""스토리지 계층 추상화 패키지 (plan ds-s1-backend-1 D 그룹, scaffold-first).
활성 백엔드 선택 = get_storage_backend():
- env DS_STORAGE_BACKEND (기본 'local') 결정 config.yaml storage 섹션 편집 없이도
동작(검색실험 Soft Lock 동안 config 불가침). 활성(외부 백엔드) D-3.
- 'local' LocalBackend(settings.nas_mount_path) : 현행 NAS NFS, /file 동작 불변.
- 'nas_api'/'nas' NasApiBackend(env DS_NAS_API_BASE_URL) : 미프로비전 503(silent fallback X).
"""
from __future__ import annotations
import os
from functools import lru_cache
from core.config import settings
from .base import StatResult, StorageBackend, StorageNotConfigured
from .local import LocalBackend
from .nas_api import NasApiBackend
__all__ = [
"StorageBackend",
"StorageNotConfigured",
"StatResult",
"LocalBackend",
"NasApiBackend",
"get_storage_backend",
]
@lru_cache(maxsize=1)
def get_storage_backend() -> StorageBackend:
"""활성 스토리지 백엔드 1개 반환 (프로세스 단위 캐시)."""
backend = os.getenv("DS_STORAGE_BACKEND", "local").lower()
if backend == "local":
return LocalBackend(settings.nas_mount_path)
if backend in ("nas_api", "nas"):
return NasApiBackend(os.getenv("DS_NAS_API_BASE_URL"))
raise StorageNotConfigured(f"unknown DS_STORAGE_BACKEND={backend!r}")
+50
View File
@@ -0,0 +1,50 @@
"""스토리지 백엔드 추상 인터페이스 — plan ds-s1-backend-1 D-1.
ABC 첫날부터 Range(offset/length) stream 계약을 포함한다 D-2 원격 streaming
Range pass-through afterthought 아니라 인터페이스 의무가 되도록.
is_local=True 백엔드는 로컬 파일시스템 경로를 노출 호출부가 Starlette FileResponse
(Range 자동 처리) 그대로 쓴다. 원격 백엔드는 stream()/stat() Range 구현한다.
"""
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from dataclasses import dataclass
class StorageNotConfigured(RuntimeError):
"""활성화되지 않은(미프로비전) 백엔드 호출 — 503 으로 표면화. silent fallback 금지."""
@dataclass
class StatResult:
exists: bool
size: int
class StorageBackend(ABC):
"""원본 파일 접근 추상 인터페이스."""
# 로컬 파일시스템 경로를 노출하는가 (FileResponse 직결 가능 여부).
is_local: bool = False
@abstractmethod
def local_path(self, rel_path: str) -> os.PathLike[str] | None:
"""is_local=True 면 물리 경로 반환(FileResponse 용). 원격 백엔드는 None."""
@abstractmethod
async def stat(self, rel_path: str) -> StatResult:
"""크기/존재 여부. 미구성 백엔드는 StorageNotConfigured raise."""
@abstractmethod
def stream(
self, rel_path: str, *, start: int | None = None, end: int | None = None
) -> AsyncIterator[bytes]:
"""[start, end] 바이트 범위(inclusive)를 async 청크로 yield (Range pass-through).
start/end None 이면 전체. 미구성 백엔드는 StorageNotConfigured raise.
"""
raise NotImplementedError
+50
View File
@@ -0,0 +1,50 @@
"""LocalBackend — 현행 NAS NFS(volume4) 마운트. /file 동작 불변 (plan D-1)."""
from __future__ import annotations
import os
from collections.abc import AsyncIterator
from pathlib import Path
from .base import StatResult, StorageBackend
_STREAM_CHUNK = 256 * 1024
class LocalBackend(StorageBackend):
"""루트(=settings.nas_mount_path) 하위 상대경로를 로컬 파일시스템으로 해석."""
is_local = True
def __init__(self, root: str) -> None:
self._root = Path(root)
def local_path(self, rel_path: str) -> os.PathLike[str]:
return self._root / rel_path
async def stat(self, rel_path: str) -> StatResult:
p = self._root / rel_path
if not p.exists():
return StatResult(exists=False, size=0)
return StatResult(exists=True, size=p.stat().st_size)
async def stream(
self, rel_path: str, *, start: int | None = None, end: int | None = None
) -> AsyncIterator[bytes]:
"""로컬 파일을 청크 stream (Range 지원). /file 의 로컬 경로는 FileResponse 가
Range 자동 처리하므로 메서드는 인터페이스 대칭/원격 동등성을 위한 구현."""
p = self._root / rel_path
with p.open("rb") as f:
if start:
f.seek(start)
remaining = None if end is None else (end - (start or 0) + 1)
while True:
to_read = _STREAM_CHUNK if remaining is None else min(_STREAM_CHUNK, remaining)
if to_read <= 0:
break
data = f.read(to_read)
if not data:
break
yield data
if remaining is not None:
remaining -= len(data)
+33
View File
@@ -0,0 +1,33 @@
"""NasApiBackend — 외부 스토리지(맥미니4TB / NAS Docker API) stub (plan D-1).
미프로비전 = 503. silent fallback 금지(다른 백엔드로 자동 우회 X). 프로비전
D-3 에서 활성화. infra_inventory.md 갱신(Update Rule) 선행이다.
"""
from __future__ import annotations
import os
from collections.abc import AsyncIterator
from .base import StatResult, StorageBackend, StorageNotConfigured
_MSG = "NasApiBackend 미구성 — 외부 스토리지 프로비전 후 활성(D-3). silent fallback 없음."
class NasApiBackend(StorageBackend):
is_local = False
def __init__(self, base_url: str | None = None) -> None:
self._base_url = base_url
def local_path(self, rel_path: str) -> os.PathLike[str] | None:
return None
async def stat(self, rel_path: str) -> StatResult:
raise StorageNotConfigured(_MSG)
async def stream(
self, rel_path: str, *, start: int | None = None, end: int | None = None
) -> AsyncIterator[bytes]:
raise StorageNotConfigured(_MSG)
yield b"" # 도달 불가 — async generator 형태 유지용(호출부 `async for` 계약 일치).
+32
View File
@@ -0,0 +1,32 @@
"""야간 dedup 컬럼 재계산 잡 (plan ds-s1-backend-1 B-4 '야간 배치').
duplicate_of / duplicate_count 비정규화 캐시다. 문서는 soft-delete only(deleted_at)
FK ON DELETE SET NULL 발화하지 않아, canonical/멤버를 soft-delete 하면 잔여 드리프트가
생긴다(멤버의 stale 포인터·canonical overcount). B-1 업로드 채움은 신규 행만 다루므로,
야간 절대 재계산이 전체 정합을 보장한다. 멱등 드리프트 없으면 no-op(로그만).
응답 계약(DocumentResponse.duplicate_count/duplicate_of) (S3) 읽으므로 정합이 중요.
"""
import logging
from core.database import async_session
from services.dedup import reconcile_dedup
logger = logging.getLogger("dedup_reconcile")
async def run() -> None:
try:
async with async_session() as session:
r = await reconcile_dedup(session, apply=True)
if r["changes"]:
logger.info(
"[dedup_reconcile] groups=%s docs=%s changes=%s applied=%s",
r["groups"], r["docs"], r["changes"], r["applied"],
)
else:
logger.info(
"[dedup_reconcile] no drift (groups=%s docs=%s)", r["groups"], r["docs"]
)
except Exception:
logger.exception("[dedup_reconcile] failed")
+72 -6
View File
@@ -17,6 +17,7 @@ md_content ref 형식: `![alt](docimg:img_001)` — image_key 가 sequence 기
plan: ~/.claude/plans/piped-humming-crystal.md
"""
import asyncio
import base64
import hashlib
import json
@@ -68,9 +69,13 @@ _FORMAT_TO_MIME = {
"gif": "image/gif",
}
# Phase 1B = PDF only. DOCX 등은 후속 Phase.
# Phase 1B = PDF only (marker-service). office/hwp 는 C-2 에서 office_md 하이브리드로 분기.
SUPPORTED_EXTENSIONS = {".pdf"}
# C-2: office/hwp → md (OOXML=markitdown / hwp=LibreOffice). 변환기가 지원하는 suffix 집합.
# 레거시 바이너리(.doc/.xls/.ppt)는 markitdown 미지원 → 여기 없음(=PDF-only 게이트에서 skip).
from workers.office_md import SUPPORTED as OFFICE_MD_SUPPORTED # noqa: E402
# config.yaml document_types 의 한국어 label 직접 사용 (Pre-flight 결과).
# Round 0 사용자 의도 = 표 중심 발주/계산/명세 도메인.
SKIP_DOC_TYPES = {
@@ -177,9 +182,18 @@ async def process(document_id: int, session: AsyncSession) -> None:
return
container_path = _to_marker_path(doc.file_path)
# ---- (3) PDF only ----
suffix = Path(container_path).suffix.lower()
# ---- (3) office/hwp → md (C-2): PDF 외 지원 포맷은 office_md 하이브리드 변환 ----
if suffix in OFFICE_MD_SUPPORTED:
await session.execute(
update(Document).where(Document.id == document_id).values(md_status="processing")
)
await session.commit()
await _process_office(doc, document_id, container_path, session)
return
# ---- (3.5) PDF only (그 외 확장자 = skip) ----
if suffix not in SUPPORTED_EXTENSIONS:
logger.info(f"markdown_skip_unsupported_extension id={document_id} ext={suffix}")
await _set_skipped(
@@ -368,6 +382,56 @@ async def _process_markdown_passthrough(
)
async def _process_office(
doc: Document, document_id: int, container_path: str, session: AsyncSession
) -> None:
"""office/hwp → md (C-2). C-5 상태머신 postcondition 의 office arm.
office_md.convert_office_to_md 이진 계약: 성공=비공백 md 반환 / 실패·빈출력·타임아웃·
의존성부재=OfficeMdError raise. 따라서:
- 성공 md_status='success' (+ 비공백 md). 불변식 md_status {success,partial} md 非공백 유지.
- 실패/예외 _fail (md_status='failed', ¬success·¬skipped). silent 'success+빈md' 절대 없음.
partial arm PDF split 전용 office 이진이라 여기 없음. 'completed' A-3 직렬화 전용(워커 미사용).
quality content-type-aware: office=scored(_compute_quality). 동기 변환은 to_thread event loop 비차단.
"""
from workers.office_md import OfficeMdError, convert_office_to_md
is_hwp = Path(container_path).suffix.lower() in (".hwp", ".hwpx")
engine = "libreoffice_hwp" if is_hwp else "markitdown"
try:
# 동기 subprocess(LibreOffice)/markitdown — 스레드로 빼서 이벤트 루프 비차단.
md_content = await asyncio.to_thread(convert_office_to_md, container_path)
except OfficeMdError as exc:
logger.warning(f"[marker] office md 변환 실패 id={document_id} engine={engine}: {exc}")
await _fail(session, document_id, f"office_md: {str(exc)[:990]}", engine=engine)
return
except Exception as exc: # 예기치 못한 예외도 failed (success+빈md 절대 금지)
logger.exception(f"[marker] office md unexpected error id={document_id}: {exc}")
await _fail(session, document_id, f"office_md_unexpected: {str(exc)[:980]}", engine=engine)
return
# 성공 — 계약상 md_content 는 비공백(빈출력은 raise). quality scored.
quality = _compute_quality(md_content, doc.extracted_text or "", {"page_count": None})
await session.execute(
update(Document).where(Document.id == document_id).values(
md_content=md_content,
md_status="success",
md_extraction_engine=engine,
md_extraction_engine_version=None,
md_extraction_quality=quality,
md_content_hash=hashlib.sha256(md_content.encode("utf-8")).hexdigest(),
md_source_hash=doc.file_hash,
md_generated_at=_now(),
md_extraction_error=None,
md_frontmatter=doc.md_frontmatter or {},
md_format_version="1.0",
content_origin="extracted",
)
)
await session.commit()
logger.info(f"[marker] office success id={document_id} engine={engine} len={len(md_content)}")
async def _process_split(
doc: Document,
document_id: int,
@@ -779,15 +843,17 @@ async def _set_skipped(session: AsyncSession, document_id: int, reason: str) ->
await session.commit()
async def _fail(session: AsyncSession, document_id: int, error: str) -> None:
"""doc-level failed (재시도 무의미)."""
async def _fail(
session: AsyncSession, document_id: int, error: str, *, engine: str = "marker"
) -> None:
"""doc-level failed (재시도 무의미). engine = 실패한 변환 엔진(office=markitdown/libreoffice_hwp)."""
await session.execute(
update(Document).where(Document.id == document_id).values(
md_status="failed",
md_content=None,
md_content_hash=None,
md_extraction_error=error,
md_extraction_engine="marker",
md_extraction_engine=engine,
md_generated_at=_now(),
content_origin="extracted",
)
+136
View File
@@ -0,0 +1,136 @@
"""office/hwp → Markdown 하이브리드 변환기 (plan ds-s1-backend-1, C-1 PoC).
PoC 상태 marker_worker 아직 연결하지 않음(그건 C-2). 모듈은 변환 *계약*
PoC 하니스(scripts/poc_office_md.py) 호출하는 순수 함수만 제공한다.
전략 (하이브리드):
- OOXML(.docx/.xlsx/.pptx) markitdown 신규 의존성(pip install markitdown). lazy import.
- .hwp/.hwpx LibreOffice(headless) HTML markdownify markdownify 기존 의존성.
(LibreOffice hwp import 필터 보유. .hwpx .hwp 다른 필터·버전 의존 E-1: prod LibreOffice
버전핀 안전컨텍스트에서 PoC 실행. fidelity 진짜 리스크 하니스가 측정.)
실패 계약 (C-5 postcondition backend 절반):
변환 실패· 출력·타임아웃·의존성 부재 OfficeMdError raise 한다.
**success + md 절대 반환하지 않는다** 호출부(C-2 marker_worker) 이를 잡아
md_status='failed'(¬success·¬skipped) 라우팅한다. 불변식: md_status {success,partial} md_content 非공백.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
OOXML_FORMATS = {".docx", ".xlsx", ".pptx"}
HWP_FORMATS = {".hwp", ".hwpx"}
SUPPORTED = OOXML_FORMATS | HWP_FORMATS
# 빈 출력 판정 임계 — 공백 제거 후 이 미만이면 '실패(빈 변환)'로 본다.
_MIN_BODY_CHARS = 16
# extract_worker.py 가 이미 `libreoffice` 바이너리로 office 텍스트 추출에 성공(컨테이너 검증된
# 이름) → 기본값 정합. soffice 만 있는 환경은 LIBREOFFICE_BIN 으로 override.
_SOFFICE_BIN = os.environ.get("LIBREOFFICE_BIN", "libreoffice")
class OfficeMdError(Exception):
"""office/hwp → md 변환 실패 신호. 호출부는 md_status='failed' 로 라우팅."""
def convert_office_to_md(path: str | Path, *, timeout: int = 90) -> str:
"""office/hwp 파일을 Markdown 문자열로 변환. 실패/빈출력 시 OfficeMdError raise."""
p = Path(path)
suffix = p.suffix.lower()
if suffix not in SUPPORTED:
raise OfficeMdError(f"unsupported suffix for office_md: {suffix!r}")
if not p.exists():
raise OfficeMdError(f"file not found: {p}")
if suffix in OOXML_FORMATS:
md = _via_markitdown(p)
else: # .hwp / .hwpx
md = _via_libreoffice_html(p, timeout=timeout)
md = (md or "").strip()
if len(md) < _MIN_BODY_CHARS:
raise OfficeMdError(f"empty/too-short conversion ({len(md)} chars) for {p.name}")
return md
def _via_markitdown(path: Path) -> str:
try:
from markitdown import MarkItDown # lazy — 신규 의존성
except ImportError as e: # noqa: BLE001
raise OfficeMdError(
"markitdown 미설치 (OOXML 변환에 필요) — `pip install markitdown`. "
"C-1 PoC 는 prod worker 이미지/버전핀 컨텍스트에서 실행(E-1)."
) from e
try:
result = MarkItDown().convert(str(path))
except Exception as e: # noqa: BLE001 — 어떤 변환 예외든 failed 로 라우팅
raise OfficeMdError(f"markitdown 변환 실패: {path.name}: {e}") from e
return getattr(result, "text_content", "") or ""
def _via_libreoffice_html(path: Path, *, timeout: int) -> str:
"""LibreOffice headless 로 HTML 변환 후 markdownify. hwp/hwpx 용."""
try:
from markdownify import markdownify # 기존 의존성
except ImportError as e: # noqa: BLE001
raise OfficeMdError("markdownify 미설치(기존 의존성이어야 함)") from e
with tempfile.TemporaryDirectory(prefix="office_md_") as tmp:
tmpdir = Path(tmp)
# soffice 동시 실행 시 user profile 락 충돌 회피 — 호출별 격리 프로필.
profile = tmpdir / "lo_profile"
cmd = [
_SOFFICE_BIN,
"--headless",
"--nologo",
"--nofirststartwizard",
f"-env:UserInstallation=file://{profile}",
"--convert-to",
"html",
"--outdir",
str(tmpdir),
str(path),
]
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout, check=False
)
except FileNotFoundError as e:
raise OfficeMdError(
f"LibreOffice 바이너리 부재({_SOFFICE_BIN}) — LIBREOFFICE_BIN 설정 또는 설치 필요"
) from e
except subprocess.TimeoutExpired as e:
raise OfficeMdError(f"LibreOffice 변환 타임아웃({timeout}s): {path.name}") from e
html_path = tmpdir / f"{path.stem}.html"
if proc.returncode != 0 or not html_path.exists():
raise OfficeMdError(
f"LibreOffice html 변환 실패: {path.name} (rc={proc.returncode}): "
f"{(proc.stderr or proc.stdout or '').strip()[:300]}"
)
html = html_path.read_text(encoding="utf-8", errors="replace")
# 표 보존 위해 markdownify 가 table 을 GFM 으로 — heading_style ATX.
return markdownify(html, heading_style="ATX", strip=["span", "font"])
def table_fidelity(md: str) -> dict:
"""E-1 표 fidelity 의 crude 지표 — GFM 표 행/구분행 카운트 (정밀 평가 아님, 회귀 신호)."""
lines = md.splitlines()
pipe_rows = sum(1 for ln in lines if ln.strip().startswith("|") and ln.strip().endswith("|"))
sep_rows = sum(
1 for ln in lines
if ln.strip().startswith("|") and set(ln.strip()) <= set("|-: ")
)
return {
"chars": len(md),
"lines": len(lines),
"table_pipe_rows": pipe_rows,
"table_separator_rows": sep_rows, # 표 개수의 근사
"has_heading": any(ln.lstrip().startswith("#") for ln in lines),
}
+20 -4
View File
@@ -1,16 +1,32 @@
import SwiftUI
import AppFeature
/// Thin @main entry: window + DI only. Injects AppModel (FixtureDSClient + AIRouter(MockAIProvider))
/// so the whole pipeline renders with zero real backend / zero real LLM. Feature logic lives in
/// AppFeature, keeping the seam to a future Xcode/iPhone target trivial.
/// Thin @main entry: window + DI only. = (GPU DS) (AppModel.live
/// LiveDSClient + AIFabric , base publicTLS = https://document.hyungi.net/api).
/// env : DSAPP_FIXTURE=1 (Fixture+Mock) / DSAPP_DS_URL base
/// (: http://100.110.63.63:8000/api). Feature logic lives in AppFeature, keeping the seam to a
/// future iPhone/Watch target trivial.
@main
struct DSApp: App {
@State private var model: AppModel
@MainActor
init() {
_model = State(initialValue: AppModel.preview)
let env = ProcessInfo.processInfo.environment
let initial: AppModel
if env["DSAPP_FIXTURE"] == "1" {
initial = .preview
} else if let raw = env["DSAPP_DS_URL"] {
// dev prod(publicTLS) silent fallback , .
let trimmed = raw.hasSuffix("/") ? String(raw.dropLast()) : raw
guard let url = URL(string: trimmed), url.scheme != nil, url.host() != nil else {
fatalError("DSAPP_DS_URL 파싱 실패: \(raw)")
}
initial = .live(base: .custom(url))
} else {
initial = .live()
}
_model = State(initialValue: initial)
}
var body: some Scene {
+5
View File
@@ -47,6 +47,11 @@ let package = Package(
dependencies: ["DSKit", "AIFabric"],
swiftSettings: [.swiftLanguageMode(.v6)]
),
.testTarget(
name: "AppFeatureTests",
dependencies: ["AppFeature", "DSKit"],
swiftSettings: [.swiftLanguageMode(.v6)]
),
.testTarget(
name: "AITests",
dependencies: ["AIFabric"],
@@ -11,15 +11,14 @@ struct DashboardView: View {
if let s = model.stats {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) {
StatCard(title: "전체", value: s.total, color: Sage.brand)
StatCard(title: "문서", value: s.documents, color: Sage.brand)
StatCard(title: "검토 대기", value: s.reviewPending, color: Sage.amber)
StatCard(title: "파이프라인 실패", value: s.pipelineFailed, color: Sage.danger)
StatCard(title: "문서", value: s.counts["document"] ?? 0, color: Sage.brand)
StatCard(title: "승인 대기", value: s.libraryPendingSuggestions, color: Sage.amber)
}
VStack(alignment: .leading, spacing: 10) {
Text("도메인 분포").font(.headline).foregroundStyle(Sage.ink)
ForEach(s.byDomain.sorted { $0.value > $1.value }, id: \.key) { key, value in
DomainBar(name: key, count: value, max: s.byDomain.values.max() ?? 1)
Text("카테고리 분포").font(.headline).foregroundStyle(Sage.ink)
ForEach(s.counts.sorted { $0.value > $1.value }, id: \.key) { key, value in
DomainBar(name: Self.categoryLabel(key), count: value, max: s.counts.values.max() ?? 1)
.contentShape(Rectangle())
.onTapGesture { model.section = .documents }
}
@@ -35,4 +34,18 @@ struct DashboardView: View {
}
.background(Sage.surface)
}
/// category enum ( raw ).
static func categoryLabel(_ key: String) -> String {
switch key {
case "document": return "문서"
case "library": return "자료실"
case "news": return "뉴스"
case "law": return "법령"
case "memo": return "메모"
case "audio": return "오디오"
case "video": return "비디오"
default: return key
}
}
}
@@ -0,0 +1,97 @@
import SwiftUI
import DSKit
/// (GPU DS) ( FU-E ).
/// refresh / ; HttpOnly refresh
/// . TOTP ( ).
public struct LoginView: View {
@Environment(AppModel.self) private var model
@State private var username = ""
@State private var password = ""
@State private var totp = ""
@State private var submitting = false
@FocusState private var focus: Field?
private enum Field { case username, password, totp }
public init() {}
public var body: some View {
VStack(spacing: 0) {
Spacer()
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Document Server")
.font(.title2.weight(.semibold))
.foregroundStyle(Sage.ink)
Text(serverHost)
.font(.caption)
.foregroundStyle(Sage.muted)
}
VStack(spacing: 10) {
TextField("아이디", text: $username)
.textFieldStyle(.roundedBorder)
.focused($focus, equals: .username)
.onSubmit { focus = .password }
SecureField("비밀번호", text: $password)
.textFieldStyle(.roundedBorder)
.focused($focus, equals: .password)
.onSubmit { submit() }
TextField("2FA 코드 (설정한 경우)", text: $totp)
.textFieldStyle(.roundedBorder)
.focused($focus, equals: .totp)
.onSubmit { submit() }
}
if let error = model.loginError {
Text(error)
.font(.callout)
.foregroundStyle(Sage.danger)
.fixedSize(horizontal: false, vertical: true)
}
Button(action: submit) {
Group {
if submitting {
ProgressView().controlSize(.small)
} else {
Text("로그인")
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(Sage.brand)
.disabled(submitting || username.isEmpty || password.isEmpty)
}
.padding(28)
.frame(width: 360)
.background(Sage.card, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line))
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Sage.surface)
.onAppear { focus = .username }
}
/// base host (: document.hyungi.net / 100.110.63.63).
private var serverHost: String {
model.base.url.host() ?? model.base.url.absoluteString
}
private func submit() {
guard !submitting, !username.isEmpty, !password.isEmpty else { return }
submitting = true
Task {
await model.login(username: username, password: password, totp: totp)
submitting = false
}
}
}
#if DEBUG
#Preview("로그인") {
@Previewable @State var model = AppModel.preview
LoginView()
.environment(model)
.frame(width: 700, height: 500)
}
#endif
@@ -3,6 +3,7 @@ import DSKit
/// DEVONthink-style 3-column shell. RootView only ROUTES; each page owns its own interior treatment
/// (no shell-level auto-inherit). macOS-only target.
/// : checking( refresh ) loggedOut(LoginView) ready(3-pane ).
public struct RootView: View {
@Environment(AppModel.self) private var model
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@@ -10,6 +11,22 @@ public struct RootView: View {
public init() {}
public var body: some View {
Group {
switch model.authPhase {
case .checking:
ProgressView("서버 연결 확인 중")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Sage.surface)
case .loggedOut:
LoginView()
case .ready:
shell
}
}
.task { await model.bootstrap() }
}
private var shell: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
Sidebar()
.navigationSplitViewColumnWidth(min: 220, ideal: 250)
@@ -21,7 +38,24 @@ public struct RootView: View {
}
.navigationSplitViewStyle(.balanced)
.tint(Sage.brand)
.task { await model.loadInitial() }
.safeAreaInset(edge: .bottom) {
// (no-silent-fallback) .
if let err = model.errorText {
HStack(spacing: 10) {
Text(err)
.font(.callout)
.foregroundStyle(.white)
.lineLimit(2)
Spacer()
Button("닫기") { model.errorText = nil }
.buttonStyle(.plain)
.foregroundStyle(.white.opacity(0.85))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Sage.danger)
}
}
}
}
@@ -120,7 +154,6 @@ struct EmptyState: View {
@Previewable @State var model = AppModel.preview
RootView()
.environment(model)
.task { await model.loadInitial() }
.frame(minWidth: 1000, minHeight: 660)
}
#endif
@@ -23,6 +23,10 @@ public final class AppModel {
}
}
/// : refresh (checking) (loggedOut)
/// (ready). Fixture refresh fixture ready.
public enum AuthPhase: Equatable { case checking, loggedOut, ready }
public var section: Section = .dashboard
public var selectedDocumentID: Int?
public var selectedMemoID: Int?
@@ -41,14 +45,23 @@ public final class AppModel {
public var digest: DigestResponse?
public var errorText: String?
public private(set) var authPhase: AuthPhase = .checking
/// ( ).
public var loginError: String?
/// bootstrap single-shot ( ).
private var didBootstrap = false
let client: any DSClient
let ai: AIService
/// Placeholder token from the auth fixture builds a real-SHAPED download URL with no expectation it resolves offline.
/// DS base URL (live()/preview ).
let base: DSBaseURL
/// access ( ?token= ). bootstrap/login .
public private(set) var accessToken: String = ""
public init(client: any DSClient, ai: AIService) {
public init(client: any DSClient, ai: AIService, base: DSBaseURL = .publicTLS) {
self.client = client
self.ai = ai
self.base = base
}
@MainActor
@@ -56,8 +69,66 @@ public final class AppModel {
AppModel(client: FixtureDSClient(), ai: AIService(router: AppAIComposition.mockRouter()))
}
/// (GPU DS) : LiveDSClient + AIFabric (realRouter). ask closure
/// client TokenProvider (401 refresh ). = InMemory
/// access 15 , HttpOnly refresh (7,
/// HTTPCookieStorage ) . Keychain .
@MainActor
public static func live(
base: DSBaseURL = .publicTLS,
persistence: TokenPersistence = InMemoryTokenStore()
) -> AppModel {
let client = LiveDSClient(base: base, persistence: persistence)
let router = AppAIComposition.realRouter(base: base) { await client.currentAccessToken() }
return AppModel(client: client, ai: AIService(router: router), base: base)
}
/// 1 (single-shot / .task ):
/// refresh . 401( /) = loggedOut( ) /
/// ( ) = loggedOut + loginError (no-silent-fallback) /
/// task ( ) = appear .
public func bootstrap() async {
guard !didBootstrap else { return }
didBootstrap = true
// authPhase .checking ready UI .
do {
let token = try await client.refresh().accessToken
accessToken = token
authPhase = .ready
await loadInitial()
} catch let e as DSError where e.isAuthExpired {
authPhase = .loggedOut
} catch {
if Task.isCancelled {
didBootstrap = false
return
}
authPhase = .loggedOut
loginError = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
}
/// (POST /auth/login JWT). totp / .
public func login(username: String, password: String, totp: String?) async {
loginError = nil
do {
let code = totp.map {
$0.replacingOccurrences(of: " ", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
}
let response = try await client.login(
username: username,
password: password,
totpCode: (code?.isEmpty ?? true) ? nil : code
)
accessToken = response.accessToken
authPhase = .ready
await loadInitial()
} catch {
loginError = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
}
public func loadInitial() async {
await guarded { self.accessToken = (try? await self.client.login(username: "hyungi", password: "x", totpCode: nil).accessToken) ?? "" }
await guarded { self.tree = try await self.client.documentTree() }
await guarded { self.stats = try await self.client.categoryCounts() }
await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items }
@@ -88,11 +159,26 @@ public final class AppModel {
public func downloadURL(for doc: DocumentResponse) -> URL? {
guard doc.hasDownloadableOriginal, !accessToken.isEmpty else { return nil }
return DSDownload.fileURL(base: .publicTLS, documentID: doc.id, accessToken: accessToken)
return DSDownload.fileURL(base: base, documentID: doc.id, accessToken: accessToken)
}
private func guarded(_ work: () async throws -> Void) async {
do { try await work() }
catch { errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
do {
try await work()
} catch let e as DSError where e.isAuthExpired {
// LiveDSClient refresh+ (refresh /) .
authPhase = .loggedOut
loginError = "세션이 만료되었습니다. 다시 로그인하세요."
} catch {
errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
await syncAccessToken()
}
/// 401 (LiveDSClient refresh) ?token= guarded
/// . = TokenProvider.
private func syncAccessToken() async {
guard let live = client as? LiveDSClient, let t = await live.currentAccessToken() else { return }
if t != accessToken { accessToken = t }
}
}
+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
}
}
@@ -39,6 +39,9 @@ public final class LiveDSClient: DSClient, @unchecked Sendable {
public func setAccessToken(_ token: String) async { await tokens.set(token) }
/// realRouter ask closure TokenProvider (401 refresh ).
public func currentAccessToken() async -> String? { await tokens.current() }
// MARK: - Request building / sending
private func makeRequest(_ endpoint: DSEndpoint, token: String?) throws -> URLRequest {
@@ -12,19 +12,21 @@ public struct DomainTreeNode: Codable, Sendable, Identifiable {
public var kids: [DomainTreeNode] { children ?? [] }
}
/// GET /documents/stats/category-counts Pydantic response model raw dict
/// . shape (total/by_domain/...) decode
/// 2026-06-07 (fixture documents_stats.json = CAPTURED_LIVE).
public struct CategoryCounts: Codable, Sendable {
public let total: Int
public let documents: Int
public let byDomain: [String: Int]
public let reviewPending: Int
public let pipelineFailed: Int
/// category(enum) : document/library/news/law/memo/audio.
public let counts: [String: Int]
public let libraryPendingSuggestions: Int
enum CodingKeys: String, CodingKey {
case total, documents
case byDomain = "by_domain"
case reviewPending = "review_pending"
case pipelineFailed = "pipeline_failed"
case counts
case libraryPendingSuggestions = "library_pending_suggestions"
}
/// (counts ) .
public var total: Int { counts.values.reduce(0, +) }
}
public struct DuplicateGroup: Codable, Sendable, Identifiable {
@@ -1,14 +1,11 @@
{
"total": 1163,
"documents": 783,
"by_domain": {
"Industrial_Safety": 426,
"Engineering": 351,
"General": 189,
"Programming": 60,
"법령": 23,
"Philosophy": 12
"counts": {
"library": 391,
"law": 229,
"document": 381,
"news": 6182,
"memo": 4,
"audio": 2
},
"review_pending": 725,
"pipeline_failed": 19
"library_pending_suggestions": 0
}
@@ -0,0 +1,182 @@
import XCTest
@testable import AppFeature
import DSKit
/// 0 (Fixture/stub ).
/// bootstrap: refresh =ready / =loggedOut. login: =ready+ / 401= .
final class AppModelAuthTests: XCTestCase {
@MainActor
private func makeModel(client: any DSClient) -> AppModel {
AppModel(client: client, ai: AIService(router: AppAIComposition.mockRouter()))
}
// refresh ( Fixture fixture ) ready +
@MainActor
func testBootstrapRefreshSuccessGoesReady() async {
let model = AppModel.preview
await model.bootstrap()
XCTAssertEqual(model.authPhase, .ready)
XCTAssertFalse(model.accessToken.isEmpty)
XCTAssertFalse(model.documentList.isEmpty, "ready 진입 시 초기 로드까지 수행해야 함")
}
// refresh ( /) loggedOut,
@MainActor
func testBootstrapRefreshFailureGoesLoggedOut() async {
let model = makeModel(client: AuthStubClient(refreshFails: true))
await model.bootstrap()
XCTAssertEqual(model.authPhase, .loggedOut)
XCTAssertTrue(model.accessToken.isEmpty)
XCTAssertTrue(model.documentList.isEmpty)
}
// loggedOut login ready +
@MainActor
func testLoginSuccessTransitionsToReady() async {
let model = makeModel(client: AuthStubClient(refreshFails: true))
await model.bootstrap()
XCTAssertEqual(model.authPhase, .loggedOut)
await model.login(username: "hyungi", password: "pw", totp: nil)
XCTAssertEqual(model.authPhase, .ready)
XCTAssertFalse(model.accessToken.isEmpty)
XCTAssertNil(model.loginError)
XCTAssertFalse(model.documentList.isEmpty)
}
// login 401 loginError + loggedOut +
@MainActor
func testLoginFailureSurfacesErrorAndStaysLoggedOut() async {
let model = makeModel(client: AuthStubClient(refreshFails: true, loginFails: true))
await model.bootstrap()
await model.login(username: "hyungi", password: "wrong", totp: nil)
XCTAssertEqual(model.authPhase, .loggedOut)
XCTAssertNotNil(model.loginError)
XCTAssertTrue(model.accessToken.isEmpty)
}
// totp / totpCode nil ( totp )
@MainActor
func testLoginSendsNilForBlankTotp() async {
let stub = AuthStubClient(refreshFails: true)
let model = makeModel(client: stub)
await model.login(username: "u", password: "p", totp: " ")
XCTAssertNotNil(stub.recordedLogin, "login 이 호출돼야 함")
XCTAssertNil(stub.recordedLogin?.totp, "공백 totp 는 nil 로 정규화")
await model.login(username: "u", password: "p", totp: "123456")
XCTAssertEqual(stub.recordedLogin?.totp, "123456")
}
// totp (/ ) "123 456\n" "123456"
@MainActor
func testLoginNormalizesTotpNewlineAndSpaces() async {
let stub = AuthStubClient(refreshFails: true)
let model = makeModel(client: stub)
await model.login(username: "u", password: "p", totp: "123 456\n")
XCTAssertEqual(stub.recordedLogin?.totp, "123456")
await model.login(username: "u", password: "p", totp: " \n ")
XCTAssertNil(stub.recordedLogin?.totp, "개행+공백뿐이면 nil")
}
// bootstrap single-shot (.task ) refresh 1, ready
@MainActor
func testBootstrapIsSingleShot() async {
let stub = AuthStubClient()
let model = makeModel(client: stub)
await model.bootstrap()
XCTAssertEqual(model.authPhase, .ready)
await model.bootstrap() // appear
XCTAssertEqual(model.authPhase, .ready, "재진입이 checking 으로 리셋하면 안 됨")
XCTAssertEqual(stub.refreshCount, 1, "refresh 는 1회만")
}
// bootstrap transport ( ) loggedOut + ( )
@MainActor
func testBootstrapTransportFailureExposesReason() async {
let model = makeModel(client: AuthStubClient(refreshTransportFails: true))
await model.bootstrap()
XCTAssertEqual(model.authPhase, .loggedOut)
XCTAssertNotNil(model.loginError, "transport 실패 사유가 로그인 화면에 노출돼야 함")
}
// ( refresh+ ) ready loggedOut
@MainActor
func testAuthExpiredDuringUseDemotesToLoggedOut() async {
let stub = AuthStubClient()
let model = makeModel(client: stub)
await model.bootstrap()
XCTAssertEqual(model.authPhase, .ready)
stub.dataAuthExpired = true // 401 (refresh )
await model.openDocument(1)
XCTAssertEqual(model.authPhase, .loggedOut)
XCTAssertNotNil(model.loginError)
}
// live : LiveDSClient + base ( )
@MainActor
func testLiveFactoryComposition() {
let model = AppModel.live(base: .tailscale)
XCTAssertTrue(model.client is LiveDSClient)
XCTAssertEqual(model.base.url.absoluteString, DSBaseURL.tailscale.url.absoluteString)
}
}
/// FixtureDSClient + ( 0).
/// task @unchecked Sendable .
final class AuthStubClient: DSClient, @unchecked Sendable {
private let inner = FixtureDSClient()
private let refreshFails: Bool
private let refreshTransportFails: Bool
private let loginFails: Bool
private(set) var recordedLogin: (username: String, totp: String?)?
private(set) var refreshCount = 0
/// true 401 ( LiveDSClient )
var dataAuthExpired = false
init(refreshFails: Bool = false, refreshTransportFails: Bool = false, loginFails: Bool = false) {
self.refreshFails = refreshFails
self.refreshTransportFails = refreshTransportFails
self.loginFails = loginFails
}
private func gateData() throws {
if dataAuthExpired { throw DSError.unauthorized(message: nil) }
}
// Auth
func login(username: String, password: String, totpCode: String?) async throws -> AccessTokenResponse {
recordedLogin = (username, totpCode)
if loginFails { throw DSError.unauthorized(message: "아이디 또는 비밀번호가 올바르지 않습니다") }
return try await inner.login(username: username, password: password, totpCode: totpCode)
}
func refresh() async throws -> AccessTokenResponse {
refreshCount += 1
if refreshTransportFails { throw DSError.transport(underlying: "Could not connect to the server") }
if refreshFails { throw DSError.unauthorized(message: "refresh failed") }
return try await inner.refresh()
}
func me() async throws -> UserResponse { try await inner.me() }
func logout() async throws { try await inner.logout() }
// Fixture (dataAuthExpired )
func documents(_ query: DocumentListQuery) async throws -> DocumentListResponse { try gateData(); return try await inner.documents(query) }
func document(id: Int) async throws -> DocumentDetailResponse { try gateData(); return try await inner.document(id: id) }
func documentContent(id: Int) async throws -> DocumentContentResponse { try await inner.documentContent(id: id) }
func documentTree() async throws -> [DomainTreeNode] { try await inner.documentTree() }
func categoryCounts() async throws -> CategoryCounts { try await inner.categoryCounts() }
func duplicates() async throws -> DuplicatesResponse { try await inner.duplicates() }
func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { try await inner.patchDocument(id: id, update) }
func putContent(id: Int, content: String) async throws { try await inner.putContent(id: id, content: content) }
func deleteDocument(id: Int) async throws { try await inner.deleteDocument(id: id) }
func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await inner.search(q: q, mode: mode, page: page, debug: debug) }
func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await inner.ask(q: q, limit: limit, backend: backend, debug: debug) }
func memos(_ query: MemoListQuery) async throws -> MemoListResponse { try await inner.memos(query) }
func memo(id: Int) async throws -> MemoResponse { try await inner.memo(id: id) }
func createMemo(_ create: MemoCreate) async throws -> MemoResponse { try await inner.createMemo(create) }
func patchMemo(id: Int, _ update: MemoUpdate) async throws -> MemoResponse { try await inner.patchMemo(id: id, update) }
func pinMemo(id: Int, pinned: Bool) async throws -> MemoResponse { try await inner.pinMemo(id: id, pinned: pinned) }
func archiveMemo(id: Int, archived: Bool) async throws -> MemoResponse { try await inner.archiveMemo(id: id, archived: archived) }
func toggleMemoTask(id: Int, taskIndex: Int, checked: Bool) async throws -> MemoResponse { try await inner.toggleMemoTask(id: id, taskIndex: taskIndex, checked: checked) }
func deleteMemo(id: Int) async throws { try await inner.deleteMemo(id: id) }
func digest(date: String?, country: String?) async throws -> DigestResponse { try await inner.digest(date: date, country: country) }
}
@@ -65,8 +65,10 @@ final class FixtureDecodeTests: XCTestCase {
func testStats() async throws {
let s = try await client.categoryCounts()
XCTAssertEqual(s.documents, 783)
XCTAssertEqual(s.byDomain["법령"], 23) // non-ASCII dict key
XCTAssertEqual(s.counts["news"], 6182)
XCTAssertEqual(s.counts["library"], 391)
XCTAssertEqual(s.libraryPendingSuggestions, 0)
XCTAssertEqual(s.total, 391 + 229 + 381 + 6182 + 4 + 2) // = counts
}
func testDuplicates() async throws {
+1 -1
View File
@@ -53,7 +53,7 @@ UserResponse { id: Int, username: String, is_active: Bool, totp_enabled: Bool, l
| GET | `/documents/{id}/file` | `?token=<access>&download=true` | **바이너리 원본** (PDF/이미지/오디오/원본) | — |
| GET | `/documents/{id}/content` | — | 경량 텍스트(`content` 15k cap) | `document_content.json` |
| GET | `/documents/tree` | — | 도메인 트리(사이드바) | `documents_tree.json` |
| GET | `/documents/stats/category-counts` | — | 카테고리 카운트 | `documents_stats.json` |
| GET | `/documents/stats/category-counts` | — | `{counts: {category: n}, library_pending_suggestions}`**raw dict 반환(Pydantic 모델 없음), 2026-06-07 라이브 재캡처로 정정**(초기 추출이 shape 합성 오류) | `documents_stats.json` |
| POST | `/documents/` (multipart) | 파일 업로드 | `DocumentResponse` (201) | `document_detail.json` |
| PATCH | `/documents/{id}` | `DocumentUpdate` | `DocumentResponse` | — |
| PUT | `/documents/{id}/content` | `{content}` (md 편집 저장) | `{}` | — |
@@ -1,14 +1,11 @@
{
"total": 1163,
"documents": 783,
"by_domain": {
"Industrial_Safety": 426,
"Engineering": 351,
"General": 189,
"Programming": 60,
"법령": 23,
"Philosophy": 12
"counts": {
"library": 391,
"law": 229,
"document": 381,
"news": 6182,
"memo": 4,
"audio": 2
},
"review_pending": 725,
"pipeline_failed": 19
"library_pending_suggestions": 0
}
+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:
+87
View File
@@ -0,0 +1,87 @@
# S1 데이터·백엔드 트랙 적용 runbook (plan ds-s1-backend-1)
> 코드는 `feat/s1-dedup-fields` 브랜치에 완성. 이 문서는 **prod(GPU) 적용 게이트** 절차.
> ⚠ 적용은 사용자 명시 go 필요 — 본 runbook 은 자동 실행되지 않는다.
## 0. 사전 조건 (게이트)
- [ ] **검색실험 Soft Lock 확인**`~/.claude/.search-experiment-active` 부재여야 함.
현재(2026-06-05) 부재 = 비활성. migration 317 은 startup 자동적용 → `docker compose up`
이 restart 를 유발하므로, 실험 활성 시엔 예외창 합의 후에만.
- [ ] **불가침 면 (검색실험 유효성)**: embedding 모델 / 벡터 인덱스(ivfflat/partial) /
retrieval config / config.yaml 의 ai·model 섹션 **미접촉**. 본 트랙 변경면은
dedup 컬럼 + office_md + storage scaffold(env) 뿐.
## 1. migration 번호
- 317(dedup 3컬럼) **단일** 클레임. P0-4=(C) 무변경이라 신규 migration 미추가.
- S2/S3 트랙이 같은 317 을 발행하지 않도록 조율(startup 카오스 방지).
## 2. restart 셋 (한 번에 배치)
| 서비스 | 변경 | 재시작 사유 |
|---|---|---|
| `fastapi` | A(317 dedup) + B(dedup API) + D(storage scaffold) | startup migration 자동적용 + 코드 |
| `marker_worker`(fastapi 내 스케줄러) | C(office_md 분기) + **markitdown 신규 pip dep** | rebuild 필요 |
> markitdown 은 신규 의존성 → `docker compose build` 필수(force-recreate 만으론 image 미갱신,
> feedback_docker_compose_build_vs_force_recreate). office 변환(OOXML)에만 필요.
## 3. 적용 순서 (inventory → config → deploy → verify)
```bash
ssh gpu && cd ~/Documents/code/hyungi_Document_Server
# (1) pre-A-1 안전망 — DB 덤프 (repo 밖)
bash scripts/s1_pre_change_backup.sh pre-a1
# (2) 코드 가져오기 + 빌드(markitdown dep 반영) + 적용
git fetch && git checkout feat/s1-dedup-fields # 또는 main 머지 후 main
docker compose build fastapi # markitdown 설치 (requirements 에 추가 필요)
docker compose up -d fastapi # startup 에서 migration 317 자동적용
# (3) migration 317 적용 확인
docker compose exec -T postgres psql -U pkm -d pkm -c \
"SELECT version,name FROM schema_migrations WHERE version=317;"
docker compose exec -T postgres psql -U pkm -d pkm -c \
"\d documents" | grep -E 'original_filename|duplicate_of|duplicate_count'
```
> **requirements**: office OOXML 변환에 `markitdown` 추가 필요(`requirements.txt`/pyproject).
> markdownify·LibreOffice 는 기존. 빌드 전 dep 추가 PR 필수(없으면 OOXML 변환이 OfficeMdError→failed,
> hwp/PDF/passthrough 는 정상).
## 4. backfill (코드 적용·검증 후, 야간 비중첩창)
> dedup 컬럼 정합은 **야간 잡 `dedup_reconcile`(03:30 KST, main.py)** 이 매일 멱등 재계산한다
> (soft-delete 잔여 드리프트 자동 정리). 아래 `backfill_dedup.py` 수동 실행은 적용 직후 1회
> 초기 채움/즉시 확인용 — 이후엔 야간 잡이 유지.
```bash
# (4a) dedup backfill (초기 1회) — 먼저 dry-run 으로 정확한 UPDATE set 확인
bash scripts/s1_pre_change_backup.sh pre-b4
docker compose exec fastapi python /app/scripts/backfill_dedup.py --dry-run
docker compose exec fastapi python /app/scripts/backfill_dedup.py --apply
# (4b) office/hwp pending markdown 백필 — C-2 라이브 ingestion 과 비중첩 야간창
docker compose exec fastapi python /app/scripts/backfill_nonpdf_markdown.py --dry-run
docker compose exec fastapi python /app/scripts/backfill_nonpdf_markdown.py --apply --limit 20 # sample 먼저
docker compose exec fastapi python /app/scripts/backfill_nonpdf_markdown.py --apply # 전체
```
## 5. verify (smoke)
```bash
# /duplicates shape
curl -s -H "Authorization: Bearer $TOK" https://document.hyungi.net/api/documents/duplicates | jq '{total_groups,total_duplicate_docs, g0:.groups[0]}'
# office 변환 결과 (sample doc)
docker compose exec -T postgres psql -U pkm -d pkm -c \
"SELECT md_status,md_extraction_engine,length(md_content) FROM documents WHERE id=<office_doc_id>;"
# md_status success→completed 직렬화 (앱 계약)
curl -s -H "Authorization: Bearer $TOK" https://document.hyungi.net/api/documents/<id> | jq '.md_status'
```
## 6. 롤백
- 컬럼만 빠른 롤백: `scripts/rollback_317.sql` (수동, schema_migrations 317 행도 삭제).
- 전체 복원: `scripts/s1_pre_change_backup.sh` 가 출력한 `.sql.gz` → psql 복원.
+126 -114
View File
@@ -3,10 +3,14 @@
import { addToast } from '$lib/stores/toast';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { ExternalLink, Save, RefreshCw } from 'lucide-svelte';
import { ExternalLink, Save } from 'lucide-svelte';
import Tabs from '$lib/components/ui/Tabs.svelte';
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
import { getViewerType } from '$lib/utils/viewerType';
import { isMdSuccess } from '$lib/utils/mdStatus';
// marked + sanitize
// 편집 미리보기 전용 plain marked (본문 렌더는 MarkdownDoc 가 담당).
marked.use({ mangle: false, headerIds: false });
function renderMd(text) {
return DOMPurify.sanitize(marked(text), {
@@ -22,33 +26,19 @@
let loading = $state(true);
let viewerType = $state('none');
// Markdown 편집
// Markdown 편집 (md/txt — extracted_text 가 표시·편집 단일 필드)
let editMode = $state(false);
let editContent = $state('');
let editTab = $state('edit');
let saving = $state(false);
let rawMarkdown = $state('');
function getViewerType(format) {
if (['md', 'txt'].includes(format)) return 'markdown';
if (format === 'pdf') return 'pdf';
if (['hwp', 'hwpx'].includes(format)) return 'preview-pdf';
if (['odoc', 'osheet', 'docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp'].includes(format)) return 'preview-pdf';
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff'].includes(format)) return 'image';
if (['csv', 'json', 'xml', 'html'].includes(format)) return 'text';
if (['dwg', 'dxf'].includes(format)) return 'cad';
return 'unsupported';
}
const ODF_FORMATS = ['ods', 'odt', 'odp', 'odoc', 'osheet'];
function getEditInfo(doc) {
// DB에 저장된 편집 URL 우선
if (doc.edit_url) return { url: doc.edit_url, label: '편집' };
// ODF 포맷 → Synology Drive
if (ODF_FORMATS.includes(doc.file_format)) return { url: 'https://link.hyungi.net', label: 'Synology Drive에서 열기' };
// CAD
if (['dwg', 'dxf'].includes(doc.file_format)) return { url: 'https://web.autocad.com', label: 'AutoCAD Web' };
function getEditInfo(d) {
if (d.edit_url) return { url: d.edit_url, label: '편집' };
if (ODF_FORMATS.includes(d.file_format)) return { url: 'https://link.hyungi.net', label: 'Synology Drive에서 열기' };
if (['dwg', 'dxf'].includes(d.file_format)) return { url: 'https://web.autocad.com', label: 'AutoCAD Web' };
return null;
}
@@ -61,18 +51,17 @@
async function loadFullDoc(id) {
loading = true;
rawMarkdown = '';
try {
fullDoc = await api(`/documents/${id}`);
viewerType = fullDoc.source_channel === 'news' ? 'article' : getViewerType(fullDoc.file_format);
viewerType = getViewerType(fullDoc.file_format, fullDoc.source_channel);
// Markdown: extracted_text 없으면 원본 파일 직접 가져오기
// 본문 markdown(md/txt) 인데 extracted_text 가 비면 원본 파일 직접 로드.
if (viewerType === 'markdown' && !fullDoc.extracted_text) {
try {
const resp = await fetch(`/api/documents/${id}/file?token=${getAccessToken()}`);
if (resp.ok) rawMarkdown = await resp.text();
} catch (e) { rawMarkdown = ''; }
} else {
rawMarkdown = '';
}
} catch (err) {
fullDoc = null;
@@ -82,6 +71,23 @@
}
}
// PDF markdown-first: marker 가 만든 canonical md_content 가 있으면 기본으로 그것을 보여주고
// "PDF 원본" 토글 제공. lastDocId 는 prop(fullDoc.id) 로 키잉 — 3-pane 은 라우트 리마운트가
// 없어 page.params 가드는 no-op 이 된다.
let pdfViewMode = $state('markdown');
let lastDocId = $state(null);
let canShowMarkdown = $derived(
!!(isMdSuccess(fullDoc?.md_status) && fullDoc?.md_content?.trim())
);
$effect(() => {
if (!fullDoc) return;
if (fullDoc.id !== lastDocId) {
lastDocId = fullDoc.id;
pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf';
}
if (!canShowMarkdown && pdfViewMode === 'markdown') pdfViewMode = 'pdf';
});
function startEdit() {
editContent = fullDoc?.extracted_text || rawMarkdown || '';
editMode = true;
@@ -113,6 +119,7 @@
}
let editInfo = $derived(fullDoc ? getEditInfo(fullDoc) : null);
const PROSE = 'prose prose-invert prose-base max-w-none';
</script>
<svelte:window on:keydown={handleKeydown} />
@@ -125,38 +132,22 @@
<div class="flex items-center gap-2">
{#if viewerType === 'markdown'}
{#if editMode}
<button
onclick={saveContent}
disabled={saving}
class="flex items-center gap-1 px-2 py-1 text-xs bg-accent text-white rounded hover:bg-accent-hover disabled:opacity-50"
>
<button onclick={saveContent} disabled={saving}
class="flex items-center gap-1 px-2 py-1 text-xs bg-accent text-white rounded hover:bg-accent-hover disabled:opacity-50">
<Save size={12} /> {saving ? '저장 중...' : '저장'}
</button>
<button
onclick={() => editMode = false}
class="px-2 py-1 text-xs text-dim hover:text-text"
>취소</button>
<button onclick={() => editMode = false} class="px-2 py-1 text-xs text-dim hover:text-text">취소</button>
{:else}
<button
onclick={startEdit}
class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded"
>편집</button>
<button onclick={startEdit} class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">편집</button>
{/if}
{/if}
{#if editInfo}
<a
href={editInfo.url}
target="_blank"
rel="noopener"
class="flex items-center gap-1 px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded"
>
<a href={editInfo.url} target="_blank" rel="noopener"
class="flex items-center gap-1 px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">
<ExternalLink size={12} /> {editInfo.label}
</a>
{/if}
<a
href="/documents/{fullDoc.id}"
class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded"
>전체 보기</a>
<a href="/documents/{fullDoc.id}" class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">전체 보기</a>
</div>
</div>
{/if}
@@ -164,109 +155,130 @@
<!-- 뷰어 본문 -->
<div class="flex-1 overflow-auto min-h-0">
{#if loading}
<div class="flex items-center justify-center h-full">
<p class="text-sm text-dim">로딩 중...</p>
</div>
<div class="flex items-center justify-center h-full"><p class="text-sm text-dim">로딩 중...</p></div>
{:else if fullDoc}
{#if viewerType === 'markdown'}
{#if editMode}
<!-- Markdown 편집 (Tabs 프리미티브 — E.4) -->
<div class="flex flex-col h-full">
<Tabs
tabs={[
{ id: 'edit', label: '편집' },
{ id: 'preview', label: '미리보기' },
]}
bind:value={editTab}
class="flex flex-col h-full"
>
<Tabs tabs={[{ id: 'edit', label: '편집' }, { id: 'preview', label: '미리보기' }]} bind:value={editTab} class="flex flex-col h-full">
{#snippet children(activeId)}
{#if activeId === 'edit'}
<textarea
bind:value={editContent}
<textarea bind:value={editContent}
class="flex-1 w-full p-4 bg-bg text-text text-sm font-mono resize-none outline-none min-h-[300px]"
spellcheck="false"
aria-label="마크다운 편집"
></textarea>
spellcheck="false" aria-label="마크다운 편집"></textarea>
{:else}
<div class="flex-1 overflow-auto p-4 markdown-body">
{@html renderMd(editContent)}
</div>
<div class="flex-1 overflow-auto p-4 markdown-body">{@html renderMd(editContent)}</div>
{/if}
{/snippet}
</Tabs>
</div>
{:else}
<div class="p-4 markdown-body">
{@html renderMd(fullDoc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
<!-- md/txt = extracted_text 단일 필드(표시=편집), MarkdownDoc 로 앵커/KaTeX/이미지 렌더 -->
<div class="p-4">
<MarkdownDoc
documentId={fullDoc.id}
mdContent={null}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
extractedText={fullDoc.extracted_text || rawMarkdown}
class={PROSE}
/>
</div>
{/if}
{:else if viewerType === 'pdf'}
<iframe
src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}"
class="w-full h-full border-0"
title={fullDoc.title}
></iframe>
{:else if viewerType === 'preview-pdf'}
<iframe
src="/api/documents/{fullDoc.id}/preview?token={getAccessToken()}"
class="w-full h-full border-0"
title={fullDoc.title}
onerror={() => {}}
></iframe>
{:else if viewerType === 'image'}
<div class="flex items-center justify-center h-full p-4">
<img
src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}"
alt={fullDoc.title}
class="max-w-full max-h-full object-contain rounded"
<div class="p-4 flex flex-col h-full">
<div class="mb-2 flex items-center gap-2 shrink-0">
<MarkdownStatusBadge mdStatus={fullDoc.md_status} mdExtractionError={fullDoc.md_extraction_error} mdExtractionQuality={fullDoc.md_extraction_quality} />
{#if canShowMarkdown}
<button onclick={() => (pdfViewMode = 'markdown')}
class="px-2 py-1 text-xs rounded border {pdfViewMode === 'markdown' ? 'bg-accent text-white border-accent' : 'text-dim border-default hover:text-accent'}">Markdown</button>
<button onclick={() => (pdfViewMode = 'pdf')}
class="px-2 py-1 text-xs rounded border {pdfViewMode === 'pdf' ? 'bg-accent text-white border-accent' : 'text-dim border-default hover:text-accent'}">PDF 원본</button>
{/if}
</div>
{#if pdfViewMode === 'markdown' && canShowMarkdown}
<div class="flex-1 overflow-auto">
<MarkdownDoc
documentId={fullDoc.id}
mdContent={fullDoc.md_content}
mdFrontmatter={fullDoc.md_frontmatter}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
extractedText={fullDoc.extracted_text}
class={PROSE}
/>
</div>
{:else}
<iframe src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}" class="flex-1 w-full border-0 rounded" title={fullDoc.title}></iframe>
{/if}
</div>
{:else if viewerType === 'hwp-markdown'}
<div class="p-4">
<MarkdownDoc
documentId={fullDoc.id}
mdContent={fullDoc.md_content}
mdFrontmatter={fullDoc.md_frontmatter}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
extractedText={fullDoc.extracted_text}
class={PROSE}
/>
</div>
{:else if viewerType === 'preview-pdf'}
<iframe src="/api/documents/{fullDoc.id}/preview?token={getAccessToken()}" class="w-full h-full border-0" title={fullDoc.title} onerror={() => {}}></iframe>
{:else if viewerType === 'image'}
<div class="flex items-center justify-center h-full p-4">
<img src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}" alt={fullDoc.title} class="max-w-full max-h-full object-contain rounded" />
</div>
{:else if viewerType === 'text'}
<div class="p-4">
<pre class="text-sm text-text whitespace-pre-wrap font-mono">{fullDoc.extracted_text || '텍스트 없음'}</pre>
<div class="p-4"><pre class="text-sm text-text whitespace-pre-wrap font-mono">{fullDoc.extracted_text || '텍스트 없음'}</pre></div>
{:else if viewerType === 'synology'}
<div class="flex flex-col items-center justify-center h-full gap-3">
<p class="text-sm text-dim">Synology Office 문서 — 외부 편집기에서 열어야 합니다.</p>
<a href={fullDoc.edit_url || 'https://link.hyungi.net'} target="_blank" rel="noopener"
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover">
<ExternalLink size={14} /> 새 창에서 열기
</a>
</div>
{:else if viewerType === 'cad'}
<div class="flex flex-col items-center justify-center h-full gap-3">
<p class="text-sm text-dim">CAD 미리보기 (향후 지원 예정)</p>
<a
href="https://web.autocad.com"
target="_blank"
class="px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent-hover"
>AutoCAD Web에서 열기</a>
<a href="https://web.autocad.com" target="_blank" rel="noopener" class="px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent-hover">AutoCAD Web에서 열기</a>
</div>
{:else if viewerType === 'article'}
<!-- 뉴스 전용 뷰어 -->
<div class="p-5 max-w-3xl mx-auto">
<h1 class="text-lg font-bold mb-2">{fullDoc.title}</h1>
<h1 class="text-lg font-bold mb-2 text-text">{fullDoc.title}</h1>
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
{#if fullDoc.ai_tags?.length}
{#each fullDoc.ai_tags.filter(t => t.startsWith('News/')) as tag}
<span class="px-1.5 py-0.5 rounded bg-blue-900/30 text-blue-400">{tag.replace('News/', '')}</span>
<span class="px-1.5 py-0.5 rounded bg-accent/15 text-accent-hover">{tag.replace('News/', '')}</span>
{/each}
{/if}
<span>{new Date(fullDoc.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div class="markdown-body mb-6">
{@html renderMd(fullDoc.extracted_text || '')}
</div>
<div class="flex items-center gap-3 pt-4 border-t border-default">
{#if fullDoc.edit_url}
<a
href={fullDoc.edit_url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover"
>
<MarkdownDoc
documentId={fullDoc.id}
mdContent={fullDoc.md_content}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
extractedText={fullDoc.extracted_text}
class="{PROSE} mb-6"
/>
{#if fullDoc.edit_url}
<div class="flex items-center gap-3 pt-4 border-t border-default">
<a href={fullDoc.edit_url} target="_blank" rel="noopener noreferrer"
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover">
<ExternalLink size={14} /> 원문 보기
</a>
{/if}
</div>
</div>
{/if}
</div>
{:else}
<div class="flex items-center justify-center h-full">
<p class="text-sm text-dim">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p>
</div>
<div class="flex items-center justify-center h-full"><p class="text-sm text-dim">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p></div>
{/if}
{/if}
</div>
+21 -1
View File
@@ -28,6 +28,9 @@
mdStatus?: string | null;
mdExtractionError?: string | null;
mdExtractionQuality?: Record<string, unknown> | null;
/** 개요 점프용 anchor: {chunk_id: md_content char offset}. 렌더 전 해당 위치에
* <span id="sec-{chunk_id}"> 주입(점프 타깃). buildAnchorMap(outlineAnchors) 산출물. */
anchorMap?: Record<number, number> | null;
placeholder?: string;
/** 추가 래퍼 클래스. tailwind prose-* / spacing 등을 호출 측에서 입혀야 할 때. */
class?: string;
@@ -41,10 +44,27 @@
mdStatus = null,
mdExtractionError = null,
mdExtractionQuality = null,
anchorMap = null,
placeholder = '*텍스트 추출 대기 중*',
class: klass = '',
}: Props = $props();
// 개요 anchor 주입: body 의 각 offset(내림차순)에 빈 <span id="sec-N"> 삽입(점프 타깃).
// offset 은 buildAnchorMap 이 body 와 동일 문자열 기준으로 산출했어야 함(호출측 책임).
function spliceAnchors(text: string, map: Record<number, number> | null): string {
if (!map) return text;
const ents = Object.entries(map)
.map(([id, off]) => [id, Number(off)] as [string, number])
.filter(([, o]) => Number.isFinite(o) && o >= 0 && o <= text.length)
.sort((a, b) => b[1] - a[1]);
if (!ents.length) return text;
let out = text;
for (const [id, off] of ents) {
out = out.slice(0, off) + `<span id="sec-${id}" class="md-anchor"></span>\n` + out.slice(off);
}
return out;
}
let usingMarkdown = $derived(!!(mdContent && mdContent.trim()));
let body = $derived(
usingMarkdown
@@ -53,7 +73,7 @@
? extractedText
: placeholder,
);
let renderedHtml = $derived(renderDocMarkdown(body));
let renderedHtml = $derived(renderDocMarkdown(spliceAnchors(body, anchorMap)));
let frontmatterEntries = $derived.by(() => {
if (!usingMarkdown || !mdFrontmatter) return [] as [string, unknown][];
@@ -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 보조 표시.
*
@@ -77,11 +77,18 @@
case 'processing':
return { tone: 'accent', label: 'Markdown 변환 중', tooltip: null };
case 'success':
case 'completed': // API field_validator 가 DB 'success'→'completed' remap (S1 backend) — 동의어
return {
tone: 'success',
label: 'Markdown',
tooltip: qualitySummary(mdExtractionQuality),
};
case 'partial':
return {
tone: 'warning',
label: 'Markdown 일부',
tooltip: qualitySummary(mdExtractionQuality) ?? mdExtractionError ?? null,
};
case 'skipped':
return {
tone: 'neutral',
@@ -15,8 +15,12 @@
interface Props {
sections: DocumentSection[];
/** 항목 클릭 시 본문 점프 콜백(부모가 #sec-{chunkId} scrollIntoView). 없으면 아코디언만. */
onJump?: (chunkId: number) => void;
/** scroll-spy 현재 절(chunk_id) — 강조용. */
activeKey?: number | null;
}
let { sections }: Props = $props();
let { sections, onJump, activeKey = null }: Props = $props();
let layout = $derived(groupOrFlat(sections));
let total = $derived(sections.length);
@@ -37,15 +41,17 @@
{#snippet itemRow(item: OutlineItem)}
{@const s = item.section}
{@const open = selectedId === s.chunk_id}
{@const active = activeKey != null && activeKey === s.chunk_id}
{@const typeLabel = sectionTypeLabel(s.section_type)}
<li>
<button
type="button"
onclick={() => toggle(item)}
onclick={() => { toggle(item); onJump?.(s.chunk_id); }}
aria-expanded={open}
aria-current={active ? 'true' : undefined}
class={[
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors',
open ? 'bg-surface-active text-text' : 'text-dim hover:bg-surface hover:text-text',
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
open ? 'bg-surface-active text-text border-accent' : active ? 'bg-surface text-accent-hover border-accent' : 'text-dim hover:bg-surface hover:text-text border-transparent',
].join(' ')}
>
<span class="flex-1 min-w-0 leading-snug break-words">{title(s)}</span>
@@ -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>
+13
View File
@@ -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);
+25
View File
@@ -0,0 +1,25 @@
// md_status 어휘 단일 source.
//
// DB CHECK enum 은 'success' 이지만, API 직렬화 시 field_validator
// `_db_success_to_completed`(app/api/documents.py) 가 'success' → 'completed' 로 remap 한다
// (S1 backend). 나머지 상태(pending/processing/partial/skipped/failed)는 양쪽 동일.
//
// 따라서 프론트는 두 어휘를 모두 "성공" 으로 취급해야 S1 backend 배포 전(API='success')·
// 후(API='completed') 모두 안전하다. (DB↔API enum divergence guard — md_status 비교는
// 반드시 이 헬퍼 경유, raw `=== 'success'` / `=== 'completed'` 산재 금지.)
/** DB 'success' 또는 API 'completed' = 변환 성공(markdown 준비됨). */
export function isMdSuccess(status: string | null | undefined): boolean {
return status === 'success' || status === 'completed';
}
/** md상태 칩 렌더 대상 상태. pending/null 은 숨김(legacy 대량 노이즈 회피). */
export function isMdStatusVisible(status: string | null | undefined): boolean {
return (
status === 'processing' ||
isMdSuccess(status) ||
status === 'partial' ||
status === 'skipped' ||
status === 'failed'
);
}
@@ -0,0 +1,128 @@
// 순수함수 회귀 테스트. 실행(로컬, 의존성 0): node --test src/lib/utils/outlineAnchors.test.ts
// (Node ≥23 또는 22.6+ --experimental-strip-types — TS 타입 네이티브 strip.)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildAnchorMap } from './outlineAnchors.ts';
import { type DocumentSection } from './headingPath.ts';
let _id = 0;
function sec(p: Partial<DocumentSection>): DocumentSection {
return {
chunk_id: ++_id,
section_title: null,
heading_path: null,
level: null,
node_type: null,
is_leaf: true,
section_type: null,
summary: null,
confidence: null,
...p,
};
}
const md = (lines: string[]) => lines.join('\n');
const lineOff = (lines: string[], idx: number) => {
let o = 0;
for (let i = 0; i < idx; i++) o += lines[i].length + 1;
return o;
};
test('ATX heading 정확 매칭 + offset', () => {
const lines = ['# 개요', '본문 a', '## 설계 기준', '본문 b'];
const s = [
sec({ chunk_id: 101, section_title: '개요' }),
sec({ chunk_id: 102, section_title: '설계 기준' }),
];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[101], lineOff(lines, 0));
assert.equal(r.anchors[102], lineOff(lines, 2));
assert.equal(r.matched, 2);
});
test('★ false early match 방어 — 상호참조가 heading 보다 먼저', () => {
const lines = ['# 개요', '본 절은 Part UW 를 참조한다.', '내용', '# Part UW', '강판'];
const s = [
sec({ chunk_id: 1, section_title: '개요' }),
sec({ chunk_id: 2, section_title: 'Part UW' }),
];
const r = buildAnchorMap(md(lines), s);
// 상호참조(line 1)가 아니라 실제 heading(line 3)으로
assert.equal(r.anchors[2], lineOff(lines, 3));
assert.notEqual(r.anchors[2], lineOff(lines, 1));
});
test('중복 제목 — 단조 커서로 N번째 출현 매칭', () => {
const lines = ['## General', 'a', '## Scope', 'b', '## General', 'c'];
const s = [
sec({ chunk_id: 1, section_title: 'General' }),
sec({ chunk_id: 2, section_title: 'Scope' }),
sec({ chunk_id: 3, section_title: 'General' }),
];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 0)); // 첫 General
assert.equal(r.anchors[2], lineOff(lines, 2)); // Scope
assert.equal(r.anchors[3], lineOff(lines, 4)); // 둘째 General (오점프 아님)
});
test('prefix 가드 — 제1조 가 제1조의2 를 오매칭 안 함', () => {
const lines = ['# 제1조의2', 'x', '# 제1조', 'y'];
const s = [sec({ chunk_id: 1, section_title: '제1조' })];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 2)); // 제1조의2(line0) 아님
});
test('비-ATX 평문 제N조 (전체-라인 매칭)', () => {
const lines = ['제1조(목적) 이 법은 OO 을 정한다.', '본문', '제2조(정의) 용어는...'];
const s = [
sec({ chunk_id: 1, section_title: '제1조(목적) 이 법은 OO 을 정한다.', node_type: 'clause' }),
sec({ chunk_id: 2, section_title: '제2조(정의) 용어는...', node_type: 'clause' }),
];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 0));
assert.equal(r.anchors[2], lineOff(lines, 2));
});
test('window 조각 skip (anchor 없음)', () => {
const lines = ['## 절', 'aaa', 'bbb'];
const s = [
sec({ chunk_id: 1, section_title: '절' }),
sec({ chunk_id: 2, section_title: '절', node_type: 'window' }), // 부모 제목 상속 조각
];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 0));
assert.equal(r.anchors[2], undefined); // window = 점프 비활성
assert.equal(r.total, 1);
});
test('코드펜스 내부 heading 제외', () => {
const lines = ['```', '# General', '```', '# General', 'x'];
const s = [sec({ chunk_id: 1, section_title: 'General' })];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 3)); // 펜스 밖
});
test('miss = anchor 없음 (점프 비활성, 오점프 아님)', () => {
const lines = ['# 개요', '본문'];
const s = [
sec({ chunk_id: 1, section_title: '개요' }),
sec({ chunk_id: 2, section_title: '존재하지 않는 절' }),
];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 0));
assert.equal(r.anchors[2], undefined);
assert.equal(r.total, 2);
assert.equal(r.matched, 1);
});
test('heading_path 마지막 세그먼트 fallback', () => {
const lines = ['# 도입', 'x'];
const s = [sec({ chunk_id: 1, section_title: null, heading_path: 'A > 도입' })];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 0));
});
test('빈 입력 안전', () => {
assert.deepEqual(buildAnchorMap('', [sec({ section_title: 'x' })]).anchors, {});
assert.deepEqual(buildAnchorMap('# x', []).anchors, {});
assert.deepEqual(buildAnchorMap(null, null).anchors, {});
});
+101
View File
@@ -0,0 +1,101 @@
// 개요(절 목차) → 본문 deterministic 점프용 anchor offset 산출 (경로 A: FE-only).
//
// hier 절(section_title)은 md_content 의 heading 라인에서 나왔으나(builder.py build_hier_tree,
// md_content 순수함수), 비-ATX(제N조/Chapter)는 본문에 markdown heading 요소·id 가 안 생기고
// 중복 제목(표-1·Part UW…)이 흔해 슬러그·textContent 매칭이 깨진다. 그래서 md_content 에서
// 각 절의 heading 위치(char offset)를 직접 찾아 <a id="sec-{chunk_id}"> 를 주입할 좌표를 만든다.
//
// ★ false early match 방어 3중 (리뷰 반영):
// 1. 라인-시작(전체-라인) 매칭 — 본문 중간 상호참조("see Part UW for…")는 라인 전체가 제목과
// 같지 않으므로 제외. heading 라인(선두 #/리스트마커 제거 후 전체)만 매칭.
// 2. 전체 매칭 + truncation 처리 — 'first-N-chars' prefix 금지('제1조'가 '제1조의2' 오매칭 차단).
// builder 가 KO/ENG 제목을 [:200] truncate 하므로 truncated(매우 긴 제목)일 때만 startsWith.
// 3. 단조 커서 + 코드펜스 회피 — 매칭은 직전 매칭 다음 라인부터(역행 불가) + ``` ~~~ 펜스 내부 제외.
// 미스/역행은 anchor 없음 = 점프 비활성(아코디언 폴백). 오점프보다 무점프.
//
// ⚠ 잔여 한계: 본문 앞 '목차(TOC)'가 절 제목을 단독 라인으로 순서대로 나열하면 커서가 TOC 를
// 먼저 잡을 수 있다(연쇄 시프트). 4-1 의 '정확도' 측정으로 검출 — 빈번하면 경로 B(builder offset).
import { cleanHeading, type DocumentSection } from './headingPath.ts';
const TRUNCATE_HINT = 180; // builder.py 가 KO/ENG 제목을 [:200] 으로 자름 → 거의 그 길이면 truncated 로 간주
function norm(s: string | null | undefined): string {
return cleanHeading(s).toLowerCase();
}
/** 한 라인을 heading 후보 텍스트로: 선두 ATX #(1~6) / 리스트마커(-*+) / blockquote(>) 제거 후 정규화. */
function normLine(raw: string): string {
const stripped = raw.replace(/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|>\s+)?/, '');
return cleanHeading(stripped).toLowerCase();
}
export interface AnchorMapResult {
/** chunk_id → md_content 내 heading 라인 시작 char offset. (없으면 점프 비활성) */
anchors: Record<number, number>;
/** 후보(비-window·제목有) 절 수 — 4-1 커버리지 분모. */
total: number;
/** 신뢰 anchor 수 — 4-1 커버리지 분자. (정확도는 별도 수작업 검증) */
matched: number;
}
/**
* sections chunk_index ( ) (GET /documents/{id}/sections ORDER BY).
*/
export function buildAnchorMap(
mdContent: string | null | undefined,
sections: DocumentSection[] | null | undefined,
): AnchorMapResult {
const anchors: Record<number, number> = {};
if (!mdContent || !sections || sections.length === 0) {
return { anchors, total: 0, matched: 0 };
}
// 라인별 (offset, 정규화 텍스트, 펜스 여부) 사전계산.
const rawLines = mdContent.split('\n');
const lines: { off: number; norm: string }[] = [];
let off = 0;
let inFence = false;
for (const raw of rawLines) {
const fenceToggle = /^\s{0,3}(```|~~~)/.test(raw);
const fencedHere = inFence || fenceToggle; // 펜스 경계 라인도 매칭 제외
lines.push({ off, norm: fencedHere ? '' : normLine(raw) });
if (fenceToggle) inFence = !inFence;
off += raw.length + 1; // '\n'
}
let cursor = 0; // 단조 전진 라인 인덱스
let total = 0;
let matched = 0;
for (const s of sections) {
// window/section_split 조각은 자체 heading 없음(부모 제목 상속) → 건너뜀.
if (s.node_type === 'window' || s.node_type === 'section_split') continue;
let nt = norm(s.section_title);
if (!nt && s.heading_path) {
const last = s.heading_path.split('>').pop();
nt = norm(last);
}
if (!nt) continue;
total++;
const truncated = nt.length >= TRUNCATE_HINT;
let foundIdx = -1;
for (let i = cursor; i < lines.length; i++) {
const ln = lines[i].norm;
if (!ln) continue; // 빈 라인 / 펜스 내부
if (ln === nt || (truncated && ln.startsWith(nt))) {
foundIdx = i;
break;
}
}
if (foundIdx >= 0) {
anchors[s.chunk_id] = lines[foundIdx].off;
cursor = foundIdx + 1; // 단조: 다음 절은 이 라인 이후만
matched++;
}
// 미스 → anchor 없음(점프 비활성, 폴백)
}
return { anchors, total, matched };
}
+46
View File
@@ -0,0 +1,46 @@
// 뷰어 타입 분류 단일 source — 상세페이지(/documents/[id])와 3-pane 중앙 리더
// (DocumentViewer)가 공유한다. 두 곳이 각자 getViewerType 을 두면 csv/hwp/office 분기가
// drift 하므로(이원화 재발) 여기 하나로 수렴한다.
//
// ⚠ 소비 컴포넌트는 이 함수가 낼 수 있는 모든 ViewerType 에 render 분기가 있어야 한다.
// (분류 통합 ≠ render 통합 — 양쪽 컴포넌트의 {#if viewerType===...} 에 누락 없는지 확인.)
export type ViewerType =
| 'article'
| 'markdown'
| 'hwp-markdown'
| 'pdf'
| 'preview-pdf'
| 'image'
| 'text'
| 'synology'
| 'cad'
| 'unsupported';
const MARKDOWN = new Set(['md', 'txt']);
// csv/json/xml/html 은 markdown 으로 렌더하면 콤마/행이 한 문단으로 뭉친다 → <pre> 로 원형 보존.
const TEXT = new Set(['csv', 'json', 'xml', 'html']);
const HWP = new Set(['hwp', 'hwpx']);
// LibreOffice headless → PDF preview (/preview) 로 인앱 표시.
const OFFICE_PREVIEW = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
// Synology Office 네이티브 — 인앱 변환 부적합, 외부 편집기로.
const SYNOLOGY = new Set(['odoc', 'osheet']);
const IMAGE = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff']);
const CAD = new Set(['dwg', 'dxf']);
export function getViewerType(
format: string | null | undefined,
sourceChannel?: string | null,
): ViewerType {
if (sourceChannel === 'news') return 'article';
const f = (format ?? '').toLowerCase();
if (MARKDOWN.has(f)) return 'markdown';
if (f === 'pdf') return 'pdf';
if (HWP.has(f)) return 'hwp-markdown';
if (OFFICE_PREVIEW.has(f)) return 'preview-pdf';
if (SYNOLOGY.has(f)) return 'synology';
if (IMAGE.has(f)) return 'image';
if (TEXT.has(f)) return 'text';
if (CAD.has(f)) return 'cad';
return 'unsupported';
}
+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}
+316 -400
View File
@@ -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-[1680px] 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_360px] 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_360px] 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_minmax(0,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 min-w-0">
<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 min-w-0 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 min-w-0 break-words">{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>
+4 -4
View File
@@ -204,8 +204,8 @@
<div class="h-full overflow-auto">
<!-- 상단 검색바 (sticky) -->
<div class="sticky top-0 z-10 bg-bg/80 backdrop-blur border-b border-default px-4 py-3">
<div class="flex items-center gap-2 max-w-5xl mx-auto">
<div class="relative flex-1">
<div class="flex flex-wrap items-center gap-2 max-w-[1680px] mx-auto">
<div class="relative flex-1 min-w-0">
<Search
size={14}
class="absolute left-3 top-1/2 -translate-y-1/2 text-dim pointer-events-none"
@@ -234,7 +234,7 @@
<select
bind:value={selectedBackend}
title="Backend 선택 — silent fallback 0 정책 (선택한 backend 만 시도, 실패 시 503)."
class="py-2 px-2 bg-surface border border-default rounded-lg text-text text-xs focus:border-accent outline-none"
class="py-2 px-2 bg-surface border border-default rounded-lg text-text text-xs focus:border-accent outline-none min-w-0 max-w-[42vw] truncate"
>
<option value="auto">Auto (router)</option>
<option value="mac-mini-default">Mac mini (default)</option>
@@ -261,7 +261,7 @@
</div>
<!-- 본문 -->
<div class="max-w-5xl mx-auto p-4">
<div class="max-w-[1680px] mx-auto p-4">
{#if backendUnavailable}
<div class="py-16">
<EmptyState
+1 -1
View File
@@ -53,7 +53,7 @@
}
</script>
<div class="p-6 max-w-[1200px] mx-auto">
<div class="p-6 max-w-[1680px] mx-auto">
<header class="flex items-center gap-2 mb-4">
<Mic size={20} />
<h1 class="text-xl font-semibold">Audio</h1>
+63 -63
View File
@@ -410,25 +410,25 @@
</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 ── */
.app {
max-width: 1180px;
max-width: 1680px;
margin: 0 auto;
background: var(--surface);
min-height: 100vh;
@@ -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
@@ -6,6 +6,8 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, getAccessToken } from '$lib/api';
import { isMdSuccess } from '$lib/utils/mdStatus';
import { buildAnchorMap } from '$lib/utils/outlineAnchors';
import { addToast } from '$lib/stores/toast';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
@@ -147,7 +149,7 @@
let pdfViewMode = $state('markdown'); // 'markdown' | 'pdf'
let lastDocId = $state(null);
let canShowMarkdown = $derived(
!!(doc?.md_status === 'success' && doc?.md_content?.trim())
!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim())
);
$effect(() => {
@@ -162,6 +164,45 @@
}
});
// ── 개요 점프 (outlineAnchors, 경로 A) ──
// anchorMap = md_content 의 각 절 heading offset. MarkdownDoc 가 <span id="sec-N"> 주입.
let anchorMap = $derived(
hasSections && canShowMarkdown && doc?.md_content
? buildAnchorMap(doc.md_content, sections).anchors
: {}
);
let activeKey = $state(null);
function jumpToSection(chunkId) {
const el = document.getElementById(`sec-${chunkId}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// scroll-spy: 화면 상단(120px)을 지난 마지막 .md-anchor = 현재 절. [id] 는 window 스크롤.
$effect(() => {
void anchorMap; // 문서/섹션 변화 시 재바인딩
if (typeof window === 'undefined') return;
let raf = 0;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
let cur = null;
document.querySelectorAll('.md-anchor').forEach((a) => {
if (a.getBoundingClientRect().top <= 120) cur = a;
});
if (cur) {
const m = cur.id.match(/^sec-(\d+)$/);
if (m) activeKey = Number(m[1]);
}
});
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => {
window.removeEventListener('scroll', onScroll);
if (raf) cancelAnimationFrame(raf);
};
});
function getViewerType(format) {
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
if (format === 'pdf') return 'pdf';
@@ -228,7 +269,7 @@
<!-- 좌측 절 목차 — xl+ sticky rail (그 아래 viewport 는 본문 상단 collapsible) -->
<aside class="hidden xl:block xl:sticky xl:top-6 xl:self-start xl:max-h-[calc(100vh-3rem)] xl:overflow-y-auto">
<Card>
<SectionOutline {sections} />
<SectionOutline {sections} onJump={jumpToSection} {activeKey} />
</Card>
</aside>
{/if}
@@ -239,7 +280,7 @@
<!-- xl 미만: 절 목차 접이식 -->
<details class="xl:hidden">
<summary class="cursor-pointer text-sm text-dim px-1 py-2 select-none">절 목차 ({sections.length})</summary>
<Card class="mt-2"><SectionOutline {sections} /></Card>
<Card class="mt-2"><SectionOutline {sections} onJump={jumpToSection} {activeKey} /></Card>
</details>
{/if}
<!-- Affordance row -->
@@ -288,6 +329,7 @@
mdStatus={doc.md_status}
mdExtractionError={doc.md_extraction_error}
mdExtractionQuality={doc.md_extraction_quality}
anchorMap={anchorMap}
extractedText={doc.extracted_text || rawMarkdown}
class="prose prose-invert prose-base lg:prose-sm max-w-none"
/>
+3 -3
View File
@@ -223,7 +223,7 @@
<title>events · hyungi PKM</title>
</svelte:head>
<div class="mx-auto max-w-3xl space-y-6 px-4 py-6">
<div class="mx-auto max-w-[1240px] space-y-6 px-4 py-6 sm:px-6 lg:px-8">
<header class="flex items-end justify-between gap-3">
<div class="space-y-1">
<h1 class="text-2xl font-semibold">events</h1>
@@ -278,13 +278,13 @@
<li>
<Card class="flex items-start gap-3 p-3 {KIND_COLOR[item.kind]}">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-xs text-slate-500">
<div class="flex min-w-0 items-center gap-2 text-xs text-slate-500">
<span>{KIND_LABEL[item.kind]}</span>
<span class="rounded px-1.5 py-0.5 text-[10px] {STATUS_COLOR[item.status]}">
{STATUS_LABEL[item.status]}
</span>
{#if item.project_tag}
<span class="text-slate-400">#{item.project_tag}</span>
<span class="min-w-0 break-all text-slate-400">#{item.project_tag}</span>
{/if}
</div>
<a href="/events/{item.id}" class="mt-1 block break-words text-sm font-medium hover:underline">
+2 -2
View File
@@ -229,7 +229,7 @@
}
</script>
<div class="p-4 lg:p-6 max-w-5xl mx-auto">
<div class="p-4 lg:p-6 max-w-[1240px] mx-auto">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
@@ -355,7 +355,7 @@
<span class="text-faint"><FormatIcon format={doc.file_format} size={14} /></span>
<a
href="/documents/{doc.id}"
class="text-sm font-medium text-text hover:text-accent truncate"
class="text-sm font-medium text-text hover:text-accent truncate min-w-0"
>
{doc.title || '제목 없음'}
</a>
+6 -6
View File
@@ -438,7 +438,7 @@
<div class="p-4 lg:p-6">
<!-- breadcrumb -->
<div class="flex items-center gap-2 text-sm mb-4 text-dim">
<div class="flex flex-wrap items-center gap-2 text-sm mb-4 text-dim">
<a href="/documents" class="hover:text-text">문서</a>
<span class="text-faint">/</span>
<span class="text-text">자료실</span>
@@ -448,7 +448,7 @@
<button
type="button"
onclick={() => navigate(activePath.split('/').slice(0, i + 1).join('/'))}
class="hover:text-text"
class="hover:text-text min-w-0 truncate max-w-[40vw]"
>
{segment}
</button>
@@ -457,14 +457,14 @@
</div>
<!-- 승인 대기함 (§2) — ai_suggestion.proposed_category='library' 문서 -->
<div class="max-w-7xl mx-auto mb-4">
<div class="max-w-[1680px] mx-auto mb-4">
<SuggestionReview
proposedCategory="library"
onChange={handleSuggestionChange}
/>
</div>
<div class="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6">
<div class="max-w-[1680px] mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- 왼쪽: 트리 (5/12) -->
<aside class="lg:col-span-5 xl:col-span-4">
<div class="bg-surface border border-default rounded-card p-3">
@@ -532,14 +532,14 @@
<button
onclick={() => navigate(n.path)}
class="flex-1 flex items-center justify-between px-2 py-1.5 rounded-md text-sm transition-colors
class="flex-1 min-w-0 flex items-center justify-between px-2 py-1.5 rounded-md text-sm transition-colors
{isActive
? 'bg-accent/15 text-accent'
: isParent
? 'text-text'
: 'text-dim hover:bg-surface-hover hover:text-text'}"
>
<span class="truncate">{n.name}</span>
<span class="truncate min-w-0">{n.name}</span>
<span class="text-xs text-dim shrink-0 ml-2">{n.count}</span>
</button>
+15 -13
View File
@@ -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">
@@ -656,12 +656,14 @@
</div>
<style>
.memo-content { overflow-wrap: anywhere; word-break: break-word; }
.memo-content :global(p) { margin: 0.2em 0; }
.memo-content :global(ul), .memo-content :global(ol) { margin: 0.2em 0; padding-left: 1.5em; }
.memo-content :global(li) { margin: 0.1em 0; }
.memo-content :global(code) { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.memo-content :global(code) { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; overflow-wrap: anywhere; word-break: break-word; }
.memo-content :global(pre) { background: var(--bg); padding: 0.75em; border-radius: 6px; overflow-x: auto; margin: 0.5em 0; }
.memo-content :global(a) { color: var(--accent); }
.memo-content :global(table) { display: block; overflow-x: auto; max-width: 100%; }
.memo-content :global(a) { color: var(--accent); overflow-wrap: anywhere; word-break: break-word; }
.memo-content :global(blockquote) { border-left: 3px solid var(--border-default); padding-left: 0.75em; color: var(--text-dim); margin: 0.5em 0; }
.memo-content :global(.memo-checkbox) {
cursor: pointer;
+245 -265
View File
@@ -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>
+23 -1
View File
@@ -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 -1
View File
@@ -50,7 +50,7 @@
}
</script>
<div class="p-6 max-w-[1400px] mx-auto">
<div class="p-6 max-w-[1680px] mx-auto">
<header class="flex items-center gap-2 mb-4">
<Film size={20} />
<h1 class="text-xl font-semibold">Video</h1>
-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;
+18
View File
@@ -0,0 +1,18 @@
-- 317_documents_dedup_fields.sql
-- S1-ADD (plan ds-s1-backend-1, A-1): 원본 파일명 + 중복검사 메타 3컬럼.
-- 계약: ds-app contract/CONTRACT.md [S1-ADD] — original_filename / duplicate_of / duplicate_count.
--
-- asyncpg exec_driver_sql 단일 statement 제약 — ALTER TABLE 다중 ADD COLUMN 절은 단일 statement 라 허용.
-- BEGIN/COMMIT 금지. PG 16: ADD COLUMN ... DEFAULT <constant> 는 fast path (table rewrite 없음).
-- duplicate_of self-FK 는 신규 all-NULL 컬럼이라 검증 스캔 trivial (NOT VALID 불요).
-- ON DELETE SET NULL: 원본(canonical) hard delete 허용 (RESTRICT=삭제 차단 / CASCADE=사본 연쇄삭제 위험 회피).
-- 기존 dup 그룹(law_monitor 제외)의 duplicate_of/duplicate_count backfill 은 B-4 별 배치 스크립트.
-- 28,941행 대량 UPDATE 를 startup migration(단일 트랜잭션)에 넣지 않는다.
--
-- original_filename 은 original_format(ODF 변환용)·original_path/original_hash(migration 007 legacy dead,
-- app 코드 미참조 — P0-1 grep 0건) 와 의미가 다르다: 업로드 시점 원본 파일명(다운로드 라벨용).
ALTER TABLE documents
ADD COLUMN IF NOT EXISTS original_filename TEXT,
ADD COLUMN IF NOT EXISTS duplicate_of BIGINT REFERENCES documents(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS duplicate_count INTEGER NOT NULL DEFAULT 0;
+90
View File
@@ -0,0 +1,90 @@
"""기존 file_hash 중복 그룹 backfill — plan ds-s1-backend-1 B-4.
목적:
A-1 migration 287 추가된 duplicate_of / duplicate_count *기존* 중복 그룹에 채운다.
migration(단일 트랜잭션) 분리한 배치(database.py:29-30 정책 대량 UPDATE
startup migration 넣지 않는다). 업로드 시점 채움(B-1) 신규 행만 다루므로 과거는 스크립트.
판정:
- file_hash exact 그룹(OFF-whitelist=law_monitor 제외, deleted 제외, count>1).
near_duplicate 영속화 보류(on-the-fly) 여기서 다루지 않는다.
- canonical = 그룹 최古(min id). canonical.duplicate_of=NULL, duplicate_count=group_size-1.
- -canonical 멤버 = duplicate_of=canonical, duplicate_count=0.
안전:
- 멱등 이미 목표값인 행은 UPDATE (재실행 안전). --dry-run 적용될 정확한 set 미리보기.
- --chunk(기본 500)/txn 청크 커밋 28,941 단일 트랜잭션 lock 회피.
실행:
docker compose exec fastapi python /app/scripts/backfill_dedup.py --dry-run
docker compose exec fastapi python /app/scripts/backfill_dedup.py --apply
# 변경 전 안전망은 E-3 pre-B-4 pg_dump (별 단계).
"""
import argparse
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from services.dedup import reconcile_dedup # 코어 재계산 (야간 잡과 공유)
async def run(*, apply: bool, chunk_size: int) -> int:
database_url = os.getenv(
"DATABASE_URL", "postgresql+asyncpg://pkm:pkm@localhost:5432/pkm"
)
engine = create_async_engine(database_url)
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with session_factory() as session:
result = await reconcile_dedup(session, apply=apply, chunk_size=chunk_size)
print(f"=== dedup 그룹 {result['groups']}개 · 관련 문서 {result['docs']}건 ===")
if result["groups"] == 0:
print("dedup 그룹 없음(OFF-whitelist 제외 후 count>1 없음) — 종료.")
return 0
already = result["docs"] - result["changes"]
print(f"변경 필요 {result['changes']}건 / 이미 목표값 {already}건 (멱등)")
if result["changes"] == 0:
print("모두 목표값 — 적용할 변경 없음.")
return 0
# 적용될/된 정확한 UPDATE set 미리보기 (상위 40건)
print("\n=== UPDATE set (id → duplicate_of / duplicate_count) ===")
for s in result["sample"]:
role = "canonical" if s["duplicate_of"] is None else f"dup→{s['duplicate_of']}"
print(
f" id={s['id']:>7} duplicate_of={s['duplicate_of']} "
f"duplicate_count={s['duplicate_count']} [{role}]"
)
if result["changes"] > len(result["sample"]):
print(f" ... 외 {result['changes'] - len(result['sample'])}")
if not apply:
print(f"\n[dry-run] {result['changes']}건 변경 예정. --apply 로 실제 적용.")
else:
print(f"\n[apply] 완료 — {result['applied']}건 갱신.")
return 0
finally:
await engine.dispose()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true", help="실제 적용 (기본 dry-run)")
parser.add_argument("--dry-run", action="store_true", help="명시적 dry-run (default 동등)")
parser.add_argument("--chunk", type=int, default=500, help="txn 당 UPDATE 행 수 (기본 500)")
args = parser.parse_args()
if args.apply and args.dry_run:
parser.error("--apply 와 --dry-run 동시 지정 불가")
return asyncio.run(run(apply=args.apply, chunk_size=args.chunk))
if __name__ == "__main__":
sys.exit(main())
+146
View File
@@ -0,0 +1,146 @@
"""과거 office/hwp pending 문서 markdown stage 백필 — plan ds-s1-backend-1 C-4.
신규 ingest classifymarkdown 전이(queue_consumer.py:142) 자동 도달하므로 스크립트는
*과거* office/hwp 행만 다룬다. C-2 office_md 변환을 붙이기 전까지 markdown stage 에서
skip 행들을 다시 큐에 넣어 md_content 생성한다.
대상 (WHERE):
- file_format IN (office_md 지원 실값: docx, xlsx, pptx, hwp, hwpx)
제외 = file_format. INCLUDE 필터가 article(file_format='article') 구조적으로 배제
P0-3 가드(md 없는 article completed 도달 금지, correctness-critical). source_channel 불필요.
레거시 바이너리(.doc/.xls/.ppt) markitdown 미지원 기본 목록 제외(넣어도 marker skip).
- md_status = 'pending' (이미 success/failed/skipped 건드리지 않음)
- extracted_text IS NOT NULL (폴백 존재 모집단)
C-5 failed-postcondition 상속: 변환 실패는 md_status='failed' 시끄럽게 남는다(앱이
'변환 실패' 표시). extracted_text NULL office(폴백 없음) 배제 실패 시끄러운
집합이라 phase2 재검토(C-4 배제 honest).
스케줄:
C-2 라이브 office ingestion 백필 비중첩 markdown 워커는 BATCH=1 직렬이라
야간 단발로 돌려 라이브 office 업로드 stall 회피(plan C-2 reflection).
실행:
docker compose exec fastapi python /app/scripts/backfill_nonpdf_markdown.py --dry-run
docker compose exec fastapi python /app/scripts/backfill_nonpdf_markdown.py --apply
docker compose exec fastapi python /app/scripts/backfill_nonpdf_markdown.py --apply --limit 50
"""
import argparse
import asyncio
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
from sqlalchemy import bindparam, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
# office_md 가 실제 변환하는 file_format(확장자 소문자, 점 없음). 단일 source.
DEFAULT_FORMATS = ("docx", "xlsx", "pptx", "hwp", "hwpx")
CANDIDATES_SQL = text(
"""
SELECT id, file_format, title, file_path
FROM documents
WHERE deleted_at IS NULL
AND md_status = 'pending'
AND extracted_text IS NOT NULL
AND file_format IN :formats
ORDER BY id
"""
).bindparams(bindparam("formats", expanding=True))
# 활성 markdown 큐 행이 없는 doc 만 통과 (UNIQUE 부분 인덱스). 충돌 = silent skip.
ENQUEUE_SQL = text(
"""
INSERT INTO processing_queue (document_id, stage, status, payload)
VALUES (:doc_id, 'markdown', 'pending', CAST(:payload AS jsonb))
ON CONFLICT DO NOTHING
"""
)
def _chunks(seq, size):
for i in range(0, len(seq), size):
yield seq[i : i + size]
async def run(*, apply: bool, formats: tuple[str, ...], limit: int | None, chunk_size: int) -> int:
database_url = os.getenv(
"DATABASE_URL", "postgresql+asyncpg://pkm:pkm@localhost:5432/pkm"
)
engine = create_async_engine(database_url)
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with session_factory() as session:
rows = (
await session.execute(CANDIDATES_SQL, {"formats": list(formats)})
).all()
if limit:
rows = rows[:limit]
print(f"=== office/hwp pending 후보 = {len(rows)}건 (formats={','.join(formats)}) ===")
if not rows:
print("후보 없음 — 종료.")
return 0
by_fmt: dict[str, int] = {}
for r in rows:
by_fmt[r.file_format] = by_fmt.get(r.file_format, 0) + 1
print("포맷별:", ", ".join(f"{k}={v}" for k, v in sorted(by_fmt.items())))
for r in rows[:20]:
print(f" id={r.id:>7} {r.file_format:<5} {(r.title or '')[:70]}")
if len(rows) > 20:
print(f" ... 외 {len(rows) - 20}")
if not apply:
print(f"\n[dry-run] {len(rows)}건 markdown 큐 enqueue 예정. --apply 로 실제 적용.")
print(" (적용 전 C-2 라이브 office ingestion 과 비중첩 야간창 확인.)")
return 0
payload = json.dumps(
{"force_reprocess": True, "reason": "c4_nonpdf_markdown_backfill"}
)
inserted = 0
processed = 0
for batch in _chunks(rows, chunk_size):
for r in batch:
result = await session.execute(
ENQUEUE_SQL, {"doc_id": r.id, "payload": payload}
)
if result.rowcount > 0:
inserted += 1
await session.commit()
processed += len(batch)
print(f"[apply] {processed}/{len(rows)} 처리 (enqueue 누적 {inserted})")
print(f"\n[apply] 완료 — {inserted}/{len(rows)} 신규 markdown 큐 추가.")
print(" (skip = 이미 활성 markdown 큐 행이 있는 문서)")
return 0
finally:
await engine.dispose()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true", help="실제 enqueue (기본 dry-run)")
parser.add_argument("--dry-run", action="store_true", help="명시적 dry-run (default 동등)")
parser.add_argument(
"--formats", type=str, default=",".join(DEFAULT_FORMATS),
help=f"쉼표 구분 file_format (기본 {','.join(DEFAULT_FORMATS)})",
)
parser.add_argument("--limit", type=int, default=None, help="후보 상한(샘플 검증용)")
parser.add_argument("--chunk", type=int, default=200, help="enqueue txn 청크 크기")
args = parser.parse_args()
if args.apply and args.dry_run:
parser.error("--apply 와 --dry-run 동시 지정 불가")
formats = tuple(f.strip().lower() for f in args.formats.split(",") if f.strip())
return asyncio.run(
run(apply=args.apply, formats=formats, limit=args.limit, chunk_size=args.chunk)
)
if __name__ == "__main__":
sys.exit(main())
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""C-1 PoC 하니스 — office/hwp → md 변환 품질(특히 표 fidelity) 측정.
plan ds-s1-backend-1 C-1/E-1:
- hwp/hwpx 결과는 LibreOffice 버전 의존 **prod extract_worker 동일 버전(버전핀 안전컨텍스트)** 에서 실행해야
신호가 transfer . live worker job 태우는 아님(점유 0).
- OOXML markitdown(신규 dep): `pip install markitdown`.
- 샘플은 trivial 말고 **대표 복잡본**(법령·KGS 중심 .hwp/.hwpx, 병합셀/다중시트 xlsx).
사용:
python scripts/poc_office_md.py <file_or_dir> [<file_or_dir> ...]
# 예: 현 코퍼스 백필 후보(doc/docx/xls/xlsx/hwp) 샘플 디렉토리
python scripts/poc_office_md.py ~/poc_samples/
파일: 변환 성공 char/ 행수/heading 지표 + 본문 미리보기.
실패(OfficeMdError) FAILED 출력 이것이 C-5 md_status='failed' 라우팅할 케이스(설계대로).
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# app/ 를 path 에 (모듈 import 용).
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
from workers.office_md import SUPPORTED, OfficeMdError, convert_office_to_md, table_fidelity # noqa: E402
def _iter_targets(args: list[str]):
for a in args:
p = Path(a).expanduser()
if p.is_dir():
for child in sorted(p.rglob("*")):
if child.is_file() and child.suffix.lower() in SUPPORTED:
yield child
elif p.is_file():
yield p
else:
print(f" (skip, 경로 없음: {p})")
def main(argv: list[str]) -> int:
if not argv:
print(__doc__)
return 2
targets = list(_iter_targets(argv))
if not targets:
print("변환 대상(.docx/.xlsx/.pptx/.hwp/.hwpx) 없음.")
return 1
ok = fail = 0
for path in targets:
print(f"\n=== {path.name} ({path.suffix.lower()}) ===")
try:
md = convert_office_to_md(path)
except OfficeMdError as e:
fail += 1
print(f" FAILED → (C-5 가 md_status='failed' 라우팅) : {e}")
continue
ok += 1
fid = table_fidelity(md)
print(f" OK chars={fid['chars']} lines={fid['lines']} "
f"table_rows={fid['table_pipe_rows']} (sep≈표수 {fid['table_separator_rows']}) "
f"heading={fid['has_heading']}")
preview = "\n".join(f" | {ln}" for ln in md.splitlines()[:12])
print(preview)
print(f"\n--- 합계: OK {ok} / FAILED {fail} / 총 {len(targets)} ---")
print("표 fidelity 가 낮으면(table_rows 0 등) 해당 포맷은 변환기/필터 재검토 — "
"OOXML↔markitdown, hwp/hwpx↔LibreOffice 경계를 데이터로 확정(C-1).")
return 0 if fail == 0 else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+18
View File
@@ -0,0 +1,18 @@
-- rollback_317.sql — plan ds-s1-backend-1 E-3. migration 317(dedup 3컬럼) 되돌림.
--
-- ★ migrations/ 밖에 둔다 — init_db() 자동 스캔(NNN_*.sql) 대상이 아니므로 자동 적용되지 않는다.
-- 수동 실행 전용:
-- docker compose cp scripts/rollback_317.sql postgres:/tmp/rollback_317.sql
-- docker compose exec -T postgres psql -U pkm -d pkm -f /tmp/rollback_317.sql
-- (또는) docker compose exec -T postgres psql -U pkm -d pkm < scripts/rollback_317.sql
--
-- 주의: original_filename / duplicate_of / duplicate_count 데이터 영구 삭제(B-1 채움·B-4 backfill 결과 포함).
-- schema_migrations 의 317 행도 함께 제거해야 재적용(다음 startup)이 가능하다.
-- 전체 복원이 필요하면 E-3 pre-change pg_dump 를 쓴다(이 스크립트는 '컬럼만 빠른 롤백').
ALTER TABLE documents
DROP COLUMN IF EXISTS duplicate_of,
DROP COLUMN IF EXISTS duplicate_count,
DROP COLUMN IF EXISTS original_filename;
DELETE FROM schema_migrations WHERE version = 317;
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# pre-change pg_dump — plan ds-s1-backend-1 E-3.
# A-1(migration 287) / B-4 backfill 적용 *전* 안전망. repo cp -p 가 아니라 진짜 DB 덤프.
#
# 사용 (GPU 서버, repo 루트에서):
# bash scripts/s1_pre_change_backup.sh # pre-A-1
# bash scripts/s1_pre_change_backup.sh pre-b4 # pre-B-4 (라벨만 다름)
#
# 백업 위치 = repo 밖 (feedback_backup_outside_repo): $HOME/.local/share/ds-s1-backups/
set -euo pipefail
LABEL="${1:-pre-a1}"
DATE="$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="${BACKUP_DIR:-$HOME/.local/share/ds-s1-backups}"
mkdir -p "$BACKUP_DIR"
OUT="$BACKUP_DIR/pkm-${LABEL}-${DATE}.sql.gz"
echo "[s1-backup] pg_dump pkm → $OUT"
# 단일 pkm DB 덤프(pg_dumpall 아님). gzip 은 redirect(파일명 추측 함정 회피).
docker compose exec -T postgres pg_dump -U pkm -d pkm | gzip > "$OUT"
echo "[s1-backup] done: $(du -h "$OUT" | cut -f1)"
echo -n "[s1-backup] gzip 무결성: "
gzip -t "$OUT" && echo "OK"
echo
echo "[s1-backup] 롤백 옵션:"
echo " (a) 287 컬럼만 되돌림(빠름): scripts/rollback_287.sql 수동 실행"
echo " (b) 전체 복원: gunzip -c '$OUT' | docker compose exec -T postgres psql -U pkm -d pkm"
echo "[s1-backup] 보존 7일 권장. (DR-grade 검증은 ephemeral restore — D5 트랙, 본 안전망 범위 밖.)"
+96
View File
@@ -0,0 +1,96 @@
"""S1-ADD (plan ds-s1-backend-1) B-2 /duplicates shape + D-2 Range 파서 + dedup 상수 단위 검증.
순수 단위(DB 불요). 실행 환경 = app/ 의존성 설치 컨텍스트(devsbx/GPU) 기존
test_s1_dedup_shape.py 동일 부트스트랩. DB 타는 검증(find_canonical/near_dup/엔드포인트)
GPU read-only/통합 매트릭스(E-1)에서.
"""
from __future__ import annotations
import json
import logging
import os
import sys
from pathlib import Path
import pytest
# logs/ 가 운영 daemon 소유일 때 import-time FileHandler PermissionError 방어 (test 한정).
_orig_file_handler = logging.FileHandler
def _safe_file_handler(filename, *args, **kwargs): # type: ignore[no-untyped-def]
try:
return _orig_file_handler(filename, *args, **kwargs)
except PermissionError:
return logging.NullHandler()
logging.FileHandler = _safe_file_handler # type: ignore[assignment]
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost:5432/test")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
from api.documents import ( # noqa: E402
DuplicateGroup,
DuplicatesResponse,
_parse_byte_range,
)
from services.dedup import DEDUP_OFF_CHANNELS # noqa: E402
_FIXDIR = Path(os.path.expanduser("~/Documents/code/ds-app/contract/fixtures"))
# ── 1. /duplicates 응답 shape = contract fixture ───────────────────────────────
def test_duplicates_response_shape_matches_total_formula():
# 엔드포인트 정의: total_duplicate_docs = Σ(멤버수-1). fixture 와 동일해야 함.
groups = [
DuplicateGroup(canonical_id=4912, members=[4912, 4977], reason="content_hash"),
DuplicateGroup(canonical_id=5120, members=[5120, 5121, 5260], reason="content_hash"),
]
total_dup = sum(len(g.members) - 1 for g in groups)
resp = DuplicatesResponse(
groups=groups, total_groups=len(groups), total_duplicate_docs=total_dup
)
assert resp.total_groups == 2
assert resp.total_duplicate_docs == 3 # (2-1)+(3-1)
@pytest.mark.skipif(not _FIXDIR.exists(), reason="ds-app contract fixtures 미존재")
def test_duplicates_contract_fixture_decodes():
payload = json.loads((_FIXDIR / "documents_duplicates.json").read_text())
m = DuplicatesResponse.model_validate(payload)
assert m.total_groups == payload["total_groups"]
assert m.total_duplicate_docs == payload["total_duplicate_docs"]
# Σ(멤버수-1) 정의가 fixture total 과 일치(계약 self-consistency).
assert sum(len(g.members) - 1 for g in m.groups) == payload["total_duplicate_docs"]
assert m.groups[0].canonical_id == payload["groups"][0]["canonical_id"]
# ── 2. D-2 Range 파서 (원격 백엔드 pass-through; local 은 FileResponse 자동) ──────
@pytest.mark.parametrize(
"header,size,expected",
[
(None, 1000, (None, None)),
("", 1000, (None, None)),
("bytes=0-99", 1000, (0, 99)),
("bytes=100-", 1000, (100, 999)), # 끝까지
("bytes=-200", 1000, (800, 999)), # suffix: 마지막 200
("bytes=0-99999", 1000, (0, 999)), # end clamp
("bytes=2000-3000", 1000, (None, None)), # start >= size → 무효(전체)
("bytes=abc-def", 1000, (None, None)), # 파싱 실패
("bytes=50-10", 1000, (None, None)), # start>end
("bytes=0-99", 0, (None, None)), # 빈 파일
],
)
def test_parse_byte_range(header, size, expected):
assert _parse_byte_range(header, size) == expected
# ── 3. dedup OFF-whitelist 단일 source ─────────────────────────────────────────
def test_dedup_off_channels_is_law_monitor_only():
# P0-2 결정: 단일 OFF-list = law_monitor (법령 개정본 보존). 확장은 의도적 결정으로만.
assert DEDUP_OFF_CHANNELS == ("law_monitor",)
+168
View File
@@ -0,0 +1,168 @@
"""S1-ADD (plan ds-s1-backend-1) A-4 — call-shape regression + md_status 매핑 동작 검증.
검증 대상 (값이 아니라 *동작*):
1. DB md_status='success' 응답 'completed' 단방향 매핑 (P0-3 silent-fallback 함정 가드의 backend 절반).
- partial/pending/failed/skipped/None 그대로 통과 ('success' 매핑).
2. [S1-ADD] 3필드(original_filename / duplicate_of / duplicate_count) 디코드 + 기본값(duplicate_count=0).
3. (있으면) ds-app contract fixtures 응답 모델로 디코드 계약 shape 비파괴.
주의 테스트는 backend 직렬화 절반만 커버한다.
앱이 'completed' 실제 md-first 렌더 분기로 태우는지(¬extracted_text) S3 fixture-render 테스트가 책임진다
(A 그룹 close = backend green AND S3 render green, owner 명기 plan A-4).
실행 환경: app/ 의존성 설치된 컨텍스트(devsbx/GPU). 순수 단위(DB 불요).
"""
from __future__ import annotations
import json
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import pytest
# logs/ 가 운영 daemon(root) 소유일 때 import-time FileHandler PermissionError 방어 (test 한정).
_orig_file_handler = logging.FileHandler
def _safe_file_handler(filename, *args, **kwargs): # type: ignore[no-untyped-def]
try:
return _orig_file_handler(filename, *args, **kwargs)
except PermissionError:
return logging.NullHandler()
logging.FileHandler = _safe_file_handler # type: ignore[assignment]
# api.documents import 가 SQLAlchemy engine init 를 트리거 — dummy DATABASE_URL (실제 connect X).
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost:5432/test")
# tests/ → 프로젝트 루트 → app/
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
from api.documents import ( # noqa: E402
DocumentDetailResponse,
DocumentListResponse,
DocumentResponse,
)
_NOW = datetime(2026, 6, 4, 8, 0, 0, tzinfo=timezone.utc)
def _base_detail(**overrides) -> dict:
"""DocumentDetailResponse 가 요구하는 전 필드(필수 포함) 완비 dict. overrides 로 일부 교체."""
d = {
"id": 4912,
"file_path": "Engineering/ASME/x.pdf",
"file_format": "pdf",
"file_size": 1338920,
"file_type": "document",
"title": "x",
"ai_domain": "Engineering",
"ai_sub_group": "압력용기",
"ai_tags": ["ASME"],
"ai_summary": "요약",
"document_type": "standard",
"importance": "high",
"ai_confidence": 0.9,
"user_note": None,
"user_tags": None,
"pinned": True,
"ask_includable": True,
"derived_path": None,
"original_format": "pdf",
"conversion_status": "completed",
"is_read": True,
"review_status": "approved",
"edit_url": None,
"preview_status": "ready",
"source_channel": "upload",
"data_origin": "external",
"doc_purpose": "reference",
"extracted_at": _NOW,
"ai_processed_at": _NOW,
"embedded_at": _NOW,
"created_at": _NOW,
"updated_at": _NOW,
# detail 전용
"extracted_text": "원문 폴백 텍스트",
"md_content": "# 제목\n본문",
"md_frontmatter": {},
"md_status": "success",
"md_extraction_engine": "marker",
"md_generated_at": _NOW,
}
d.update(overrides)
return d
# ── 1. ★ md_status 단방향 매핑 (success → completed) ──────────────────────────
def test_db_success_serializes_as_completed():
m = DocumentDetailResponse.model_validate(_base_detail(md_status="success"))
assert m.md_status == "completed", "DB 'success' 는 응답에서 'completed' 로 매핑돼야 함(MD-first 렌더 트리거)"
# model_dump(직렬화) 까지 확인 — 앱이 받는 실제 값.
assert m.model_dump()["md_status"] == "completed"
@pytest.mark.parametrize("raw", ["pending", "processing", "partial", "failed", "skipped", None])
def test_non_success_statuses_pass_through(raw):
m = DocumentDetailResponse.model_validate(_base_detail(md_status=raw))
assert m.md_status == raw, f"'{raw}' 는 매핑 대상 아님 — 그대로 통과해야 함"
def test_mapping_is_read_only_not_a_write_path():
# 이 모델은 응답 직렬화 전용 — write(ORM) 경로가 'completed' 를 DB 로 되쓰지 않는지의 1차 방어선.
# 'completed' 입력이 들어와도(예: fixture) 그대로 'completed' (재매핑 없음, 멱등).
m = DocumentDetailResponse.model_validate(_base_detail(md_status="completed"))
assert m.md_status == "completed"
# ── 2. [S1-ADD] 3필드 디코드 + 기본값 ────────────────────────────────────────
def test_s1add_fields_default_on_list_response():
# DocumentResponse(리스트 행)에도 3필드 존재 — 미제공 시 기본값.
base = {k: v for k, v in _base_detail().items()
if k not in {"extracted_text", "md_content", "md_frontmatter", "md_status",
"md_extraction_engine", "md_generated_at"}}
m = DocumentResponse.model_validate(base)
assert m.duplicate_count == 0
assert m.duplicate_of is None
assert m.original_filename is None
def test_s1add_fields_roundtrip_values():
m = DocumentDetailResponse.model_validate(
_base_detail(original_filename="보고서.docx", duplicate_of=4912, duplicate_count=2)
)
assert m.original_filename == "보고서.docx"
assert m.duplicate_of == 4912
assert m.duplicate_count == 2
# ── 3. ds-app contract fixtures 디코드 (있으면) ──────────────────────────────
_FIXDIR = Path(os.path.expanduser("~/Documents/code/ds-app/contract/fixtures"))
@pytest.mark.skipif(not _FIXDIR.exists(), reason="ds-app contract fixtures 미존재(독립 repo) — 디코드 회귀 skip")
@pytest.mark.parametrize("fname", ["document_detail.json", "document_detail_pending_md.json"])
def test_contract_detail_fixture_decodes(fname):
payload = json.loads((_FIXDIR / fname).read_text())
m = DocumentDetailResponse.model_validate(payload)
# fixture 의 md_status 는 이미 API 어휘('completed'/'pending') — 매핑 멱등.
assert m.md_status == payload["md_status"]
# [S1-ADD] 필드가 fixture 에 있으면 디코드 일치.
if "duplicate_count" in payload:
assert m.duplicate_count == payload["duplicate_count"]
@pytest.mark.skipif(not _FIXDIR.exists(), reason="ds-app contract fixtures 미존재")
def test_contract_list_fixture_decodes():
payload = json.loads((_FIXDIR / "documents_list.json").read_text())
m = DocumentListResponse.model_validate(payload)
assert m.total == payload["total"]
assert len(m.items) == len(payload["items"])