Compare commits

..

5 Commits

Author SHA1 Message Date
hyungi 1808ba1813 fix(ds-shell): 실기기 설치용 서명 허용 — 프로젝트 기본 CODE_SIGNING 차단 제거
ds-watch 와 동일하게 CODE_SIGNING_ALLOWED/REQUIRED=NO 를 제거해
아이폰 실기기 설치 시 Xcode 자동서명(본인 Apple ID 팀) 가능하도록.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:58:12 +09:00
hyungi 3cf5364955 fix(ds-watch): 실기기 설치용 서명 허용 — 프로젝트 기본 CODE_SIGNING 차단 제거
헤드리스 시뮬 빌드용으로 박아둔 CODE_SIGNING_ALLOWED/REQUIRED=NO 가 Xcode 실기기
설치를 막아, 프로젝트 기본에서 제거(자동 서명 유지). 시뮬 빌드는 CLI 플래그로 계속 처리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:55:52 +09:00
hyungi 6133eb6926 feat(ds-shell): 맥·iOS 웹 래퍼 앱 — document.hyungi.net WKWebView + DS 아이콘
- 맥·iOS 2타깃, WKWebView 로 웹 UI 100% 재사용(2026-06-15 결정: 맥/아이폰=웹 래퍼)
- 영속 쿠키(로그인 유지), 첨부 응답 다운로드 처리, 업로드는 네이티브 피커 자동
- 맥 창 off-screen 가드(분리 모니터 좌표 저장 시 중앙 복귀)
- DS 초록 라운드 아이콘(맥=라운드/iOS=풀블리드, 1024px 생성)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:55:52 +09:00
hyungi e717de69ca feat(ds-watch): Apple Watch 앱 신규 — 4기능 셸 + 공부/할일/브리핑/이드 라이브 결선 + DS 아이콘
- standalone watchOS(WKApplication + WKWatchOnly), 다크 OLED, xcodegen 단일 타깃
- 4기능 = 이드(AI채팅)·공부(암기카드)·할일·브리핑
- 라이브: 공부 /study-cards(due·rate·flag) · 할일 /events(today·complete)
  · 브리핑 /briefing/latest · 이드 /eid/chat(SSE 누적, unavailable 처리)
- 1회 로그인(access 메모리 + refresh 쿠키 7일 영속) + 401 자동 refresh+재시도
- 햅틱 피드백 + 정직한 로딩/빈/오류 상태 + DS 초록 아이콘(원형 마스킹)
- 맥·아이폰은 웹 래퍼로(2026-06-15 결정), 순수 네이티브는 워치 전용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:55:52 +09:00
hyungi 05296b3166 feat(ds-app): macOS 앱 마무리 — 업로드·다운로드·로그아웃 + 4섹션 페이지
- FU-C 멀티파트 업로드(DSClient.uploadDocument + LiveDSClient 401 재시도 공유 + 툴바/상태바)
- FU-D 네이티브 다운로드(NSSavePanel + URLSession, ?token= 미노출, 임시파일 정리)
- 로그아웃(AppModel.logout 세션 전체 초기화 + 계정 메뉴)
- 셸 2-column 재구성: 질문/이드 제거, 홈 코크핏 + 문서 3-pane 컬럼 브라우저
  (인스펙터 TL;DR/핵심점/심층/불일치) + 도메인 필터 전체 load-all
- 적대 리뷰 반영(stale 401 데모션·다운로드 임시파일 정리·메모 저장 saveMemo 경유·도메인 필터 선택 정합)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:55:52 +09:00
55 changed files with 2529 additions and 1490 deletions
-94
View File
@@ -1,94 +0,0 @@
"""study_concepts API — 이론공부 홈(오늘의 개념 · 진도 · 회독 SR). prefix = /api/study.
문제풀이 표면 무접촉. 개념문서(가스기사 태그) 읽기 집계 + 회독 SR write 만. 단일 토픽(가스기사=4).
경로: GET /curriculum · GET /today-concepts · POST /concepts/{doc_id}/read.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.user import User
from services.study import concept_curriculum as cc
from services.study import concept_links as cl
router = APIRouter()
# 가스기사 단일 토픽 운영(현행). 다토픽 확장 시 쿼리 파라미터로 승격.
DEFAULT_TOPIC_ID = 4
@router.get("/curriculum")
async def get_curriculum(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
topic_id: int = DEFAULT_TOPIC_ID,
):
"""과목별 회독 진도 + 개념/문항 복습 due 요약."""
return await cc.curriculum(session, user.id, topic_id)
@router.get("/today-concepts")
async def get_today_concepts(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
topic_id: int = DEFAULT_TOPIC_ID,
limit: int = 6,
):
"""오늘 공부할 개념(재복습 → 미독 빈출순)."""
return await cc.today_concepts(session, user.id, topic_id, limit)
@router.get("/concepts/weakness-map")
async def get_weakness_map(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
topic_id: int = DEFAULT_TOPIC_ID,
limit: int = 12,
):
"""개념 약점 지도 — 링크된 기출 정답률로 약점 개념(정답률<60%) 우선(이론↔문제)."""
name = await cc._topic_name(session, topic_id)
if not name:
return {"weak": [], "weak_total": 0, "evaluated_total": 0}
return await cl.weakness_map(session, user.id, name, limit)
@router.get("/concepts/{doc_id}")
async def get_concept_detail(
doc_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
topic_id: int = DEFAULT_TOPIC_ID,
):
"""개념 리더 재료 — 구조 파싱(요약/본문/빈출/관련) + 백링크 해소 + 회독/SR + 이전/다음."""
detail = await cc.concept_detail(session, user.id, topic_id, doc_id)
if detail is None:
raise HTTPException(status_code=404, detail="concept not found")
return detail
@router.get("/concepts/{doc_id}/questions")
async def get_concept_questions(
doc_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
limit: int = 20,
):
"""개념 관련 기출 + 내 정답률 (이론↔문제 브리지)."""
return await cl.related_questions(session, user.id, doc_id, limit)
@router.post("/concepts/{doc_id}/read")
async def post_concept_read(
doc_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
topic_id: int = DEFAULT_TOPIC_ID,
):
"""개념 회독 처리 → 회독 플래그 + SR 입고/전진."""
return await cc.mark_read(session, user.id, topic_id, doc_id)
-3
View File
@@ -33,7 +33,6 @@ from api.study_sessions import router as study_sessions_router
from api.study_topics import router as study_topics_router
from api.study_reminders import router as study_reminders_router
from api.study_cards import router as study_cards_router
from api.study_concepts import router as study_concepts_router
from api.video import router as video_router
from core.config import settings
from core.database import async_session, engine, init_db
@@ -250,8 +249,6 @@ app.include_router(study_reminders_router, prefix="/api/study-reminders", tags=[
app.include_router(study_cards_router, prefix="/api/study-cards", tags=["study-cards"])
# Phase 1: 학습 진행 상태 (review-complete + review-queue). prefix=/api/study-topics 안에 정의됨.
app.include_router(study_question_progress_router, prefix="/api", tags=["study-progress"])
# 이론공부 홈: 오늘의 개념·진도·회독 SR (개념문서 소비 표면, 문제풀이 무접촉).
app.include_router(study_concepts_router, prefix="/api/study", tags=["study-theory"])
# TODO: Phase 5에서 추가
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
-46
View File
@@ -1,46 +0,0 @@
"""study_concept_progress — 사용자 × 개념문서 단위 간격반복(SR) 진행 (이론공부 홈).
문제 SR(study_question_progress)의 개념(이론)판. '개념문서' = documents 한 건(가스기사 태그).
회독(첫 read) → 복습 큐 진입, 이후 회독마다 sr_schedule 산술(1·3·7·14·졸업) 공용 전진.
concept_doc_id 는 documents.id 를 가리키나 FK 미설정 — hot 테이블(documents) 락 회피(clause_study 선례).
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, SmallInteger, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class StudyConceptProgress(Base):
__tablename__ = "study_concept_progress"
__table_args__ = (
UniqueConstraint(
"user_id", "concept_doc_id", name="uq_concept_progress_user_doc"
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
study_topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False
)
# documents.id 참조 — FK 없음(락 회피). 개념문서 삭제 시 고아 행은 read 집계에서 자연 제외.
concept_doc_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
# 복습 큐 (sr_schedule 공용): stage 0~3 = 1·3·7·14일, 4 = 졸업(due_at NULL)
review_stage: Mapped[int | None] = mapped_column(SmallInteger)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
-284
View File
@@ -1,284 +0,0 @@
"""concept_curriculum — 이론공부 홈 재료 (오늘의 개념 · 진도 · 회독 SR).
개념문서 = documents (user_tags = @library/{topic}/{과목}/... , 가스기사). is_read = 회독,
md_content 의 ★ 개수 = 빈출 tier(★★★=3 / ★★=2 / else 1). 회독 SR = study_concept_progress
+ sr_schedule(문제 SR 공용 산술). 읽기 전용 집계 + mark_read(회독+SR 입고)만 write. LLM 0.
문제풀이 표면 무접촉 — 여기서 읽는 study_question_progress 는 '문항 due 카운트'만(홈 표시용).
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from models.document_read import DocumentRead
from models.study_concept_progress import StudyConceptProgress
from models.study_question_progress import StudyQuestionProgress
from models.study_topic import StudyTopic
from services.study.concept_parser import parse_concept, resolve_related
from services.study.sr_schedule import advance, first_due
# 개념 행 조회 — 태그로 개념문서 필터 + 회독 진행 LEFT JOIN. md_content 는 전송 안 하고
# ★ 유무만 서버측 boolean 으로(홈이 자주 호출돼도 페이로드 최소).
# is_read = document_reads(회독 정본, is_read 컬럼 아님) EXISTS. library unread 와 동일 기준.
_CONCEPT_ROWS_SQL = text(
"""
SELECT d.id AS doc_id,
d.title AS title,
EXISTS (
SELECT 1 FROM document_reads r
WHERE r.document_id = d.id AND r.user_id = :uid
) AS is_read,
(d.md_content LIKE '%★★★%') AS f3,
(d.md_content LIKE '%★★%') AS f2,
split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject,
p.review_stage AS review_stage,
p.due_at AS due_at,
p.last_read_at AS last_read_at
FROM documents d
LEFT JOIN study_concept_progress p
ON p.concept_doc_id = d.id AND p.user_id = :uid
WHERE d.user_tags::text LIKE :like
AND d.deleted_at IS NULL
"""
)
async def _topic_name(session: AsyncSession, topic_id: int) -> str | None:
return (
await session.execute(select(StudyTopic.name).where(StudyTopic.id == topic_id))
).scalar_one_or_none()
async def _concept_rows(session: AsyncSession, user_id: int, topic_name: str):
like = f"%@library/{topic_name}/%"
return (
await session.execute(_CONCEPT_ROWS_SQL, {"uid": user_id, "like": like})
).mappings().all()
def _freq(row) -> int:
if row["f3"]:
return 3
if row["f2"]:
return 2
return 1
def _is_due(row, now: datetime) -> bool:
return (
row["due_at"] is not None
and row["due_at"] <= now
and (row["review_stage"] or 0) < 4
)
def _item(row) -> dict:
return {
"doc_id": row["doc_id"],
"title": row["title"],
"subject": row["subject"],
"freq": _freq(row),
"review_stage": row["review_stage"],
"due_at": row["due_at"],
}
async def _question_due_count(session: AsyncSession, user_id: int, topic_id: int, now: datetime) -> int:
"""문항 복습 due (기존 study_question_progress 엔진 재사용, 홈 표시용)."""
return (
await session.execute(
select(func.count())
.select_from(StudyQuestionProgress)
.where(
StudyQuestionProgress.user_id == user_id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestionProgress.due_at.is_not(None),
StudyQuestionProgress.due_at <= now,
or_(
StudyQuestionProgress.review_stage.is_(None),
StudyQuestionProgress.review_stage < 4,
),
)
)
).scalar_one()
async def curriculum(session: AsyncSession, user_id: int, topic_id: int) -> dict:
"""과목별 회독 진도 + 개념/문항 복습 due 요약 (진도 대시보드)."""
name = await _topic_name(session, topic_id)
rows = await _concept_rows(session, user_id, name) if name else []
now = datetime.now(timezone.utc)
subj: dict[str, dict] = {}
for r in rows:
s = subj.setdefault(r["subject"], {"subject": r["subject"], "total": 0, "read": 0})
s["total"] += 1
if r["is_read"]:
s["read"] += 1
total = len(rows)
read = sum(1 for r in rows if r["is_read"])
concept_due = sum(1 for r in rows if _is_due(r, now))
question_due = await _question_due_count(session, user_id, topic_id, now)
return {
"topic_id": topic_id,
"topic_name": name,
"subjects": sorted(subj.values(), key=lambda x: x["subject"]),
"total": total,
"read": read,
"concept_due": concept_due,
"question_due": question_due,
}
async def today_concepts(
session: AsyncSession, user_id: int, topic_id: int, limit: int = 6
) -> dict:
"""오늘 공부할 개념 = 재복습(SR due) 먼저 → 미독(빈출 우선). 졸업/재복습대기 제외."""
name = await _topic_name(session, topic_id)
rows = await _concept_rows(session, user_id, name) if name else []
now = datetime.now(timezone.utc)
due = [r for r in rows if _is_due(r, now)]
due.sort(key=lambda r: r["due_at"])
# 미독 & 아직 SR 큐 진입 전(due_at NULL) → 빈출 높은 순
unread = [r for r in rows if not r["is_read"] and r["due_at"] is None]
unread.sort(key=lambda r: (-_freq(r), r["subject"], r["title"]))
picked = [{**_item(r), "reason": "재복습"} for r in due]
picked += [{**_item(r), "reason": "신규"} for r in unread]
return {
"concepts": picked[:limit],
"due_total": len(due),
"unread_total": len(unread),
}
async def mark_read(
session: AsyncSession, user_id: int, topic_id: int, doc_id: int, now: datetime | None = None
) -> dict:
"""개념 회독 처리 = document_reads(+1) + 회독 SR 입고/전진.
회독 정본 = document_reads(append-only), documents.is_read 컬럼 아님(library unread 와 정합).
첫 회독 → first_due(stage 0, 내일). 이후 회독은 'due 도래(due_at<=now)' 때만 correct 로 전진
(이른 재열람/다중클릭 과전진 방지). stage 4 졸업 후엔 due_at NULL 이라 전진 없음.
"""
now = now or datetime.now(timezone.utc)
# 회독 로그 append (+1) — 사용자 명시 회독. 자동 아님(엔드포인트 = 명시 POST).
session.add(DocumentRead(user_id=user_id, document_id=doc_id, read_at=now))
prog = (
await session.execute(
select(StudyConceptProgress).where(
StudyConceptProgress.user_id == user_id,
StudyConceptProgress.concept_doc_id == doc_id,
)
)
).scalar_one_or_none()
if prog is None:
stage, due = first_due(now)
prog = StudyConceptProgress(
user_id=user_id,
study_topic_id=topic_id,
concept_doc_id=doc_id,
review_stage=stage,
due_at=due,
last_read_at=now,
)
session.add(prog)
else:
# due 도래 시에만 전진 — 미래 due(재열람 이른 클릭)는 stage 불변, last_read_at 만 갱신.
if prog.due_at is not None and prog.due_at <= now:
res = advance(prog.review_stage, "correct", now)
if res is not None:
prog.review_stage, prog.due_at = res
prog.last_read_at = now
await session.commit()
await session.refresh(prog)
return {"ok": True, "review_stage": prog.review_stage, "due_at": prog.due_at}
_CONCEPT_ONE_SQL = text(
"""
SELECT d.id AS doc_id, d.title AS title, d.md_content AS md_content,
split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject,
(d.md_content LIKE '%★★★%') AS f3,
(d.md_content LIKE '%★★%') AS f2,
EXISTS (
SELECT 1 FROM document_reads r
WHERE r.document_id = d.id AND r.user_id = :uid
) AS is_read,
p.review_stage AS review_stage,
p.due_at AS due_at
FROM documents d
LEFT JOIN study_concept_progress p ON p.concept_doc_id = d.id AND p.user_id = :uid
WHERE d.id = :doc_id AND d.deleted_at IS NULL AND d.user_tags::text LIKE :like
"""
)
async def concept_detail(
session: AsyncSession, user_id: int, topic_id: int, doc_id: int
) -> dict | None:
"""개념 리더 재료 — md 구조 파싱 + 관련개념 백링크 해소 + 회독/SR 상태 + 같은 과목 이전/다음."""
name = await _topic_name(session, topic_id)
if not name:
return None
like = f"%@library/{name}/%"
row = (
await session.execute(
_CONCEPT_ONE_SQL, {"uid": user_id, "doc_id": doc_id, "like": like}
)
).mappings().first()
if row is None:
return None
parsed = parse_concept(row["md_content"] or "")
# 백링크 해소 + 이전/다음 = 같은 토픽 개념 title 인덱스(회독 rows 재사용)
idx = await _concept_rows(session, user_id, name)
title_index = [(r["doc_id"], r["title"], r["subject"]) for r in idx]
resolved = resolve_related(parsed["related"], title_index)
# 이전/다음 = 같은 과목, title 순
same = sorted(
[(r["doc_id"], r["title"]) for r in idx if r["subject"] == row["subject"]],
key=lambda x: (x[1] or "", x[0]),
)
ids = [d for d, _ in same]
prev_id = next_id = None
if doc_id in ids:
pos = ids.index(doc_id)
if pos > 0:
prev_id = ids[pos - 1]
if pos < len(ids) - 1:
next_id = ids[pos + 1]
freq = 3 if row["f3"] else (2 if row["f2"] else 1)
return {
"doc_id": row["doc_id"],
"db_title": row["title"],
"title": parsed["title"] or row["title"],
"subject": row["subject"],
"freq": freq,
"summary": parsed["summary"],
"body": parsed["body"],
"bincheol": parsed["bincheol"],
"related": resolved,
"is_read": row["is_read"],
"review_stage": row["review_stage"],
"due_at": row["due_at"],
"prev_id": prev_id,
"next_id": next_id,
}
-139
View File
@@ -1,139 +0,0 @@
"""concept_links — 이론↔문제 브리지 롤업 (Stage B).
study_concept_links(개념 doc ↔ 기출문항, 임베딩 코사인) + study_question_progress(내 풀이상태)를
조인해 (a) 개념별 관련 기출 + 내 정답률(related_questions), (b) 개념 약점 지도(weakness_map) 산출.
읽기 전용 집계 · LLM 0. 링크 적재는 scripts/concept_links_backfill.sql(임베딩) 배치.
정답률 = 링크된 문항 중 progress.last_outcome 기준(attempted=풀이이력 보유, correct=최근정답).
"""
from __future__ import annotations
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
_ACCURACY_WEAK_PCT = 60 # 정답률 < 60% = 약점(attempted>0 일 때만)
_AGG_SQL = text(
"""
SELECT count(*) AS linked,
count(pr.study_question_id) FILTER (WHERE pr.last_outcome IS NOT NULL) AS attempted,
count(*) FILTER (WHERE pr.last_outcome = 'correct') AS correct
FROM study_concept_links l
LEFT JOIN study_question_progress pr
ON pr.study_question_id = l.question_id AND pr.user_id = :uid
WHERE l.concept_doc_id = :doc_id AND l.link_source = 'embedding'
"""
)
_QROWS_SQL = text(
"""
SELECT q.id AS id, q.subject AS subject, q.exam_round AS exam_round,
q.exam_question_number AS qnum, l.score AS score,
pr.last_outcome AS last_outcome, pr.review_stage AS review_stage
FROM study_concept_links l
JOIN study_questions q ON q.id = l.question_id AND q.deleted_at IS NULL AND q.is_active
LEFT JOIN study_question_progress pr
ON pr.study_question_id = q.id AND pr.user_id = :uid
WHERE l.concept_doc_id = :doc_id AND l.link_source = 'embedding'
ORDER BY l.score DESC
LIMIT :limit
"""
)
_WEAKNESS_SQL = text(
"""
SELECT d.id AS doc_id, d.title AS title,
split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject,
count(l.id) AS linked,
count(pr.study_question_id) FILTER (WHERE pr.last_outcome IS NOT NULL) AS attempted,
count(*) FILTER (WHERE pr.last_outcome = 'correct') AS correct
FROM documents d
JOIN study_concept_links l ON l.concept_doc_id = d.id AND l.link_source = 'embedding'
LEFT JOIN study_question_progress pr
ON pr.study_question_id = l.question_id AND pr.user_id = :uid
WHERE d.user_tags::text LIKE :like AND d.deleted_at IS NULL
GROUP BY d.id, d.title, subject
"""
)
async def related_questions(
session: AsyncSession, user_id: int, doc_id: int, limit: int = 20
) -> dict:
"""개념 doc 의 관련 기출 + 내 정답률(전체 링크 기준 집계 + 상위 N 표시용)."""
agg = (
await session.execute(_AGG_SQL, {"uid": user_id, "doc_id": doc_id})
).mappings().first()
rows = (
await session.execute(
_QROWS_SQL, {"uid": user_id, "doc_id": doc_id, "limit": limit}
)
).mappings().all()
linked = (agg["linked"] if agg else 0) or 0
attempted = (agg["attempted"] if agg else 0) or 0
correct = (agg["correct"] if agg else 0) or 0
accuracy = round(100 * correct / attempted) if attempted else None
return {
"linked": linked,
"attempted": attempted,
"correct": correct,
"accuracy": accuracy,
"questions": [
{
"id": r["id"],
"subject": r["subject"],
"exam_round": r["exam_round"],
"qnum": r["qnum"],
"score": round(r["score"], 3) if r["score"] is not None else None,
"last_outcome": r["last_outcome"],
"review_stage": r["review_stage"],
}
for r in rows
],
}
async def weakness_map(
session: AsyncSession, user_id: int, topic_name: str, limit: int = 12
) -> dict:
"""개념 약점 지도 — 링크된 기출 정답률로 개념 채색. 약점(attempted>0·정답률<60%) 우선 정렬."""
like = f"%@library/{topic_name}/%"
rows = (
await session.execute(_WEAKNESS_SQL, {"uid": user_id, "like": like})
).mappings().all()
concepts = []
for r in rows:
attempted = r["attempted"] or 0
correct = r["correct"] or 0
accuracy = round(100 * correct / attempted) if attempted else None
if accuracy is None:
state = "unattempted"
elif accuracy < _ACCURACY_WEAK_PCT:
state = "weak"
else:
state = "ok"
concepts.append(
{
"doc_id": r["doc_id"],
"title": r["title"],
"subject": r["subject"],
"linked": r["linked"] or 0,
"attempted": attempted,
"accuracy": accuracy,
"state": state,
}
)
# 약점 우선(정답률 오름차순) → 미평가는 뒤로. 홈 위젯용 상위 N.
weak = sorted(
[c for c in concepts if c["state"] == "weak"],
key=lambda c: (c["accuracy"], -c["attempted"], c["doc_id"]),
)
return {
"weak": weak[:limit],
"weak_total": len(weak),
"evaluated_total": sum(1 for c in concepts if c["state"] != "unattempted"),
}
-175
View File
@@ -1,175 +0,0 @@
"""concept_parser — 개념노트 markdown 구조 파서 + 관련개념 백링크 해소 (이론 리더용).
정찰 실측 불변식(273/273): 개념노트는 고정 골격을 100% 따름 —
# {H1 제목} (첫 줄, DB title 과 다른 표시용 제목)
> **한 줄 요약**: {요약} (blockquote, 라벨 고정)
## {본문 라벨} ... (BODY, 자유 라벨 H2 0~N, 트레일 ★ 가능)
## 빈출 포인트 (항상, 관련개념 직전)
## 관련 개념 (항상, 문서 최종 섹션)
코드펜스(``` ASCII 도식) 내부의 ##/- 는 무시. 헤딩 트레일 ★ 는 스트립(라벨 정규화).
'빈출 포인트'/'관련 개념' 앵커만 이름으로 잡고 나머지 BODY 는 순서·위치로 처리(라벨 화이트리스트 금지).
순수 함수 · LLM 0.
"""
from __future__ import annotations
import re
_FENCE = re.compile(r"^\s*```")
_H1 = re.compile(r"^#\s+(.+?)\s*$")
_H2 = re.compile(r"^##\s+(.+?)\s*$") # ### 는 매칭 안 됨(## 뒤 \s 요구)
_SUMMARY = re.compile(r"^>\s*\*\*한 줄 요약\*\*:\s*(.+)$")
_STAR_SUFFIX = re.compile(r"\s*★+\s*$")
_TRAIL_STARS = re.compile(r"★+\s*$")
_BINCHEOL_ITEM = re.compile(r"^\s*-\s+(★*)\s*(.+)$")
_RELATED_ITEM = re.compile(r"^\s*-\s+(.+)$")
_PAREN = re.compile(r"\s*\(.*$") # 괄호부터 끝(clarifier 힌트 절단)
_NUM_PREFIX = re.compile(r"^\d+_")
_STRIP_SYM = re.compile(r"[\s_·,./()\-]")
_ANCHOR_BINCHEOL = "빈출 포인트"
_ANCHOR_RELATED = "관련 개념"
def parse_concept(md: str) -> dict:
"""개념노트 md → {title, summary, body[{label,stars,md}], bincheol[{tier,text}], related[{raw,phrase,hint}]}."""
lines = (md or "").split("\n")
title: str | None = None
summary: str | None = None
body: list[dict] = []
bincheol_lines: list[str] = []
related_lines: list[str] = []
in_fence = False
zone = "pre" # pre | body | bincheol | related
body_cur: dict | None = None
def emit(line: str) -> None:
if body_cur is not None:
body_cur["_lines"].append(line)
elif zone == "bincheol":
bincheol_lines.append(line)
elif zone == "related":
related_lines.append(line)
# pre-zone 내용(요약 앞 잡음)은 버림
for ln in lines:
if _FENCE.match(ln):
in_fence = not in_fence
emit(ln)
continue
if in_fence:
emit(ln)
continue
if title is None:
m = _H1.match(ln)
if m:
title = m.group(1).strip()
continue
if summary is None:
m = _SUMMARY.match(ln)
if m:
summary = m.group(1).strip()
continue
m2 = _H2.match(ln)
if m2:
raw_label = m2.group(1).strip()
star_m = _TRAIL_STARS.search(raw_label)
stars = len(star_m.group(0).strip()) if star_m else 0
label = _STAR_SUFFIX.sub("", raw_label).strip()
if label == _ANCHOR_BINCHEOL:
zone = "bincheol"
body_cur = None
continue
if label == _ANCHOR_RELATED:
zone = "related"
body_cur = None
continue
body_cur = {"label": label, "stars": stars, "_lines": []}
body.append(body_cur)
zone = "body"
continue
emit(ln)
body_out = []
for s in body:
text = "\n".join(s["_lines"]).strip()
if text or s["label"]:
body_out.append({"label": s["label"], "stars": s["stars"], "md": text})
bincheol = []
for ln in bincheol_lines:
m = _BINCHEOL_ITEM.match(ln)
if m:
bincheol.append({"tier": len(m.group(1)), "text": m.group(2).strip()})
related = []
for ln in related_lines:
m = _RELATED_ITEM.match(ln)
if m:
raw = m.group(1).strip()
phrase = _PAREN.sub("", raw).strip()
hint = raw[len(phrase):].strip() if len(raw) > len(phrase) else ""
if phrase:
related.append({"raw": raw, "phrase": phrase, "hint": hint})
return {
"title": title,
"summary": summary,
"body": body_out,
"bincheol": bincheol,
"related": related,
}
def _normalize(s: str) -> str:
"""해소용 정규화: NN_ 접두 제거 → 소문자 → 공백/기호 제거. 영문은 lowercase 유지."""
s = _NUM_PREFIX.sub("", s or "")
s = s.lower()
s = _STRIP_SYM.sub("", s)
return s
def resolve_related(related: list[dict], title_index: list[tuple]) -> list[dict]:
"""관련개념 구절 → 개념 doc 해소. title_index = [(doc_id, title, subject), ...].
다단 fallback(정찰 ~79%): 정규화 exact → 양방향 substring(≥2자 가드) → 미해소=dangling(doc_id None).
"""
norm_exact: dict[str, int] = {}
norm_list: list[tuple[str, int, str]] = []
for did, ttl, _subj in title_index:
n = _normalize(ttl)
if n:
norm_exact.setdefault(n, did)
norm_list.append((n, did, ttl))
out = []
for it in related:
pn = _normalize(it["phrase"])
did: int | None = None
rtitle: str | None = None
if pn and len(pn) >= 2:
if pn in norm_exact:
did = norm_exact[pn]
else:
# substring 폴백: title-norm ⊆ phrase-norm 방향만(짧은 phrase 가 더 큰 title 을
# 삼키는 오결선 방지, 예: '염산'→'염산나트륨' X) + 길이차 최소(가장 구체적) +
# doc_id tiebreak(순서 무관 결정성). 후보 없으면 dangling(doc_id None).
cands = [
(abs(len(n) - len(pn)), cand, ttl)
for n, cand, ttl in norm_list
if len(n) >= 2 and n in pn
]
if cands:
cands.sort(key=lambda c: (c[0], c[1]))
_, did, rtitle = cands[0]
if did is not None and rtitle is None:
rtitle = next((t for d, t, _ in title_index if d == did), None)
out.append(
{"phrase": it["phrase"], "hint": it["hint"], "doc_id": did, "title": rtitle}
)
return out
@@ -0,0 +1,63 @@
import AppKit
import Foundation
/// macOS + . AppKit(NSOpenPanel/NSSavePanel) AppFeature
/// (OS UI ) DSKit ( iOS/watchOS). @MainActor.
@MainActor
enum FilePanels {
/// 1 . nil.
static func pickFileToUpload() -> URL? {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.message = "업로드할 문서를 선택하세요"
panel.prompt = "업로드"
return panel.runModal() == .OK ? panel.url : nil
}
/// . nil. = (files.user-selected).
static func pickSaveDestination(suggestedName: String) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = suggestedName
panel.message = "원본 파일을 저장할 위치"
panel.prompt = "저장"
return panel.runModal() == .OK ? panel.url : nil
}
}
/// . URL ?token= ( ),
/// URL / . NSSavePanel .
@MainActor
enum FileDownloader {
enum Outcome: Equatable {
case saved(URL)
case cancelled
case failed(String)
}
/// `url` = DSDownload.fileURL ?token= URL. `suggestedName` = .
static func download(from url: URL, suggestedName: String) async -> Outcome {
guard let dest = FilePanels.pickSaveDestination(suggestedName: suggestedName) else {
return .cancelled
}
do {
let (temp, response) = try await URLSession.shared.download(from: url)
// (async download )
// . move temp removeItem no-op.
defer { try? FileManager.default.removeItem(at: temp) }
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
// URL/ .
return .failed("다운로드 실패 (HTTP \(http.statusCode))")
}
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.moveItem(at: temp, to: dest)
return .saved(dest)
} catch {
// URLError/ localizedDescription URL .
return .failed("저장 실패: \((error as NSError).localizedDescription)")
}
}
}
@@ -1,85 +0,0 @@
import SwiftUI
import AIFabric
/// RAG proof page: routes corpusAsk through AIService (-> AIRouter -> MockAIProvider). Explicit backend
/// pick sets explicitProvider; an explicit-unavailable result renders a visible, non-retrying error.
struct AskView: View {
@Environment(AppModel.self) private var model
@State private var backend: BackendChoice = .auto
var body: some View {
@Bindable var model = model
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Picker("백엔드", selection: $backend) {
ForEach(BackendChoice.allCases) { Text($0.label).tag($0) }
}
.pickerStyle(.segmented)
HStack(spacing: 8) {
TextField("코퍼스 전체에 질문", text: $model.askQuery)
.textFieldStyle(.roundedBorder)
.onSubmit { Task { await model.runAsk(backend: backend.provider) } }
Button("질문") { Task { await model.runAsk(backend: backend.provider) } }
.buttonStyle(.borderedProminent)
}
if let result = model.askResult {
switch result {
case .success(let response):
AICompletionView(response: response) { docID in
model.section = .documents
Task { await model.openDocument(docID) }
}
if let meta = model.askMeta {
HStack(spacing: 6) {
Chip("완성도 \(meta.completeness)", Sage.muted)
if let aspects = meta.coveredAspects {
ForEach(aspects, id: \.self) { Chip($0, Sage.brand) }
}
}
}
case .failure(let err):
ErrorBanner(text: message(for: err))
}
} else {
EmptyState(text: "질문을 입력하세요").frame(minHeight: 160)
}
}
.padding(16)
}
.background(Sage.surface)
}
private func message(for error: AIServiceError) -> String {
switch error {
case .explicitUnavailable(let id):
return "\(id.displayName) 백엔드를 쓸 수 없습니다 — 다른 백엔드로 자동 전환하지 않았습니다. 다른 백엔드를 고르세요."
case .notConfigured(let id): return "\(id.displayName) 백엔드 미구성"
case .noneAvailable: return "응답 가능한 백엔드가 없습니다."
case .providerFailed(let s): return "응답 실패: \(s)"
case .unknown(let s): return "오류: \(s)"
}
}
}
enum BackendChoice: String, CaseIterable, Identifiable {
case auto, onDevice, localMLX, remoteDS
var id: String { rawValue }
var label: String {
switch self {
case .auto: return "자동"
case .onDevice: return "온디바이스"
case .localMLX: return "맥미니"
case .remoteDS: return "원격 DS"
}
}
var provider: AIProviderID? {
switch self {
case .auto: return nil
case .onDevice: return .onDevice
case .localMLX: return .localMLX
case .remoteDS: return .remoteDS
}
}
}
@@ -1,51 +1,386 @@
import SwiftUI
import DSKit
/// Corpus-health overview (not a dumped table). Stat hero + domain distribution bars; tapping a
/// domain jumps to Documents (cross-page nav proof).
/// = ( 1). detail 1000pt , 2.
/// ( + + ) (·)/(·).
struct DashboardView: View {
@Environment(AppModel.self) private var model
var body: some View {
ScrollView {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 18) {
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.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.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 }
}
}
.padding(16)
.background(Sage.card, in: RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Sage.line))
} else {
GreetingHeader()
if model.stats == nil && model.tree.isEmpty {
ProgressView().frame(maxWidth: .infinity, minHeight: 200)
} else {
TodayStrip()
HStack(alignment: .top, spacing: 18) {
VStack(alignment: .leading, spacing: 18) {
CaptureCard()
ActivityTimeline()
}
.frame(maxWidth: .infinity)
VStack(alignment: .leading, spacing: 18) {
DomainDistribution()
PinnedItems()
}
.frame(width: 312)
}
}
}
.padding(20)
.frame(maxWidth: 1000, alignment: .leading)
.padding(.horizontal, 30)
.padding(.vertical, 26)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.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
// MARK: - Greeting
private struct GreetingHeader: View {
@Environment(AppModel.self) private var model
var body: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("안녕하세요, \(model.currentUser?.username ?? "사용자")")
.font(.system(size: 22, weight: .bold)).kerning(-0.4).foregroundStyle(Sage.ink)
Text("오늘도 지식 쌓는 날.").font(.callout).foregroundStyle(Sage.muted)
}
Text(Self.today).font(.caption).foregroundStyle(Sage.muted.opacity(0.8))
}
.padding(.bottom, 4)
}
static var today: String {
let f = DateFormatter()
f.locale = Locale(identifier: "ko_KR")
f.dateFormat = "y년 M월 d일 EEEE"
return f.string(from: Date())
}
}
// MARK: - Today strip (hero)
private struct TodayStrip: View {
@Environment(AppModel.self) private var model
var body: some View {
VStack(spacing: 14) {
HStack(alignment: .top, spacing: 0) {
reviewQueue
.frame(minWidth: 150, alignment: .leading)
Rectangle().fill(Sage.line).frame(width: 1).padding(.horizontal, 22)
digestTeaser
.frame(maxWidth: .infinity, alignment: .leading)
}
Divider().overlay(Sage.line)
statRow
}
.dashCard(padding: 20)
}
private var reviewQueue: some View {
VStack(alignment: .leading, spacing: 4) {
Text(model.reviewPendingCount.map(String.init) ?? "")
.font(.system(size: 38, weight: .bold)).kerning(-1.5).monospacedDigit()
.foregroundStyle(Sage.amber)
Text("검토 대기 문서").font(.caption).foregroundStyle(Sage.muted)
Button { model.section = .documents } label: {
Text("검토 시작 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
}
.buttonStyle(.plain)
}
}
@ViewBuilder private var digestTeaser: some View {
if let t = topTopic {
Button { model.section = .digest } label: {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Chip("속보", Sage.danger)
Text("\(model.digest?.digestDateDisplay ?? "") 브리핑")
.font(.caption2).foregroundStyle(Sage.muted)
}
Text(t.label).font(.system(size: 15)).foregroundStyle(Sage.ink)
.lineLimit(2).fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
Text(t.meta).font(.caption2).foregroundStyle(Sage.muted)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
} else {
Text("오늘 브리핑이 아직 없습니다").font(.callout).foregroundStyle(Sage.muted)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var statRow: some View {
HStack(spacing: 0) {
StatCell(value: model.stats?.total ?? 0, label: "전체", color: Sage.brand)
StatCell(value: model.stats?.counts["document"] ?? 0, label: "문서")
StatCell(value: domainCount("Industrial_Safety"), label: "산업안전",
color: Sage.domainColor("Industrial_Safety"))
StatCell(value: domainCount("Engineering"), label: "엔지니어링",
color: Sage.domainColor("Engineering"))
StatCell(value: domainCount("General"), label: "자료실", color: Sage.domainColor("General"))
StatCell(value: model.stats?.counts["memo"] ?? model.memoList.count, label: "메모")
}
}
private func domainCount(_ name: String) -> Int {
model.tree.first { $0.name == name }?.count ?? 0
}
private var topTopic: (label: String, meta: String)? {
guard let digest = model.digest else { return nil }
var best: (TopicResponse, String)?
for c in digest.countries {
for t in c.topics where best == nil || (t.importanceScore ?? 0) > (best!.0.importanceScore ?? 0) {
best = (t, c.country)
}
}
guard let (t, country) = best else { return nil }
let arts = t.articleCount ?? t.articles.count
var meta = "관련 기사 \(arts)"
if let imp = t.importanceScore { meta += " · 중요도 \(String(format: "%.0f", imp))" }
if !country.isEmpty { meta += " · \(country)" }
return (t.topicLabel, meta)
}
}
// MARK: - Left column
private struct CaptureCard: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var m = model
VStack(alignment: .leading, spacing: 12) {
SectionLabel("빠른 캡처")
HStack(spacing: 8) {
TextField("메모 한 줄 남기기…", text: $m.captureText)
.textFieldStyle(.plain)
.padding(.horizontal, 14).frame(height: 38)
.background(Sage.surface, in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Sage.line))
.onSubmit { Task { await model.saveMemo() } }
Button { Task { await model.saveMemo() } } label: {
Text("저장").font(.callout.weight(.semibold)).foregroundStyle(.white)
.padding(.horizontal, 18).frame(height: 38)
.background(Sage.brand, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(model.captureText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Button {
guard let url = FilePanels.pickFileToUpload() else { return }
Task { await model.uploadPicked(url) }
} label: {
Text(" 파일 업로드").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Sage.brand.opacity(0.12), in: Capsule())
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, alignment: .leading)
.dashCard()
}
}
private struct ActivityTimeline: View {
@Environment(AppModel.self) private var model
private var recent: [DocumentResponse] {
model.documentList
.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
.prefix(5).map { $0 }
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline) {
SectionLabel("최근 활동")
Spacer()
Button { model.section = .documents } label: {
Text("전체 보기 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
}
.buttonStyle(.plain)
}
if recent.isEmpty {
Text("최근 활동이 없습니다").font(.caption).foregroundStyle(Sage.muted)
} else {
VStack(spacing: 0) {
ForEach(Array(recent.enumerated()), id: \.element.id) { idx, doc in
ActivityRow(doc: doc, isLast: idx == recent.count - 1)
if idx != recent.count - 1 { Divider().overlay(Sage.line) }
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.dashCard()
}
}
private struct ActivityRow: View {
@Environment(AppModel.self) private var model
let doc: DocumentResponse
let isLast: Bool
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text(Self.relative(doc.updatedAt))
.font(.caption2).foregroundStyle(Sage.muted)
.frame(width: 54, alignment: .trailing)
VStack(spacing: 0) {
Circle().fill(Sage.domainColor(doc.aiDomain)).frame(width: 8, height: 8).padding(.top, 4)
if !isLast { Rectangle().fill(Sage.line).frame(width: 1).frame(maxHeight: .infinity) }
}
.frame(width: 14)
VStack(alignment: .leading, spacing: 3) {
Text("\(localizedDomain(doc.aiDomain)) · \(doc.displayFormat.uppercased())")
.font(.caption2.weight(.bold)).foregroundStyle(Sage.domainColor(doc.aiDomain))
Text(doc.title ?? doc.downloadLabel).font(.callout).foregroundStyle(Sage.ink).lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, isLast ? 0 : 10)
}
.contentShape(Rectangle())
.onTapGesture { model.section = .documents; Task { await model.openDocument(doc.id) } }
}
static func relative(_ date: Date?) -> String {
guard let date else { return "" }
let f = RelativeDateTimeFormatter()
f.locale = Locale(identifier: "ko_KR")
f.unitsStyle = .short
return f.localizedString(for: date, relativeTo: Date())
}
}
// MARK: - Right column
private struct DomainDistribution: View {
@Environment(AppModel.self) private var model
private var domains: [DomainTreeNode] { model.tree.sorted { $0.count > $1.count } }
private var domainTotal: Int { domains.reduce(0) { $0 + $1.count } }
private var sum: Int { max(1, domainTotal) } // 0- ( )
var body: some View {
VStack(alignment: .leading, spacing: 12) {
SectionLabel("도메인 분포")
// = / ( ) .
HStack(alignment: .firstTextBaseline, spacing: 3) {
Text("분류").font(.caption).foregroundStyle(Sage.muted)
Text("\(domainTotal)").font(.system(size: 18, weight: .semibold))
.monospacedDigit().foregroundStyle(Sage.ink)
Text("").font(.caption).foregroundStyle(Sage.muted)
}
GeometryReader { geo in
HStack(spacing: 2) {
ForEach(domains) { d in
Rectangle().fill(Sage.domainColor(d.name))
.frame(width: max(2, geo.size.width * CGFloat(d.count) / CGFloat(sum)))
}
}
}
.frame(height: 8)
.clipShape(RoundedRectangle(cornerRadius: 4))
VStack(spacing: 7) {
ForEach(domains) { d in
Button {
model.section = .documents
Task { await model.loadDocuments(domain: d.path) }
} label: {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 2).fill(Sage.domainColor(d.name)).frame(width: 10, height: 10)
Text(localizedDomain(d.name)).font(.caption).foregroundStyle(Sage.ink)
.lineLimit(1).frame(maxWidth: .infinity, alignment: .leading)
Text("\(d.count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted)
}
}
.buttonStyle(.plain)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.dashCard()
}
}
private struct PinnedItems: View {
@Environment(AppModel.self) private var model
private var docs: [DocumentResponse] { model.documentList.filter { $0.pinned == true } }
private var memos: [MemoResponse] { model.memoList.filter { $0.isPinned } }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
SectionLabel("고정 항목")
Spacer()
Button { model.section = .documents } label: {
Text("관리 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
}
.buttonStyle(.plain)
}
if docs.isEmpty && memos.isEmpty {
Text("고정된 항목이 없습니다").font(.caption).foregroundStyle(Sage.muted)
} else {
VStack(spacing: 8) {
ForEach(docs) { d in
PinRow(kind: "문서", kindColor: Sage.domainColor("Engineering"),
title: d.title ?? d.downloadLabel, date: d.updatedAtRaw) {
model.section = .documents; Task { await model.openDocument(d.id) }
}
}
ForEach(memos) { m in
PinRow(kind: "메모", kindColor: Sage.brand,
title: m.title ?? (m.content ?? "메모"), date: m.updatedAtRaw ?? "") {
model.section = .memos; Task { await model.openMemo(m.id) }
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.dashCard()
}
}
private struct PinRow: View {
let kind: String
let kindColor: Color
let title: String
let date: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(alignment: .top, spacing: 10) {
Chip(kind, kindColor)
Text(title).font(.caption).foregroundStyle(Sage.ink).lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
Text(date.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
}
.padding(10)
.background(Sage.surface, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
#if DEBUG
#Preview("Dashboard") {
@Previewable @State var model = AppModel.preview
DashboardView()
.environment(model)
.frame(width: 1100, height: 760)
.task { await model.bootstrap() }
}
#endif
@@ -1,91 +1,367 @@
import SwiftUI
import DSKit
struct DocumentListView: View {
/// = DEVONthink . () , detail
/// HSplitView 3-pane = | MD | ().
/// model.loadDocuments(domain:) .
struct DocumentsBrowser: View {
@Environment(AppModel.self) private var model
@State private var showInspector = true
@State private var sortOrder = [KeyPathComparator(\DocumentResponse.sortUpdated, order: .reverse)]
var body: some View {
HSplitView {
DocumentListTable(sortOrder: $sortOrder)
.frame(minWidth: 300, idealWidth: 360, maxWidth: 460)
DocumentReader(showInspector: $showInspector)
.frame(minWidth: 420, maxWidth: .infinity)
if showInspector, let d = model.documentDetail {
DocumentInspector(detail: d)
.frame(minWidth: 280, idealWidth: 320, maxWidth: 360)
}
}
.task { await model.ensureDocumentsLoaded() } // load-all
}
}
// MARK: - Column list (sortable Table)
private extension DocumentResponse {
var sortTitle: String { title ?? downloadLabel }
var sortFormat: String { (originalFormat ?? fileFormat ?? "").lowercased() }
var sortUpdated: String { updatedAtRaw }
/// "PDFMD" / "MD" .
var formatBadge: String {
if let orig = originalFormat, orig.lowercased() != (fileFormat ?? "").lowercased() {
return "\(orig.uppercased())→MD"
}
return displayFormat.uppercased()
}
}
struct DocumentListTable: View {
@Environment(AppModel.self) private var model
@Binding var sortOrder: [KeyPathComparator<DocumentResponse>]
private var documents: [DocumentResponse] { model.documentList.sorted(using: sortOrder) }
var body: some View {
let selection = Binding<Int?>(
get: { model.selectedDocumentID },
set: { if let id = $0 { Task { await model.openDocument(id) } } }
)
List(model.documentList, selection: selection) { doc in
DocumentRow(doc: doc)
}
.listStyle(.inset)
.background(Sage.surface)
}
}
struct DocumentRow: View {
let doc: DocumentResponse
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Chip(doc.displayFormat.uppercased(), Sage.formatColor(doc.displayFormat))
Text(doc.title ?? doc.downloadLabel)
.font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1)
Spacer()
if doc.pinned == true { Text("고정").font(.caption2).foregroundStyle(Sage.amber) }
}
HStack(spacing: 6) {
if let d = doc.aiDomain { Chip(d, Sage.domainColor(d)) }
if let r = doc.reviewStatus {
Text(r).font(.caption2).foregroundStyle(Sage.reviewStatusColor(r))
Group {
if model.documentList.isEmpty {
EmptyState(text: "문서가 없습니다")
} else {
Table(documents, selection: selection, sortOrder: $sortOrder) {
TableColumn("제목", value: \.sortTitle) { doc in
VStack(alignment: .leading, spacing: 2) {
Text(doc.title ?? doc.downloadLabel)
.font(.system(size: 12.5, weight: .semibold)).foregroundStyle(Sage.ink).lineLimit(1)
Text(localizedDomain(doc.aiDomain))
.font(.system(size: 11)).foregroundStyle(Sage.muted).lineLimit(1)
}
.padding(.vertical, 2)
}
TableColumn("종류", value: \.sortFormat) { doc in
Chip(doc.formatBadge, Sage.formatColor(doc.originalFormat ?? doc.displayFormat))
}
.width(min: 66, ideal: 74, max: 96)
TableColumn("수정", value: \.sortUpdated) { doc in
Text(doc.updatedAtRaw.prefix(10))
.font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
}
.width(min: 78, ideal: 86, max: 110)
}
Spacer()
Text(doc.updatedAtRaw.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
.tint(Sage.brand)
}
}
.padding(.vertical, 4)
.background(Sage.card)
}
}
/// MD-first detail: render md_content when renderable, else extracted_text fallback + 'MD '
/// badge + emphasized original-download button. (Download builds a real-shaped ?token= URL.)
struct DocumentDetailView: View {
// MARK: - Reader
struct DocumentReader: View {
@Environment(AppModel.self) private var model
@Binding var showInspector: Bool
var body: some View {
Group {
if let detail = model.documentDetail {
VStack(spacing: 0) {
ReaderHeader(detail: detail, showInspector: $showInspector)
ReaderBody(detail: detail)
}
} else {
EmptyState(text: "문서를 선택하세요")
}
}
.background(Sage.card)
}
}
private struct ReaderHeader: View {
let detail: DocumentDetailResponse
@Binding var showInspector: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(crumb).font(.system(size: 11)).foregroundStyle(Sage.muted).lineLimit(1)
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(detail.base.title ?? detail.base.downloadLabel)
.font(.system(size: 18, weight: .heavy)).foregroundStyle(Sage.ink).lineLimit(2)
Spacer()
DownloadButton(doc: detail.base, compact: true)
inspectorToggle
}
metaBadges
tagRow
}
.padding(.horizontal, 26).padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Sage.card)
.overlay(alignment: .bottom) { Rectangle().fill(Sage.line).frame(height: 1) }
}
private var crumb: String {
let dom = localizedDomain(detail.base.aiDomain)
if let sub = detail.base.aiSubGroup, !sub.isEmpty { return "\(dom) \(sub)" }
return dom
}
/// : · · tier DEEP · · PDFMD success.
@ViewBuilder private var metaBadges: some View {
let b = detail.base
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
if let d = b.aiDomain { Chip(localizedDomain(d), Sage.domainColor(d)) }
if let t = b.documentType, !t.isEmpty { Chip(t, Sage.muted) }
if b.aiAnalysisTier == "deep" { Chip("tier DEEP", Sage.brand) }
if let c = b.aiConfidence { Chip("신뢰도 \(String(format: "%.2f", c))", Sage.brandDark) }
if detail.mdIsRenderable { Chip("PDF→MD success", Sage.mdStatusColor("completed")) }
}
}
}
private var inspectorToggle: some View {
Button { withAnimation(.easeInOut(duration: 0.2)) { showInspector.toggle() } } label: {
Image(systemName: "info.circle").font(.system(size: 15))
.foregroundStyle(showInspector ? Sage.brandDark : Sage.muted)
.frame(width: 30, height: 30)
.background(showInspector ? Sage.brand.opacity(0.14) : Sage.card, in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(showInspector ? Sage.brand : Sage.line))
}
.buttonStyle(.plain)
.help("인스펙터")
}
@ViewBuilder private var tagRow: some View {
let tags = detail.base.aiTags ?? []
if detail.mdStatus != nil || !tags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
if let st = detail.mdStatus { Chip("MD \(st)", Sage.mdStatusColor(st)) }
ForEach(tags, id: \.self) { Chip($0, Sage.brand) }
}
}
}
}
}
private struct ReaderBody: View {
let detail: DocumentDetailResponse
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text(detail.base.title ?? detail.base.downloadLabel)
.font(.title2.weight(.bold)).foregroundStyle(Sage.ink)
HStack(spacing: 8) {
if let d = detail.base.aiDomain { Chip(d, Sage.domainColor(d)) }
Chip(detail.base.displayFormat.uppercased(), Sage.formatColor(detail.base.displayFormat))
if let conf = detail.base.aiConfidence {
Chip("AI \(String(format: "%.0f%%", conf * 100))", Sage.muted)
}
Spacer()
if let url = model.downloadURL(for: detail.base) {
Link(detail.base.downloadLabel, destination: url).font(.callout.weight(.semibold))
}
}
if let tags = detail.base.aiTags, !tags.isEmpty {
HStack(spacing: 6) { ForEach(tags, id: \.self) { Chip($0, Sage.brand) } }
}
Divider()
if detail.mdIsRenderable, let md = detail.mdContent {
MarkdownView(md)
} else {
HStack { Chip("MD 변환 대기", Sage.amber); Spacer() }
Text(detail.extractedText ?? "본문 없음")
.font(.body).foregroundStyle(Sage.muted)
.frame(maxWidth: .infinity, alignment: .leading)
if let url = model.downloadURL(for: detail.base) {
Link("원본 다운로드 — \(detail.base.downloadLabel)", destination: url)
.font(.callout.weight(.semibold))
HStack(spacing: 0) {
Spacer(minLength: 0)
VStack(alignment: .leading, spacing: 14) {
if detail.mdIsRenderable, let md = detail.mdContent {
MarkdownView(md)
} else {
HStack { Chip("MD 변환 대기", Sage.amber); Spacer() }
Text(detail.extractedText ?? "본문 없음")
.font(.body).foregroundStyle(Sage.muted)
.frame(maxWidth: .infinity, alignment: .leading)
DownloadButton(doc: detail.base, compact: false)
}
}
.frame(maxWidth: 700, alignment: .leading)
Spacer(minLength: 0)
}
.padding(.horizontal, 28).padding(.top, 22).padding(.bottom, 44)
}
.background(Sage.card)
}
}
// MARK: - Inspector
struct DocumentInspector: View {
let detail: DocumentDetailResponse
private var base: DocumentResponse { detail.base }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
// ( : TL;DR · · · )
if let tldr = (base.aiTldr ?? base.aiSummary), !tldr.isEmpty {
InspectorSection("TL;DR") {
Text(tldr).font(.system(size: 12)).foregroundStyle(Sage.ink).lineSpacing(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
if let bullets = base.aiBullets, !bullets.isEmpty {
InspectorSection("핵심점") {
VStack(alignment: .leading, spacing: 6) {
ForEach(bullets, id: \.self) { b in
HStack(alignment: .top, spacing: 6) {
Text("·").font(.system(size: 12, weight: .bold)).foregroundStyle(Sage.amber)
Text(b).font(.system(size: 12)).foregroundStyle(Sage.ink)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
}
if let deep = base.aiDetailSummary, !deep.isEmpty {
InspectorSection("심층") {
VStack(alignment: .leading, spacing: 6) {
if base.aiAnalysisTier == "deep" { Chip("DEEP", Sage.brand) }
Text(deep).font(.system(size: 11.5)).foregroundStyle(Sage.ink).lineSpacing(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
if let inc = base.aiInconsistencies, !inc.isEmpty {
InspectorSection("불일치 \(inc.count)") {
VStack(alignment: .leading, spacing: 5) {
ForEach(inc, id: \.self) { x in
Text("· \(x)").font(.system(size: 11.5)).foregroundStyle(Sage.ink)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
//
InspectorSection("정보") {
VStack(spacing: 0) {
KV("종류", base.formatBadge)
KV("도메인", localizedDomain(base.aiDomain))
KV("하위", base.aiSubGroup ?? "")
KV("수정", String(base.updatedAtRaw.prefix(10)))
if let size = base.fileSize {
KV("원본", ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))
}
if let st = detail.mdStatus { KV("md 상태", st, color: Sage.mdStatusColor(st)) }
if let tier = base.aiAnalysisTier { KV("tier", tier, color: Sage.brandDark) }
if let c = base.aiConfidence { KV("신뢰도", String(format: "%.2f", c), color: Sage.brand) }
KV("읽음", "\(base.reads)")
}
}
if let tags = base.aiTags, !tags.isEmpty {
InspectorSection("태그") { TagWrap(tags: tags) }
}
}
.padding(.horizontal, 16).padding(.vertical, 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Sage.sidebar)
.overlay(alignment: .leading) { Rectangle().fill(Sage.line).frame(width: 1) }
}
}
private struct InspectorSection<Content: View>: View {
let title: String
@ViewBuilder let content: Content
init(_ title: String, @ViewBuilder content: () -> Content) { self.title = title; self.content = content() }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.system(size: 10, weight: .heavy)).tracking(0.8)
.textCase(.uppercase).foregroundStyle(Sage.muted.opacity(0.8))
content
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct KV: View {
let k: String
let v: String
var color: Color = Sage.ink
init(_ k: String, _ v: String, color: Color = Sage.ink) { self.k = k; self.v = v; self.color = color }
var body: some View {
HStack {
Text(k).font(.system(size: 12)).foregroundStyle(Sage.muted)
Spacer()
Text(v).font(.system(size: 12, weight: .semibold)).foregroundStyle(color)
.multilineTextAlignment(.trailing)
}
.padding(.vertical, 3)
}
}
/// (2 Layout ).
private struct TagWrap: View {
let tags: [String]
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(stride(from: 0, to: tags.count, by: 2)), id: \.self) { i in
HStack(spacing: 6) {
Chip(tags[i], Sage.brand)
if i + 1 < tags.count { Chip(tags[i + 1], Sage.brand) }
Spacer(minLength: 0)
}
}
}
}
}
// MARK: - Native download button (preserved)
/// . ?token= URL NSSavePanel (
/// ). + / . note .
struct DownloadButton: View {
@Environment(AppModel.self) private var model
let doc: DocumentResponse
/// compact = () / false = .
var compact: Bool
@State private var busy = false
@State private var status: String?
@State private var isError = false
var body: some View {
if let url = model.downloadURL(for: doc) {
HStack(spacing: 8) {
Button {
Task {
busy = true; status = nil; isError = false
let outcome = await FileDownloader.download(from: url, suggestedName: doc.downloadLabel)
busy = false
switch outcome {
case .saved(let dest): status = "저장됨: \(dest.lastPathComponent)"; isError = false
case .cancelled: status = nil
case .failed(let msg): status = msg; isError = true
}
}
} label: {
Label(compact ? doc.downloadLabel : "원본 다운로드 — \(doc.downloadLabel)",
systemImage: "arrow.down.circle")
.font(.callout.weight(.semibold))
}
.buttonStyle(.borderless)
.disabled(busy)
if busy { ProgressView().controlSize(.small) }
if let s = status {
Text(s).font(.caption)
.foregroundStyle(isError ? Sage.danger : Sage.muted)
.lineLimit(1)
}
}
.padding(20)
}
.background(Sage.surface)
}
}
@@ -13,11 +13,10 @@ struct MemoListView: View {
.textFieldStyle(.roundedBorder)
Button("저장") {
let content = draft
draft = ""
Task { _ = try? await model.client.createMemo(MemoCreate(content: content)) }
Task { if await model.saveMemo(content) { draft = "" } }
}
.buttonStyle(.bordered)
.disabled(draft.isEmpty)
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(12)
@@ -1,50 +0,0 @@
import SwiftUI
import DSKit
/// Distinct from the Documents table: relevance-forward result cards (score bar + match_reason).
struct SearchView: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
TextField("검색어를 입력하세요", text: $model.searchQuery)
.textFieldStyle(.roundedBorder)
.onSubmit { Task { await model.runSearch() } }
Button("검색") { Task { await model.runSearch() } }
.buttonStyle(.borderedProminent)
}
.padding(12)
if let response = model.searchResponse {
List(response.results) { result in
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
if let d = result.aiDomain { Chip(d, Sage.domainColor(d)) }
Text(result.title ?? "문서 \(result.id)")
.font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1)
Spacer()
if let m = result.matchReason {
Text(m).font(.caption2).foregroundStyle(Sage.muted)
}
}
Text(result.snippet ?? result.aiSummary ?? "")
.font(.caption).foregroundStyle(Sage.muted).lineLimit(2)
if let score = result.score { ScoreBar(score: score) }
}
.padding(.vertical, 4)
.contentShape(Rectangle())
.onTapGesture {
model.section = .documents
Task { await model.openDocument(result.id) }
}
}
.listStyle(.inset)
} else {
EmptyState(text: "검색어를 입력하세요")
}
}
.background(Sage.surface)
}
}
@@ -1,5 +1,58 @@
import SwiftUI
/// raw (/ enum ) . Sage.domainColor(raw) raw
/// raw, . .
func localizedDomain(_ raw: String?) -> String {
guard let raw, !raw.isEmpty else { return "미분류" }
// (Philosophy/Aesthetics) leaf , leaf
let leaf = raw.split(separator: "/").last.map(String.init) ?? raw
let map: [String: String] = [
"Engineering": "엔지니어링", "Industrial_Safety": "산업안전", "General": "자료실",
"Programming": "프로그래밍", "법령": "법령", "Philosophy": "철학",
]
return map[raw] ?? map[leaf] ?? leaf
}
/// / (·heavy·muted) / .
struct SectionLabel: View {
let text: String
init(_ text: String) { self.text = text }
var body: some View {
Text(text)
.font(.caption.weight(.heavy))
.textCase(.uppercase)
.kerning(0.7)
.foregroundStyle(Sage.muted)
}
}
/// (Sage.card + corner 12 + Sage.line stroke + ).
struct DashCard: ViewModifier {
var padding: CGFloat = 18
func body(content: Content) -> some View {
content
.padding(padding)
.background(Sage.card, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line))
}
}
extension View { func dashCard(padding: CGFloat = 18) -> some View { modifier(DashCard(padding: padding)) } }
/// ( ). StatCard .
struct StatCell: View {
let value: Int
let label: String
var color: Color = Sage.ink
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text("\(value)").font(.system(size: 20, weight: .semibold)).kerning(-0.6)
.monospacedDigit().foregroundStyle(color)
Text(label).font(.caption2).foregroundStyle(Sage.muted)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct StatCard: View {
let title: String
let value: Int
@@ -1,9 +1,10 @@
import SwiftUI
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 ).
/// 2-column ( + detail). detail
/// (= / = HSplitView 3-pane / =+). 3-column
/// . macOS-only.
/// : checking(refresh ) loggedOut(LoginView) ready().
public struct RootView: View {
@Environment(AppModel.self) private var model
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@@ -29,38 +30,45 @@ public struct RootView: View {
private var shell: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
Sidebar()
.navigationSplitViewColumnWidth(min: 220, ideal: 250)
} content: {
ContentColumn()
.navigationSplitViewColumnWidth(min: 300, ideal: 380)
.navigationSplitViewColumnWidth(min: 200, ideal: 215, max: 270)
} detail: {
DetailColumn()
SectionDetail()
}
.navigationSplitViewStyle(.balanced)
.tint(Sage.brand)
.toolbar {
ToolbarItem(placement: .primaryAction) { UploadToolbarButton() }
ToolbarItem(placement: .primaryAction) { AccountMenu() }
}
.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))
VStack(spacing: 0) {
UploadStatusBar()
// (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)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Sage.danger)
}
}
}
}
// MARK: - Sidebar
struct Sidebar: View {
@Environment(AppModel.self) private var model
private let navSections: [AppModel.Section] = [.dashboard, .documents, .digest, .memos]
var body: some View {
let selection = Binding<AppModel.Section?>(
@@ -68,73 +76,132 @@ struct Sidebar: View {
set: { if let v = $0 { model.section = v } }
)
List(selection: selection) {
BrandRow().selectionDisabled()
Section {
ForEach(AppModel.Section.allCases) { s in
Text(s.title).tag(s)
ForEach(navSections) { s in
Label(s.title, systemImage: Self.icon(s)).tag(s)
}
}
if model.section == .documents, !model.tree.isEmpty {
Section("도메인") {
ForEach(model.tree) { node in
DomainRow(node: node)
}
}
// ( 4- ).
if model.section == .documents {
DocumentsSourceSidebar()
}
}
.listStyle(.sidebar)
.background(Sage.sidebar)
}
}
struct DomainRow: View {
@Environment(AppModel.self) private var model
let node: DomainTreeNode
var body: some View {
HStack(spacing: 8) {
Circle().fill(Sage.domainColor(node.name)).frame(width: 8, height: 8)
Text(node.name).font(.callout).foregroundStyle(Sage.ink)
Spacer()
Text("\(node.count)").font(.caption).foregroundStyle(Sage.muted)
static func icon(_ s: AppModel.Section) -> String {
switch s {
case .dashboard: return "house"
case .documents: return "folder"
case .digest: return "newspaper"
case .memos: return "note.text"
}
.contentShape(Rectangle())
.onTapGesture { model.section = .documents }
}
}
struct ContentColumn: View {
struct BrandRow: View {
var body: some View {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 7).fill(Sage.brand).frame(width: 26, height: 26)
.overlay(Text("DS").font(.system(size: 10, weight: .heavy)).foregroundStyle(.white))
Text("Document Server").font(.system(size: 13.5, weight: .heavy)).foregroundStyle(Sage.ink)
}
.padding(.vertical, 4)
}
}
/// : ( = ) + /( placeholder).
struct DocumentsSourceSidebar: View {
@Environment(AppModel.self) private var model
var body: some View {
Section("분류") {
SourceRow(label: "전체 문서", color: nil, count: model.stats?.total,
selected: model.documentDomainFilter == nil) {
Task { await model.loadDocuments(domain: nil) }
}
ForEach(model.tree) { node in
SourceRow(label: localizedDomain(node.name), color: Sage.domainColor(node.name),
count: node.count, selected: model.documentDomainFilter == node.path) {
Task { await model.loadDocuments(domain: node.path) }
}
}
}
// IA ( ).
Section("스마트 그룹") {
ForEach(["최근 7일", "검토 대기", "법령 알림"], id: \.self) { t in
Text(t).font(.callout).foregroundStyle(Sage.muted).opacity(0.5)
}
}
Section("태그") {
ForEach(["압력용기", "ASME", "받은편지함"], id: \.self) { t in
Text("#\(t)").font(.callout).foregroundStyle(Sage.muted).opacity(0.5)
}
}
}
}
/// (). brand-soft List ( ).
struct SourceRow: View {
let label: String
let color: Color?
let count: Int?
let selected: Bool
let action: () -> Void
var body: some View {
HStack(spacing: 8) {
if let color { RoundedRectangle(cornerRadius: 3).fill(color).frame(width: 8, height: 8) }
Text(label).font(.callout)
.foregroundStyle(selected ? Sage.brandDark : Sage.ink)
.fontWeight(selected ? .bold : .regular)
.lineLimit(1)
Spacer()
if let count { Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted) }
}
.padding(.vertical, 2)
.contentShape(Rectangle())
.onTapGesture(perform: action)
.listRowBackground(selected ? Sage.brand.opacity(0.14) : Color.clear)
}
}
// MARK: - Section router
/// detail . inspector/list .
struct SectionDetail: View {
@Environment(AppModel.self) private var model
var body: some View {
Group {
switch model.section {
case .dashboard: DashboardView()
case .documents: DocumentListView()
case .search: SearchView()
case .ask: AskView()
case .memos: MemoListView()
case .digest: DigestView()
case .dashboard: DashboardView() //
case .documents: DocumentsBrowser() // HSplitView 3-pane
case .digest: DigestView() // ( )
case .memos: MemosBoard() // + ( )
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Sage.surface)
.navigationTitle(model.section.title)
}
}
struct DetailColumn: View {
/// v1 + split ( ).
struct MemosBoard: View {
@Environment(AppModel.self) private var model
var body: some View {
Group {
switch model.section {
case .documents:
if let d = model.documentDetail { DocumentDetailView(detail: d) }
else { EmptyState(text: "문서를 선택하세요") }
case .memos:
HSplitView {
MemoListView()
.frame(minWidth: 300, idealWidth: 360, maxWidth: 460)
Group {
if let m = model.memoDetail { MemoDetailView(memo: m) }
else { EmptyState(text: "메모를 선택하세요") }
default:
EmptyState(text: model.section.title)
}
.frame(minWidth: 360, maxWidth: .infinity)
}
}
}
@@ -149,11 +216,96 @@ struct EmptyState: View {
}
}
// MARK: - Toolbar items
/// NSOpenPanel . .
struct UploadToolbarButton: View {
@Environment(AppModel.self) private var model
var body: some View {
Button {
guard let fileURL = FilePanels.pickFileToUpload() else { return }
Task { await model.uploadPicked(fileURL) }
} label: {
Label("업로드", systemImage: "square.and.arrow.up")
}
.help("문서 업로드")
.disabled(isUploading)
}
private var isUploading: Bool {
if case .uploading = model.uploadState { return true }
return false
}
}
/// + ( ).
struct AccountMenu: View {
@Environment(AppModel.self) private var model
@State private var confirmLogout = false
var body: some View {
Menu {
Button("로그아웃", role: .destructive) { confirmLogout = true }
} label: {
Label(model.currentUser?.username ?? "계정", systemImage: "person.crop.circle")
}
.help("계정")
.confirmationDialog("로그아웃하시겠습니까?", isPresented: $confirmLogout, titleVisibility: .visible) {
Button("로그아웃", role: .destructive) { Task { await model.logout() } }
Button("취소", role: .cancel) {}
}
}
}
/// / . uploading=( ) / done=( )+ / failed=+.
struct UploadStatusBar: View {
@Environment(AppModel.self) private var model
var body: some View {
switch model.uploadState {
case .idle:
EmptyView()
case .uploading(let name):
row(bg: Sage.brand) {
ProgressView().controlSize(.small).tint(.white)
Text("업로드 중 — \(name)").font(.callout).foregroundStyle(.white).lineLimit(1)
Spacer()
}
case .done(let title):
row(bg: Sage.brand) {
Text("업로드 완료 — \(title) (처리 대기 중)").font(.callout).foregroundStyle(.white).lineLimit(1)
Spacer()
closeButton
}
case .failed(let msg):
row(bg: Sage.danger) {
Text("업로드 실패 — \(msg)").font(.callout).foregroundStyle(.white).lineLimit(2)
Spacer()
closeButton
}
}
}
private var closeButton: some View {
Button("닫기") { model.dismissUploadStatus() }
.buttonStyle(.plain)
.foregroundStyle(.white.opacity(0.85))
}
private func row<Content: View>(bg: Color, @ViewBuilder _ content: () -> Content) -> some View {
HStack(spacing: 10) { content() }
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(bg)
}
}
#if DEBUG
#Preview("DS App — full shell") {
@Previewable @State var model = AppModel.preview
RootView()
.environment(model)
.frame(minWidth: 1000, minHeight: 660)
.frame(minWidth: 1100, minHeight: 700)
}
#endif
@@ -2,23 +2,24 @@ import SwiftUI
import Observation
import DSKit
import AIFabric
import UniformTypeIdentifiers
/// The single app-state store driving the 3-pane shell. @MainActor @Observable: mutations are
/// main-isolated; the DSClient returns Sendable models; AIService is an actor.
@MainActor
@Observable
public final class AppModel {
/// = ···. (ask)·(AI chat) v1 macOS (2026-06-15)
/// AIFabric(S2) iPhone/Watch , UI .
public enum Section: String, CaseIterable, Identifiable, Hashable {
case dashboard, documents, search, ask, memos, digest
case dashboard, documents, digest, memos
public var id: String { rawValue }
public var title: String {
switch self {
case .dashboard: return "대시보드"
case .dashboard: return ""
case .documents: return "문서"
case .search: return "검색"
case .ask: return "질문"
case .memos: return "메모"
case .digest: return "뉴스"
case .memos: return "메모"
}
}
}
@@ -27,19 +28,33 @@ public final class AppModel {
/// (ready). Fixture refresh fixture ready.
public enum AuthPhase: Equatable { case checking, loggedOut, ready }
/// / + . done/failed .
public enum UploadState: Equatable, Sendable {
case idle
case uploading(name: String)
case done(title: String)
case failed(String)
}
public var section: Section = .dashboard
public var selectedDocumentID: Int?
public var selectedMemoID: Int?
public var tree: [DomainTreeNode] = []
public var stats: CategoryCounts?
/// ( ). loadInitial count . nil=.
public var reviewPendingCount: Int?
/// ( ). loadInitial me() .
public var currentUser: UserResponse?
public private(set) var uploadState: UploadState = .idle
/// (CaptureCard , saveMemo ).
public var captureText: String = ""
public var documentList: [DocumentResponse] = []
public var documentDetail: DocumentDetailResponse?
public var searchQuery: String = ""
public var searchResponse: SearchResponse?
public var askQuery: String = ""
public var askResult: AIResult?
public var askMeta: DSKit.AskResponse? // qualified: AIFabric also defines an AskResponse
/// ( path, nil = ).
public var documentDomainFilter: String?
/// ( load-all ). .
public private(set) var documentsFullyLoaded = false
public var memoList: [MemoResponse] = []
public var memoDetail: MemoResponse?
public var digest: DigestResponse?
@@ -129,11 +144,16 @@ public final class AppModel {
}
public func loadInitial() async {
await guarded { self.currentUser = try await self.client.me() }
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 }
await guarded { self.memoList = try await self.client.memos(MemoListQuery()).items }
await guarded { self.digest = try await self.client.digest(date: nil, country: nil) }
await guarded {
var q = DocumentListQuery(); q.reviewStatus = "pending"; q.pageSize = 1
self.reviewPendingCount = try await self.client.documents(q).total
}
}
public func openDocument(_ id: Int) async {
@@ -141,15 +161,60 @@ public final class AppModel {
await guarded { self.documentDetail = try await self.client.document(id: id) }
}
public func runSearch() async {
guard !searchQuery.isEmpty else { return }
await guarded { self.searchResponse = try await self.client.search(q: self.searchQuery, mode: .hybrid, page: 1, debug: false) }
/// ( ). load-all.
public func ensureDocumentsLoaded() async {
if !documentsFullyLoaded { await loadDocuments(domain: documentDomainFilter) }
}
public func runAsk(backend: AIProviderID?) async {
guard !askQuery.isEmpty else { return }
askResult = await ai.corpusAsk(question: askQuery, explicit: backend)
await guarded { self.askMeta = try await self.client.ask(q: self.askQuery, limit: nil, backend: nil, debug: false) }
/// **** load-all ( page_size 100
/// 1582 ). append .
/// / 3-pane .
public func loadDocuments(domain: String?) async {
documentDomainFilter = domain
documentsFullyLoaded = false
documentList = []
let pageSize = 100
var page = 1
do {
while page <= 80 { // ~8000
var q = DocumentListQuery(); q.domain = domain; q.page = page; q.pageSize = pageSize
let resp = try await client.documents(q)
documentList.append(contentsOf: resp.items)
if resp.items.count < pageSize || documentList.count >= resp.total { break }
page += 1
}
documentsFullyLoaded = true
} catch let e as DSError where e.isAuthExpired {
authPhase = .loggedOut
loginError = "세션이 만료되었습니다. 다시 로그인하세요."
} catch {
errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
await syncAccessToken()
if let sel = selectedDocumentID, !documentList.contains(where: { $0.id == sel }) {
selectedDocumentID = nil
documentDetail = nil
}
}
/// . true. / (false).
/// guarded errorText ( ).
@discardableResult
public func saveMemo(_ text: String) async -> Bool {
let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !t.isEmpty else { return false }
var ok = false
await guarded {
let memo = try await self.client.createMemo(MemoCreate(content: t))
self.memoList.insert(memo, at: 0)
ok = true
}
return ok
}
/// captureText , .
public func saveMemo() async {
if await saveMemo(captureText) { captureText = "" }
}
public func openMemo(_ id: Int) async {
@@ -162,6 +227,67 @@ public final class AppModel {
return DSDownload.fileURL(base: base, documentID: doc.id, accessToken: accessToken)
}
/// : / (best-effort) loggedOut.
/// stale . .
public func logout() async {
try? await client.logout()
accessToken = ""
currentUser = nil
tree = []
stats = nil
reviewPendingCount = nil
captureText = ""
documentList = []
documentDetail = nil
documentDomainFilter = nil
documentsFullyLoaded = false
memoList = []
memoDetail = nil
digest = nil
selectedDocumentID = nil
selectedMemoID = nil
section = .dashboard // ( LOW: )
errorText = nil
uploadState = .idle
authPhase = .loggedOut
}
/// (NSOpenPanel URL) . IO uploadState .
public func uploadPicked(_ fileURL: URL) async {
let accessed = fileURL.startAccessingSecurityScopedResource()
defer { if accessed { fileURL.stopAccessingSecurityScopedResource() } }
let filename = fileURL.lastPathComponent
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch {
uploadState = .failed("파일을 읽을 수 없습니다: \((error as NSError).localizedDescription)")
return
}
let mime = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType
await upload(DocumentUpload(filename: filename, data: data, mimeType: mime))
}
/// + . ( = ).
public func upload(_ payload: DocumentUpload) async {
uploadState = .uploading(name: payload.filename)
do {
let doc = try await client.uploadDocument(payload)
uploadState = .done(title: doc.title ?? doc.downloadLabel)
await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items }
} catch let e as DSError where e.isAuthExpired {
authPhase = .loggedOut
loginError = "세션이 만료되었습니다. 다시 로그인하세요."
uploadState = .failed("세션이 만료되었습니다.")
} catch {
uploadState = .failed((error as? LocalizedError)?.errorDescription ?? "\(error)")
}
await syncAccessToken()
}
/// (done/failed ).
public func dismissUploadStatus() { uploadState = .idle }
private func guarded(_ work: () async throws -> Void) async {
do {
try await work()
@@ -23,6 +23,8 @@ public protocol DSClient: Sendable {
func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse
func putContent(id: Int, content: String) async throws
func deleteDocument(id: Int) async throws
/// (POST /documents/) Inbox + . 201 DocumentResponse.
func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse
// Search / Ask
func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse
@@ -53,6 +53,9 @@ public struct FixtureDSClient: DSClient {
}
public func putContent(id: Int, content: String) async throws {}
public func deleteDocument(id: Int) async throws {}
public func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse {
try load("document_detail", as: DocumentDetailResponse.self).base
}
// Search / Ask
public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse {
@@ -64,15 +64,26 @@ public final class LiveDSClient: DSClient, @unchecked Sendable {
}
private func perform(_ endpoint: DSEndpoint) async throws -> Data {
let request = try makeRequest(endpoint, token: await tokens.current())
try await performWithRetry(requiresBearer: endpoint.requiresBearer) { token in
try self.makeRequest(endpoint, token: token)
}
}
/// 401 - refresh + 1 . `build` ( )URLRequest ,
/// 401 . JSON (perform) .
private func performWithRetry(
requiresBearer: Bool,
_ build: (_ token: String?) throws -> URLRequest
) async throws -> Data {
let request = try build(await tokens.current())
let (data, response) = try await dataOrTransport(request)
guard let http = response as? HTTPURLResponse else {
throw DSError.transport(underlying: "no HTTP response")
}
if http.statusCode == 401, endpoint.requiresBearer {
if http.statusCode == 401, requiresBearer {
// Single-flight refresh + one retry.
let newToken = try await tokens.refreshOnce()
let retry = try makeRequest(endpoint, token: newToken)
let retry = try build(newToken)
let (data2, response2) = try await dataOrTransport(retry)
guard let http2 = response2 as? HTTPURLResponse else {
throw DSError.transport(underlying: "no HTTP response")
@@ -122,6 +133,44 @@ public final class LiveDSClient: DSClient, @unchecked Sendable {
public func putContent(id: Int, content: String) async throws { try await sendVoid(.putContent(id, content)) }
public func deleteDocument(id: Int) async throws { try await sendVoid(.deleteDocument(id)) }
public func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse {
let boundary = "DSBoundary-\(UUID().uuidString)"
let body = LiveDSClient.multipartBody(for: upload, boundary: boundary)
// (POST /documents/) base (appendingPathComponent strip).
let raw = base.url.absoluteString + "/documents/"
guard let url = URL(string: raw) else { throw DSError.transport(underlying: "bad URL \(raw)") }
let data = try await performWithRetry(requiresBearer: true) { token in
var request = URLRequest(url: url)
request.httpMethod = "POST"
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = body
return request
}
do { return try decoder.decode(DocumentResponse.self, from: data) }
catch { throw DSError.decoding("documents/ upload: \(error)") }
}
/// multipart/form-data . file + form (doc_purpose/library_path).
/// internal( ) UTF-8 (Starlette ).
static func multipartBody(for upload: DocumentUpload, boundary: String) -> Data {
var body = Data()
func appendField(_ name: String, _ value: String) {
body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8))
body.append(Data("\(value)\r\n".utf8))
}
if let p = upload.docPurpose { appendField("doc_purpose", p) }
if let lp = upload.libraryPath { appendField("library_path", lp) }
body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(upload.filename)\"\r\n".utf8))
body.append(Data("Content-Type: \(upload.mimeType ?? "application/octet-stream")\r\n\r\n".utf8))
body.append(upload.data)
body.append(Data("\r\n".utf8))
body.append(Data("--\(boundary)--\r\n".utf8))
return body
}
public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await send(.search(q, mode, page, debug), as: SearchResponse.self) }
public func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await send(.ask(q, limit, backend, debug), as: AskResponse.self) }
@@ -24,6 +24,25 @@ public struct MemoListQuery: Sendable {
public init() {}
}
/// (POST /documents/). `file` + form .
/// `data` ( ) .
public struct DocumentUpload: Sendable {
public var filename: String
public var data: Data
public var mimeType: String?
/// "business" | "knowledge" | nil. business @library .
public var docPurpose: String?
public var libraryPath: String?
public init(filename: String, data: Data, mimeType: String? = nil,
docPurpose: String? = nil, libraryPath: String? = nil) {
self.filename = filename
self.data = data
self.mimeType = mimeType
self.docPurpose = docPurpose
self.libraryPath = libraryPath
}
}
public struct DocumentUpdate: Codable, Sendable {
public var title: String?
public var userNote: String?
@@ -0,0 +1,50 @@
import XCTest
@testable import AppFeature
import DSKit
/// + 0 (Fixture).
final class AppModelActionsTests: XCTestCase {
// ready loggedOut + //
@MainActor
func testLogoutResetsStateAndLogsOut() async {
let model = AppModel.preview
await model.bootstrap()
XCTAssertEqual(model.authPhase, .ready)
XCTAssertFalse(model.documentList.isEmpty)
XCTAssertNotNil(model.currentUser, "loadInitial 이 me() 로 사용자 채움")
await model.logout()
XCTAssertEqual(model.authPhase, .loggedOut)
XCTAssertTrue(model.accessToken.isEmpty)
XCTAssertNil(model.currentUser)
XCTAssertTrue(model.documentList.isEmpty)
XCTAssertNil(model.documentDetail)
XCTAssertTrue(model.tree.isEmpty)
XCTAssertEqual(model.uploadState, .idle)
}
// uploadState=.done +
@MainActor
func testUploadSuccessSetsDoneAndReloads() async {
let model = AppModel.preview
await model.bootstrap()
await model.upload(DocumentUpload(filename: "x.pdf", data: Data("x".utf8), mimeType: "application/pdf"))
if case .done = model.uploadState {} else {
XCTFail("기대 .done, 실제 \(model.uploadState)")
}
XCTAssertFalse(model.documentList.isEmpty)
}
// (Equatable )
@MainActor
func testDismissUploadStatusReturnsToIdle() async {
let model = AppModel.preview
await model.bootstrap()
await model.upload(DocumentUpload(filename: "x.pdf", data: Data("x".utf8)))
model.dismissUploadStatus()
XCTAssertEqual(model.uploadState, .idle)
}
}
@@ -168,6 +168,7 @@ final class AuthStubClient: DSClient, @unchecked Sendable {
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 uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse { try await inner.uploadDocument(upload) }
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) }
@@ -0,0 +1,42 @@
import XCTest
@testable import DSKit
/// Fixture + multipart (// // ).
final class UploadTests: XCTestCase {
func testFixtureUploadReturnsDocument() async throws {
let doc = try await FixtureDSClient().uploadDocument(
DocumentUpload(filename: "a.pdf", data: Data("x".utf8), mimeType: "application/pdf"))
XCTAssertGreaterThan(doc.id, 0)
}
func testMultipartBodyShape() throws {
let upload = DocumentUpload(
filename: "보고서.pdf",
data: Data("PDFDATA".utf8),
mimeType: "application/pdf",
docPurpose: "knowledge"
)
let boundary = "TESTBOUNDARY"
let body = LiveDSClient.multipartBody(for: upload, boundary: boundary)
let s = try XCTUnwrap(String(data: body, encoding: .utf8))
XCTAssertTrue(s.contains("--TESTBOUNDARY\r\n"), "경계 마커")
XCTAssertTrue(s.contains(#"Content-Disposition: form-data; name="file"; filename=".pdf""#),
"file 파트 + 한글 파일명")
XCTAssertTrue(s.contains("Content-Type: application/pdf"), "파일 mime")
XCTAssertTrue(s.contains(#"Content-Disposition: form-data; name="doc_purpose""#), "선택 form 필드")
XCTAssertTrue(s.contains("knowledge"))
XCTAssertTrue(s.contains("PDFDATA"), "파일 데이터")
XCTAssertTrue(s.hasSuffix("--TESTBOUNDARY--\r\n"), "종료 경계")
}
func testMultipartOmitsAbsentOptionalFields() throws {
let upload = DocumentUpload(filename: "x.txt", data: Data("a".utf8))
let body = LiveDSClient.multipartBody(for: upload, boundary: "B")
let s = try XCTUnwrap(String(data: body, encoding: .utf8))
XCTAssertFalse(s.contains("doc_purpose"), "미지정 doc_purpose 는 본문에 없어야 함")
XCTAssertFalse(s.contains("library_path"), "미지정 library_path 는 본문에 없어야 함")
XCTAssertTrue(s.contains("Content-Type: application/octet-stream"), "mime 미지정 = octet-stream 폴백")
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ UserResponse { id: Int, username: String, is_active: Bool, totp_enabled: Bool, l
| GET | `/documents/{id}/content` | — | 경량 텍스트(`content` 15k cap) | `document_content.json` |
| GET | `/documents/tree` | — | 도메인 트리(사이드바) | `documents_tree.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` |
| POST | `/documents/` (multipart/form-data) | `file`(필수) + `doc_purpose?`(business\|knowledge) `library_path?` `facet_*?` | `DocumentResponse` (201) | `document_detail.json` |
| PATCH | `/documents/{id}` | `DocumentUpdate` | `DocumentResponse` | — |
| PUT | `/documents/{id}/content` | `{content}` (md 편집 저장) | `{}` | — |
| POST | `/documents/{id}/accept-suggestion` | `{expected_source_updated_at}` | `DocumentResponse` | — |
+4
View File
@@ -0,0 +1,4 @@
DSShell.xcodeproj/
Support/
.build/
*.xcuserstate
@@ -0,0 +1,74 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
},
"images" : [
{
"scale" : "1x",
"filename" : "mac_16.png",
"idiom" : "mac",
"size" : "16x16"
},
{
"idiom" : "mac",
"size" : "16x16",
"scale" : "2x",
"filename" : "mac_32.png"
},
{
"filename" : "mac_32.png",
"size" : "32x32",
"scale" : "1x",
"idiom" : "mac"
},
{
"scale" : "2x",
"idiom" : "mac",
"size" : "32x32",
"filename" : "mac_64.png"
},
{
"idiom" : "mac",
"size" : "128x128",
"filename" : "mac_128.png",
"scale" : "1x"
},
{
"size" : "128x128",
"idiom" : "mac",
"scale" : "2x",
"filename" : "mac_256.png"
},
{
"filename" : "mac_256.png",
"scale" : "1x",
"idiom" : "mac",
"size" : "256x256"
},
{
"filename" : "mac_512.png",
"scale" : "2x",
"size" : "256x256",
"idiom" : "mac"
},
{
"filename" : "mac_512.png",
"size" : "512x512",
"idiom" : "mac",
"scale" : "1x"
},
{
"filename" : "mac_1024.png",
"size" : "512x512",
"scale" : "2x",
"idiom" : "mac"
},
{
"idiom" : "universal",
"filename" : "ios_1024.png",
"size" : "1024x1024",
"platform" : "ios"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
+29
View File
@@ -0,0 +1,29 @@
import SwiftUI
/// DS document.hyungi.net . WKWebsiteDataStore.default()
/// (). ·iOS @main.
@main
struct DSShellApp: App {
private let url = URL(string: "https://document.hyungi.net")!
var body: some Scene {
WindowGroup {
RootWeb(url: url)
}
#if os(macOS)
.windowStyle(.automatic)
#endif
}
}
struct RootWeb: View {
let url: URL
var body: some View {
WebView(url: url)
.ignoresSafeArea()
#if os(macOS)
.frame(minWidth: 900, minHeight: 600)
.background(WindowOnScreenGuard()) //
#endif
}
}
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
import WebKit
#if os(macOS)
import AppKit
#else
import UIKit
#endif
/// document.hyungi.net WKWebView (=NSViewRepresentable / iOS=UIViewRepresentable).
/// = . (Content-Disposition: attachment) .
/// (file input) WKWebView .
struct WebView {
let url: URL
func makeCoordinator() -> Coordinator { Coordinator() }
@MainActor
fileprivate func makeWebView(coordinator: Coordinator) -> WKWebView {
let cfg = WKWebViewConfiguration()
cfg.websiteDataStore = .default() // ()
let wv = WKWebView(frame: .zero, configuration: cfg)
wv.navigationDelegate = coordinator
wv.allowsBackForwardNavigationGestures = true
wv.load(URLRequest(url: url))
return wv
}
final class Coordinator: NSObject, WKNavigationDelegate, WKDownloadDelegate {
// , (PDF ).
func webView(_ webView: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
if let http = navigationResponse.response as? HTTPURLResponse,
let cd = http.value(forHTTPHeaderField: "Content-Disposition"),
cd.lowercased().contains("attachment") {
decisionHandler(.download)
} else {
decisionHandler(.allow)
}
}
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
download.delegate = self
}
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
download.delegate = self
}
func download(_ download: WKDownload,
decideDestinationUsing response: URLResponse,
suggestedFilename: String) async -> URL? {
#if os(macOS)
let folder = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
#else
let folder = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
#endif
let dir = folder ?? FileManager.default.temporaryDirectory
var dest = dir.appendingPathComponent(suggestedFilename.isEmpty ? "download" : suggestedFilename)
// (name_1.ext )
let base = dest.deletingPathExtension().lastPathComponent
let ext = dest.pathExtension
var n = 1
while FileManager.default.fileExists(atPath: dest.path) {
let name = ext.isEmpty ? "\(base)_\(n)" : "\(base)_\(n).\(ext)"
dest = dir.appendingPathComponent(name); n += 1
}
return dest
}
}
}
#if os(macOS)
extension WebView: NSViewRepresentable {
func makeNSView(context: Context) -> WKWebView { makeWebView(coordinator: context.coordinator) }
func updateNSView(_ nsView: WKWebView, context: Context) {}
}
/// ( ) " " .
struct WindowOnScreenGuard: NSViewRepresentable {
func makeNSView(context: Context) -> NSView { OnScreenView() }
func updateNSView(_ nsView: NSView, context: Context) {}
final class OnScreenView: NSView {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard let win = window else { return }
if !NSScreen.screens.contains(where: { $0.visibleFrame.intersects(win.frame) }) { win.center() }
}
}
}
#else
extension WebView: UIViewRepresentable {
func makeUIView(context: Context) -> WKWebView { makeWebView(coordinator: context.coordinator) }
func updateUIView(_ uiView: WKWebView, context: Context) {}
}
#endif
+87
View File
@@ -0,0 +1,87 @@
# DS 웹 래퍼 — document.hyungi.net 을 WKWebView 로 감싼 네이티브 앱(맥 + iOS).
# 웹 UI 100% 재사용·항상 최신·코드 1벌(2026-06-15 결정). 순수 네이티브는 워치(clients/ds-watch)만.
# project.yml = source of truth, *.xcodeproj/Support = 생성물(gitignore).
name: DSShell
options:
bundleIdPrefix: net.hyungi
deploymentTarget:
macOS: "14.0"
iOS: "17.0"
createIntermediateGroups: true
minimumXcodeGenVersion: "2.40.0"
settings:
base:
SWIFT_VERSION: "6.0"
CODE_SIGN_STYLE: Automatic
# 실기기 설치(맥/아이폰)는 Xcode 자동서명(본인 Apple ID 팀). 헤드리스 빌드만 CLI 로 CODE_SIGNING_ALLOWED=NO 전달.
GENERATE_INFOPLIST_FILE: "NO"
targets:
DSShellMac:
type: application
platform: macOS
deploymentTarget: "14.0"
sources:
- path: Sources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dsshell
PRODUCT_NAME: DS
MARKETING_VERSION: "0.1"
CURRENT_PROJECT_VERSION: "1"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
info:
path: Support/Mac-Info.plist
properties:
CFBundleName: DS
CFBundleDisplayName: DS
CFBundleShortVersionString: "0.1"
CFBundleVersion: "1"
CFBundlePackageType: APPL
LSMinimumSystemVersion: "14.0"
LSApplicationCategoryType: public.app-category.productivity
entitlements:
path: Support/Mac.entitlements
properties:
com.apple.security.app-sandbox: true
com.apple.security.network.client: true
com.apple.security.files.downloads.read-write: true # 원본 다운로드 저장
com.apple.security.files.user-selected.read-write: true # 업로드 파일 선택
DSShelliOS:
type: application
platform: iOS
deploymentTarget: "17.0"
sources:
- path: Sources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dsshell
PRODUCT_NAME: DS
MARKETING_VERSION: "0.1"
CURRENT_PROJECT_VERSION: "1"
TARGETED_DEVICE_FAMILY: "1,2"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
info:
path: Support/iOS-Info.plist
properties:
CFBundleName: DS
CFBundleDisplayName: DS
CFBundleShortVersionString: "0.1"
CFBundleVersion: "1"
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
schemes:
DSShellMac:
build:
targets: { DSShellMac: all }
run: { config: Debug }
DSShelliOS:
build:
targets: { DSShelliOS: all }
run: { config: Debug }
+5
View File
@@ -0,0 +1,5 @@
# xcodegen 생성물 (project.yml 이 source of truth)
DSWatch.xcodeproj/
Support/
.build/
*.xcuserstate
@@ -0,0 +1,6 @@
{
"images" : [
{ "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024", "filename" : "watch_1024.png" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
+28
View File
@@ -0,0 +1,28 @@
import SwiftUI
/// DS (standalone). 4 = (AI)·()· ·.
/// = (/study-cards/due·rate) / = . OLED.
@main
struct DSWatchApp: App {
@State private var model = WatchModel()
var body: some Scene {
WindowGroup {
RootGate()
.environment(model)
.task { await model.bootstrap() }
}
}
}
/// : checking( ) loggedOut() ready().
struct RootGate: View {
@Environment(WatchModel.self) private var model
var body: some View {
switch model.phase {
case .checking: ProgressView()
case .loggedOut: LoginView()
case .ready: RootMenu()
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import WatchKit
/// / ( ).
@MainActor
enum Haptics {
static func success() { WKInterfaceDevice.current().play(.success) }
static func retry() { WKInterfaceDevice.current().play(.retry) }
static func click() { WKInterfaceDevice.current().play(.click) }
}
+262
View File
@@ -0,0 +1,262 @@
import Foundation
/// API . DS TLS(document.hyungi.net) Tailscale .
/// access = / refresh =HTTPCookieStorage(7 ) 1 .
/// Pydantic (study_cards.py CardItem/RateBody) .
enum WatchAPI {
static let baseString = "https://document.hyungi.net/api"
}
/// GET /study-cards/due CardItem ( ).
struct WCard: Decodable, Identifiable, Sendable {
let id: Int
let format: String
let cue: String
let fact: String
let clozeText: String?
let needsReview: Bool
let reviewStage: Int?
enum CodingKeys: String, CodingKey {
case id, format, cue, fact
case clozeText = "cloze_text"
case needsReview = "needs_review"
case reviewStage = "review_stage"
}
}
/// GET /events/today EventResponse ( ).
struct WEvent: Decodable, Identifiable, Sendable {
let id: Int
let title: String
let status: String
let dueAt: String?
let completedAt: String?
enum CodingKeys: String, CodingKey {
case id, title, status
case dueAt = "due_at"
case completedAt = "completed_at"
}
var isDone: Bool { status == "completed" || completedAt != nil }
}
private struct WEventList: Decodable { let items: [WEvent] }
/// GET /briefing/latest / ( ).
struct WPerspective: Decodable, Identifiable, Sendable {
let country: String
let summary: String
var id: String { country }
}
struct WTopic: Decodable, Identifiable, Sendable {
let id: Int
let topicLabel: String
let headline: String
let countryPerspectives: [WPerspective]
enum CodingKeys: String, CodingKey {
case id, headline
case topicLabel = "topic_label"
case countryPerspectives = "country_perspectives"
}
}
struct WBriefing: Decodable, Sendable {
let status: String
let headlineOneliner: String?
let topics: [WTopic]
enum CodingKeys: String, CodingKey {
case status, topics
case headlineOneliner = "headline_oneliner"
}
}
/// SSE unavailable( /).
struct ChatResult: Sendable {
let answer: String
let unavailable: Bool
let reason: String?
}
private struct AccessTokenBody: Decodable { let accessToken: String
enum CodingKeys: String, CodingKey { case accessToken = "access_token" } }
enum WCError: Error, LocalizedError {
case transport(String)
case http(Int, String?)
case decoding(String)
var errorDescription: String? {
switch self {
case .transport(let m): return "네트워크 오류: \(m)"
case .http(let s, let m): return m ?? "서버 오류 (\(s))"
case .decoding(let m): return "응답 해석 실패: \(m)"
}
}
var isUnauthorized: Bool { if case .http(401, _) = self { return true }; return false }
}
actor WatchClient {
private let session: URLSession
private var accessToken: String?
init() {
let cfg = URLSessionConfiguration.default
cfg.httpCookieStorage = .shared
cfg.httpShouldSetCookies = true
cfg.waitsForConnectivity = true
session = URLSession(configuration: cfg)
}
private func url(_ path: String) -> URL { URL(string: WatchAPI.baseString + "/" + path)! }
private func send(_ req: URLRequest) async throws -> (Data, HTTPURLResponse) {
do {
let (d, r) = try await session.data(for: req)
guard let h = r as? HTTPURLResponse else { throw WCError.transport("no HTTP response") }
return (d, h)
} catch let e as WCError { throw e }
catch { throw WCError.transport("\(error.localizedDescription)") }
}
private static func decodeMessage(_ data: Data) -> String? {
guard let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
if let s = o["detail"] as? String { return s }
if let d = o["detail"] as? [String: Any] { return d["message"] as? String }
return nil
}
// MARK: auth
func login(username: String, password: String, totp: String?) async throws {
var req = URLRequest(url: url("auth/login"))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = ["username": username, "password": password]
if let totp, !totp.isEmpty { body["totp_code"] = totp }
req.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, http) = try await send(req)
guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) }
accessToken = try decodeToken(data)
}
@discardableResult
func refresh() async throws -> String {
var req = URLRequest(url: url("auth/refresh"))
req.httpMethod = "POST"
let (data, http) = try await send(req)
guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) }
let t = try decodeToken(data)
accessToken = t
return t
}
func logout() async {
accessToken = nil
var req = URLRequest(url: url("auth/logout")); req.httpMethod = "POST"
_ = try? await send(req)
}
private func decodeToken(_ data: Data) throws -> String {
do { return try JSONDecoder().decode(AccessTokenBody.self, from: data).accessToken }
catch { throw WCError.decoding("token: \(error)") }
}
// MARK: authed request (401 single refresh + retry)
private func authed(_ path: String, method: String = "GET", json: [String: Any]? = nil) async throws -> Data {
func make(_ token: String?) throws -> URLRequest {
var r = URLRequest(url: url(path))
r.httpMethod = method
if let token { r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
if let json { r.httpBody = try JSONSerialization.data(withJSONObject: json); r.setValue("application/json", forHTTPHeaderField: "Content-Type") }
return r
}
let (data, http) = try await send(make(accessToken))
if http.statusCode == 401 {
let newToken = try await refresh()
let (d2, h2) = try await send(make(newToken))
guard (200..<300).contains(h2.statusCode) else { throw WCError.http(h2.statusCode, Self.decodeMessage(d2)) }
return d2
}
guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) }
return data
}
// MARK: study cards
func dueCards() async throws -> [WCard] {
let data = try await authed("study-cards/due")
do { return try JSONDecoder().decode([WCard].self, from: data) }
catch { throw WCError.decoding("due: \(error)") }
}
func rate(cardId: Int, outcome: String) async throws {
_ = try await authed("study-cards/\(cardId)/rate", method: "POST", json: ["outcome": outcome])
}
func flag(cardId: Int) async throws {
_ = try await authed("study-cards/\(cardId)", method: "PATCH", json: ["needs_review": true])
}
// MARK: events ()
func events() async throws -> [WEvent] {
let data = try await authed("events/today")
do { return try JSONDecoder().decode(WEventList.self, from: data).items }
catch { throw WCError.decoding("events: \(error)") }
}
func completeEvent(id: Int) async throws {
_ = try await authed("events/\(id)/complete", method: "POST")
}
// MARK: briefing ( )
func briefing() async throws -> WBriefing {
let data = try await authed("briefing/latest")
do { return try JSONDecoder().decode(WBriefing.self, from: data) }
catch { throw WCError.decoding("briefing: \(error)") }
}
// MARK: eid chat (SSE 26B via DS )
func chat(_ text: String) async throws -> ChatResult {
let payload: [String: Any] = ["mode": "daily", "messages": [["role": "user", "content": text]]]
func make(_ token: String?) throws -> URLRequest {
var r = URLRequest(url: url("eid/chat"))
r.httpMethod = "POST"
r.timeoutInterval = 120
if let token { r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
r.setValue("text/event-stream", forHTTPHeaderField: "Accept")
r.httpBody = try JSONSerialization.data(withJSONObject: payload)
return r
}
var (stream, resp) = try await session.bytes(for: make(accessToken))
if (resp as? HTTPURLResponse)?.statusCode == 401 {
let t = try await refresh()
(stream, resp) = try await session.bytes(for: make(t))
}
guard let http = resp as? HTTPURLResponse else { throw WCError.transport("no HTTP response") }
let ctype = http.value(forHTTPHeaderField: "Content-Type") ?? ""
if ctype.contains("text/event-stream") {
var answer = ""
for try await line in stream.lines {
guard line.hasPrefix("data:") else { continue }
let body = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
if body == "[DONE]" || body.isEmpty { continue }
if let d = body.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: d) as? [String: Any],
let choices = obj["choices"] as? [[String: Any]],
let delta = choices.first?["delta"] as? [String: Any],
let content = delta["content"] as? String {
answer += content
}
}
return ChatResult(answer: answer, unavailable: answer.isEmpty,
reason: answer.isEmpty ? "빈 응답" : nil)
}
// - = unavailable JSONResponse ( /) .
var raw = Data()
for try await b in stream { raw.append(b) }
return ChatResult(answer: "", unavailable: true, reason: Self.decodeMessage(raw) ?? "이드 연결 불가")
}
}
+46
View File
@@ -0,0 +1,46 @@
import SwiftUI
/// = 4 . .
struct RootMenu: View {
var body: some View {
NavigationStack {
List {
NavigationLink { EidView() } label: {
MenuRow(symbol: "bubble.left.and.bubble.right.fill", title: "이드", sub: "AI 채팅")
}
NavigationLink { StudyView() } label: {
MenuRow(symbol: "rectangle.on.rectangle.angled.fill", title: "공부", sub: "암기 카드")
}
NavigationLink { TodoView() } label: {
MenuRow(symbol: "checklist", title: "할 일", sub: "오늘")
}
NavigationLink { BriefingView() } label: {
MenuRow(symbol: "newspaper.fill", title: "브리핑", sub: "모닝")
}
}
.navigationTitle("DS")
}
.tint(WT.accent)
}
}
struct MenuRow: View {
let symbol: String
let title: String
let sub: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: symbol)
.font(.system(size: 16))
.foregroundStyle(WT.accent)
.frame(width: 24)
VStack(alignment: .leading, spacing: 1) {
Text(title).font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink)
Text(sub).font(.system(size: 11)).foregroundStyle(WT.muted)
}
}
.padding(.vertical, 3)
}
}
#Preview { RootMenu() }
+160
View File
@@ -0,0 +1,160 @@
import SwiftUI
// MARK: - (Todo) GET /events/today + POST /complete
struct TodoView: View {
@Environment(WatchModel.self) private var model
@State private var loaded = false
var body: some View {
Group {
if model.eventsLoading && model.events.isEmpty {
ProgressView()
} else if let e = model.eventsError, model.events.isEmpty {
retry("불러오기 실패\n\(e)") { await model.loadEvents() }
} else if model.events.isEmpty {
retry("오늘 할 일이 없어요", color: WT.muted) { await model.loadEvents() }
} else {
List(model.events) { ev in
Button {
if !ev.isDone { Haptics.success() }
Task { await model.completeEvent(ev.id) }
} label: {
HStack(spacing: 10) {
Image(systemName: ev.isDone ? "checkmark.circle.fill" : "circle")
.font(.system(size: 17))
.foregroundStyle(ev.isDone ? WT.accent : WT.muted)
Text(ev.title)
.font(.system(size: 14))
.foregroundStyle(ev.isDone ? WT.muted : WT.ink)
.strikethrough(ev.isDone, color: WT.muted)
Spacer()
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
}
}
}
.navigationTitle("할 일")
.task { if !loaded { loaded = true; await model.loadEvents() } }
}
}
// MARK: - () GET /briefing/latest,
struct BriefingView: View {
@Environment(WatchModel.self) private var model
@State private var loaded = false
var body: some View {
ScrollView {
if model.briefingLoading && model.briefing == nil {
ProgressView().padding(.top, 20)
} else if let e = model.briefingError, model.briefing == nil {
retry("불러오기 실패\n\(e)") { await model.loadBriefing() }
} else if let b = model.briefing, !b.topics.isEmpty {
VStack(alignment: .leading, spacing: 10) {
if let one = b.headlineOneliner, !one.isEmpty {
Text(one).font(.system(size: 15, weight: .semibold)).foregroundStyle(WT.ink)
}
ForEach(b.topics) { t in
VStack(alignment: .leading, spacing: 5) {
Text(t.headline).font(.system(size: 13, weight: .semibold)).foregroundStyle(WT.ink)
ForEach(t.countryPerspectives) { p in
HStack(alignment: .top, spacing: 5) {
Text(p.country.uppercased())
.font(.system(size: 9, weight: .bold)).foregroundStyle(WT.accent)
.frame(minWidth: 22, alignment: .leading)
Text(p.summary).font(.system(size: 11)).foregroundStyle(WT.muted).lineLimit(4)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(WT.card, in: RoundedRectangle(cornerRadius: 12))
}
}
} else {
retry("오늘 브리핑이 아직 없어요", color: WT.muted) { await model.loadBriefing() }
}
}
.navigationTitle("브리핑")
.task { if !loaded { loaded = true; await model.loadBriefing() } }
}
}
// MARK: - (AI ) POST /eid/chat ( 26B via DS )
struct EidView: View {
@Environment(WatchModel.self) private var model
@State private var draft = ""
var body: some View {
ScrollView {
VStack(spacing: 8) {
HStack(spacing: 6) {
TextField("물어보기…", text: $draft)
.textFieldStyle(.plain)
.padding(8)
.background(WT.card, in: RoundedRectangle(cornerRadius: 10))
Button {
let t = draft; draft = ""
Task { await model.sendChat(t) }
} label: {
Image(systemName: "arrow.up.circle.fill").font(.system(size: 22))
}
.buttonStyle(.plain)
.foregroundStyle(WT.accent)
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.chatSending)
}
if model.chatSending {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("이드 생각 중…").font(.system(size: 11)).foregroundStyle(WT.muted)
}
}
ForEach(model.chatTurns.reversed()) { turn in
ChatBubble(turn: turn)
}
if model.chatTurns.isEmpty && !model.chatSending {
Text("음성·키보드로 묻고\n맥미니 26B 가 답합니다")
.font(.system(size: 11)).foregroundStyle(WT.muted)
.multilineTextAlignment(.center).padding(.top, 8)
}
}
}
.navigationTitle("이드")
}
}
private struct ChatBubble: View {
let turn: WatchModel.ChatTurn
var body: some View {
let isUser = turn.role == "user"
let isError = turn.role == "error"
HStack {
if isUser { Spacer(minLength: 24) }
Text(turn.text)
.font(.system(size: 12))
.foregroundStyle(isUser ? .black : (isError ? WT.danger : WT.ink))
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
.padding(8)
.background(isUser ? WT.accent : (isError ? WT.danger.opacity(0.15) : WT.card),
in: RoundedRectangle(cornerRadius: 10))
if !isUser { Spacer(minLength: 24) }
}
}
}
// MARK: - /
@MainActor
private func retry(_ text: String, color: Color = WT.danger, _ action: @escaping () async -> Void) -> some View {
VStack(spacing: 10) {
Text(text).font(.system(size: 13)).foregroundStyle(color).multilineTextAlignment(.center)
Button("다시 불러오기") { Task { await action() } }.tint(WT.accent)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 6).padding(.top, 16)
}
+132
View File
@@ -0,0 +1,132 @@
import SwiftUI
/// () ( cue fact) + 2 (/).
/// (B5): 2 ( ), ' ' ( /), OLED.
/// = GET /study-cards/due, = POST /{id}/rate (correct/wrong), = PATCH needs_review.
struct StudyView: View {
@Environment(WatchModel.self) private var model
@State private var index = 0
@State private var revealed = false
@State private var correctCount = 0
@State private var flagged = false
@State private var loaded = false
var body: some View {
Group {
if model.studyLoading && model.cards.isEmpty {
ProgressView()
} else if let err = model.studyError, model.cards.isEmpty {
stateText("불러오기 실패\n\(err)", color: WT.danger, retry: true)
} else if model.cards.isEmpty {
stateText("복습할 카드가 없어요", color: WT.muted, retry: true)
} else if index >= model.cards.count {
ResultView(total: model.cards.count, correct: correctCount) { Task { await reload() } }
} else {
cardScreen(model.cards[index])
}
}
.navigationTitle("공부")
.task { if !loaded { loaded = true; await model.loadDue(); reset() } }
}
private func cardScreen(_ c: WCard) -> some View {
VStack(spacing: 8) {
HStack {
Text("\(index + 1) / \(model.cards.count)").font(.system(size: 11)).foregroundStyle(WT.muted)
Spacer()
Button {
flagged = true
Haptics.click()
Task { await model.flag(cardId: c.id) }
} label: {
Image(systemName: flagged ? "flag.fill" : "flag")
.font(.system(size: 11)).foregroundStyle(flagged ? WT.amber : WT.muted)
}
.buttonStyle(.plain)
}
ScrollView {
VStack(spacing: 10) {
Text(c.cue)
.font(.system(size: 17, weight: .semibold)).foregroundStyle(WT.ink)
.multilineTextAlignment(.center)
if revealed {
Divider().overlay(WT.muted.opacity(0.4))
Text(c.fact)
.font(.system(size: 15)).foregroundStyle(WT.accent)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(12)
.background(WT.card, in: RoundedRectangle(cornerRadius: 14))
}
if revealed {
HStack(spacing: 8) {
rateButton("다시", sub: "내일", color: WT.danger) { advance(c, correct: false) }
rateButton("알아요", sub: nil, color: WT.accent) { advance(c, correct: true) }
}
} else {
Button { withAnimation(.easeOut(duration: 0.15)) { revealed = true } } label: {
Text("답 보기").frame(maxWidth: .infinity)
}
.tint(WT.accent)
}
}
.padding(.horizontal, 4)
}
private func rateButton(_ title: String, sub: String?, color: Color, _ action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 1) {
Text(title).font(.system(size: 14, weight: .semibold))
if let sub { Text(sub).font(.system(size: 9)).opacity(0.8) }
}
.frame(maxWidth: .infinity).padding(.vertical, 2)
}
.tint(color)
}
private func stateText(_ text: String, color: Color, retry: Bool) -> some View {
VStack(spacing: 10) {
Text(text).font(.system(size: 13)).foregroundStyle(color).multilineTextAlignment(.center)
if retry { Button("다시 불러오기") { Task { await reload() } }.tint(WT.accent) }
}
.padding(.horizontal, 6)
}
private func advance(_ c: WCard, correct: Bool) {
if correct { correctCount += 1 }
Haptics.success() // (/ )
Task { await model.rate(cardId: c.id, outcome: correct ? "correct" : "wrong") }
flagged = false
revealed = false
index += 1
}
private func reload() async { await model.loadDue(); reset() }
private func reset() { index = 0; revealed = false; correctCount = 0; flagged = false }
}
/// tally( streak X).
struct ResultView: View {
let total: Int
let correct: Int
let onRestart: () -> Void
var body: some View {
ScrollView {
VStack(spacing: 10) {
Image(systemName: "checkmark.seal.fill").font(.system(size: 30)).foregroundStyle(WT.accent)
Text("오늘 복습 완료").font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink)
Text("\(correct) / \(total) 알아요").font(.system(size: 13)).foregroundStyle(WT.muted)
Text("애매하거나 몰랐던 카드는 내일 다시 만나요")
.font(.system(size: 11)).foregroundStyle(WT.muted).multilineTextAlignment(.center)
Button("다시 불러오기", action: onRestart).tint(WT.accent).padding(.top, 4)
}
.frame(maxWidth: .infinity).padding(.vertical, 6)
}
.navigationTitle("결과")
}
}
+162
View File
@@ -0,0 +1,162 @@
import SwiftUI
import Observation
/// . refresh . .
@MainActor
@Observable
final class WatchModel {
enum Phase: Equatable { case checking, loggedOut, ready }
var phase: Phase = .checking
var loginError: String?
// (study)
var cards: [WCard] = []
var studyLoading = false
var studyError: String?
// (events)
var events: [WEvent] = []
var eventsLoading = false
var eventsError: String?
//
var briefing: WBriefing?
var briefingLoading = false
var briefingError: String?
// (chat)
struct ChatTurn: Identifiable, Sendable { let id: Int; let role: String; let text: String }
var chatTurns: [ChatTurn] = []
var chatSending = false
private var chatSeq = 0
private let client = WatchClient()
func bootstrap() async {
do { _ = try await client.refresh(); phase = .ready }
catch { phase = .loggedOut } // / =
}
func login(username: String, password: String, totp: String?) async {
loginError = nil
let code = totp?.trimmingCharacters(in: .whitespacesAndNewlines)
do {
try await client.login(username: username, password: password,
totp: (code?.isEmpty ?? true) ? nil : code)
phase = .ready
} catch {
loginError = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
}
func logout() async {
await client.logout()
cards = []; studyError = nil
phase = .loggedOut
}
func loadDue() async {
studyLoading = true; studyError = nil
do { cards = try await client.dueCards() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
studyLoading = false
}
/// (correct/wrong). ( ) .
func rate(cardId: Int, outcome: String) async {
do { try await client.rate(cardId: cardId, outcome: outcome) }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
}
func flag(cardId: Int) async {
do { try await client.flag(cardId: cardId) }
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
}
// MARK: (events)
func loadEvents() async {
eventsLoading = true; eventsError = nil
do { events = try await client.events() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { eventsError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
eventsLoading = false
}
func completeEvent(_ id: Int) async {
do { try await client.completeEvent(id: id); await loadEvents() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { eventsError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
}
// MARK:
func loadBriefing() async {
briefingLoading = true; briefingError = nil
do { briefing = try await client.briefing() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { briefingError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
briefingLoading = false
}
// MARK: (chat)
func sendChat(_ text: String) async {
let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !t.isEmpty, !chatSending else { return }
chatSeq += 1; chatTurns.append(.init(id: chatSeq, role: "user", text: t))
chatSending = true
do {
let result = try await client.chat(t)
chatSeq += 1
if result.unavailable {
chatTurns.append(.init(id: chatSeq, role: "error", text: result.reason ?? "이드 연결 불가"))
} else {
chatTurns.append(.init(id: chatSeq, role: "assistant", text: result.answer))
}
} catch let e as WCError where e.isUnauthorized {
phase = .loggedOut
} catch {
chatSeq += 1
chatTurns.append(.init(id: chatSeq, role: "error",
text: (error as? LocalizedError)?.errorDescription ?? "\(error)"))
}
chatSending = false
}
}
/// 1 (refresh 7 1). TOTP 6 .
struct LoginView: View {
@Environment(WatchModel.self) private var model
@State private var username = ""
@State private var password = ""
@State private var totp = ""
@State private var busy = false
var body: some View {
ScrollView {
VStack(spacing: 8) {
Text("DS 로그인").font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink)
TextField("아이디", text: $username)
.textContentType(.username)
SecureField("비밀번호", text: $password)
TextField("OTP 6자리", text: $totp)
if let err = model.loginError {
Text(err).font(.system(size: 11)).foregroundStyle(WT.danger).multilineTextAlignment(.center)
}
Button {
busy = true
Task { await model.login(username: username, password: password, totp: totp); busy = false }
} label: {
if busy { ProgressView() } else { Text("로그인").frame(maxWidth: .infinity) }
}
.tint(WT.accent)
.disabled(busy || username.isEmpty || password.isEmpty)
}
.padding(.horizontal, 4)
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import SwiftUI
/// OLED ( watch-app: --wgreen #37d67a). = OLED ·.
enum WT {
static let bg = Color.black
static let card = Color(white: 0.12)
static let accent = Color(red: 0x37 / 255, green: 0xd6 / 255, blue: 0x7a / 255) // #37d67a
static let ink = Color.white
static let muted = Color(white: 0.62)
static let amber = Color(red: 0xf2 / 255, green: 0xb6 / 255, blue: 0x3c / 255)
static let danger = Color(red: 0xe5 / 255, green: 0x6a / 255, blue: 0x5a / 255)
}
+55
View File
@@ -0,0 +1,55 @@
# DS Apple Watch 앱 (단일 타깃 standalone watchOS, WKApplication). 맥/아이폰은 웹 래퍼로 가고
# 순수 네이티브는 워치 전용(2026-06-15 사용자 결정). 시뮬레이터 빌드·스크린샷으로 검증, 실기기
# 설치는 사용자 Xcode 서명. project.yml = source of truth, *.xcodeproj/Support 는 생성물(gitignore).
name: DSWatch
options:
bundleIdPrefix: net.hyungi
deploymentTarget:
watchOS: "11.0"
createIntermediateGroups: true
minimumXcodeGenVersion: "2.40.0"
settings:
base:
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
WATCHOS_DEPLOYMENT_TARGET: "11.0"
CODE_SIGN_STYLE: Automatic
# 실기기 설치 시 Xcode 에서 Signing → 본인 Apple ID 팀 선택하면 자동 서명.
# (헤드리스 시뮬 빌드는 xcodebuild 에 CODE_SIGNING_ALLOWED=NO 를 CLI 로 전달)
targets:
DSWatch:
type: application
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: Sources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dswatch
PRODUCT_NAME: DS
GENERATE_INFOPLIST_FILE: "NO"
MARKETING_VERSION: "0.1"
CURRENT_PROJECT_VERSION: "1"
TARGETED_DEVICE_FAMILY: "4" # Apple Watch
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
info:
path: Support/Info.plist
properties:
CFBundleDisplayName: DS
CFBundleName: DS
CFBundleVersion: "1"
CFBundleShortVersionString: "0.1"
WKApplication: true # 단일 타깃 standalone 워치 앱 (컴패니언 불요)
WKWatchOnly: true # 컴패니언 iOS 앱 없는 watch-only (설치 필수 키)
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
schemes:
DSWatch:
build:
targets:
DSWatch: all
run:
config: Debug
+4 -124
View File
@@ -1,58 +1,13 @@
<script>
// /study — 학습 hub + 데일리 랜딩('오늘의 공부' 대시보드).
// 상단 = 이론 홈(진도·오늘의 개념·복습 due, 재노출 트리거). 하단 = 기존 모드 진입.
// /study — 학습 hub.
// 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수.
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity, CalendarCheck, Target } from 'lucide-svelte';
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity } from 'lucide-svelte';
let cardReviewCount = $state(0);
let questionFlagCount = $state(0);
// 오늘의 공부 (이론 홈)
let curriculum = $state(null);
let todayConcepts = $state([]);
let weakConcepts = $state([]); // 약점 개념(관련 기출 정답률 낮음)
let dashLoading = $state(true);
let readPct = $derived(
curriculum && curriculum.total ? Math.round((curriculum.read / curriculum.total) * 100) : 0
);
async function loadDashboard() {
dashLoading = true;
try {
const [cur, today] = await Promise.all([
api('/study/curriculum'),
api('/study/today-concepts?limit=6'),
]);
curriculum = cur;
todayConcepts = today?.concepts ?? [];
} catch {
// 코어 대시보드 실패해도 허브 나머지는 동작 (조용히)
} finally {
dashLoading = false;
}
// 약점 개념 = 비차단(신규 엔드포인트 실패해도 코어 대시보드 블랙아웃 방지)
try {
const weak = await api('/study/concepts/weakness-map?limit=5');
weakConcepts = weak?.weak ?? [];
} catch {}
}
async function markRead(doc) {
try {
await api(`/study/concepts/${doc.doc_id}/read`, { method: 'POST' });
todayConcepts = todayConcepts.filter((c) => c.doc_id !== doc.doc_id);
addToast('success', `회독: ${doc.title}`);
loadDashboard(); // 진도 갱신
} catch {
addToast('error', '회독 처리 실패');
}
}
onMount(async () => {
loadDashboard();
try {
const r = await api('/study-cards/needs-review/count');
cardReviewCount = r?.count ?? 0;
@@ -72,80 +27,6 @@
<p class="text-sm text-dim mt-1">주제별 퀴즈·복습(SRS)·통계 / 학습 자료 회독 / 손글씨 필사 세션.</p>
</header>
<!-- 오늘의 공부 (이론 홈 대시보드 = 데일리 트리거) -->
<section class="mb-5 rounded-lg border border-default bg-surface p-4 md:p-5">
<div class="flex items-center gap-2 mb-3">
<CalendarCheck size={18} class="text-accent" />
<h2 class="text-base font-semibold text-text">오늘의 공부</h2>
{#if curriculum}
<span class="ml-auto text-xs text-dim">이론 회독 <span class="text-text font-medium">{curriculum.read}</span> / {curriculum.total} ({readPct}%)</span>
{/if}
</div>
{#if dashLoading}
<p class="text-xs text-dim">불러오는 중…</p>
{:else}
{#if curriculum}
<div class="h-2 rounded-full bg-bg overflow-hidden mb-3">
<div class="h-full bg-accent" style="width: {readPct}%"></div>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-xs text-dim">
{#each curriculum.subjects as s}
<span>{s.subject} <span class="text-text">{s.read}/{s.total}</span></span>
{/each}
</div>
<div class="flex flex-wrap gap-2 mb-4">
<a
href="/study/topics/{curriculum.topic_id}/review-queue"
class="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-dim hover:border-accent hover:text-text transition-colors"
>
<Repeat size={13} /> 문항 복습 <span class="font-semibold text-text">{curriculum.question_due}</span>
</a>
<span class="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-dim">
<BookOpen size={13} /> 개념 재복습 <span class="font-semibold text-text">{curriculum.concept_due}</span>
</span>
</div>
{/if}
<div class="text-xs text-dim mb-2">오늘의 개념</div>
{#if todayConcepts.length === 0}
<p class="text-xs text-dim">오늘 볼 개념이 없습니다. 잘 하고 있어요.</p>
{:else}
<ul class="space-y-1.5">
{#each todayConcepts as c (c.doc_id)}
<li class="flex items-center gap-2 rounded border border-default px-3 py-2">
<span class="text-accent shrink-0 text-xs" title="빈출">{#each Array(c.freq) as _}{/each}</span>
<a href="/study/read/{c.doc_id}" class="text-sm text-text hover:text-accent truncate flex-1">{c.title}</a>
<span class="shrink-0 text-[10px] rounded-full px-2 py-0.5 {c.reason === '재복습' ? 'bg-accent/15 text-accent' : 'bg-surface border border-default text-dim'}">{c.reason}</span>
<button
type="button"
onclick={() => markRead(c)}
class="shrink-0 text-xs rounded border border-default px-2 py-1 text-dim hover:border-accent hover:text-accent transition-colors"
>읽음</button>
</li>
{/each}
</ul>
{/if}
{#if weakConcepts.length > 0}
<div class="mt-4 pt-3 border-t border-default">
<div class="text-xs text-dim mb-2 flex items-center gap-1.5">
<Target size={13} class="text-error" /> 약점 개념 <span class="text-faint">(관련 기출 정답률 낮음)</span>
</div>
<div class="flex flex-wrap gap-2">
{#each weakConcepts as w (w.doc_id)}
<a href="/study/read/{w.doc_id}"
class="text-xs rounded-full border border-error/40 bg-error/10 text-error px-3 py-1 hover:bg-error/20 transition-colors">
{w.title.replace(/^\d+_/, '')} <span class="font-semibold">{w.accuracy}%</span>
</a>
{/each}
</div>
</div>
{/if}
{/if}
</section>
<a
href="/study/topics"
class="block mb-3 p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
@@ -245,8 +126,7 @@
<div class="mt-6 p-4 rounded-lg border border-dashed border-default/60 text-xs text-dim">
<div class="font-medium text-dim mb-1">예정</div>
<ul class="list-disc list-inside space-y-0.5">
<li>개념 학습 리더 (가리고 떠올리기 · 빈출★ · 관련개념 백링크)</li>
<li>이론↔문제 연결 (개념별 정답률 · 약점 개념 지도)</li>
<li>애플워치 빠른복습 + 공부 알람(push)</li>
</ul>
</div>
</div>
@@ -1,254 +0,0 @@
<script>
/**
* /study/read/[docId] — 개념 학습 리더.
* 개념노트(가스기사 documents)를 구조(요약/본문/빈출★/관련개념)로 렌더 +
* '떠올리기' 능동 회상 토글 + 회독 SR(POST read) + 관련개념 백링크 + 이전/다음.
* 본문 렌더 = MarkdownDoc(KaTeX + docimg 내장). 서버 파싱 = /api/study/concepts/{id}.
*/
import { page } from '$app/stores';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import { BookOpen, ArrowLeft, Eye, EyeOff, Check, ChevronLeft, ChevronRight, FileQuestion } from 'lucide-svelte';
let docId = $derived($page.params.docId);
let concept = $state(null);
let relatedQ = $state(null); // 관련 기출(이론↔문제, 비차단)
let loading = $state(true);
let notFound = $state(false);
let mode = $state('read'); // 'read' | 'recall'(떠올리기)
let revealed = $state({}); // {sectionIndex: true}
let marking = $state(false);
const STAGE_LABEL = { 0: '복습 시작', 1: '복습 1단계', 2: '복습 2단계', 3: '복습 3단계', 4: '학습 완료' };
const OUTCOME_MARK = { correct: '○', wrong: '✕', unsure: '?' };
const OUTCOME_CLASS = { correct: 'text-success', wrong: 'text-error', unsure: 'text-warning' };
const outcomeMark = (o) => OUTCOME_MARK[o] ?? '';
const outcomeClass = (o) => OUTCOME_CLASS[o] ?? 'text-faint';
async function load() {
const reqId = docId; // in-flight 가드: 백링크 연타 시 stale 응답 무시
loading = true;
notFound = false;
concept = null;
relatedQ = null;
revealed = {};
mode = 'read';
try {
const data = await api(`/study/concepts/${reqId}`);
if (reqId !== docId) return; // 그새 다른 개념으로 이동 → 폐기
concept = data;
} catch (e) {
if (reqId !== docId) return;
if (e?.status === 404) notFound = true;
else addToast('error', '개념을 불러오지 못했습니다');
return; // 본문 실패 → 관련기출 스킵
} finally {
if (reqId === docId) loading = false;
}
// 관련 기출(비차단 — 실패해도 본문 표시엔 영향 없음)
try {
const rq = await api(`/study/concepts/${reqId}/questions?limit=6`);
if (reqId === docId) relatedQ = rq;
} catch {}
}
// $effect 가 마운트 1회 + docId 변경(백링크/이전·다음) 재로드를 모두 커버 (onMount 불필요)
$effect(() => {
void docId;
load();
});
function toggleMode() {
mode = mode === 'read' ? 'recall' : 'read';
revealed = {};
}
function reveal(i) {
revealed = { ...revealed, [i]: true };
}
function shown(i) {
return mode === 'read' || revealed[i];
}
async function markRead() {
marking = true;
try {
const r = await api(`/study/concepts/${docId}/read`, { method: 'POST' });
if (concept) {
concept.is_read = true;
concept.review_stage = r?.review_stage ?? concept.review_stage;
concept.due_at = r?.due_at ?? concept.due_at;
}
addToast('success', '회독 완료 — 다음 복습에 다시 나옵니다');
} catch {
addToast('error', '회독 처리 실패');
} finally {
marking = false;
}
}
</script>
<svelte:head><title>{concept?.title ?? '개념'} — 공부</title></svelte:head>
<div class="p-4 md:p-6 max-w-3xl mx-auto">
<!-- 상단 네비 -->
<div class="flex items-center gap-2 text-xs md:text-sm mb-4 min-w-0">
<a href="/study" class="text-dim hover:text-text flex items-center gap-1 shrink-0">
<ArrowLeft size={14} /> 공부
</a>
{#if concept?.subject}
<span class="text-faint shrink-0">/</span>
<span class="text-dim truncate">{concept.subject}</span>
{/if}
</div>
{#if loading}
<Skeleton h="h-10" rounded="card" />
<div class="mt-3 space-y-2">
{#each Array(4) as _}<Skeleton h="h-24" rounded="card" />{/each}
</div>
{:else if notFound}
<EmptyState icon={BookOpen} title="개념을 찾을 없습니다" description="삭제되었거나 잘못된 주소입니다." />
{:else if concept}
<!-- 제목 + 빈출 tier -->
<header class="mb-3">
<div class="flex items-start gap-2">
<h1 class="text-xl md:text-2xl font-semibold text-text flex-1">{concept.title}</h1>
<span class="text-accent text-sm shrink-0 mt-1" title="빈출도">
{#each Array(concept.freq) as _}{/each}
</span>
</div>
{#if concept.is_read || (concept.review_stage !== null && concept.review_stage !== undefined)}
<div class="mt-1 text-xs text-dim">
{#if concept.review_stage !== null && concept.review_stage !== undefined}
{STAGE_LABEL[concept.review_stage] ?? '복습 중'}
{:else}회독함{/if}
</div>
{/if}
</header>
<!-- 한 줄 요약 (고정 표시) -->
{#if concept.summary}
<div class="mb-4 rounded-lg border-l-4 border-accent bg-accent/10 px-4 py-3 markdown-body text-sm text-text">
{@html renderMathMarkdownInline(concept.summary)}
</div>
{/if}
<!-- 모드 토글 -->
<div class="flex items-center gap-2 mb-4">
<Button variant={mode === 'recall' ? 'primary' : 'secondary'} size="sm" icon={mode === 'recall' ? EyeOff : Eye} onclick={toggleMode}>
{mode === 'recall' ? '떠올리기 모드' : '읽기 모드'}
</Button>
{#if mode === 'recall'}
<span class="text-xs text-dim">각 섹션을 떠올린 뒤 확인하세요</span>
{/if}
</div>
<!-- 본문 섹션 -->
{#if concept.body.length > 0}
<div class="space-y-3 mb-5">
{#each concept.body as sec, i (i)}
<section class="rounded-lg border border-default bg-surface overflow-hidden">
<div class="flex items-center gap-2 px-4 py-2.5 border-b border-default bg-surface-hover">
<h2 class="text-sm font-semibold text-text flex-1">{sec.label}</h2>
{#if sec.stars > 0}
<span class="text-accent text-xs shrink-0">{#each Array(sec.stars) as _}{/each}</span>
{/if}
</div>
{#if shown(i)}
<div class="px-4 py-3">
<MarkdownDoc documentId={concept.doc_id} mdContent={sec.md} mdStatus={null}
class="markdown-body max-w-none text-text" />
</div>
{:else}
<button type="button" onclick={() => reveal(i)}
class="w-full px-4 py-6 text-center text-sm text-dim hover:text-accent hover:bg-accent/5 transition-colors">
<Eye size={16} class="inline mr-1" /> 떠올린 뒤 확인
</button>
{/if}
</section>
{/each}
</div>
{/if}
<!-- 빈출 포인트 -->
{#if concept.bincheol.length > 0}
<section class="mb-5 rounded-lg border border-default bg-surface p-4">
<h2 class="text-sm font-semibold text-text mb-2 flex items-center gap-1.5">
<span class="text-accent"></span> 빈출 포인트
</h2>
<ul class="space-y-1.5">
{#each concept.bincheol as item}
<li class="flex gap-2 text-sm text-text">
<span class="text-accent shrink-0 text-xs mt-0.5">{#each Array(item.tier || 1) as _}{/each}</span>
<span class="markdown-body flex-1">{@html renderMathMarkdownInline(item.text)}</span>
</li>
{/each}
</ul>
</section>
{/if}
<!-- 관련 개념 (백링크) -->
{#if concept.related.length > 0}
<section class="mb-5">
<h2 class="text-xs text-dim mb-2">관련 개념</h2>
<div class="flex flex-wrap gap-2">
{#each concept.related as rel}
{#if rel.doc_id}
<a href="/study/read/{rel.doc_id}"
class="text-xs rounded-full border border-accent/40 bg-accent/10 text-accent px-3 py-1 hover:bg-accent/20 transition-colors">
{rel.phrase}
</a>
{:else}
<span class="text-xs rounded-full border border-default bg-surface text-faint px-3 py-1" title="아직 없는 개념">
{rel.phrase}
</span>
{/if}
{/each}
</div>
</section>
{/if}
<!-- 관련 기출 (이론↔문제 브리지) -->
{#if relatedQ && relatedQ.linked > 0}
<section class="mb-5 rounded-lg border border-default bg-surface p-4">
<h2 class="text-sm font-semibold text-text mb-2 flex items-center gap-1.5">
<FileQuestion size={15} class="text-accent" /> 관련 기출
<span class="ml-1 text-xs font-normal text-dim">
{relatedQ.linked}문항{#if relatedQ.accuracy !== null} · 정답률 <span class="{relatedQ.accuracy < 60 ? 'text-error' : 'text-text'} font-medium">{relatedQ.accuracy}%</span>{:else} · 아직 안 풂{/if}
</span>
</h2>
<ul class="space-y-0.5">
{#each relatedQ.questions as q (q.id)}
<li>
<a href="/study/topics/4/questions/{q.id}"
class="flex items-center gap-2 text-xs py-1 text-dim hover:text-accent transition-colors">
<span class="{outcomeClass(q.last_outcome)} shrink-0 w-4 text-center font-bold">{outcomeMark(q.last_outcome)}</span>
<span class="truncate">{q.subject ?? '기출'}{#if q.exam_round} · {q.exam_round}{/if}</span>
</a>
</li>
{/each}
</ul>
</section>
{/if}
<!-- 액션바 -->
<div class="flex items-center gap-2 border-t border-default pt-4 mt-2">
{#if concept.prev_id}
<Button variant="ghost" size="sm" icon={ChevronLeft} href="/study/read/{concept.prev_id}">이전</Button>
{/if}
<div class="flex-1"></div>
<Button variant="primary" size="sm" icon={Check} onclick={markRead} loading={marking}>
{concept.is_read ? '다시 회독' : '회독 완료'}
</Button>
{#if concept.next_id}
<Button variant="secondary" size="sm" icon={ChevronRight} href="/study/read/{concept.next_id}">다음 개념</Button>
{/if}
</div>
{/if}
</div>
-16
View File
@@ -1,16 +0,0 @@
-- 381_study_concept_progress.sql — 이론 개념(문서) 간격반복(SR) 진행. 이론공부 홈 트리거.
-- concept_doc_id 는 documents.id 를 가리키나 FK 미설정(hot 테이블 락 회피, clause_study 380 선례).
-- SR 산술은 study_question_progress 와 동일(sr_schedule 공용): stage 0→1→2→3(1·3·7·14일)→4 졸업.
CREATE TABLE IF NOT EXISTS study_concept_progress (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
study_topic_id bigint NOT NULL REFERENCES study_topics(id) ON DELETE CASCADE,
concept_doc_id bigint NOT NULL,
review_stage smallint,
due_at timestamptz,
last_read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_concept_progress_user_doc UNIQUE (user_id, concept_doc_id)
);
CREATE INDEX IF NOT EXISTS idx_concept_progress_due ON study_concept_progress(user_id, due_at) WHERE due_at IS NOT NULL;
-15
View File
@@ -1,15 +0,0 @@
-- 382_study_concept_links.sql — 개념문서 ↔ 기출문항 링크 (이론↔문제 브리지, Stage B).
-- concept_doc_id=documents.id, question_id=study_questions.id — FK 없음(hot 테이블 락 회피, 선례).
-- link_source: 'embedding'(bge-m3 코사인 top-k, 주력) | 'ref'(해설 .md 참조, 후속 enrichment).
-- score=코사인 유사도(0~1). UNIQUE(doc,question,source) — source별 공존 허용(재튜닝=source 전삭제 후 재삽입).
CREATE TABLE IF NOT EXISTS study_concept_links (
id bigserial PRIMARY KEY,
concept_doc_id bigint NOT NULL,
question_id bigint NOT NULL,
link_source text NOT NULL,
score double precision,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_concept_link UNIQUE (concept_doc_id, question_id, link_source)
);
CREATE INDEX IF NOT EXISTS idx_concept_links_doc ON study_concept_links(concept_doc_id);
CREATE INDEX IF NOT EXISTS idx_concept_links_q ON study_concept_links(question_id);
-23
View File
@@ -1,23 +0,0 @@
-- concept_links_backfill.sql — 개념↔문항 임베딩 링크 재생성 (Stage B, 멱등·재실행 안전).
-- 정찰 확정: bge-m3 1024d 코사인, per-concept top-k=10, threshold 0.62 → ~2362링크·284/289개념·964문항.
-- 재튜닝 시 DELETE(embedding 소스만) 후 재삽입 = ref 링크(후속) 불변. 개념 doc = 가스기사 태그.
DELETE FROM study_concept_links WHERE link_source = 'embedding';
INSERT INTO study_concept_links (concept_doc_id, question_id, link_source, score)
WITH cd AS (
SELECT id, embedding FROM documents
WHERE user_tags::text LIKE '%@library/가스기사/%'
AND deleted_at IS NULL AND embedding IS NOT NULL
),
ranked AS (
SELECT cd.id AS concept_doc_id, q.id AS question_id,
1 - (q.embedding <=> cd.embedding) AS score,
row_number() OVER (PARTITION BY cd.id ORDER BY q.embedding <=> cd.embedding) AS rn
FROM cd
JOIN study_questions q
ON q.study_topic_id = 4 AND q.embedding IS NOT NULL
AND q.deleted_at IS NULL AND q.is_active
)
SELECT concept_doc_id, question_id, 'embedding', score
FROM ranked
WHERE rn <= 10 AND score >= 0.62
ON CONFLICT (concept_doc_id, question_id, link_source) DO NOTHING;