Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 624b9d523d | |||
| 5cabf728e6 | |||
| cd694e7386 | |||
| 7247d242a2 | |||
| 5efe19b5a3 | |||
| 9434017114 | |||
| 753a432c25 | |||
| 66f3287564 | |||
| a850745f85 | |||
| 513c6507bc | |||
| 677a59b422 | |||
| af74312a57 | |||
| 381fcfc675 | |||
| 3ff1d7c65d | |||
| 884ea1e669 | |||
| 523c509954 | |||
| 205a7bf3d5 | |||
| 4d5f35b26e | |||
| df4b07d29c | |||
| 3729083dc0 | |||
| 455a5a66ff | |||
| 124b50af53 | |||
| 0d3c841577 | |||
| 690b22fe58 | |||
| 3565ef9ac4 | |||
| 719c35afbc | |||
| e664d7b187 | |||
| 3ba9537515 | |||
| d58565ef38 | |||
| 70f90bc914 | |||
| 688532b1fa | |||
| 3a22d225a0 | |||
| 8a625bfb27 | |||
| 844a5e0204 | |||
| 456dfaa9f2 | |||
| cb7c0fdc4f | |||
| 2e19dc3d37 | |||
| 2ad32c5c84 | |||
| c11f113cf1 | |||
| 9c22337647 | |||
| d8ad097a3a | |||
| 3a780c0d06 | |||
| ac7de71ecd | |||
| 35d7c7eab7 | |||
| ffe4c776e9 | |||
| 60f3b259df | |||
| fabbca64e9 | |||
| a6d5734f6c | |||
| fe8235d726 | |||
| 4927c585c7 | |||
| b0a73f8506 | |||
| 2d6d1b8e8a | |||
| 4c111ca7f2 | |||
| f325bd0509 | |||
| d4e1f76e81 | |||
| a82b0724df |
@@ -289,13 +289,16 @@ class AIClient:
|
||||
return response.json()
|
||||
|
||||
async def _call_chat(self, model_config, prompt: str) -> str:
|
||||
"""OpenAI 호환 API 호출 + 자동 폴백"""
|
||||
try:
|
||||
return await self._request(model_config, prompt)
|
||||
except (httpx.TimeoutException, httpx.ConnectError):
|
||||
if model_config == self.ai.primary:
|
||||
return await self._request(self.ai.fallback, prompt)
|
||||
raise
|
||||
"""OpenAI 호환 API 호출 (R6: 무동의 클라우드 폴백 제거).
|
||||
|
||||
이전엔 primary(맥미니) TimeoutException/ConnectError 시 동의·과금 통제 없이
|
||||
self.ai.fallback(Claude API)로 자동 전환 → 개인 문서/쿼리/메모가 Anthropic 으로
|
||||
silent egress. on-prem 추론 프라이버시 계약 위반이라 봉쇄한다. 실패는 그대로 전파:
|
||||
배치 워커는 재시도/StageDeferred(R3·queue_consumer), interactive 호출자는 5xx 표면화
|
||||
(documents.analyze 등 이미 502/504 변환). 클라우드는 premium explicit-trigger
|
||||
(summarize force_premium) 또는 call_fallback 명시 호출로만 — 자동 진입 금지.
|
||||
"""
|
||||
return await self._request(model_config, prompt)
|
||||
|
||||
async def _request(self, model_config, prompt: str, system: str | None = None) -> str:
|
||||
"""단일 모델 API 호출 (OpenAI 호환 + Anthropic Messages API).
|
||||
|
||||
@@ -195,8 +195,14 @@ async def regenerate(
|
||||
date 미지정 시 오늘 KST. 같은 날 row 존재 시 transaction 안에서 삭제 후 신규 생성.
|
||||
응답 status='success' | 'partial' | 'failed' | 'empty'.
|
||||
"""
|
||||
from core.config import settings
|
||||
from workers.briefing_worker import run
|
||||
|
||||
# held(정책상 정상 보류)를 409 로 표면화 (R8) — digest.py 정본 대칭. 이전엔 briefing_worker.run()
|
||||
# 이 held/timeout/exception 셋 다 None 반환 → API 가 셋 다 500 으로 오보(silent-state-conflation).
|
||||
if "briefing" in settings.pipeline_held_stages:
|
||||
raise HTTPException(status_code=409, detail="briefing 단계가 일시 보류(held) 상태입니다")
|
||||
|
||||
result = await run(target_date=date)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=500, detail="briefing 워커 실행 실패 (로그 확인)")
|
||||
|
||||
@@ -69,6 +69,19 @@ def _upload_error(status_code: int, error_code: str, message: str) -> HTTPExcept
|
||||
)
|
||||
|
||||
|
||||
async def get_live_document(session: AsyncSession, doc_id: int) -> Document:
|
||||
"""soft-delete(deleted_at) 가드 포함 문서 조회 — 없거나 삭제됐으면 404 (R7).
|
||||
|
||||
조회/수정 경로는 deleted_at 을 일관 가드하나 파일/콘텐츠 서빙 엔드포인트가 누락 →
|
||||
삭제 문서의 원본/preview/전문이 doc_id(+유효 토큰)만으로 노출되던 비대칭. '경로마다
|
||||
deleted_at 기억'에 의존하지 않게 헬퍼로 구조 강제(추가될 서빙 경로도 자동 보호).
|
||||
"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc or doc.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
return doc
|
||||
|
||||
|
||||
async def _near_dup_scan_bg(doc_id: int) -> None:
|
||||
"""B-3: post-upload near_duplicate 스캔 (BackgroundTask). 자체 세션, best-effort.
|
||||
|
||||
@@ -838,9 +851,7 @@ async def get_document_file(
|
||||
# 일반 Bearer 헤더 인증 시도
|
||||
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
|
||||
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
# note(메모)는 물리 파일이 없음
|
||||
if not doc.file_path:
|
||||
@@ -943,10 +954,8 @@ async def get_document_image_raw(
|
||||
if not payload or payload.get("type") != "access":
|
||||
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
|
||||
|
||||
# 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단)
|
||||
doc = await session.get(Document, doc_id)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
# 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단 + soft-delete 가드)
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
img = await session.scalar(
|
||||
select(DocumentImage).where(
|
||||
@@ -1357,9 +1366,8 @@ async def save_document_content(
|
||||
body: dict = None,
|
||||
):
|
||||
"""Markdown 원본 파일 저장 + extracted_text 갱신"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
# soft-delete 문서엔 쓰기 차단 (R7 — 삭제 문서 resurrect / NAS 재기록 방지)
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
if doc.file_format not in ("md", "txt"):
|
||||
raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)")
|
||||
@@ -1399,9 +1407,7 @@ async def get_document_preview(
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
|
||||
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
preview_path = Path(settings.nas_mount_path) / "PKM" / ".preview" / f"{doc_id}.pdf"
|
||||
if not preview_path.exists():
|
||||
@@ -1427,18 +1433,24 @@ async def delete_document(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
delete_file: bool = Query(False, description="NAS 파일도 함께 삭제"),
|
||||
delete_file: bool = Query(False, description="NAS 원본도 삭제 (grace 후 retention sweep 이 물리삭제)"),
|
||||
):
|
||||
"""문서 삭제 (기본: DB만 삭제, 파일 유지)"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
"""문서 삭제. 기본: soft-delete(숨김, 파일 보존). delete_file=true: purge 예약 (R7)."""
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
# soft-delete (물리 파일은 cleanup job에서 나중에 정리)
|
||||
doc.deleted_at = datetime.now(timezone.utc)
|
||||
# soft-delete(숨김). delete_file=true 면 purge_requested_at 마커를 추가로 set —
|
||||
# retention sweep cron(document_purge_sweep)이 grace(30일) 경과 후 NAS 원본 물리삭제
|
||||
# + audit-log. ★일반 숨김(delete_file=false)은 파일 보존 = undelete 가능. sweep 는
|
||||
# deleted_at 이 아니라 purge_requested_at 기준이라 단순 숨김이 영구삭제되지 않는다.
|
||||
now = datetime.now(timezone.utc)
|
||||
doc.deleted_at = now
|
||||
if delete_file:
|
||||
doc.purge_requested_at = now
|
||||
await session.commit()
|
||||
|
||||
return {"message": f"문서 {doc_id} soft-delete 완료"}
|
||||
if delete_file:
|
||||
return {"message": f"문서 {doc_id} 삭제 — NAS 원본은 30일 후 정리 예약"}
|
||||
return {"message": f"문서 {doc_id} soft-delete 완료 (파일 보존)"}
|
||||
|
||||
|
||||
@router.get("/{doc_id}/content")
|
||||
@@ -1448,9 +1460,7 @@ async def get_document_content(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""문서 전문 텍스트 반환 (서비스 호출용)."""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
raw_text = doc.extracted_text or ""
|
||||
content = raw_text[:15000]
|
||||
|
||||
@@ -21,7 +21,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
@@ -388,10 +388,10 @@ async def list_events(
|
||||
)
|
||||
|
||||
base = select(Event).where(and_(*where))
|
||||
total_q = await session.execute(
|
||||
select(Event.id).where(and_(*where))
|
||||
)
|
||||
total = len(total_q.scalars().all())
|
||||
# R10: 전체 ID 로딩 후 len() 대신 DB COUNT 푸시다운 (행 수 선형 메모리/전송 비용 제거).
|
||||
total = (
|
||||
await session.execute(select(func.count(Event.id)).where(and_(*where)))
|
||||
).scalar() or 0
|
||||
|
||||
rows = await session.execute(
|
||||
base.order_by(Event.created_at.desc())
|
||||
|
||||
@@ -6,6 +6,7 @@ Bearer token 보호 (settings.internal_worker_token).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Path, Response, status
|
||||
@@ -28,7 +29,10 @@ def _verify_token(authorization: str | None = Header(default=None)) -> None:
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="missing Bearer token")
|
||||
token = authorization[7:].strip()
|
||||
if token != settings.internal_worker_token:
|
||||
# 상수시간 비교 (R7) — 일반 != 는 첫 불일치에서 단락돼 prefix 길이로 바이트 추정 가능한
|
||||
# timing side-channel. 이 토큰이 RAG 정답 포함 endpoint 를 보호하므로 compare_digest 로
|
||||
# 통일(search.py 정본과 일치).
|
||||
if not hmac.compare_digest(token, settings.internal_worker_token):
|
||||
raise HTTPException(status_code=403, detail="invalid token")
|
||||
|
||||
|
||||
|
||||
@@ -473,72 +473,35 @@ async def get_facet_counts(
|
||||
|
||||
result = FacetCountsResponse(company=[], topic=[], year=[], doctype=[])
|
||||
|
||||
# company counts (다른 facet 필터 적용, 자기 자신 제외)
|
||||
q_company = base_query()
|
||||
if facet_topic:
|
||||
q_company = q_company.where(Document.facet_topic == facet_topic)
|
||||
if facet_year:
|
||||
q_company = q_company.where(Document.facet_year == facet_year)
|
||||
if facet_doctype:
|
||||
q_company = q_company.where(Document.facet_doctype == facet_doctype)
|
||||
rows = await session.execute(
|
||||
select(Document.facet_company, func.count())
|
||||
.where(Document.facet_company != None) # noqa: E711
|
||||
.where(Document.id.in_(q_company.with_only_columns(Document.id).subquery().select()))
|
||||
.group_by(Document.facet_company)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
result.company = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
|
||||
|
||||
# topic counts
|
||||
q_topic = base_query()
|
||||
# R10: 4 facet 블록 중복 제거 — 적용된 facet 필터(값 있는 것만)를 모아 각 축 집계 시
|
||||
# '자기 자신 축'만 제외하고 적용하는 헬퍼로. 쿼리/자기제외/order_by/value 매핑 모두 동일.
|
||||
applied: dict = {}
|
||||
if facet_company:
|
||||
q_topic = q_topic.where(Document.facet_company == facet_company)
|
||||
if facet_year:
|
||||
q_topic = q_topic.where(Document.facet_year == facet_year)
|
||||
if facet_doctype:
|
||||
q_topic = q_topic.where(Document.facet_doctype == facet_doctype)
|
||||
rows = await session.execute(
|
||||
select(Document.facet_topic, func.count())
|
||||
.where(Document.facet_topic != None) # noqa: E711
|
||||
.where(Document.id.in_(q_topic.with_only_columns(Document.id).subquery().select()))
|
||||
.group_by(Document.facet_topic)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
result.topic = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
|
||||
|
||||
# year counts
|
||||
q_year = base_query()
|
||||
if facet_company:
|
||||
q_year = q_year.where(Document.facet_company == facet_company)
|
||||
applied["company"] = Document.facet_company == facet_company
|
||||
if facet_topic:
|
||||
q_year = q_year.where(Document.facet_topic == facet_topic)
|
||||
if facet_doctype:
|
||||
q_year = q_year.where(Document.facet_doctype == facet_doctype)
|
||||
rows = await session.execute(
|
||||
select(Document.facet_year, func.count())
|
||||
.where(Document.facet_year != None) # noqa: E711
|
||||
.where(Document.id.in_(q_year.with_only_columns(Document.id).subquery().select()))
|
||||
.group_by(Document.facet_year)
|
||||
.order_by(Document.facet_year.desc())
|
||||
)
|
||||
result.year = [FacetCountItem(value=str(r[0]), count=r[1]) for r in rows]
|
||||
|
||||
# doctype counts
|
||||
q_doctype = base_query()
|
||||
if facet_company:
|
||||
q_doctype = q_doctype.where(Document.facet_company == facet_company)
|
||||
if facet_topic:
|
||||
q_doctype = q_doctype.where(Document.facet_topic == facet_topic)
|
||||
applied["topic"] = Document.facet_topic == facet_topic
|
||||
if facet_year:
|
||||
q_doctype = q_doctype.where(Document.facet_year == facet_year)
|
||||
rows = await session.execute(
|
||||
select(Document.facet_doctype, func.count())
|
||||
.where(Document.facet_doctype != None) # noqa: E711
|
||||
.where(Document.id.in_(q_doctype.with_only_columns(Document.id).subquery().select()))
|
||||
.group_by(Document.facet_doctype)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
result.doctype = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
|
||||
applied["year"] = Document.facet_year == facet_year
|
||||
if facet_doctype:
|
||||
applied["doctype"] = Document.facet_doctype == facet_doctype
|
||||
|
||||
async def _facet_count(name, facet_col, order_by, value_fn):
|
||||
q = base_query()
|
||||
for k, cond in applied.items():
|
||||
if k != name: # 자기 자신 facet 필터는 제외 (다른 축만 적용)
|
||||
q = q.where(cond)
|
||||
rows = await session.execute(
|
||||
select(facet_col, func.count())
|
||||
.where(facet_col != None) # noqa: E711
|
||||
.where(Document.id.in_(q.with_only_columns(Document.id).subquery().select()))
|
||||
.group_by(facet_col)
|
||||
.order_by(order_by)
|
||||
)
|
||||
return [FacetCountItem(value=value_fn(r[0]), count=r[1]) for r in rows]
|
||||
|
||||
result.company = await _facet_count("company", Document.facet_company, func.count().desc(), lambda v: v)
|
||||
result.topic = await _facet_count("topic", Document.facet_topic, func.count().desc(), lambda v: v)
|
||||
result.year = await _facet_count("year", Document.facet_year, Document.facet_year.desc(), lambda v: str(v))
|
||||
result.doctype = await _facet_count("doctype", Document.facet_doctype, func.count().desc(), lambda v: v)
|
||||
|
||||
return result
|
||||
|
||||
@@ -300,9 +300,13 @@ async def list_memos(
|
||||
base = base.where(Document.pinned == pinned)
|
||||
|
||||
if tag:
|
||||
# 파라미터 바인딩 (R7) — f-string 으로 사용자 tag 를 JSON 배열 리터럴에 직접 삽입하면
|
||||
# tag 안 " 나 ] 가 JSON 을 깨 500 + 필터 의미 변형. jsonb_build_array 로 tag 를
|
||||
# 바인드 파라미터로 전달(@> JSONB containment).
|
||||
tag_arr = func.jsonb_build_array(tag)
|
||||
base = base.where(
|
||||
Document.user_tags.op("@>")(f'["{tag}"]')
|
||||
| Document.ai_tags.op("@>")(f'["{tag}"]')
|
||||
Document.user_tags.op("@>")(tag_arr)
|
||||
| Document.ai_tags.op("@>")(tag_arr)
|
||||
)
|
||||
|
||||
count_query = select(func.count()).select_from(base.subquery())
|
||||
@@ -688,6 +692,57 @@ async def dismiss_event_suggestion(
|
||||
return _to_memo_response(doc)
|
||||
|
||||
|
||||
@router.post("/{memo_id}/promote-to-document", status_code=201)
|
||||
async def promote_memo_to_document(
|
||||
memo_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""메모 1건 → 문서함 정식 Document 로 승격 ("자료로 보내기", P1).
|
||||
|
||||
동작 (in-place 변환 — 별 row 생성 X, extracted_text/태그/이력 보존):
|
||||
- source_channel memo/voice/hermes → 'manual' (메모 목록서 빠지고 문서함 진입)
|
||||
- file_type 'note' → 'editable' (문서함 목록 필터 `file_type != 'note'` 통과)
|
||||
- category='library' (자료실), content_origin='manual'
|
||||
- classify/embed/chunk 재큐 → 도메인 재부여 + 요약/심층분석(26B escalate) + 임베딩/청크 갱신
|
||||
P2 'draft' 워커(후속)가 거친 메모를 구조화 마크다운(md_content)으로 정리 예정.
|
||||
"""
|
||||
doc = await session.get(Document, memo_id)
|
||||
if (
|
||||
not doc
|
||||
or doc.deleted_at is not None
|
||||
or doc.source_channel not in ("memo", "voice", "hermes")
|
||||
or doc.file_type != "note"
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="승격할 메모를 찾을 수 없습니다")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
doc.source_metadata = {
|
||||
**(doc.source_metadata or {}),
|
||||
"promoted_from_memo": True,
|
||||
"promoted_at": now.isoformat(),
|
||||
"original_source_channel": doc.source_channel,
|
||||
# P2: memo_draft_worker 가 집어 26B 로 구조화 마크다운(md_content) 생성.
|
||||
"needs_draft": True,
|
||||
}
|
||||
doc.source_channel = "manual"
|
||||
doc.file_type = "editable"
|
||||
doc.category = "library"
|
||||
doc.content_origin = "manual"
|
||||
doc.updated_at = now
|
||||
|
||||
# 문서 컨텍스트로 재처리 — 도메인 재부여 + 요약/심층분석 + 임베딩/청크 갱신.
|
||||
await _enqueue_ai_stages(session, doc.id)
|
||||
await session.commit()
|
||||
await session.refresh(doc)
|
||||
|
||||
return {
|
||||
"document_id": doc.id,
|
||||
"category": doc.category,
|
||||
"message": "문서함으로 보냈습니다. AI 분류·요약·심층분석을 진행합니다.",
|
||||
}
|
||||
|
||||
|
||||
# ─── Memo Intake Upgrade PR-2C: voice upload ───
|
||||
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@ async def create_source(
|
||||
):
|
||||
from core.url_validator import validate_feed_url
|
||||
try:
|
||||
validate_feed_url(body.feed_url)
|
||||
# getaddrinfo(DNS) 는 blocking — 이벤트 루프 점유 방지 위해 off-thread (R5)
|
||||
await asyncio.to_thread(validate_feed_url, body.feed_url)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=f"feed_url 검증 실패: {e}")
|
||||
source = NewsSource(**body.model_dump())
|
||||
@@ -194,10 +195,17 @@ async def trigger_collect(
|
||||
if _collect_lock.locked():
|
||||
raise HTTPException(status_code=429, detail="수집이 이미 진행 중입니다")
|
||||
|
||||
# TOCTOU 제거 (R9) — 기존엔 locked() 체크 후 실제 acquire 가 별도 task 안에서 일어나, 그
|
||||
# 사이 다른 요청이 끼어들어 이중 수집 task 가 생길 수 있었다. 핸들러에서 동기적으로(uncontended
|
||||
# Lock.acquire 는 이벤트루프 양보 없이 즉시 완료) acquire 하고 task 의 finally 에서 release.
|
||||
await _collect_lock.acquire()
|
||||
|
||||
async def _run_with_lock():
|
||||
async with _collect_lock:
|
||||
try:
|
||||
from workers.news_collector import run
|
||||
await run()
|
||||
finally:
|
||||
_collect_lock.release()
|
||||
|
||||
asyncio.create_task(_run_with_lock())
|
||||
return {"message": "뉴스 수집 시작됨"}
|
||||
|
||||
@@ -108,6 +108,7 @@ class BackgroundJobItem(BaseModel):
|
||||
stale = running 인데 heartbeat 가 오래 끊김(프로세스 사망 추정)."""
|
||||
id: int
|
||||
kind: str
|
||||
machine: str
|
||||
label: str | None
|
||||
state: Literal["running", "done", "failed"]
|
||||
processed: int
|
||||
|
||||
@@ -282,7 +282,7 @@ async def search(
|
||||
content={
|
||||
"error_reason": "unknown_reranker_backend",
|
||||
"backend_requested": reranker_backend,
|
||||
"allowed": ["baseline", "cand_gte_ml_base"],
|
||||
"allowed": ["baseline"],
|
||||
"detail": msg,
|
||||
},
|
||||
)
|
||||
@@ -291,7 +291,7 @@ async def search(
|
||||
content={
|
||||
"error_reason": "unknown_embedding_backend",
|
||||
"backend_requested": embedding_backend,
|
||||
"allowed": ["baseline", "cand_me5_large_inst", "cand_snowflake_l_v2"],
|
||||
"allowed": ["baseline"],
|
||||
"detail": msg,
|
||||
},
|
||||
)
|
||||
@@ -710,7 +710,9 @@ async def ask(
|
||||
# 30s 로 align → classifier 동작 안정. ask 응답 latency 상한 ↑ 의도.
|
||||
try:
|
||||
classifier_result = await asyncio.wait_for(classifier_task, timeout=30.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
except asyncio.CancelledError:
|
||||
raise # 요청 취소는 전파 — broad except 가 삼키지 않게 명시 (R3)
|
||||
except Exception:
|
||||
classifier_result = ClassifierResult("timeout", None, [], [], 0.0)
|
||||
|
||||
defense_log["classifier"] = {
|
||||
@@ -872,7 +874,9 @@ async def ask(
|
||||
# → classifier 와 동일 패턴 (search.py:522 가 6s→15s swap 했던 case). 10s 로 align.
|
||||
try:
|
||||
verifier_result = await asyncio.wait_for(verifier_task, timeout=10.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
except asyncio.CancelledError:
|
||||
raise # 요청 취소는 전파 — broad except 가 삼키지 않게 명시 (R3)
|
||||
except Exception:
|
||||
verifier_result = VerifierResult("timeout", [], 0.0)
|
||||
|
||||
# Verifier contradictions → grounding flags 머지 (prefix 로 구분, severity 3단계)
|
||||
|
||||
@@ -1009,7 +1009,16 @@ async def submit_attempt(
|
||||
# PR-10: 세션 연동. 기본은 None.
|
||||
quiz_session: StudyQuizSession | None = None
|
||||
if body.quiz_session_id is not None:
|
||||
quiz_session = await session.get(StudyQuizSession, body.quiz_session_id)
|
||||
# FOR UPDATE 로 행 잠금 (R9) — 모바일 더블탭/재시도로 같은 세션에 동시 제출이 들어오면
|
||||
# 둘 다 cursor=N 을 읽고 둘 다 cursor+1·count 가산하는 race(이중 가산). 잠금으로 직렬화 →
|
||||
# 두 번째 제출은 첫 commit 후 cursor=N+1 을 보고 cursor 불일치 409 로 거부된다.
|
||||
quiz_session = (
|
||||
await session.execute(
|
||||
select(StudyQuizSession)
|
||||
.where(StudyQuizSession.id == body.quiz_session_id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if quiz_session is None or quiz_session.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="quiz_session 을 찾을 수 없습니다")
|
||||
if quiz_session.study_topic_id != q.study_topic_id:
|
||||
|
||||
@@ -169,6 +169,14 @@ class Settings(BaseModel):
|
||||
# 1 = 구 single-inference 동작. 2 = continuous batching 활용 (llm_gate docstring 참조).
|
||||
mlx_gate_concurrency: int = 1
|
||||
|
||||
# digest/briefing 생성 LLM 호출 파라미터 (2026-06-15, 모델 교체 후 타임아웃 단일소스화).
|
||||
# 구 하드코딩 25s(빠른 Gemma 기준)가 Qwen3.6-27B-6bit(콜당 ~90~300s) 교체 sweep 에서
|
||||
# 누락돼 digest 600s 하드캡 초과·briefing 4/4 폴백을 유발 → config 단일소스로 이관.
|
||||
# 동시성은 별 키 아님 — 전역 mlx_gate_concurrency(게이트 단일 budget)가 담당.
|
||||
digest_llm_timeout_s: int = 200
|
||||
digest_llm_attempts: int = 2
|
||||
digest_pipeline_hard_cap_s: int = 1800
|
||||
|
||||
# PR-MacMini-Derived-Worker-1: study explanation owner = Mac mini
|
||||
# GPU 측은 false 로 설정 (.env), explanation 분기 skip guard 트리거.
|
||||
study_explanation_enabled: bool = True
|
||||
@@ -257,6 +265,9 @@ def load_settings() -> Settings:
|
||||
|
||||
pipeline_held_stages: list[str] = []
|
||||
mlx_gate_concurrency = 1
|
||||
digest_llm_timeout_s = 200
|
||||
digest_llm_attempts = 2
|
||||
digest_pipeline_hard_cap_s = 1800
|
||||
if config_path.exists() and raw and "pipeline" in raw:
|
||||
held_raw = (raw.get("pipeline") or {}).get("held_stages") or []
|
||||
# 스칼라(문자열) 오기입 시 char-split 방지 — 단일 항목 리스트로 수용.
|
||||
@@ -269,6 +280,19 @@ def load_settings() -> Settings:
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
mlx_gate_concurrency = 1
|
||||
_pl = raw.get("pipeline") or {}
|
||||
try:
|
||||
digest_llm_timeout_s = max(1, int(_pl.get("digest_llm_timeout_s", 200)))
|
||||
except (TypeError, ValueError):
|
||||
digest_llm_timeout_s = 200
|
||||
try:
|
||||
digest_llm_attempts = max(1, int(_pl.get("digest_llm_attempts", 2)))
|
||||
except (TypeError, ValueError):
|
||||
digest_llm_attempts = 2
|
||||
try:
|
||||
digest_pipeline_hard_cap_s = max(60, int(_pl.get("digest_pipeline_hard_cap_s", 1800)))
|
||||
except (TypeError, ValueError):
|
||||
digest_pipeline_hard_cap_s = 1800
|
||||
|
||||
taxonomy = raw.get("taxonomy", {}) if config_path.exists() and raw else {}
|
||||
document_types = raw.get("document_types", []) if config_path.exists() and raw else []
|
||||
@@ -300,6 +324,9 @@ def load_settings() -> Settings:
|
||||
internal_worker_token=internal_worker_token,
|
||||
pipeline_held_stages=pipeline_held_stages,
|
||||
mlx_gate_concurrency=mlx_gate_concurrency,
|
||||
digest_llm_timeout_s=digest_llm_timeout_s,
|
||||
digest_llm_attempts=digest_llm_attempts,
|
||||
digest_pipeline_hard_cap_s=digest_pipeline_hard_cap_s,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,55 @@ def _validate_sql_content(name: str, sql: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
# R1: baseline 스냅샷이 대표하는 마지막 마이그레이션 버전 (이하 버전은 baseline 에 포함).
|
||||
# 새 baseline 재생성 시 이 값을 갱신한다 (migrations/_baseline/<cutoff>_schema_baseline.sql).
|
||||
_BASELINE_CUTOFF = 358
|
||||
|
||||
|
||||
async def _load_baseline_if_fresh(conn, migrations_dir: Path) -> None:
|
||||
"""fresh DB(documents 부재)면 baseline 스키마 스냅샷 적재 + schema_migrations 1..cutoff 스탬프.
|
||||
|
||||
기존 DB(documents 존재)는 즉시 반환 — baseline 미적재, 무영향. baseline 파일 부재 시도
|
||||
기존 replay 경로 유지(하위호환).
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
baseline_dir = migrations_dir / "_baseline"
|
||||
baseline_files = (
|
||||
sorted(baseline_dir.glob("*_schema_baseline.sql")) if baseline_dir.is_dir() else []
|
||||
)
|
||||
if not baseline_files:
|
||||
return
|
||||
|
||||
docs_exists = (
|
||||
await conn.execute(text("SELECT to_regclass('public.documents') IS NOT NULL"))
|
||||
).scalar()
|
||||
if docs_exists:
|
||||
return # 기존 DB — baseline skip
|
||||
|
||||
baseline_path = baseline_files[-1]
|
||||
logger.info(f"[migration] fresh DB 감지 — baseline 적재: {baseline_path.name}")
|
||||
# baseline 은 multi-statement 덤프 — exec_driver_sql(asyncpg prepared)은 multi-statement
|
||||
# 불허("cannot insert multiple commands into a prepared statement"). raw asyncpg 의 simple
|
||||
# 프로토콜 execute() 로 적재한다(같은 connection = 현재 트랜잭션 내). psql 스모크는 이 제약을
|
||||
# 못 잡으므로 init_db 런타임 검증으로 확인됨.
|
||||
raw = await conn.get_raw_connection()
|
||||
await raw.driver_connection.execute(baseline_path.read_text(encoding="utf-8"))
|
||||
# baseline = cutoff 까지의 스키마 → 실제 파일 버전 기준으로 schema_migrations 스탬프.
|
||||
versions = [v for v, _, _ in _parse_migration_files(migrations_dir) if v <= _BASELINE_CUTOFF]
|
||||
for v in versions:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO schema_migrations (version, name) "
|
||||
"VALUES (:v, :n) ON CONFLICT DO NOTHING"
|
||||
),
|
||||
{"v": v, "n": f"baseline:{v}"},
|
||||
)
|
||||
logger.info(
|
||||
f"[migration] baseline 적재 + schema_migrations {len(versions)}건 스탬프 (cutoff {_BASELINE_CUTOFF})"
|
||||
)
|
||||
|
||||
|
||||
async def _run_migrations(conn) -> None:
|
||||
"""미적용 migration 실행 (호출자가 트랜잭션 관리)"""
|
||||
from sqlalchemy import text
|
||||
@@ -90,10 +139,6 @@ async def _run_migrations(conn) -> None:
|
||||
f"SELECT pg_advisory_xact_lock({_MIGRATION_LOCK_KEY})"
|
||||
))
|
||||
|
||||
# 적용 이력 조회
|
||||
result = await conn.execute(text("SELECT version FROM schema_migrations"))
|
||||
applied = {row[0] for row in result}
|
||||
|
||||
# migration 파일 스캔
|
||||
# /app/core/database.py → parent.parent = /app → /app/migrations (volume mount 위치)
|
||||
migrations_dir = Path(__file__).resolve().parent.parent / "migrations"
|
||||
@@ -101,6 +146,15 @@ async def _run_migrations(conn) -> None:
|
||||
logger.info("[migration] migrations/ 디렉토리 없음, 스킵")
|
||||
return
|
||||
|
||||
# R1: fresh DB(documents 부재)면 baseline 스냅샷 먼저 적재 + schema_migrations 스탬프.
|
||||
# migrations/ 전체 replay 는 누적 비-replayable(011 view 의존·326 enum-same-txn 등)로
|
||||
# 깨지므로 신규/DR 환경은 prod 스키마 스냅샷에서 출발한다. 기존 DB 는 skip(무영향).
|
||||
await _load_baseline_if_fresh(conn, migrations_dir)
|
||||
|
||||
# 적용 이력 조회 (baseline 스탬프 반영 — fresh DB 는 1..cutoff 가 이미 applied)
|
||||
result = await conn.execute(text("SELECT version FROM schema_migrations"))
|
||||
applied = {row[0] for row in result}
|
||||
|
||||
files = _parse_migration_files(migrations_dir)
|
||||
pending = [(v, name, path) for v, name, path in files if v not in applied]
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ async def lifespan(app: FastAPI):
|
||||
from workers.briefing_worker import run as morning_briefing_run
|
||||
from workers.daily_digest import run as daily_digest_run
|
||||
from workers.dedup_reconcile import run as dedup_reconcile_run
|
||||
from workers.document_purge_sweep import run as purge_sweep_run
|
||||
from workers.digest_worker import run as global_digest_run
|
||||
from workers.file_watcher import watch_inbox
|
||||
from workers.mailplus_archive import run as mailplus_run
|
||||
@@ -64,7 +65,7 @@ async def lifespan(app: FastAPI):
|
||||
from workers.csb_collector import run as csb_collector_run
|
||||
from workers.api_standards_collector import run as api_standards_run
|
||||
from workers.ccps_collector import run as ccps_collector_run
|
||||
from workers.queue_consumer import consume_queue, consume_fast_queue, consume_markdown_queue
|
||||
from workers.queue_consumer import consume_queue, consume_fast_queue, consume_markdown_queue, consume_deep_queue
|
||||
from workers.study_queue_consumer import consume_study_queue
|
||||
from workers.study_session_queue_consumer import consume_study_session_queue
|
||||
from workers.study_memo_card_jobs_consumer import consume_study_memo_card_queue
|
||||
@@ -77,6 +78,8 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
from workers.tier_backfill import run as tier_backfill_run
|
||||
from workers.upload_cleanup import cleanup_orphan_uploads
|
||||
from workers.memo_draft_worker import run as memo_draft_run
|
||||
from workers.auto_review_worker import run as auto_review_run
|
||||
|
||||
# 시작: DB 연결 확인
|
||||
await init_db()
|
||||
@@ -101,8 +104,14 @@ async def lifespan(app: FastAPI):
|
||||
# 2026-06-12 fast-consumer split: embed/chunk(건당 <1s)를 LLM 사이클에서 분리 —
|
||||
# classify(~190s×3)가 사이클을 점유해 벡터 적재가 굶던 구조 캡 해소 (markdown 선례).
|
||||
scheduler.add_job(consume_fast_queue, "interval", minutes=1, id="fast_queue_consumer")
|
||||
# 2026-06-15 deep-consumer split: deep_summary(70~300s)를 메인 루프에서 분리 (markdown/fast 선례).
|
||||
scheduler.add_job(consume_deep_queue, "interval", minutes=1, id="deep_queue_consumer")
|
||||
scheduler.add_job(watch_inbox, "interval", minutes=5, id="file_watcher")
|
||||
scheduler.add_job(cleanup_orphan_uploads, "interval", minutes=10, id="upload_cleanup")
|
||||
# P2: 메모→문서 승격분 26B 문서화 (needs_draft 마커 → md_content). 26B 콜이라 소량·2분 간격.
|
||||
scheduler.add_job(memo_draft_run, "interval", minutes=2, id="memo_draft", max_instances=1)
|
||||
# 검토 대기 자동검토: 고신뢰(ai_confidence>=0.9) 자동승인 + 저신뢰 수동 잔류. 순수 DB(LLM 없음).
|
||||
scheduler.add_job(auto_review_run, "interval", minutes=3, id="auto_review", max_instances=1)
|
||||
# PR-4: study_questions 자동 임베딩 (status='none/failed/stale' 행을 batch=10 처리).
|
||||
# 별도 큐 테이블 없이 status 자체가 큐. backfill 도 cron 이 'none' 행을 자연스럽게 처리.
|
||||
scheduler.add_job(study_q_embed_run, "interval", minutes=1, id="study_q_embed")
|
||||
@@ -142,6 +151,9 @@ async def lifespan(app: FastAPI):
|
||||
# plan ds-s1-backend-1 B-4: dedup 컬럼(duplicate_of/duplicate_count) 야간 절대 재계산.
|
||||
# soft-delete 잔여 드리프트 정리(멱등, 드리프트 없으면 no-op). cron 03:30 (다른 잡과 비충돌).
|
||||
scheduler.add_job(dedup_reconcile_run, CronTrigger(hour=3, minute=30, timezone=KST), id="dedup_reconcile")
|
||||
# R7: delete_file=true purge 요청 문서의 NAS 원본 grace(30일) 후 물리삭제 + audit.
|
||||
# purge_requested_at 마커 기준(단순 숨김은 보존). 03:20 = 다른 새벽 잡과 비충돌 슬롯.
|
||||
scheduler.add_job(purge_sweep_run, CronTrigger(hour=3, minute=20, timezone=KST), id="purge_sweep")
|
||||
# B-3 PR4: 레거시 paper 행 arXiv DataCite DOI 스탬프(재유입 차단). keyless·in-DB·enqueue 0.
|
||||
# dedup_reconcile(03:30)·fulltext_reconcile(03:40) 와 별 worker·비충돌 슬롯.
|
||||
scheduler.add_job(paper_doi_reconcile_run, CronTrigger(hour=3, minute=50, timezone=KST), id="paper_doi_reconcile")
|
||||
@@ -228,21 +240,27 @@ SETUP_BYPASS_PREFIXES = (
|
||||
"/api/setup", "/api/config", "/setup", "/health", "/docs", "/openapi.json", "/redoc",
|
||||
)
|
||||
|
||||
# R10: 셋업 완료(user 존재)는 단조(monotonic) — 한 번 확인되면 영구. 매 요청 COUNT 쿼리
|
||||
# 대신 캐시 플래그로 전환 (setup 후 모든 요청이 users COUNT 하던 per-request 비용 제거).
|
||||
_setup_complete = False
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def setup_redirect_middleware(request: Request, call_next):
|
||||
global _setup_complete # 함수 내 read+assign 둘 다 모듈 전역 참조 (UnboundLocalError 방지)
|
||||
path = request.url.path
|
||||
# 바이패스 경로는 항상 통과
|
||||
if any(path.startswith(p) for p in SETUP_BYPASS_PREFIXES):
|
||||
# 셋업 완료됐거나 바이패스 경로면 즉시 통과 (DB 쿼리 없음)
|
||||
if _setup_complete or any(path.startswith(p) for p in SETUP_BYPASS_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
# 유저 존재 여부 확인
|
||||
# 유저 존재 여부 확인 (셋업 완료 전 1회성 — 완료 확인되면 플래그 set 후 영구 skip)
|
||||
try:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(func.count(User.id)))
|
||||
user_count = result.scalar()
|
||||
if user_count == 0:
|
||||
return RedirectResponse(url="/setup")
|
||||
_setup_complete = True
|
||||
except Exception:
|
||||
pass # DB 연결 실패 시 통과 (health에서 확인 가능)
|
||||
|
||||
|
||||
@@ -52,7 +52,8 @@ class Document(Base):
|
||||
|
||||
# 2계층: AI 가공
|
||||
ai_summary: Mapped[str | None] = mapped_column(Text)
|
||||
ai_tags: Mapped[dict | None] = mapped_column(JSONB, default=[])
|
||||
# R11a: 주석 dict→list 정정(실제 list 적재), 공유 가변 default=[] → callable default=list.
|
||||
ai_tags: Mapped[list | None] = mapped_column(JSONB, default=list)
|
||||
ai_domain: Mapped[str | None] = mapped_column(String(100))
|
||||
ai_sub_group: Mapped[str | None] = mapped_column(String(100))
|
||||
ai_model_version: Mapped[str | None] = mapped_column(String(50))
|
||||
@@ -79,7 +80,7 @@ class Document(Base):
|
||||
user_note: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# 사용자 태그 (ai_tags와 분리, #태그 파싱 결과 또는 수동 입력)
|
||||
user_tags: Mapped[list | None] = mapped_column(JSONB, default=[])
|
||||
user_tags: Mapped[list | None] = mapped_column(JSONB, default=list) # R11a: 공유 가변 default 제거
|
||||
|
||||
# 핀 고정
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
@@ -105,6 +106,9 @@ class Document(Base):
|
||||
# 승인/삭제
|
||||
review_status: Mapped[str | None] = mapped_column(String(20), default="pending")
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# delete_file=true 명시 삭제 요청 마커 (R7) — retention sweep(document_purge_sweep)이
|
||||
# grace 후 NAS 원본 물리삭제. deleted_at(단순 숨김, 파일 보존)과 분리.
|
||||
purge_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# 외부 편집 URL
|
||||
edit_url: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
@@ -7,7 +7,7 @@ PR-2 가드레일:
|
||||
- correct_choice 변경 시 기존 attempt.is_correct 재계산 안 함 (기록은 그 시점의 사실).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, SmallInteger, String, Text
|
||||
@@ -128,7 +128,9 @@ class StudyQuestionAttempt(Base):
|
||||
# PR-9: outcome 권장값 (correct/wrong/unsure). 강한 enum 미사용.
|
||||
outcome: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
answered_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
# TZ-aware 명시 (R8) — naive datetime.now() 는 컨테이너 TZ 의존. 현 컨테이너=UTC 라
|
||||
# 값 동일(백필 불요)이나, 컨테이너 TZ 가 바뀌면 9시간 어긋나는 잠복 의존 제거.
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
# PR-10: 어떤 quiz 세션의 attempt 인지 (NULL = 세션 외 직접 입력 또는 세션 삭제됨).
|
||||
quiz_session_id: Mapped[int | None] = mapped_column(
|
||||
|
||||
@@ -18,12 +18,14 @@ from typing import Any
|
||||
import numpy as np
|
||||
|
||||
from ai.client import parse_json_response
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from services.clustering_common import normalize_vector
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
|
||||
logger = setup_logger("briefing_comparator")
|
||||
|
||||
LLM_CALL_TIMEOUT = 25 # 초. Phase 4 와 동일
|
||||
LLM_CALL_TIMEOUT = settings.digest_llm_timeout_s # 2026-06-15 config 단일소스 (Phase 4 와 동일 키)
|
||||
HISTORICAL_TOP_K = 5
|
||||
HISTORICAL_SIMILARITY_MIN = 0.70
|
||||
HISTORICAL_WINDOW_DAYS = 30
|
||||
@@ -39,7 +41,6 @@ MAX_ARTICLE_IDS_PER_COUNTRY = 5 # country_perspectives[].article_ids 후
|
||||
FALLBACK_HEADLINE = "LLM 분석 실패로 원문 기사 묶음만 표시합니다."
|
||||
FALLBACK_TOPIC_LABEL = "주요 뉴스 묶음"
|
||||
|
||||
_llm_sem = asyncio.Semaphore(1)
|
||||
_PROMPT_PATH = Path(__file__).resolve().parent.parent.parent / "prompts" / "briefing_comparative.txt"
|
||||
_PROMPT_TEMPLATE: str | None = None
|
||||
|
||||
@@ -112,7 +113,8 @@ def retrieve_historical(
|
||||
|
||||
|
||||
async def _try_call_llm(client: Any, prompt: str) -> str:
|
||||
async with _llm_sem:
|
||||
# 전역 MLX gate(BACKGROUND) 경유 — 영구 룰(llm_gate): 새 Semaphore 금지, timeout 은 gate 안쪽.
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND):
|
||||
return await asyncio.wait_for(
|
||||
client.call_primary(prompt),
|
||||
timeout=LLM_CALL_TIMEOUT,
|
||||
@@ -282,7 +284,7 @@ async def compare_cluster_with_fallback(
|
||||
historical_docs = historical_docs or []
|
||||
prompt = build_prompt(selected, historical_docs)
|
||||
|
||||
for attempt in range(2):
|
||||
for attempt in range(settings.digest_llm_attempts): # 2026-06-15 config 단일소스
|
||||
try:
|
||||
raw = await _try_call_llm(client, prompt)
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
regenerate 정책: briefing_date UNIQUE 충돌 시 transaction 안에서 DELETE+INSERT.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
@@ -15,7 +16,9 @@ from sqlalchemy import delete
|
||||
|
||||
from ai.client import AIClient
|
||||
from core.database import async_session
|
||||
from core.database import engine as db_engine
|
||||
from core.utils import setup_logger
|
||||
from services import background_jobs as bgj
|
||||
from models.briefing import BriefingTopic, MorningBriefing
|
||||
from services.briefing.clustering import LAMBDA, cluster_global
|
||||
from services.briefing.comparator import (
|
||||
@@ -33,7 +36,6 @@ KST = ZoneInfo("Asia/Seoul")
|
||||
NIGHT_WINDOW_HOURS = 5 # KST 00:00 ~ 05:00
|
||||
SELECT_K = 7 # Plan §"Clustering 파라미터" briefing K_PER_CLUSTER=7
|
||||
SELECT_LAMBDA_MMR = 0.6 # Plan briefing MMR lambda 0.6
|
||||
PIPELINE_HARD_CAP = 600 # 초. Phase 4 와 동일
|
||||
|
||||
|
||||
def _compute_window(target_date: date | None = None) -> tuple[datetime, datetime, date]:
|
||||
@@ -143,7 +145,7 @@ async def _save_briefing(
|
||||
return new.id
|
||||
|
||||
|
||||
async def run_briefing_pipeline(target_date: date | None = None) -> dict[str, Any]:
|
||||
async def run_briefing_pipeline(target_date: date | None = None, job_id: int | None = None) -> dict[str, Any]:
|
||||
"""야간 뉴스 브리핑 1회 실행. cron 또는 수동 regenerate API 에서 호출.
|
||||
|
||||
Returns:
|
||||
@@ -206,16 +208,36 @@ async def run_briefing_pipeline(target_date: date | None = None) -> dict[str, An
|
||||
usable_count = 0
|
||||
|
||||
try:
|
||||
# 2026-06-15: cluster 호출 gather 동시 실행. 실동시성 = 전역 MLX gate
|
||||
# (config.mlx_gate_concurrency, BACKGROUND 우선순위). rank/순서 보존.
|
||||
jobs = []
|
||||
for rank, cluster in enumerate(clusters, start=1):
|
||||
selected = select_for_llm(cluster, k=SELECT_K, lambda_mmr=SELECT_LAMBDA_MMR)
|
||||
historical_docs = (
|
||||
retrieve_historical(cluster, historical_candidates)
|
||||
if historical_enabled() else []
|
||||
)
|
||||
llm_calls += 1
|
||||
envelope = await compare_cluster_with_fallback(
|
||||
jobs.append((rank, cluster, selected, historical_docs))
|
||||
|
||||
if job_id is not None:
|
||||
await bgj.heartbeat(db_engine, job_id, total=len(jobs))
|
||||
_prog = {"n": 0}
|
||||
|
||||
async def _run_one(cluster, selected, historical_docs):
|
||||
r = await compare_cluster_with_fallback(
|
||||
client, cluster, selected, historical_docs=historical_docs
|
||||
)
|
||||
if job_id is not None:
|
||||
_prog["n"] += 1
|
||||
await bgj.heartbeat(db_engine, job_id, processed=_prog["n"])
|
||||
return r
|
||||
|
||||
results = await asyncio.gather(
|
||||
*[_run_one(c, s, h) for (_, c, s, h) in jobs]
|
||||
)
|
||||
|
||||
for (rank, cluster, selected, historical_docs), envelope in zip(jobs, results):
|
||||
llm_calls += 1
|
||||
if envelope.get("llm_fallback_used"):
|
||||
llm_failures += 1
|
||||
if _is_usable_topic(envelope, envelope["topic_label"]):
|
||||
|
||||
@@ -10,6 +10,7 @@ Step:
|
||||
7. start/end 로그 + generation_ms + fallback 비율 health metric
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -19,7 +20,9 @@ from sqlalchemy import delete
|
||||
|
||||
from ai.client import AIClient
|
||||
from core.database import async_session
|
||||
from core.database import engine as db_engine
|
||||
from core.utils import setup_logger
|
||||
from services import background_jobs as bgj
|
||||
from models.digest import DigestTopic, GlobalDigest
|
||||
|
||||
from .clustering import LAMBDA, cluster_country
|
||||
@@ -73,7 +76,7 @@ def _build_topic_row(
|
||||
)
|
||||
|
||||
|
||||
async def run_digest_pipeline() -> dict:
|
||||
async def run_digest_pipeline(job_id: int | None = None) -> dict:
|
||||
"""전체 파이프라인 실행. worker entry 에서 호출.
|
||||
|
||||
Returns:
|
||||
@@ -107,20 +110,37 @@ async def run_digest_pipeline() -> dict:
|
||||
stats = {"llm_calls": 0, "fallback_used": 0}
|
||||
|
||||
try:
|
||||
# 2026-06-15: cluster 호출을 gather 로 동시 실행. 실제 동시성은 전역 MLX gate
|
||||
# (config.mlx_gate_concurrency, BACKGROUND 우선순위) 가 제한한다. rank/순서 보존.
|
||||
jobs = []
|
||||
for country, docs in docs_by_country.items():
|
||||
clusters = cluster_country(country, docs)
|
||||
if not clusters:
|
||||
continue # sparse country 자동 제외
|
||||
|
||||
for rank, cluster in enumerate(clusters, start=1):
|
||||
selected = select_for_llm(cluster)
|
||||
stats["llm_calls"] += 1
|
||||
llm_result = await summarize_cluster_with_fallback(client, cluster, selected)
|
||||
if llm_result["llm_fallback_used"]:
|
||||
stats["fallback_used"] += 1
|
||||
all_topic_rows.append(
|
||||
_build_topic_row(country, rank, cluster, selected, llm_result, primary_model)
|
||||
)
|
||||
jobs.append((country, rank, cluster, selected))
|
||||
|
||||
if job_id is not None:
|
||||
await bgj.heartbeat(db_engine, job_id, total=len(jobs))
|
||||
_prog = {"n": 0}
|
||||
|
||||
async def _run_one(cluster, selected):
|
||||
r = await summarize_cluster_with_fallback(client, cluster, selected)
|
||||
if job_id is not None:
|
||||
_prog["n"] += 1
|
||||
await bgj.heartbeat(db_engine, job_id, processed=_prog["n"])
|
||||
return r
|
||||
|
||||
results = await asyncio.gather(*[_run_one(c, s) for (_, _, c, s) in jobs])
|
||||
|
||||
for (country, rank, cluster, selected), llm_result in zip(jobs, results):
|
||||
stats["llm_calls"] += 1
|
||||
if llm_result["llm_fallback_used"]:
|
||||
stats["fallback_used"] += 1
|
||||
all_topic_rows.append(
|
||||
_build_topic_row(country, rank, cluster, selected, llm_result, primary_model)
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
핵심 결정:
|
||||
- AIClient._call_chat 직접 호출 (client.py 수정 회피, fallback 로직 재사용)
|
||||
- Semaphore(1) 로 MLX 과부하 회피
|
||||
- Per-call timeout 25초 (asyncio.wait_for) — MLX hang / fallback Claude API stall 방어
|
||||
- 전역 MLX gate(BACKGROUND) 경유로 동시성 제어 (services.search.llm_gate 단일 게이트)
|
||||
- Per-call timeout = config.digest_llm_timeout_s (asyncio.wait_for, gate 안쪽)
|
||||
- JSON 파싱 실패 → 1회 재시도 → 그래도 실패 시 minimal fallback (drop 금지)
|
||||
- fallback: topic_label="주요 뉴스 묶음", summary = top member ai_summary[:200]
|
||||
"""
|
||||
@@ -13,15 +13,16 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ai.client import parse_json_response
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
|
||||
logger = setup_logger("digest_summarizer")
|
||||
|
||||
LLM_CALL_TIMEOUT = 25 # 초. MLX 평균 5초 + tail latency 마진
|
||||
# 2026-06-15: config 단일소스 (구 하드코딩 25s = 빠른 Gemma 기준, Qwen 27B 교체 후 누락).
|
||||
LLM_CALL_TIMEOUT = settings.digest_llm_timeout_s
|
||||
FALLBACK_SUMMARY_LIMIT = 200
|
||||
|
||||
_llm_sem = asyncio.Semaphore(1)
|
||||
|
||||
_PROMPT_PATH = Path(__file__).resolve().parent.parent.parent / "prompts" / "digest_topic.txt"
|
||||
_PROMPT_TEMPLATE: str | None = None
|
||||
|
||||
@@ -48,8 +49,12 @@ def build_prompt(selected: list[dict]) -> str:
|
||||
|
||||
|
||||
async def _try_call_llm(client: Any, prompt: str) -> str:
|
||||
"""Semaphore + per-call timeout 으로 감싼 단일 호출."""
|
||||
async with _llm_sem:
|
||||
"""전역 MLX gate(BACKGROUND) + per-call timeout 으로 감싼 단일 호출.
|
||||
|
||||
영구 룰(llm_gate): Mac mini endpoint 는 단일 게이트 공유, 새 Semaphore 금지.
|
||||
동시성 lever = config.mlx_gate_concurrency. timeout 은 gate 안쪽에서만.
|
||||
"""
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND):
|
||||
return await asyncio.wait_for(
|
||||
client._call_chat(client.ai.primary, prompt),
|
||||
timeout=LLM_CALL_TIMEOUT,
|
||||
@@ -86,7 +91,7 @@ async def summarize_cluster_with_fallback(
|
||||
"""
|
||||
prompt = build_prompt(selected)
|
||||
|
||||
for attempt in range(2): # 1회 재시도 포함
|
||||
for attempt in range(settings.digest_llm_attempts): # config 단일소스 (기본 2 = 1회 재시도)
|
||||
try:
|
||||
raw = await _try_call_llm(client, prompt)
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
@@ -26,7 +26,16 @@ _ATX = re.compile(r'^(#{1,6})\s+(?P<title>\S.*?)\s*#*\s*$')
|
||||
_KO_JANG = re.compile(r'^\s*(?P<title>제\s*\d+\s*장\b.*)$')
|
||||
_KO_JEOL = re.compile(r'^\s*(?P<title>제\s*\d+\s*절\b.*)$')
|
||||
_KO_JO = re.compile(r'^\s*(?P<title>제\s*\d+\s*조\b.*)$')
|
||||
_ENG = re.compile(r'^\s*(?P<title>(?:Chapter|Section|Article|Part|PART)\s+[\dIVXLA-Z]+\b.*)$')
|
||||
# _ENG: 영문 구조 헤딩(ATX 미사용 문서용). ASME 파트는 보통 ATX(`# PART PG`)로 잡혀 _ENG 의존 낮음.
|
||||
# D1: 식별자 뒤가 소문자 문장연속이면("Part III to demonstrate to the satisfaction…") 본문이므로
|
||||
# 미탐지 — 가짜 절 차단. 선택 제목은 대문자/괄호/숫자로 시작해야 헤딩 인정(소문자 시작=문장으로 봄).
|
||||
# 식별자는 번호/PG/3.31/UHX/A-1 등 (.·- 소수·하이픈 확장 허용).
|
||||
_ENG = re.compile(
|
||||
r'^\s*(?P<title>(?:Chapter|Section|Article|Part|PART)\s+'
|
||||
r'[\dIVXLA-Z]+(?:[.\-][\dA-Za-z]+)*'
|
||||
r'(?:\s+[A-Z(\d][^\n]*)?'
|
||||
r')\s*$'
|
||||
)
|
||||
|
||||
# 코드펜스 경계 (FE outlineAnchors.ts:60 `/^\s{0,3}(```|~~~)/` 와 동일). 펜스 내부 라인은
|
||||
# heading 미탐지 — 코드블록 안 '# foo' 가 가짜 절을 만들지 않게(O3).
|
||||
|
||||
@@ -32,7 +32,8 @@ async def find_paper_holder(session, raw_or_normalized_doi):
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.where(Document.material_type == "paper", _DOI_EXPR == doi)
|
||||
.where(Document.material_type == "paper", _DOI_EXPR == doi,
|
||||
Document.deleted_at.is_(None))
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@@ -426,6 +426,16 @@ async def build_overview(session: AsyncSession) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
# kind -> 처리 머신 (보드 머신 카드 귀속용). 미상 kind = gpu(오케스트레이션 호스트).
|
||||
_BG_JOB_MACHINE = {
|
||||
"global_digest": "macmini",
|
||||
"morning_briefing": "macmini",
|
||||
"section_summary": "macmini",
|
||||
"hier_backfill": "gpu",
|
||||
"hier_redecompose": "gpu",
|
||||
}
|
||||
|
||||
|
||||
_BACKGROUND_JOBS_SQL = """
|
||||
SELECT id, kind, label, state, processed, total,
|
||||
EXTRACT(EPOCH FROM (now() - started_at))::int AS elapsed_sec,
|
||||
@@ -456,6 +466,7 @@ async def _fetch_background_jobs(session: AsyncSession) -> list[dict]:
|
||||
"processed": int(r["processed"] or 0), "total": r["total"],
|
||||
"elapsed_sec": int(r["elapsed_sec"] or 0), "stale": bool(r["stale"]),
|
||||
"error": r["error"],
|
||||
"machine": _BG_JOB_MACHINE.get(r["kind"], "gpu"),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -44,11 +44,10 @@ RERANK_TIMEOUT = 5.0
|
||||
# server-side allowlist map. query parameter 가 raw endpoint URL 받지 않음.
|
||||
RERANKER_BACKEND_MAP: dict[str, dict[str, str] | None] = {
|
||||
"baseline": None, # production reranker (config.yaml endpoint via AIClient.rerank)
|
||||
"cand_gte_ml_base": {
|
||||
"endpoint": "http://rerank-cand-gte-ml-base:80/rerank",
|
||||
},
|
||||
# mxbai_large 후보 (deberta-v2 → TEI 1.7 미지원) Phase 2B-Extended 이관
|
||||
# bge_v2_gemma_2b 후보 (LLM-based reranker, 1_Pooling/config.json 부재) Phase 2B-Extended 이관
|
||||
# Phase 2B 후보 reranker 전부 NO-GO 종결 (2026-06-18 teardown):
|
||||
# - cand_gte_ml_base : 컨테이너·DB 테이블(마이그 360)·override.rerank-cand.yml 제거됨
|
||||
# - mxbai_large (deberta-v2 → TEI 1.7 미지원) / bge_v2_gemma_2b (1_Pooling 부재) 미진입
|
||||
# dispatcher scaffold(_resolve_reranker)는 향후 후보 재진입 위해 보존.
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -54,42 +54,10 @@ QUERY_EMBED_MAXSIZE = 500
|
||||
# server-side allowlist map. query parameter 가 raw table name 받지 않음.
|
||||
CANDIDATE_BACKEND_MAP: dict[str, dict[str, str] | None] = {
|
||||
"baseline": None,
|
||||
"cand_me5_large_inst": {
|
||||
"docs_table": "documents_cand_me5_large_inst",
|
||||
"chunks_table": "document_chunks_cand_me5_large_inst",
|
||||
"embed_endpoint": "http://embedding-cand-me5-inst:80/embed",
|
||||
},
|
||||
"cand_snowflake_l_v2": {
|
||||
"docs_table": "documents_cand_snowflake_l_v2",
|
||||
"chunks_table": "document_chunks_cand_snowflake_l_v2",
|
||||
"embed_endpoint": "http://embedding-cand-snowflake-l-v2:80/embed",
|
||||
},
|
||||
# ─── Phase 2A (embedding-phase2a-1, 2026-06-12): Qwen3-Embedding 후보 3종 ───
|
||||
# embed_kind="ollama" = /api/embed 호출 + 쿼리측 instruct prefix (비대칭 사용,
|
||||
# G-1 fixture 실측: prefix 가 관련쌍 cos +0.016). 문서측은 backfill 이 plain 으로 적재.
|
||||
# qwen4m = 4B 의 MRL 1024d (dimensions 옵션 — Ollama 가 truncate+재정규화 수행, G-1 실측).
|
||||
"cand_qwen06": {
|
||||
"docs_table": "documents_cand_qwen06",
|
||||
"chunks_table": "document_chunks_cand_qwen06",
|
||||
"embed_endpoint": "http://ollama:11434/api/embed",
|
||||
"embed_kind": "ollama",
|
||||
"embed_model": "qwen3-embedding:0.6b",
|
||||
},
|
||||
"cand_qwen4": {
|
||||
"docs_table": "documents_cand_qwen4",
|
||||
"chunks_table": "document_chunks_cand_qwen4",
|
||||
"embed_endpoint": "http://ollama:11434/api/embed",
|
||||
"embed_kind": "ollama",
|
||||
"embed_model": "qwen3-embedding:4b",
|
||||
},
|
||||
"cand_qwen4m": {
|
||||
"docs_table": "documents_cand_qwen4m",
|
||||
"chunks_table": "document_chunks_cand_qwen4m",
|
||||
"embed_endpoint": "http://ollama:11434/api/embed",
|
||||
"embed_kind": "ollama",
|
||||
"embed_model": "qwen3-embedding:4b",
|
||||
"embed_dimensions": 1024,
|
||||
},
|
||||
# Phase 2A 임베딩 후보(me5_large_inst·snowflake_l_v2·qwen06·qwen4·qwen4m) 전량 no-go
|
||||
# 종결(2026-06-12, 후보 전부 -0.03~-0.04) → cand 슬러그·테이블 제거 (R13, 마이그 360
|
||||
# DROP). read-path 슬러그를 먼저 빼야 embedding_backend=cand_X /search 가 dropped 테이블을
|
||||
# 읽어 500 나지 않는다. baseline(production)만 잔존.
|
||||
}
|
||||
|
||||
# G-1 핀 고정 instruct 문자열 (inventory 2026-06-12-c 기록과 동일해야 함 —
|
||||
|
||||
@@ -32,6 +32,8 @@ from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.database import async_session
|
||||
|
||||
from . import query_analyzer, query_rewriter
|
||||
from .fusion_service import (
|
||||
DEFAULT_FUSION,
|
||||
@@ -188,6 +190,7 @@ async def run_search(
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
reranker_backend=reranker_backend,
|
||||
rewrite_backend=rewrite_backend,
|
||||
axis=axis,
|
||||
)
|
||||
|
||||
timing: dict[str, float] = {}
|
||||
@@ -536,6 +539,7 @@ async def search_with_rewrite(
|
||||
snapshot_chunk_id_max: int | None,
|
||||
reranker_backend: str | None,
|
||||
rewrite_backend: str,
|
||||
axis: "AxisFilter | None" = None,
|
||||
) -> PipelineResult:
|
||||
"""Phase 2Q multi-query retrieval 합성 path (plan v6 §5.5).
|
||||
|
||||
@@ -579,13 +583,20 @@ async def search_with_rewrite(
|
||||
async def _variant_retrieve(
|
||||
v: str,
|
||||
) -> "tuple[list[SearchResult], list[SearchResult], dict[int, list[SearchResult]]]":
|
||||
text = await search_text(session, v, per_variant_k)
|
||||
raw_chunks = await search_vector(
|
||||
session, v, per_variant_k,
|
||||
embedding_backend=embedding_backend,
|
||||
snapshot_doc_id_max=snapshot_doc_id_max,
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
)
|
||||
# 변형별 독립 AsyncSession (fan-out). 공유 session 을 asyncio.gather 로 동시
|
||||
# execute 에 넘기면 SQLAlchemy async 가 'another operation in progress' 로
|
||||
# 부하 의존적 비결정 크래시 — variant 마다 독립 연결로 분리한다.
|
||||
# axis(material_type/jurisdiction/year) 도 single-query path 와 동일하게 전달
|
||||
# (rewrite 경로가 axis 필터를 조용히 누락하던 결함 수정).
|
||||
async with async_session() as vsession:
|
||||
text = await search_text(vsession, v, per_variant_k, axis=axis)
|
||||
raw_chunks = await search_vector(
|
||||
vsession, v, per_variant_k,
|
||||
embedding_backend=embedding_backend,
|
||||
snapshot_doc_id_max=snapshot_doc_id_max,
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
axis=axis,
|
||||
)
|
||||
vector, chunks_by_doc = compress_chunks_to_docs(raw_chunks, per_variant_k)
|
||||
return text, vector, chunks_by_doc
|
||||
|
||||
|
||||
@@ -95,8 +95,10 @@ except FileNotFoundError:
|
||||
)
|
||||
|
||||
|
||||
# ─── in-memory LRU (FIFO 근사, query_analyzer 패턴 복제) ─
|
||||
_CACHE: dict[str, SynthesisResult] = {}
|
||||
# ─── in-memory 캐시 (FIFO eviction + TTL, query_analyzer 패턴 복제) ─
|
||||
# R10: (ts, result) 저장 — TTL 미적용으로 원문 수정돼도 CACHE_MAXSIZE 찰 때까지 stale answer
|
||||
# 반환하던 결함 수정. query_rewriter 의 expire_at TTL enforce 정본 복제.
|
||||
_CACHE: dict[str, tuple[float, SynthesisResult]] = {}
|
||||
|
||||
|
||||
def _model_version() -> str:
|
||||
@@ -122,10 +124,11 @@ def get_cached(query: str, chunk_ids: list[int], backend_name: str = "gemma-macm
|
||||
entry = _CACHE.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
# TTL 체크는 elapsed_ms 를 악용할 수 없으므로 별도 저장
|
||||
# 여기서는 단순 policy 로 처리: entry 가 있으면 반환 (eviction 은 FIFO 시점)
|
||||
# 정확한 TTL 이 필요하면 (ts, result) tuple 로 저장해야 함.
|
||||
return entry
|
||||
ts, result = entry
|
||||
if time.time() - ts > CACHE_TTL:
|
||||
_CACHE.pop(key, None) # 만료 — 삭제 후 miss
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def _should_cache(result: SynthesisResult) -> bool:
|
||||
@@ -143,8 +146,9 @@ def set_cached(query: str, chunk_ids: list[int], result: SynthesisResult, backen
|
||||
if not _should_cache(result):
|
||||
return
|
||||
key = _cache_key(query, chunk_ids, backend_name)
|
||||
now = time.time()
|
||||
if key in _CACHE:
|
||||
_CACHE[key] = result
|
||||
_CACHE[key] = (now, result)
|
||||
return
|
||||
if len(_CACHE) >= CACHE_MAXSIZE:
|
||||
try:
|
||||
@@ -152,7 +156,7 @@ def set_cached(query: str, chunk_ids: list[int], result: SynthesisResult, backen
|
||||
_CACHE.pop(oldest, None)
|
||||
except StopIteration:
|
||||
pass
|
||||
_CACHE[key] = result
|
||||
_CACHE[key] = (now, result)
|
||||
|
||||
|
||||
def cache_stats() -> dict[str, int]:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
@@ -42,7 +43,7 @@ class LocalBackend(StorageBackend):
|
||||
to_read = _STREAM_CHUNK if remaining is None else min(_STREAM_CHUNK, remaining)
|
||||
if to_read <= 0:
|
||||
break
|
||||
data = f.read(to_read)
|
||||
data = await asyncio.to_thread(f.read, to_read)
|
||||
if not data:
|
||||
break
|
||||
yield data
|
||||
|
||||
@@ -252,12 +252,15 @@ async def gather_explanation_context(
|
||||
client = AIClient()
|
||||
query = _build_query(question)
|
||||
try:
|
||||
# 두 조회 병렬화 (rerank 호출이 별개라 lock 충돌 없음)
|
||||
docs, questions = await asyncio.gather(
|
||||
_gather_document_evidence(session, user_id, question.study_topic_id, query, client),
|
||||
_gather_question_evidence(
|
||||
session, user_id, question.study_topic_id, question.id, query, client
|
||||
),
|
||||
# 같은 AsyncSession 을 asyncio.gather 로 동시 execute 에 넘기면 SQLAlchemy async 가
|
||||
# 'another operation in progress' 로 부하 의존적 비결정 크래시(이전 주석 'lock 충돌
|
||||
# 없음' 은 rerank HTTP 만 보고 DB execute 동시성을 간과한 오인). 백그라운드 prefetch
|
||||
# 라 순차 직렬화 — 사용자 대면 rewrite 경로(독립 세션 fan-out)와는 다른 처방.
|
||||
docs = await _gather_document_evidence(
|
||||
session, user_id, question.study_topic_id, query, client
|
||||
)
|
||||
questions = await _gather_question_evidence(
|
||||
session, user_id, question.study_topic_id, question.id, query, client
|
||||
)
|
||||
return ExplanationContext(documents=docs, questions=questions)
|
||||
finally:
|
||||
|
||||
@@ -238,9 +238,13 @@ async def gather_subject_note_context(
|
||||
client = AIClient()
|
||||
query = _build_query(subject, scope)
|
||||
try:
|
||||
docs, questions = await asyncio.gather(
|
||||
_gather_document_evidence(session, user_id, study_topic_id, query, client),
|
||||
_gather_question_evidence(session, user_id, study_topic_id, subject, scope, query, client),
|
||||
# 같은 AsyncSession 동시 execute 회피 — 순차 직렬화(백그라운드 prefetch).
|
||||
# explanation_rag.gather_explanation_context 와 동형(R2 공유세션 동시성 수정).
|
||||
docs = await _gather_document_evidence(
|
||||
session, user_id, study_topic_id, query, client
|
||||
)
|
||||
questions = await _gather_question_evidence(
|
||||
session, user_id, study_topic_id, subject, scope, query, client
|
||||
)
|
||||
return SubjectNoteContext(documents=docs, questions=questions)
|
||||
finally:
|
||||
|
||||
@@ -303,10 +303,12 @@ async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
watermark = _watermark(src, category)
|
||||
newest_seen: datetime | None = None
|
||||
capped = False # 이번 run 이 cap 으로 카테고리 중도 절단됐는지 (R4)
|
||||
max_pages = (10**6 if bulk else _MAX_PAGES_PER_CAT)
|
||||
try:
|
||||
for page in range(max_pages):
|
||||
if inserted >= run_cap:
|
||||
capped = True
|
||||
break
|
||||
xml_text = await _fetch(client, query, page * _PAGE_SIZE)
|
||||
total, entries = parse_arxiv_feed(xml_text)
|
||||
@@ -329,12 +331,18 @@ async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
else:
|
||||
await session.rollback()
|
||||
if inserted >= run_cap:
|
||||
capped = True
|
||||
break
|
||||
await asyncio.sleep(_REQ_SLEEP)
|
||||
if stop or (page + 1) * _PAGE_SIZE >= total:
|
||||
break
|
||||
# 카테고리 워터마크 전진(이번 run 최신 발행일)
|
||||
if newest_seen:
|
||||
# 카테고리 워터마크 전진 — cap 으로 절단된 run 은 미전진 (R4).
|
||||
# 절단 시 newest_seen 으로 전진하면 [oldest-ingested, 옛 watermark] 사이
|
||||
# 미적재 항목이 다음 run 의 watermark 필터(entry.published <= watermark)에
|
||||
# 영구 배제(silent data loss). 미전진하면 다음 run 이 최신부터 재스캔하며
|
||||
# 적재분은 dedup-skip(_ingest_entry False, cap 미소모)하고 gap 까지 내려가
|
||||
# 이어 적재 → 백로그가 run 당 cap 씩 소화(livelock 회피). bulk 은 cap 무관.
|
||||
if newest_seen and not capped:
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
_set_watermark(src, category, newest_seen)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""검토 대기(review_status='pending') 자동 검토 — 고신뢰 자동승인 + 저신뢰 수동 잔류.
|
||||
|
||||
classify 가 이미 부여한 ai_confidence 를 게이트로 사용 — **재-LLM 호출 없음**(대량 2천건에
|
||||
맥미니/GPU 부하 0, 분류 confidence 가 곧 AI 의 자기-신뢰도). ai_domain 보유 +
|
||||
ai_confidence >= THRESHOLD 인 pending 문서를 review_status='approved' 로 자동승인하고
|
||||
audit(source_metadata.auto_reviewed)를 남긴다. 저신뢰/미분류는 그대로 두어 수동 검토
|
||||
큐(/inbox)에 잔류.
|
||||
|
||||
설계 근거(게이트 실측):
|
||||
- review_status 는 inbox 카운트(dashboard) + 수집기 ingest 에서만 사용, 검색/RAG/digest/
|
||||
ask 경로 필터에 **미사용** → 자동승인은 노출(검색결과) 변동 없이 검토 큐만 비운다.
|
||||
- pending 2,161 중 ai_suggestion 보유 0 → 이 큐는 '분류 변경 제안'(accept_suggestion)이
|
||||
아니라 '미검토 자동분류'. 승인 = review_status 플립.
|
||||
배치·interval 점진 드레인(관찰·중단 가능). 되돌리기 = source_metadata.auto_reviewed 마커로
|
||||
대상 식별 후 review_status='pending' 복원.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.database import async_session
|
||||
from models.document import Document
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 고신뢰 자동승인 바 (튜닝 가능). 실측 분포: >=0.9 → 1,981건 자동 / 저신뢰·미분류 ~180건 수동 잔류.
|
||||
_CONFIDENCE_THRESHOLD = 0.9
|
||||
# 한 틱 처리량 — 순수 DB UPDATE(LLM 없음)라 가볍지만, 2천 행 일괄 락 회피 위해 배치.
|
||||
_BATCH = 300
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
"""pending 고신뢰 문서를 배치 자동승인 (interval job, no-arg)."""
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Document)
|
||||
.where(
|
||||
Document.review_status == "pending",
|
||||
Document.deleted_at.is_(None),
|
||||
Document.ai_domain.isnot(None),
|
||||
Document.ai_confidence.isnot(None),
|
||||
Document.ai_confidence >= _CONFIDENCE_THRESHOLD,
|
||||
)
|
||||
.order_by(Document.id)
|
||||
.limit(_BATCH)
|
||||
)
|
||||
).scalars().all()
|
||||
if not rows:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for doc in rows:
|
||||
doc.review_status = "approved"
|
||||
doc.source_metadata = {
|
||||
**(doc.source_metadata or {}),
|
||||
"auto_reviewed": {
|
||||
"by": "confidence_gate",
|
||||
"confidence": float(doc.ai_confidence),
|
||||
"threshold": _CONFIDENCE_THRESHOLD,
|
||||
"at": now.isoformat(),
|
||||
},
|
||||
}
|
||||
doc.updated_at = now
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"auto_review: approved %d pending docs (ai_confidence >= %.2f)",
|
||||
len(rows),
|
||||
_CONFIDENCE_THRESHOLD,
|
||||
)
|
||||
@@ -9,12 +9,15 @@ import asyncio
|
||||
from datetime import date
|
||||
|
||||
from core.config import settings
|
||||
from core.database import engine as db_engine
|
||||
from core.utils import setup_logger
|
||||
from services.background_jobs import finish_job, start_job
|
||||
from services.briefing.pipeline import run_briefing_pipeline
|
||||
|
||||
logger = setup_logger("briefing_worker")
|
||||
|
||||
PIPELINE_HARD_CAP = 600
|
||||
# 2026-06-15: config 단일소스 (digest 와 공유 키). 구 600s = 빠른 Gemma 기준.
|
||||
PIPELINE_HARD_CAP = settings.digest_pipeline_hard_cap_s
|
||||
|
||||
|
||||
async def run(target_date: date | None = None) -> dict | None:
|
||||
@@ -26,19 +29,24 @@ async def run(target_date: date | None = None) -> dict | None:
|
||||
if "briefing" in settings.pipeline_held_stages:
|
||||
logger.info("[briefing] 보류 (pipeline.held_stages) — 이번 실행 skip")
|
||||
return None
|
||||
# 보드 가시화: 큐 밖 cron 생성 작업이라 background_jobs 로 노출 (best-effort, 맥미니 귀속)
|
||||
job_id = await start_job(db_engine, "morning_briefing", label="조간 브리핑 생성")
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
run_briefing_pipeline(target_date),
|
||||
run_briefing_pipeline(target_date, job_id=job_id),
|
||||
timeout=PIPELINE_HARD_CAP,
|
||||
)
|
||||
await finish_job(db_engine, job_id, state="done")
|
||||
logger.info(f"[briefing] 워커 완료: {result}")
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
await finish_job(db_engine, job_id, state="failed", error=f"HARD CAP {PIPELINE_HARD_CAP}s 초과")
|
||||
logger.error(
|
||||
f"[briefing] HARD CAP {PIPELINE_HARD_CAP}s 초과 — 워커 강제 중단. "
|
||||
f"기존 briefing 은 commit 시점에만 갱신되므로 그대로 유지됨."
|
||||
)
|
||||
except Exception as e:
|
||||
await finish_job(db_engine, job_id, state="failed", error=str(e)[:300])
|
||||
logger.exception(f"[briefing] 워커 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -272,15 +272,20 @@ async def _lookup_news_source(
|
||||
if not source_name:
|
||||
return None, None, None
|
||||
|
||||
# news_sources에서 이름이 일치하는 레코드 찾기 (prefix match)
|
||||
result = await session.execute(select(NewsSource))
|
||||
sources = result.scalars().all()
|
||||
for src in sources:
|
||||
if source_name and (
|
||||
src.name.split(" ")[0] == source_name
|
||||
or src.name.startswith(source_name + " ")
|
||||
):
|
||||
return src.country, src.name, src.language
|
||||
# news_sources prefix 매칭 — R10: 전체 로드+Python 루프 대신 DB 필터 푸시다운.
|
||||
# (name == source_name) OR (name 이 "source_name " 로 시작) = 기존 split[0]==source_name 동치
|
||||
# (첫 토큰 일치 = 정확일치 또는 'source_name ' prefix). autoescape 로 %/_ 안전.
|
||||
result = await session.execute(
|
||||
select(NewsSource)
|
||||
.where(
|
||||
(NewsSource.name == source_name)
|
||||
| NewsSource.name.startswith(source_name + " ", autoescape=True)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
src = result.scalars().first()
|
||||
if src is not None:
|
||||
return src.country, src.name, src.language
|
||||
|
||||
logger.warning(
|
||||
f"[chunk] news_source 매핑 실패: doc_id={doc.id} ai_sub_group={source_name!r} "
|
||||
|
||||
@@ -563,7 +563,9 @@ async def process(
|
||||
doc.facet_doctype = ai_doctype
|
||||
|
||||
# ─── ai_suggestion 저장 (자료실 승인 대기함 제안, §1) ───
|
||||
if ai_doctype in LIBRARY_SUGGESTION_DOCTYPES:
|
||||
# R9: 기존 제안(material_type 제안 등) 우선 — doc.ai_suggestion is None 가드 추가
|
||||
# (material 제안 블록과 대칭). 없으면 거래문서 제안이 기존 제안을 clobber('기존 제안 우선' 위반).
|
||||
if ai_doctype in LIBRARY_SUGGESTION_DOCTYPES and doc.ai_suggestion is None:
|
||||
year = doc.facet_year or datetime.now(timezone.utc).year
|
||||
doc.ai_suggestion = {
|
||||
"proposed_category": "library",
|
||||
|
||||
@@ -5,7 +5,8 @@ DEVONthink/OmniFocus → PostgreSQL/CalDAV 쿼리로 전환.
|
||||
SMTP 발송은 2026-06-10 제거 (한 번도 전달 성공한 적 없는 기능 — 폐기 결정).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import asyncio
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from pathlib import Path
|
||||
|
||||
@@ -20,17 +21,36 @@ from models.queue import ProcessingQueue
|
||||
logger = setup_logger("daily_digest")
|
||||
|
||||
|
||||
def _write_and_rotate(digest_dir: Path, today: str, markdown: str) -> Path:
|
||||
"""digest 파일 저장 + 90일 초과 아카이브 이동 (blocking — caller 가 to_thread, R8)."""
|
||||
digest_dir.mkdir(parents=True, exist_ok=True)
|
||||
digest_path = digest_dir / f"{today}_digest.md"
|
||||
digest_path.write_text(markdown, encoding="utf-8")
|
||||
archive_dir = digest_dir / "archive"
|
||||
archive_dir.mkdir(exist_ok=True)
|
||||
cutoff = datetime.now(timezone.utc).timestamp() - (90 * 86400)
|
||||
for old in digest_dir.glob("*_digest.md"):
|
||||
if old.stat().st_mtime < cutoff:
|
||||
old.rename(archive_dir / old.name)
|
||||
return digest_path
|
||||
|
||||
|
||||
async def run():
|
||||
"""일일 다이제스트 생성 + 저장 + 발송"""
|
||||
# KST 기준 오늘 (cron 이 KST timezone fix 후 20:00 KST 에 fire). date 객체로 비교 — Document.created_at::date 와 직접 매칭.
|
||||
today = datetime.now(ZoneInfo("Asia/Seoul")).date()
|
||||
# KST 기준 오늘 (cron 이 KST timezone fix 후 20:00 KST 에 fire).
|
||||
kst = ZoneInfo("Asia/Seoul")
|
||||
today = datetime.now(kst).date()
|
||||
# KST 하루를 UTC 범위로 변환 (R8) — func.date(created_at)는 pg TimeZone(UTC) 기준 날짜라
|
||||
# KST 0~9시 생성 문서(UTC 전날)가 누락되던 경계 버그. created_at(UTC저장) 범위 비교로.
|
||||
start_utc = datetime.combine(today, time.min, tzinfo=kst).astimezone(timezone.utc)
|
||||
end_utc = start_utc + timedelta(days=1)
|
||||
sections = []
|
||||
|
||||
async with async_session() as session:
|
||||
# ─── 1. 오늘 추가된 문서 ───
|
||||
added = await session.execute(
|
||||
select(Document.ai_domain, func.count(Document.id))
|
||||
.where(func.date(Document.created_at) == today)
|
||||
.where(Document.created_at >= start_utc, Document.created_at < end_utc)
|
||||
.group_by(Document.ai_domain)
|
||||
)
|
||||
added_rows = added.all()
|
||||
@@ -49,7 +69,8 @@ async def run():
|
||||
select(Document.title)
|
||||
.where(
|
||||
Document.source_channel == "law_monitor",
|
||||
func.date(Document.created_at) == today,
|
||||
Document.created_at >= start_utc,
|
||||
Document.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
law_rows = law_docs.scalars().all()
|
||||
@@ -66,7 +87,8 @@ async def run():
|
||||
select(func.count(Document.id))
|
||||
.where(
|
||||
Document.source_channel == "email",
|
||||
func.date(Document.created_at) == today,
|
||||
Document.created_at >= start_utc,
|
||||
Document.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
email_total = email_count.scalar() or 0
|
||||
@@ -101,7 +123,7 @@ async def run():
|
||||
)
|
||||
failed_count = failed.scalar() or 0
|
||||
if failed_count > 0:
|
||||
section += f"\n⚠️ **실패 {failed_count}건** — 수동 확인 필요\n"
|
||||
section += f"\n**[주의] 실패 {failed_count}건** — 수동 확인 필요\n"
|
||||
sections.append(section)
|
||||
|
||||
# ─── 5. Inbox 미분류 ───
|
||||
@@ -119,18 +141,8 @@ async def run():
|
||||
markdown += "\n".join(sections)
|
||||
markdown += f"\n---\n*생성: {datetime.now(timezone.utc).isoformat()}*\n"
|
||||
|
||||
# ─── NAS 저장 ───
|
||||
# ─── NAS 저장 + 90일 아카이브 (blocking 파일 I/O off-thread, R8/R5 일관) ───
|
||||
digest_dir = Path(settings.nas_mount_path) / "PKM" / "Archive" / "digests"
|
||||
digest_dir.mkdir(parents=True, exist_ok=True)
|
||||
digest_path = digest_dir / f"{today}_digest.md"
|
||||
digest_path.write_text(markdown, encoding="utf-8")
|
||||
|
||||
# ─── 90일 초과 아카이브 ───
|
||||
archive_dir = digest_dir / "archive"
|
||||
archive_dir.mkdir(exist_ok=True)
|
||||
cutoff = datetime.now(timezone.utc).timestamp() - (90 * 86400)
|
||||
for old in digest_dir.glob("*_digest.md"):
|
||||
if old.stat().st_mtime < cutoff:
|
||||
old.rename(archive_dir / old.name)
|
||||
digest_path = await asyncio.to_thread(_write_and_rotate, digest_dir, str(today), markdown)
|
||||
|
||||
logger.info(f"다이제스트 생성 완료: {digest_path}")
|
||||
|
||||
@@ -144,9 +144,13 @@ async def process(
|
||||
logger.info(f"[deep] id={document_id} 맥북 일시 불가 — 보류 (deferred)")
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 호출 실패(네트워크/API 5xx 등)는 삼키지 않고 전파 (R3) — queue_consumer 가
|
||||
# attempts 소진까지 재시도 후 status=failed(dead-letter)로 가시화한다. 삼키면
|
||||
# worker_fn 이 정상 반환 → 큐가 completed 로 확정 → ai_detail_summary 영구 누락 +
|
||||
# tier 가 triage 에 고착(silent 영구 손실). extract/marker/fulltext/stt 정본과 일치.
|
||||
# 완주 전 doc 쓰기(168~)는 일어나지 않으므로 부분 쓰기 0 (sleep-안전).
|
||||
logger.warning(f"[deep] 호출 실패 id={document_id} model={used_cfg.model}: {exc}")
|
||||
parse_error = "call_failed"
|
||||
raw = ""
|
||||
raise
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@@ -11,12 +11,15 @@ global_digests / digest_topics 테이블에 저장한다.
|
||||
import asyncio
|
||||
|
||||
from core.config import settings
|
||||
from core.database import engine as db_engine
|
||||
from core.utils import setup_logger
|
||||
from services.background_jobs import finish_job, start_job
|
||||
from services.digest.pipeline import run_digest_pipeline
|
||||
|
||||
logger = setup_logger("digest_worker")
|
||||
|
||||
PIPELINE_HARD_CAP = 600 # 10분 hard cap
|
||||
# 2026-06-15: config 단일소스 (구 600s = 빠른 Gemma 기준, Qwen 27B 교체 후 누락 → 초과).
|
||||
PIPELINE_HARD_CAP = settings.digest_pipeline_hard_cap_s
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
@@ -28,19 +31,24 @@ async def run() -> None:
|
||||
if "digest" in settings.pipeline_held_stages:
|
||||
logger.info("[global_digest] 보류 (pipeline.held_stages) — 이번 실행 skip")
|
||||
return
|
||||
# 보드 가시화: 큐 밖 cron 생성 작업이라 background_jobs 로 노출 (best-effort, 맥미니 귀속)
|
||||
job_id = await start_job(db_engine, "global_digest", label="글로벌 다이제스트 생성")
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
run_digest_pipeline(),
|
||||
run_digest_pipeline(job_id=job_id),
|
||||
timeout=PIPELINE_HARD_CAP,
|
||||
)
|
||||
await finish_job(db_engine, job_id, state="done")
|
||||
logger.info(f"[global_digest] 워커 완료: {result}")
|
||||
except asyncio.TimeoutError:
|
||||
await finish_job(db_engine, job_id, state="failed", error=f"HARD CAP {PIPELINE_HARD_CAP}s 초과")
|
||||
logger.error(
|
||||
f"[global_digest] HARD CAP {PIPELINE_HARD_CAP}s 초과 — 워커 강제 중단. "
|
||||
f"기존 digest 는 commit 시점에만 갱신되므로 그대로 유지됨. "
|
||||
f"다음 cron 실행에서 재시도."
|
||||
)
|
||||
except Exception as e:
|
||||
await finish_job(db_engine, job_id, state="failed", error=str(e)[:300])
|
||||
logger.exception(f"[global_digest] 워커 실패: {e}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""delete_file=true 로 요청된 문서의 NAS 원본을 grace 후 물리삭제 (R7 retention sweep).
|
||||
|
||||
purge_requested_at 마커 기준(deleted_at 아님 — 일반 soft-delete/숨김은 파일 보존, undelete
|
||||
가능). grace(30일) 경과 + 파일 존재 시 unlink + AUDIT 로그. 파일 존재 체크로 멱등
|
||||
(재실행 시 이미 삭제된 건 skip). 요청 경로(DELETE)엔 동기 비가역 op 0 — 모두 이 cron 으로.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from models.document import Document
|
||||
|
||||
logger = logging.getLogger("purge_sweep")
|
||||
|
||||
PURGE_GRACE_DAYS = 30
|
||||
|
||||
|
||||
def _unlink_if_exists(p: Path) -> bool:
|
||||
"""파일이 있으면 unlink (blocking — caller 가 to_thread). 존재 여부 반환(멱등)."""
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def run() -> int:
|
||||
"""purge 요청 + grace 경과 문서의 NAS 원본 물리삭제. 삭제 건수 반환."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=PURGE_GRACE_DAYS)
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Document.id, Document.file_path, Document.purge_requested_at).where(
|
||||
Document.purge_requested_at.is_not(None),
|
||||
Document.purge_requested_at < cutoff,
|
||||
Document.file_path.is_not(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
purged = 0
|
||||
for doc_id, file_path, requested_at in rows:
|
||||
nas_path = Path(settings.nas_mount_path) / file_path
|
||||
try:
|
||||
existed = await asyncio.to_thread(_unlink_if_exists, nas_path)
|
||||
if existed:
|
||||
purged += 1
|
||||
# AUDIT — 물리삭제 기록 (가시화). doc_id / 경로 / 요청일 / grace.
|
||||
logger.warning(
|
||||
"PURGE doc_id=%s file=%s requested_at=%s grace_days=%s",
|
||||
doc_id,
|
||||
file_path,
|
||||
requested_at.isoformat() if requested_at else None,
|
||||
PURGE_GRACE_DAYS,
|
||||
)
|
||||
except OSError as e:
|
||||
logger.error("PURGE 실패 doc_id=%s file=%s: %s", doc_id, file_path, e)
|
||||
|
||||
if purged:
|
||||
logger.info("[purge_sweep] NAS 원본 %d건 물리삭제 (grace %d일)", purged, PURGE_GRACE_DAYS)
|
||||
return purged
|
||||
@@ -17,6 +17,7 @@ Web/Blog ingest (devonagent 트랙, plan db-snuggly-petal.md):
|
||||
- sidecar (.json) 누락 시: skip 안 하고 ingest, web_meta.sidecar_missing=true
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -136,6 +137,10 @@ def _canonicalize_url(url: str) -> str:
|
||||
|
||||
같은 글의 utm 변형 (`?utm_source=foo`) 과 fragment 변형 (`#section`) 을
|
||||
한 row 로 수렴시키기 위해 file_hash 산출 전 반드시 거친다.
|
||||
|
||||
★R11c: news_collector._normalize_url(news 채널)과 의도적으로 다르다 — 이쪽(web_clip)은
|
||||
query-sort/trailing-slash/소문자화로 공격적 정규화하지만, news 쪽은 query-식별 사이트의
|
||||
별개 기사 붕괴 방지를 위해 보수적이다. 두 함수 통합 금지(채널별 dedup 의도가 다름).
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
@@ -246,7 +251,8 @@ async def watch_inbox():
|
||||
async with async_session() as session:
|
||||
# ─── Web/ 트랙 (devonagent) — DEVONthink Smart Rule 이 떨군 .html 만 진입 ───
|
||||
if web_root.exists():
|
||||
for file_path in web_root.rglob("*.html"):
|
||||
# rglob NFS 디렉토리 walk(blocking stat 다발)를 off-thread 로 수집 (R5).
|
||||
for file_path in await asyncio.to_thread(lambda: list(web_root.rglob("*.html"))):
|
||||
if not file_path.is_file() or should_skip(file_path):
|
||||
continue
|
||||
rel_path = str(file_path.relative_to(nas_root))
|
||||
@@ -264,7 +270,8 @@ async def watch_inbox():
|
||||
Path(sub).name, (None, None, None)
|
||||
)
|
||||
|
||||
for file_path in scan_root.rglob("*"):
|
||||
# NFS 디렉토리 walk(blocking) off-thread 수집 (R5).
|
||||
for file_path in await asyncio.to_thread(lambda: list(scan_root.rglob("*"))):
|
||||
if not file_path.is_file() or should_skip(file_path):
|
||||
continue
|
||||
|
||||
@@ -278,7 +285,11 @@ async def watch_inbox():
|
||||
continue
|
||||
|
||||
rel_path = str(file_path.relative_to(nas_root))
|
||||
fhash = file_hash(file_path)
|
||||
# GB 파일 SHA-256 은 이벤트 루프를 점유 → 같은 루프의 모든 1분 주기 consumer
|
||||
# + FastAPI 요청이 수십초~분 동시 정지. to_thread 오프로드. 스캔 루프가 이미
|
||||
# 순차라 file_hash 는 한 번에 하나만 실행(직렬화) — 병렬 해싱 X = NFS 2.5GbE
|
||||
# 대역폭·버퍼 메모리 blowup 방지 (R5).
|
||||
fhash = await asyncio.to_thread(file_hash, file_path)
|
||||
|
||||
result = await session.execute(
|
||||
select(Document).where(Document.file_path == rel_path)
|
||||
|
||||
@@ -297,6 +297,10 @@ async def collect_disaster_cases(session) -> int:
|
||||
await _ingest_attachment(session, boardno, filenm, filepath)
|
||||
except FeedError as e:
|
||||
logger.warning(f"[kosha] 첨부 실패 skip ({boardno}/{filenm}): {e}")
|
||||
|
||||
# 케이스 단위 commit (R4) — 이후 페이지/케이스의 _api_get 실패가 앞서 적재한
|
||||
# 케이스까지 전체 rollback 하지 않게 부분 적재 보존 (csb/api_standards idiom).
|
||||
await session.commit()
|
||||
if page_all_dup:
|
||||
break # 등록일 역순 — 페이지 전체가 기존이면 이후 페이지도 기존
|
||||
|
||||
@@ -374,6 +378,8 @@ async def collect_fatal_accidents(session) -> int:
|
||||
await enqueue_stage(session, doc.id, "embed")
|
||||
await enqueue_stage(session, doc.id, "chunk")
|
||||
new_count += 1
|
||||
# 케이스 단위 commit (R4) — 이후 페이지 실패가 앞 케이스 전체 rollback 방지.
|
||||
await session.commit()
|
||||
if page_all_dup:
|
||||
break # 등록일 역순 — 페이지 전체가 기존이면 이후 페이지도 기존
|
||||
|
||||
@@ -450,6 +456,8 @@ async def collect_kosha_guide(session, cap: int = _GUIDE_DAILY_CAP) -> int:
|
||||
await session.flush()
|
||||
await enqueue_stage(session, doc.id, "extract")
|
||||
ingested += 1
|
||||
# 항목 단위 commit (R4) — 다운로드 실패가 앞서 적재한 GUIDE 항목 전체 rollback 방지.
|
||||
await session.commit()
|
||||
|
||||
# silent cap 금지 — 잔량 가시화 (자동 점진 백필: 내일 cap 만큼 또 소화)
|
||||
logger.info(f"[kosha] GUIDE 신규/개정 {len(new_specs)}건 중 {ingested}건 ingest"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""메모 → 문서 승격 시 거친 메모를 구조화된 마크다운 문서로 정리 (26B, P2).
|
||||
|
||||
`POST /memos/{id}/promote-to-document` 가 `source_metadata.needs_draft=true` 마커를
|
||||
찍으면 본 스케줄 워커가 집어 AIClient.call_primary(26B Mac mini = 로컬, 과금규칙 부합)로
|
||||
md_content 를 생성한다. markdown canonical Phase 1A 스키마 재사용:
|
||||
- content_origin='ai_drafted' + md_draft_status='draft'
|
||||
(migration 212 제약: md_draft_status NOT NULL → content_origin='ai_drafted' 필수)
|
||||
- md_status='success', md_extraction_engine='ai_draft'
|
||||
원본 메모는 extracted_text 에 보존(검색/청크는 원문 사용). "필요시" = 이미 정돈된 메모는
|
||||
프롬프트가 형식만 다듬고, 거친 메모는 구조화하도록 지시(사실 추가 금지).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ai.client import AIClient, strip_thinking
|
||||
from core.database import async_session
|
||||
from models.document import Document
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 한 번에 처리할 승격 문서 수 (26B 콜 = 무겁다 → 소량 순차). interval 잡이라 다음 틱에 이어 처리.
|
||||
_BATCH = 2
|
||||
# 너무 짧은 메모는 문서화 의미 없음 — 마커만 정리하고 md 생성 스킵.
|
||||
_MIN_CHARS = 20
|
||||
|
||||
_DRAFT_SYSTEM = (
|
||||
"당신은 사용자의 거친 메모를 사실 추가 없이 깔끔한 마크다운 문서로 정리하는 도우미입니다."
|
||||
)
|
||||
_DRAFT_PROMPT = """다음은 사용자가 빠르게 적은 메모입니다. 이를 정식 자료 문서로 정리하세요.
|
||||
|
||||
규칙:
|
||||
- 메모에 있는 정보만 사용하고, 내용·사실을 추가하거나 추측하지 마세요.
|
||||
- 이미 잘 정돈돼 있으면 형식만 다듬고, 거친 메모면 제목·소제목·목록으로 구조화하세요.
|
||||
- 원문 언어를 유지하세요(한국어는 한국어, 영어는 영어).
|
||||
- 출력은 마크다운 본문만. 인사말·메타 설명 없이 문서 내용만 출력하세요.
|
||||
|
||||
--- 메모 ---
|
||||
{content}
|
||||
--- 끝 ---"""
|
||||
|
||||
|
||||
async def _ids_needing_draft() -> list[int]:
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Document.id)
|
||||
.where(
|
||||
Document.deleted_at.is_(None),
|
||||
# JSONB 마커 (json/jsonb 공통 ->> 연산자). promote 가 needs_draft=true 세팅.
|
||||
Document.source_metadata.op("->>")("needs_draft") == "true",
|
||||
)
|
||||
.order_by(Document.id)
|
||||
.limit(_BATCH)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
"""needs_draft 마커가 찍힌 승격 문서를 26B로 문서화 (interval job, no-arg)."""
|
||||
ids = await _ids_needing_draft()
|
||||
if not ids:
|
||||
return
|
||||
|
||||
client = AIClient()
|
||||
for doc_id in ids:
|
||||
# 문서별 독립 세션·트랜잭션 — 1건 실패가 나머지를 막지 않게.
|
||||
async with async_session() as session:
|
||||
try:
|
||||
doc = await session.get(Document, doc_id)
|
||||
if doc is None or not (doc.source_metadata or {}).get("needs_draft"):
|
||||
continue # 경합/이미 처리됨
|
||||
|
||||
source = (doc.extracted_text or "").strip()
|
||||
now = datetime.now(timezone.utc)
|
||||
meta = dict(doc.source_metadata or {})
|
||||
|
||||
md = ""
|
||||
if len(source) >= _MIN_CHARS:
|
||||
# 26B 호출은 반드시 mlx gate(Semaphore 1) 안에서 — 동시 호출 pile-up 방지
|
||||
# ([[feedback_llm_verification_load_pileup]]). BACKGROUND = 사용자 대면보다 양보.
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND):
|
||||
raw = await client.call_primary(
|
||||
_DRAFT_PROMPT.format(content=source), system=_DRAFT_SYSTEM
|
||||
)
|
||||
md = strip_thinking(raw or "").strip()
|
||||
|
||||
if md:
|
||||
doc.md_content = md
|
||||
# 제약(212): md_draft_status NOT NULL 이면 content_origin='ai_drafted' 여야 함.
|
||||
doc.content_origin = "ai_drafted"
|
||||
doc.md_draft_status = "draft"
|
||||
doc.md_status = "success"
|
||||
doc.md_extraction_engine = "ai_draft"
|
||||
doc.md_generated_at = now
|
||||
meta["drafted_at"] = now.isoformat()
|
||||
|
||||
# 성공/스킵 모두 마커 해제(무한 재시도 방지). 26B 호출 자체가 예외면 except 로 빠져 마커 유지.
|
||||
meta["needs_draft"] = False
|
||||
doc.source_metadata = meta
|
||||
doc.updated_at = now
|
||||
await session.commit()
|
||||
logger.info("memo_draft doc=%s md_len=%d", doc_id, len(md))
|
||||
except Exception:
|
||||
logger.exception("memo_draft 실패 doc=%s (다음 틱 재시도)", doc_id)
|
||||
await session.rollback()
|
||||
@@ -83,6 +83,10 @@ def _normalize_url(url: str) -> str:
|
||||
query 전체 제거 금지: hada.io/topic?id= · aitimes articleView.html?idxno= ·
|
||||
HN item?id= 등 query-식별 사이트에서 별개 기사가 같은 URL 로 붕괴된다.
|
||||
저장(edit_url)·조회 양쪽이 이 함수를 공유해야 dedup 이 성립.
|
||||
|
||||
★R11c: file_watcher._canonicalize_url(web_clip 채널)과 의도적으로 다르다 — 이쪽은 콘텐츠
|
||||
식별 query 보존(별개 기사 붕괴 방지)이 핵심이라 query-sort/trailing-slash/소문자화를 안 한다.
|
||||
두 함수 통합 금지(news dedup 가 깨짐). 채널별 normalization 은 의도된 설계.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
kept = [
|
||||
@@ -397,6 +401,55 @@ def _doc_identity(source: NewsSource, source_short: str, category: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def _already_ingested(session, article_id: str, normalized_url: str, link: str) -> bool:
|
||||
"""이미 적재된 기사인지 — file_hash 또는 정규화/raw edit_url 매칭 (3 fetch 공통, R11c).
|
||||
|
||||
레거시 raw URL + 교차 게시 다중 매칭 내성(first). _fetch_rss/_fetch_api_guardian/
|
||||
_fetch_api_nyt 가 복제하던 동일 존재체크를 단일화.
|
||||
"""
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
(Document.file_hash == article_id)
|
||||
| (Document.edit_url.in_([normalized_url, link]))
|
||||
).limit(1)
|
||||
)
|
||||
return existing.scalars().first() is not None
|
||||
|
||||
|
||||
def _build_news_doc(source, ident, source_short, article_id, title, body,
|
||||
extractor_version, normalized_url, pub_dt) -> Document:
|
||||
"""3 fetch 공통 뉴스 Document 빌더 (R11c). 채널별 차이는 인자로만 — body(NYT=summary)·
|
||||
extractor_version·ident(category 계산 차이 흡수)만 다르고 22 필드 구조는 정적 동일.
|
||||
edit_url 은 조회와 동일 정규화 저장(raw 저장 시 URL dedup 무력화)."""
|
||||
return Document(
|
||||
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
|
||||
file_hash=article_id,
|
||||
file_format="article",
|
||||
file_size=len(body.encode()),
|
||||
file_type="note",
|
||||
title=title,
|
||||
extracted_text=f"{title}\n\n{body}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version=extractor_version,
|
||||
# article = 텍스트 네이티브 → 생성 시점 terminal 'skipped' 명시(markdown 변환 비대상,
|
||||
# 미명시 시 'pending' 영구 비수렴 → backlog 지표 오염). page 정책은 fulltext_worker 승격.
|
||||
md_status="skipped",
|
||||
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel=source.source_channel,
|
||||
data_origin="external",
|
||||
edit_url=normalized_url,
|
||||
review_status="approved",
|
||||
ai_domain=ident["ai_domain"],
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=ident["ai_tags"],
|
||||
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
|
||||
material_type=ident["material_type"],
|
||||
jurisdiction=ident["jurisdiction"],
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta=_build_extract_meta(source, pub_dt),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_rss(session, source: NewsSource) -> tuple[int, str]:
|
||||
"""RSS 피드 수집 — redirect 재검증 + 크기/content-type 제한 + 조건부 GET (A-1).
|
||||
|
||||
@@ -515,13 +568,7 @@ async def _fetch_rss(session, source: NewsSource) -> tuple[int, str]:
|
||||
article_id = _article_hash(title, pub_dt.strftime("%Y%m%d"), source.name)
|
||||
normalized_url = _normalize_url(link)
|
||||
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
(Document.file_hash == article_id) |
|
||||
(Document.edit_url.in_([normalized_url, link]))
|
||||
).limit(1)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
if await _already_ingested(session, article_id, normalized_url, link):
|
||||
continue
|
||||
|
||||
# A-6 2차: 포털 전재 dedup (first-wins — 먼저 적재된 쪽이 정본)
|
||||
@@ -533,35 +580,9 @@ async def _fetch_rss(session, source: NewsSource) -> tuple[int, str]:
|
||||
source_short = source.name.split(" ")[0] # "경향신문 문화" → "경향신문"
|
||||
ident = _doc_identity(source, source_short, category)
|
||||
|
||||
doc = Document(
|
||||
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
|
||||
file_hash=article_id,
|
||||
file_format="article",
|
||||
file_size=len(body.encode()),
|
||||
file_type="note",
|
||||
title=title,
|
||||
extracted_text=f"{title}\n\n{body}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version=extractor_version,
|
||||
# article = 텍스트 네이티브(본문=extracted_text). markdown 단계 미enqueue 라
|
||||
# 기본값 'pending' 이면 영구 비수렴 → backlog 지표 오염 + md_status_pending partial
|
||||
# 인덱스 비대. 생성 시점에 terminal 'skipped' 로 명시(변환 비대상).
|
||||
# fulltext_policy='page' 소스는 fulltext_worker 가 승격 시 success 로 갱신.
|
||||
md_status="skipped",
|
||||
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel=source.source_channel,
|
||||
data_origin="external",
|
||||
# 조회와 동일하게 정규화해 저장 — raw(tracking param 포함) 저장 시 URL dedup 무력화
|
||||
edit_url=normalized_url,
|
||||
review_status="approved",
|
||||
ai_domain=ident["ai_domain"],
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=ident["ai_tags"],
|
||||
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
|
||||
material_type=ident["material_type"],
|
||||
jurisdiction=ident["jurisdiction"],
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta=_build_extract_meta(source, pub_dt),
|
||||
doc = _build_news_doc(
|
||||
source, ident, source_short, article_id, title, body,
|
||||
extractor_version, normalized_url, pub_dt,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
@@ -658,13 +679,7 @@ async def _fetch_api_guardian(session, source: NewsSource) -> tuple[int, str]:
|
||||
normalized_url = _normalize_url(link)
|
||||
|
||||
# RSS 수집부와 동일: 레거시 raw URL + 교차 게시 다중 매칭 내성 (first)
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
(Document.file_hash == article_id) |
|
||||
(Document.edit_url.in_([normalized_url, link]))
|
||||
).limit(1)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
if await _already_ingested(session, article_id, normalized_url, link):
|
||||
continue
|
||||
|
||||
if await _is_portal_duplicate(session, title):
|
||||
@@ -675,30 +690,9 @@ async def _fetch_api_guardian(session, source: NewsSource) -> tuple[int, str]:
|
||||
source_short = source.name.split(" ")[0]
|
||||
ident = _doc_identity(source, source_short, category)
|
||||
|
||||
doc = Document(
|
||||
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
|
||||
file_hash=article_id,
|
||||
file_format="article",
|
||||
file_size=len(body.encode()),
|
||||
file_type="note",
|
||||
title=title,
|
||||
extracted_text=f"{title}\n\n{body}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version="guardian_api_full" if is_full else "guardian_api",
|
||||
md_status="skipped",
|
||||
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel=source.source_channel,
|
||||
data_origin="external",
|
||||
edit_url=normalized_url,
|
||||
review_status="approved",
|
||||
ai_domain=ident["ai_domain"],
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=ident["ai_tags"],
|
||||
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
|
||||
material_type=ident["material_type"],
|
||||
jurisdiction=ident["jurisdiction"],
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta=_build_extract_meta(source, pub_dt),
|
||||
doc = _build_news_doc(
|
||||
source, ident, source_short, article_id, title, body,
|
||||
"guardian_api_full" if is_full else "guardian_api", normalized_url, pub_dt,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
@@ -755,13 +749,7 @@ async def _fetch_api_nyt(session, source: NewsSource) -> tuple[int, str]:
|
||||
normalized_url = _normalize_url(link)
|
||||
|
||||
# RSS 수집부와 동일: 레거시 raw URL + 교차 게시 다중 매칭 내성 (first)
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
(Document.file_hash == article_id) |
|
||||
(Document.edit_url.in_([normalized_url, link]))
|
||||
).limit(1)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
if await _already_ingested(session, article_id, normalized_url, link):
|
||||
continue
|
||||
|
||||
if await _is_portal_duplicate(session, title):
|
||||
@@ -772,33 +760,9 @@ async def _fetch_api_nyt(session, source: NewsSource) -> tuple[int, str]:
|
||||
source_short = source.name.split(" ")[0]
|
||||
|
||||
ident = _doc_identity(source, source_short, category)
|
||||
doc = Document(
|
||||
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
|
||||
file_hash=article_id,
|
||||
file_format="article",
|
||||
file_size=len(summary.encode()),
|
||||
file_type="note",
|
||||
title=title,
|
||||
extracted_text=f"{title}\n\n{summary}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version="nyt_api",
|
||||
# article = 텍스트 네이티브(본문=extracted_text). markdown 단계 미enqueue 라
|
||||
# 기본값 'pending' 이면 영구 비수렴 → backlog 지표 오염 + md_status_pending partial
|
||||
# 인덱스 비대. 생성 시점에 terminal 'skipped' 로 명시(변환 비대상).
|
||||
md_status="skipped",
|
||||
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel=source.source_channel,
|
||||
data_origin="external",
|
||||
edit_url=normalized_url,
|
||||
review_status="approved",
|
||||
ai_domain=ident["ai_domain"],
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=ident["ai_tags"],
|
||||
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
|
||||
material_type=ident["material_type"],
|
||||
jurisdiction=ident["jurisdiction"],
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta=_build_extract_meta(source, pub_dt),
|
||||
doc = _build_news_doc(
|
||||
source, ident, source_short, article_id, title, summary,
|
||||
"nyt_api", normalized_url, pub_dt,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
@@ -331,11 +331,13 @@ async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
filter_str = (build_issn_filter(wm_key, watermark) if kind == "issn"
|
||||
else build_filter(wm_key, watermark))
|
||||
newest: str | None = None
|
||||
capped = False # 이번 run 이 cap 으로 시드 중도 절단됐는지 (R4)
|
||||
cursor = "*"
|
||||
max_pages = (10**6 if bulk else _MAX_PAGES_PER_KW)
|
||||
try:
|
||||
for _page in range(max_pages):
|
||||
if inserted >= run_cap:
|
||||
capped = True
|
||||
break
|
||||
text = await _fetch(client, key, filter_str, cursor)
|
||||
_count, next_cursor, works = parse_openalex_works(text)
|
||||
@@ -353,12 +355,17 @@ async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
else:
|
||||
await session.rollback()
|
||||
if inserted >= run_cap:
|
||||
capped = True
|
||||
break
|
||||
await asyncio.sleep(_REQ_SLEEP)
|
||||
if not next_cursor:
|
||||
break
|
||||
cursor = next_cursor
|
||||
if newest:
|
||||
# cap 절단 시 워터마크 미전진 — 미페치 works 가 다음 run 의 watermark 필터
|
||||
# (publication_date > watermark)에 영구 배제되는 silent loss 방지. 미전진하면
|
||||
# 다음 run 이 옛 watermark 부터 재페치하며 적재분 dedup-skip(cap 미소모) 후
|
||||
# 이어 적재 → 백로그 run 당 cap 소화 (R4). bulk 은 cap 무관.
|
||||
if newest and not capped:
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
_set_watermark(src, wm_key, newest)
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Phase 2A 후보 임베딩 백필 CLI (embedding-phase2a-1 E-1).
|
||||
|
||||
docker compose exec -T fastapi python -m workers.phase2a_cand_backfill \
|
||||
--target qwen06 --doc-id-max 41944 --chunk-id-max 104140 [--batch 32]
|
||||
|
||||
설계 원칙 (plan r3):
|
||||
- resumable/idempotent: 대상 = NOT EXISTS(후보 테이블) — 중단/재실행 시 이어서.
|
||||
배치 단위 커밋. C-1 백필 게이트 = "후보 카운트 == 동결셋 카운트".
|
||||
- 동결셋: id <= *_id_max AND 베이스라인 embedding IS NOT NULL (AND docs.deleted_at IS NULL).
|
||||
cand 테이블은 동결 범위로만 INSERT (retrieval cand path 가 snapshot filter 를 안 타는 전제).
|
||||
- 문서/청크 입력 = production 경로와 동일 구성(embed_worker._build_embed_input /
|
||||
chunk_worker 의 [제목][섹션][본문]) + plain (instruct prefix 는 쿼리 측 전용 — G-1 불변식).
|
||||
- 임베딩 = Ollama /api/embed 배치 호출 (G-1 fixture: 정규화 출력).
|
||||
- qwen4m 은 본 CLI 대상이 아님 — qwen4 적재 후 SQL 파생(subvector+l2_normalize), plan E-1.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from workers.embed_worker import _build_embed_input
|
||||
|
||||
logger = setup_logger("phase2a_cand_backfill")
|
||||
|
||||
OLLAMA_EMBED = "http://ollama:11434/api/embed"
|
||||
|
||||
TARGETS = {
|
||||
"qwen06": {
|
||||
"model": "qwen3-embedding:0.6b", "dim": 1024,
|
||||
"docs": "documents_cand_qwen06", "chunks": "document_chunks_cand_qwen06",
|
||||
},
|
||||
"qwen4": {
|
||||
"model": "qwen3-embedding:4b", "dim": 2560,
|
||||
"docs": "documents_cand_qwen4", "chunks": "document_chunks_cand_qwen4",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _embed_batch(client: httpx.AsyncClient, model: str, texts: list[str]) -> list[list[float]]:
|
||||
r = await client.post(OLLAMA_EMBED, json={"model": model, "input": texts}, timeout=600)
|
||||
r.raise_for_status()
|
||||
embs = r.json()["embeddings"]
|
||||
if len(embs) != len(texts):
|
||||
raise RuntimeError(f"embed count mismatch: {len(embs)} != {len(texts)}")
|
||||
return embs
|
||||
|
||||
|
||||
async def backfill_docs(target: dict, doc_id_max: int, batch: int, http: httpx.AsyncClient) -> int:
|
||||
total = 0
|
||||
while True:
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(text(f"""
|
||||
SELECT d.id FROM documents d
|
||||
WHERE d.id <= :m AND d.embedding IS NOT NULL AND d.deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM {target['docs']} c WHERE c.doc_id = d.id)
|
||||
ORDER BY d.id LIMIT :b
|
||||
"""), {"m": doc_id_max, "b": batch})).scalars().all()
|
||||
if not rows:
|
||||
break
|
||||
docs = [(await session.get(Document, i)) for i in rows]
|
||||
inputs = [_build_embed_input(d) for d in docs]
|
||||
embs = await _embed_batch(http, target["model"], inputs)
|
||||
for d, inp, e in zip(docs, inputs, embs):
|
||||
await session.execute(text(f"""
|
||||
INSERT INTO {target['docs']} (doc_id, embed_input_hash, embedding)
|
||||
VALUES (:i, :h, cast(:e AS vector))
|
||||
ON CONFLICT (doc_id) DO NOTHING
|
||||
"""), {"i": d.id, "h": hashlib.sha256(inp.encode()).hexdigest()[:16], "e": str(e)})
|
||||
await session.commit()
|
||||
total += len(rows)
|
||||
if total % (batch * 10) < batch:
|
||||
logger.info(f"[{target['docs']}] +{total} (last id={rows[-1]})")
|
||||
return total
|
||||
|
||||
|
||||
async def backfill_chunks(target: dict, chunk_id_max: int, batch: int, http: httpx.AsyncClient) -> int:
|
||||
total = 0
|
||||
while True:
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(text(f"""
|
||||
SELECT c.id, c.doc_id, c.chunk_index, c.section_title, c.text, d.title
|
||||
FROM corpus_chunks c JOIN documents d ON d.id = c.doc_id
|
||||
WHERE c.id <= :m AND c.embedding IS NOT NULL AND d.deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM {target['chunks']} k WHERE k.id = c.id)
|
||||
ORDER BY c.id LIMIT :b
|
||||
"""), {"m": chunk_id_max, "b": batch})).all()
|
||||
if not rows:
|
||||
break
|
||||
inputs = [
|
||||
f"[제목] {r.title or ''}\n[섹션] {r.section_title or ''}\n[본문] {r.text}"
|
||||
for r in rows
|
||||
]
|
||||
embs = await _embed_batch(http, target["model"], inputs)
|
||||
for r, e in zip(rows, embs):
|
||||
await session.execute(text(f"""
|
||||
INSERT INTO {target['chunks']} (id, doc_id, chunk_index, section_title, text, embedding)
|
||||
VALUES (:i, :d, :x, :s, :t, cast(:e AS vector))
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"""), {"i": r.id, "d": r.doc_id, "x": r.chunk_index,
|
||||
"s": r.section_title, "t": r.text, "e": str(e)})
|
||||
await session.commit()
|
||||
total += len(rows)
|
||||
if total % (batch * 10) < batch:
|
||||
logger.info(f"[{target['chunks']}] +{total} (last id={rows[-1]})")
|
||||
return total
|
||||
|
||||
|
||||
async def run(target_key: str, doc_id_max: int, chunk_id_max: int, batch: int) -> None:
|
||||
target = TARGETS[target_key]
|
||||
start = time.monotonic()
|
||||
async with httpx.AsyncClient() as http:
|
||||
nd = await backfill_docs(target, doc_id_max, batch, http)
|
||||
nc = await backfill_chunks(target, chunk_id_max, batch, http)
|
||||
mins = (time.monotonic() - start) / 60
|
||||
async with async_session() as session:
|
||||
cd = (await session.execute(text(f"SELECT count(*) FROM {target['docs']}"))).scalar_one()
|
||||
cc = (await session.execute(text(f"SELECT count(*) FROM {target['chunks']}"))).scalar_one()
|
||||
logger.info(
|
||||
f"[{target_key}] 완료 — 이번 run docs +{nd} chunks +{nc} ({mins:.1f}분) · "
|
||||
f"누적 docs {cd} / chunks {cc} (동결 게이트 = 베이스라인 동결셋 카운트와 일치 확인)"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="Phase 2A 후보 임베딩 백필 (resumable)")
|
||||
p.add_argument("--target", required=True, choices=sorted(TARGETS))
|
||||
p.add_argument("--doc-id-max", type=int, required=True)
|
||||
p.add_argument("--chunk-id-max", type=int, required=True)
|
||||
p.add_argument("--batch", type=int, default=32)
|
||||
a = p.parse_args()
|
||||
asyncio.run(run(a.target, a.doc_id_max, a.chunk_id_max, a.batch))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -47,10 +47,15 @@ MARKDOWN_STALE_THRESHOLD_MINUTES = int(os.getenv("MARKDOWN_STALE_MINUTES", "120"
|
||||
# STT 도 장기 작업 가능성이 있으나 본 PR 범위 밖 — main 에 유지(follow-up).
|
||||
MAIN_QUEUE_STAGES = [
|
||||
"extract", "classify", "summarize",
|
||||
"preview", "stt", "thumbnail", "deep_summary", "fulltext",
|
||||
"preview", "stt", "thumbnail", "fulltext",
|
||||
]
|
||||
MARKDOWN_QUEUE_STAGES = ["markdown"]
|
||||
|
||||
# 2026-06-15: deep_summary(26B, 콜당 70~300s)를 메인 루프에서 분리 (markdown/fast 선례).
|
||||
# 단일 deep 호출이 1분 틱을 초과해 메인 consume_queue 가 영구 coalesce 되고 extract/
|
||||
# classify 등 경량 stage 까지 굶던 문제 제거. 집합 disjoint(자기 집합만 stale reset).
|
||||
DEEP_QUEUE_STAGES = ["deep_summary"]
|
||||
|
||||
# 고속(비-LLM·경량 GPU) stage — LLM 사이클(분 단위)에서 분리해 1분 잡 전용 소비.
|
||||
# embed/chunk 는 건당 <1s 라 main 루프에 두면 classify(~190s×3) 뒤에서 굶는다
|
||||
# (2026-06-12 실측: 적체 3,570 · 4070 가동률 0%). markdown 분리(05-01)와 동일 패턴.
|
||||
@@ -270,7 +275,15 @@ async def _process_stage(stage, worker_fn):
|
||||
item.status = "completed"
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
await skip_session.commit()
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
# 완료 커밋 후 enqueue — 실패가 outer except 로 전파돼 completed 재오픈
|
||||
# 되지 않게 격리 (R3, 정상 완료 경로와 동일 처리).
|
||||
try:
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
except Exception as enq_err:
|
||||
logger.error(
|
||||
f"[{stage}] document_id={document_id} skip(note) 완료됐으나 "
|
||||
f"다음 단계 enqueue 실패: {enq_err}"
|
||||
)
|
||||
logger.info(f"[{stage}] document_id={document_id} skip (note)")
|
||||
continue
|
||||
|
||||
@@ -288,7 +301,15 @@ async def _process_stage(stage, worker_fn):
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
# 완료는 이미 커밋됨. enqueue_next_stage 실패가 outer except 로 전파되면
|
||||
# completed 항목을 재오픈(pending/failed)해 같은 단계를 재실행 = 비싼 작업 중복
|
||||
# + 부분 재쓰기. 자체 try 로 격리하고 ERROR 로 가시화한다 (R3).
|
||||
try:
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
except Exception as enq_err:
|
||||
logger.error(
|
||||
f"[{stage}] document_id={document_id} 완료됐으나 다음 단계 enqueue 실패: {enq_err}"
|
||||
)
|
||||
logger.info(f"[{stage}] document_id={document_id} 완료")
|
||||
|
||||
except StageDeferred as defer:
|
||||
@@ -405,3 +426,24 @@ async def consume_markdown_queue():
|
||||
|
||||
for stage in MARKDOWN_QUEUE_STAGES:
|
||||
await _process_stage(stage, workers[stage])
|
||||
|
||||
|
||||
async def consume_deep_queue():
|
||||
"""deep_summary 전용 큐 소비자 (2026-06-15) — 26B 심층요약을 메인 파이프라인과 분리.
|
||||
|
||||
deep_summary 1콜이 70~300s(맥미니 Qwen 27B 폴백)라 메인 consume_queue(1분 틱) 안에
|
||||
있으면 매 틱이 interval 을 초과해 영구 "maximum running instances" coalesce 되고
|
||||
extract/classify 등 경량 stage 까지 함께 굶었다. 분리 후 = deep 만 자기 1분 잡에서
|
||||
coalesce, 나머지 메인 루프는 틱 내 완료. max_instances=1 로 동시 deep 2건은 방지.
|
||||
"""
|
||||
workers = _load_workers()
|
||||
|
||||
try:
|
||||
await reset_stale_items(DEEP_QUEUE_STAGES, STALE_THRESHOLD_MINUTES)
|
||||
except Exception:
|
||||
logger.exception("deep stale reset failed, but continuing queue consumption")
|
||||
|
||||
for stage in DEEP_QUEUE_STAGES:
|
||||
if stage in settings.pipeline_held_stages:
|
||||
continue
|
||||
await _process_stage(stage, workers[stage])
|
||||
|
||||
@@ -102,7 +102,9 @@ async def _process_one(session: AsyncSession, qid: int, client: AIClient) -> boo
|
||||
try:
|
||||
async with asyncio.timeout(EMBED_TIMEOUT_S):
|
||||
vec = await client.embed(text)
|
||||
except (asyncio.TimeoutError, Exception) as e:
|
||||
except asyncio.CancelledError:
|
||||
raise # 취소는 전파 — broad except 가 삼키지 않게 명시 (R3)
|
||||
except Exception as e:
|
||||
logger.warning("study_q_embed_failed qid=%s err=%s: %s", qid, type(e).__name__, e)
|
||||
# 실패 — status='failed'. 직전 embedding 보존.
|
||||
q.embedding_status = "failed"
|
||||
|
||||
@@ -121,7 +121,12 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
|
||||
ok = _extract_thumbnail(source, output, seek)
|
||||
if not ok:
|
||||
return
|
||||
# 썸네일 추출 실패(ffmpeg)는 삼키지 않고 raise (R3) — queue_consumer 가 attempts
|
||||
# 소진까지 재시도 후 status=failed 로 가시화. silent return 이면 큐가 completed 로
|
||||
# 확정 + 썸네일 영구 누락 + 재시도/추적 0 (silent skip). 손상 영상이면 failed 로 안착.
|
||||
raise RuntimeError(
|
||||
f"thumbnail 추출 실패: document_id={document_id} source={source}"
|
||||
)
|
||||
|
||||
doc.thumbnail_path = str(output)
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -52,6 +52,11 @@ DOMAIN_PRIORITY: list[tuple[str, str]] = [
|
||||
("manual", "source_channel = 'manual'"),
|
||||
]
|
||||
|
||||
# R12: filter_clause 는 SQL 에 직접 보간되므로 이 allowlist(DOMAIN_PRIORITY 출처) 통과분만
|
||||
# 허용 — 현재 모듈 상수라 injection 경로 0 이나, 외부 입력화 시 즉시 차단하는 final gate
|
||||
# (retrieval_service 의 _VALID_DOCS_TABLE allowlist 정본 대비 비대칭 해소).
|
||||
_ALLOWED_FILTER_CLAUSES: frozenset[str] = frozenset(c for _, c in DOMAIN_PRIORITY)
|
||||
|
||||
|
||||
async def _classify_pending(session: AsyncSession) -> int:
|
||||
return int(await session.scalar(text("""
|
||||
@@ -66,6 +71,9 @@ async def _enqueue_domain(session: AsyncSession, filter_clause: str, limit: int)
|
||||
extracted_text 빈 문자열 (LENGTH=0) 도 제외 — classify_worker 는 not doc.extracted_text
|
||||
truthy 체크라 빈 문자열에서 ValueError raise. 무한 retry 루프 방지.
|
||||
"""
|
||||
# R12: SQL 직접 보간 전 allowlist final gate.
|
||||
if filter_clause not in _ALLOWED_FILTER_CLAUSES:
|
||||
raise ValueError(f"비허용 filter_clause (allowlist 외): {filter_clause!r}")
|
||||
sql = text(f"""
|
||||
INSERT INTO processing_queue (document_id, stage, status, attempts, max_attempts)
|
||||
SELECT id, 'classify', 'pending', 0, 3
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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,386 +1,51 @@
|
||||
import SwiftUI
|
||||
import DSKit
|
||||
|
||||
/// 홈 = 풀폭 데일리 코크핏 (시안 안1). detail 전폭을 받아 1000pt 캔버스로 좌측 정렬, 내부 2칼럼.
|
||||
/// 인사 → 오늘 스트립(검토 큐 + 속보 + 스탯) → 좌(빠른캡처·최근활동)/우(도메인분포·고정).
|
||||
/// Corpus-health overview (not a dumped table). Stat hero + domain distribution bars; tapping a
|
||||
/// domain jumps to Documents (cross-page nav proof).
|
||||
struct DashboardView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
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)
|
||||
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 {
|
||||
ProgressView().frame(maxWidth: .infinity, minHeight: 200)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 1000, alignment: .leading)
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.vertical, 26)
|
||||
.padding(20)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.background(Sage.surface)
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
/// 서버 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
|
||||
}
|
||||
.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,367 +1,91 @@
|
||||
import SwiftUI
|
||||
import DSKit
|
||||
|
||||
/// 문서 = DEVONthink식 컬럼 브라우저. 소스트리(분류)는 글로벌 사이드바에 있고, 이 페이지는 detail
|
||||
/// 전폭 안에서 내부 HSplitView 3-pane = 컬럼 리스트 | MD 리더 | 인스펙터(토글). 도메인 필터는
|
||||
/// 사이드바가 model.loadDocuments(domain:) 로 서버 재조회.
|
||||
struct DocumentsBrowser: View {
|
||||
struct DocumentListView: 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 }
|
||||
/// "PDF→MD" / "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) } } }
|
||||
)
|
||||
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)
|
||||
}
|
||||
.tint(Sage.brand)
|
||||
}
|
||||
List(model.documentList, selection: selection) { doc in
|
||||
DocumentRow(doc: doc)
|
||||
}
|
||||
.background(Sage.card)
|
||||
.listStyle(.inset)
|
||||
.background(Sage.surface)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 · 신뢰도 · PDF→MD 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 {
|
||||
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
|
||||
struct DocumentRow: View {
|
||||
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)
|
||||
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))
|
||||
}
|
||||
Spacer()
|
||||
Text(doc.updatedAtRaw.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@Environment(AppModel.self) private var model
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(Sage.surface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@ struct MemoListView: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("저장") {
|
||||
let content = draft
|
||||
Task { if await model.saveMemo(content) { draft = "" } }
|
||||
draft = ""
|
||||
Task { _ = try? await model.client.createMemo(MemoCreate(content: content)) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.disabled(draft.isEmpty)
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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,58 +1,5 @@
|
||||
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,10 +1,9 @@
|
||||
import SwiftUI
|
||||
import DSKit
|
||||
|
||||
/// 2-column 셸 (사이드바 + 단일 detail). 각 섹션이 detail 전폭을 받아 자기 내부 레이아웃을 소유한다
|
||||
/// (개요=풀폭 캔버스 / 문서=내부 HSplitView 3-pane / 메모=리스트+상세). 이전 3-column 이 대시보드를
|
||||
/// 좁은 가운데칸에 욱여넣어 깨지던 문제를 구조적으로 제거. macOS-only.
|
||||
/// 인증 게이트: checking(refresh 쿠키 복귀) → loggedOut(LoginView) → ready(셸).
|
||||
/// DEVONthink-style 3-column shell. RootView only ROUTES; each page owns its own interior treatment
|
||||
/// (no shell-level auto-inherit). macOS-only target.
|
||||
/// 인증 게이트: checking(부팅 시 refresh 쿠키 복귀 시도) → loggedOut(LoginView) → ready(3-pane 셸).
|
||||
public struct RootView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
@@ -30,45 +29,38 @@ public struct RootView: View {
|
||||
private var shell: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
Sidebar()
|
||||
.navigationSplitViewColumnWidth(min: 200, ideal: 215, max: 270)
|
||||
.navigationSplitViewColumnWidth(min: 220, ideal: 250)
|
||||
} content: {
|
||||
ContentColumn()
|
||||
.navigationSplitViewColumnWidth(min: 300, ideal: 380)
|
||||
} detail: {
|
||||
SectionDetail()
|
||||
DetailColumn()
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.tint(Sage.brand)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) { UploadToolbarButton() }
|
||||
ToolbarItem(placement: .primaryAction) { AccountMenu() }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
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)
|
||||
// 라이브 데이터 호출 실패 가시화 (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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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?>(
|
||||
@@ -76,132 +68,73 @@ struct Sidebar: View {
|
||||
set: { if let v = $0 { model.section = v } }
|
||||
)
|
||||
List(selection: selection) {
|
||||
BrandRow().selectionDisabled()
|
||||
Section {
|
||||
ForEach(navSections) { s in
|
||||
Label(s.title, systemImage: Self.icon(s)).tag(s)
|
||||
ForEach(AppModel.Section.allCases) { s in
|
||||
Text(s.title).tag(s)
|
||||
}
|
||||
}
|
||||
// 문서 섹션일 때만 분류 소스트리 노출 (다른 섹션은 4-섹션만 보임).
|
||||
if model.section == .documents {
|
||||
DocumentsSourceSidebar()
|
||||
if model.section == .documents, !model.tree.isEmpty {
|
||||
Section("도메인") {
|
||||
ForEach(model.tree) { node in
|
||||
DomainRow(node: node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.background(Sage.sidebar)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
struct DomainRow: 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
|
||||
let node: DomainTreeNode
|
||||
|
||||
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)
|
||||
Circle().fill(Sage.domainColor(node.name)).frame(width: 8, height: 8)
|
||||
Text(node.name).font(.callout).foregroundStyle(Sage.ink)
|
||||
Spacer()
|
||||
if let count { Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted) }
|
||||
Text("\(node.count)").font(.caption).foregroundStyle(Sage.muted)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
.listRowBackground(selected ? Sage.brand.opacity(0.14) : Color.clear)
|
||||
.onTapGesture { model.section = .documents }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section router
|
||||
|
||||
/// 선택 섹션을 detail 전폭으로 라우팅. 셸 차원 inspector/list 칼럼 없음 — 각 페이지가 내부에서 소유.
|
||||
struct SectionDetail: View {
|
||||
struct ContentColumn: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch model.section {
|
||||
case .dashboard: DashboardView() // 풀폭 캔버스
|
||||
case .documents: DocumentsBrowser() // 내부 HSplitView 3-pane
|
||||
case .digest: DigestView() // 풀폭 (뉴스 — 후속 모닝브리핑 재구성)
|
||||
case .memos: MemosBoard() // 리스트 + 상세 (후속 버킷 트리아지)
|
||||
case .dashboard: DashboardView()
|
||||
case .documents: DocumentListView()
|
||||
case .search: SearchView()
|
||||
case .ask: AskView()
|
||||
case .memos: MemoListView()
|
||||
case .digest: DigestView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Sage.surface)
|
||||
.navigationTitle(model.section.title)
|
||||
}
|
||||
}
|
||||
|
||||
/// 메모 — v1 리스트+상세 split (확정 버킷 트리아지는 후속 트랙).
|
||||
struct MemosBoard: View {
|
||||
struct DetailColumn: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
MemoListView()
|
||||
.frame(minWidth: 300, idealWidth: 360, maxWidth: 460)
|
||||
Group {
|
||||
Group {
|
||||
switch model.section {
|
||||
case .documents:
|
||||
if let d = model.documentDetail { DocumentDetailView(detail: d) }
|
||||
else { EmptyState(text: "문서를 선택하세요") }
|
||||
case .memos:
|
||||
if let m = model.memoDetail { MemoDetailView(memo: m) }
|
||||
else { EmptyState(text: "메모를 선택하세요") }
|
||||
default:
|
||||
EmptyState(text: model.section.title)
|
||||
}
|
||||
.frame(minWidth: 360, maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,96 +149,11 @@ 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: 1100, minHeight: 700)
|
||||
.frame(minWidth: 1000, minHeight: 660)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -2,24 +2,23 @@ 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, digest, memos
|
||||
case dashboard, documents, search, ask, memos, digest
|
||||
public var id: String { rawValue }
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .dashboard: return "홈"
|
||||
case .dashboard: return "대시보드"
|
||||
case .documents: return "문서"
|
||||
case .digest: return "뉴스"
|
||||
case .search: return "검색"
|
||||
case .ask: return "질문"
|
||||
case .memos: return "메모"
|
||||
case .digest: return "뉴스"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,33 +27,19 @@ 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?
|
||||
/// 문서 사이드바 분류 필터 (선택된 도메인 path, nil = 전체 문서).
|
||||
public var documentDomainFilter: String?
|
||||
/// 현재 필터의 전체 문서를 다 불러왔는지 (페이지네이션 load-all 완료). 섹션 재진입 중복로드 방지.
|
||||
public private(set) var documentsFullyLoaded = false
|
||||
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
|
||||
public var memoList: [MemoResponse] = []
|
||||
public var memoDetail: MemoResponse?
|
||||
public var digest: DigestResponse?
|
||||
@@ -144,16 +129,11 @@ 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 {
|
||||
@@ -161,60 +141,15 @@ public final class AppModel {
|
||||
await guarded { self.documentDetail = try await self.client.document(id: id) }
|
||||
}
|
||||
|
||||
/// 문서 섹션 진입 시 현재 필터의 전체 문서 확보 (중복로드 방지). 미로드 상태일 때만 load-all.
|
||||
public func ensureDocumentsLoaded() async {
|
||||
if !documentsFullyLoaded { await loadDocuments(domain: documentDomainFilter) }
|
||||
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 (서버 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 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) }
|
||||
}
|
||||
|
||||
public func openMemo(_ id: Int) async {
|
||||
@@ -227,67 +162,6 @@ 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,8 +23,6 @@ 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,9 +53,6 @@ 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,26 +64,15 @@ public final class LiveDSClient: DSClient, @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func perform(_ endpoint: DSEndpoint) async throws -> Data {
|
||||
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 request = try makeRequest(endpoint, token: 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, requiresBearer {
|
||||
if http.statusCode == 401, endpoint.requiresBearer {
|
||||
// Single-flight refresh + one retry.
|
||||
let newToken = try await tokens.refreshOnce()
|
||||
let retry = try build(newToken)
|
||||
let retry = try makeRequest(endpoint, token: newToken)
|
||||
let (data2, response2) = try await dataOrTransport(retry)
|
||||
guard let http2 = response2 as? HTTPURLResponse else {
|
||||
throw DSError.transport(underlying: "no HTTP response")
|
||||
@@ -133,44 +122,6 @@ 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,25 +24,6 @@ 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?
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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,7 +168,6 @@ 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) }
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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 폴백")
|
||||
}
|
||||
}
|
||||
@@ -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/form-data) | `file`(필수) + `doc_purpose?`(business\|knowledge) `library_path?` `facet_*?` | `DocumentResponse` (201) | `document_detail.json` |
|
||||
| POST | `/documents/` (multipart) | 파일 업로드 | `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` | — |
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
DSShell.xcodeproj/
|
||||
Support/
|
||||
.build/
|
||||
*.xcuserstate
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 569 B |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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
|
||||
@@ -1,88 +0,0 @@
|
||||
# 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
|
||||
CODE_SIGNING_ALLOWED: "NO"
|
||||
CODE_SIGNING_REQUIRED: "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 }
|
||||
@@ -1,5 +0,0 @@
|
||||
# xcodegen 생성물 (project.yml 이 source of truth)
|
||||
DSWatch.xcodeproj/
|
||||
Support/
|
||||
.build/
|
||||
*.xcuserstate
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024", "filename" : "watch_1024.png" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
|
Before Width: | Height: | Size: 38 KiB |
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
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) ?? "이드 연결 불가")
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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() }
|
||||
@@ -1,160 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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("결과")
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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
|
||||
@@ -1,8 +1,6 @@
|
||||
# hyungi_Document_Server 설정
|
||||
|
||||
ai:
|
||||
gateway:
|
||||
endpoint: "http://ai-gateway:8080"
|
||||
|
||||
models:
|
||||
# ─── 단일 generation 호스트 routing (2026-05-14 GPU LLM 제거) ───
|
||||
@@ -13,7 +11,7 @@ ai:
|
||||
|
||||
# triage: 상시 분류·요약·근거 선별. Mac mini Qwen 27B (primary 와 동일 endpoint, 짧은 max_tokens).
|
||||
triage:
|
||||
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
|
||||
endpoint: "http://100.76.254.116:8890/v1/chat/completions"
|
||||
model: "mlx-community/Qwen3.6-27B-6bit"
|
||||
max_tokens: 4096
|
||||
timeout: 480 # 프리필 실측 ~112 tok/s — 120K자 장문 커버 (2026-06-11)
|
||||
@@ -22,7 +20,7 @@ ai:
|
||||
|
||||
# primary: 에스컬레이션 전용. Qwen 27B MLX (맥미니 Semaphore(1) 보호 대상).
|
||||
primary:
|
||||
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
|
||||
endpoint: "http://100.76.254.116:8890/v1/chat/completions"
|
||||
model: "mlx-community/Qwen3.6-27B-6bit"
|
||||
max_tokens: 8192
|
||||
timeout: 900 # 프리필 실측 ~112 tok/s — 260K자 상한 장문 커버 (2026-06-11)
|
||||
@@ -72,7 +70,7 @@ ai:
|
||||
# Phase 3.5a answerability classifier. 2026-05-14 GPU LLM 제거 후 Mac mini 26B 로 swap.
|
||||
# classifier_service 가 hasattr 체크로 optional 이므로 이 섹션 제거 시 classifier gate 는 자동 skip (score-only).
|
||||
classifier:
|
||||
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
|
||||
endpoint: "http://100.76.254.116:8890/v1/chat/completions"
|
||||
model: "mlx-community/Qwen3.6-27B-6bit" # 2026-06-11 B안 동승 — gemma id 잔존 시 mlx 서버가 Gemma 를 재로드(이중 적재) 위험
|
||||
max_tokens: 512
|
||||
timeout: 30 # 2026-05-17: 15s 도 동시 부하 시 elapsed 14.4s 직전이라 tight — 30s 로 2x 마진. classifier_service.LLM_TIMEOUT_MS=30000 와 align (초과 = score-only skip, graceful)
|
||||
@@ -199,8 +197,14 @@ schedule:
|
||||
# 이력: 2026-06-11 맥미니 모델 확정까지 8키 홀드 → 同日 Qwen3.6-27B-6bit 전환과 함께 해제([]).
|
||||
pipeline:
|
||||
held_stages: []
|
||||
# mlx gate 동시 실행 상한 (2026-06-12 fair-share): 구 "1 고정" 룰의 전제(single-inference
|
||||
# 서버)가 소멸 — 현 mlx_vlm 은 continuous batching (2026-06-11 밤 6~8 concurrent 실측 정상).
|
||||
# 2 = 워커 LLM 호출과 인터랙티브(ask/eid)가 서로 안 막힘 + 집계 throughput ~1.8배.
|
||||
# 게이트(상한+우선순위)는 유지 — thundering herd 방지. 1 로 되돌리면 구 동작.
|
||||
# mlx gate 동시 실행 상한 (config.mlx_gate_concurrency). 현 mlx_vlm = continuous batching
|
||||
# (2026-06-11 밤 6~8 concurrent 실측 정상). 2026-06-15: 2→4 — digest/briefing 합성을
|
||||
# 이 단일 게이트(BACKGROUND 우선순위)로 라우팅하며 digest(클러스터 44~68)가 하드캡 내
|
||||
# 완료되도록 동시성 확보. ask/eid(FOREGROUND)는 큐 점프라 영향 최소. 되돌리면 구 동작.
|
||||
mlx_gate_concurrency: 2
|
||||
# 2026-06-15: digest/briefing 생성 LLM 파라미터 (모델 교체 후 단일소스, 상세 = config.py).
|
||||
# 구 하드코딩 25s(빠른 Gemma)가 Qwen 27B(콜당 ~90~300s) 교체 sweep 누락 → digest 600s
|
||||
# 초과·briefing 4/4 폴백. 동시성은 위 mlx_gate_concurrency 가 담당(별 키 없음).
|
||||
digest_llm_timeout_s: 300
|
||||
digest_llm_attempts: 2
|
||||
digest_pipeline_hard_cap_s: 5400
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Phase 2A — Embedding candidate compose override (Diagnose only)
|
||||
#
|
||||
# Profile-isolated: `--profile embed-cand` 명시 opt-in. default up 시 미기동.
|
||||
# production fastapi/postgres/reranker 에 영향 0.
|
||||
# 본 PR 종료 시 별 chore (PR-2A-Chunks-Cand-Cleanup-1) 에서 제거.
|
||||
#
|
||||
# 후보 상태 (2026-05-23):
|
||||
# - me5_large_inst : ✅ smoke PASS (dim 1024)
|
||||
# - bge_mgemma2 : ❌ Phase 2A-Extended 별 PR 이관 (9B FP16 → VRAM OOM risk + 다운로드 cost)
|
||||
# - me5_ko : ❌ 폐기 (401 Unauthorized, gated/모델명 부정확)
|
||||
# - snowflake_l_v2 : 신규 추가 (Snowflake/snowflake-arctic-embed-l-v2.0, 2024-12, multilingual 강화)
|
||||
#
|
||||
# 사용:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.override.cand.yml \
|
||||
# --profile embed-cand up -d embedding-cand-me5-inst
|
||||
#
|
||||
# 호출 (DS network 내부):
|
||||
# http://embedding-cand-me5-inst:80/embed
|
||||
# http://embedding-cand-snowflake-l-v2:80/embed
|
||||
|
||||
services:
|
||||
embedding-cand-me5-inst:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
restart: unless-stopped
|
||||
container_name: hyungi_document_server-embedding-cand-me5-inst-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=intfloat/multilingual-e5-large-instruct
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- embedding_cand_me5_inst_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
profiles: ["embed-cand"]
|
||||
|
||||
embedding-cand-snowflake-l-v2:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
restart: unless-stopped
|
||||
container_name: hyungi_document_server-embedding-cand-snowflake-l-v2-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=Snowflake/snowflake-arctic-embed-l-v2.0
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- embedding_cand_snowflake_l_v2_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
profiles: ["embed-cand"]
|
||||
|
||||
# ===== 비활성 후보 (Phase 2A-Extended 별 PR 이관 또는 폐기) =====
|
||||
# 진단 박제만 보존. 본 PR scope 외.
|
||||
|
||||
embedding-cand-bge-mgemma2:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
container_name: hyungi_document_server-embedding-cand-bge-mgemma2-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=BAAI/bge-multilingual-gemma2
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- embedding_cand_bge_mgemma2_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 300s
|
||||
profiles: ["embed-cand-extended"] # 본 PR 미사용. extended 별 profile.
|
||||
|
||||
embedding-cand-me5-ko:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
container_name: hyungi_document_server-embedding-cand-me5-ko-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=dragonkue/multilingual-e5-large-ko
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- embedding_cand_me5_ko_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
profiles: ["embed-cand-disabled"] # 401 fail. 사용 X.
|
||||
|
||||
volumes:
|
||||
embedding_cand_me5_inst_cache:
|
||||
embedding_cand_snowflake_l_v2_cache:
|
||||
embedding_cand_bge_mgemma2_cache:
|
||||
embedding_cand_me5_ko_cache:
|
||||
@@ -1,101 +0,0 @@
|
||||
# Phase 2B — Reranker candidate compose override (Diagnose only)
|
||||
#
|
||||
# Profile-isolated: `--profile rerank-cand` 명시 opt-in. default up 시 미기동.
|
||||
# production fastapi/postgres/reranker(bge-reranker-v2-m3) 에 영향 0.
|
||||
# 본 PR 종료 후 별 chore (PR-2B-Rerank-Cand-Cleanup-1) 에서 제거.
|
||||
#
|
||||
# 후보 상태 (2026-05-23):
|
||||
# - gte_ml_base : Apache 2.0, 305M, smoke 대기
|
||||
# - mxbai_large : Apache 2.0, ~435M, safetensors 부재 — TEI smoke risk
|
||||
# - bge_v2_gemma_2b : Gemma 라이센스, 2.5B FP16 ~5GB, smoke 대기
|
||||
#
|
||||
# 사용:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.override.rerank-cand.yml \
|
||||
# --profile rerank-cand up -d rerank-cand-gte-ml-base
|
||||
|
||||
services:
|
||||
rerank-cand-gte-ml-base:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
restart: unless-stopped
|
||||
container_name: hyungi_document_server-rerank-cand-gte-ml-base-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=Alibaba-NLP/gte-multilingual-reranker-base
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- rerank_cand_gte_ml_base_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
profiles: ["rerank-cand"]
|
||||
|
||||
rerank-cand-mxbai-large:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
restart: unless-stopped
|
||||
container_name: hyungi_document_server-rerank-cand-mxbai-large-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=mixedbread-ai/mxbai-rerank-large-v1
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- rerank_cand_mxbai_large_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
profiles: ["rerank-cand"]
|
||||
|
||||
rerank-cand-bge-v2-gemma-2b:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
restart: unless-stopped
|
||||
container_name: hyungi_document_server-rerank-cand-bge-v2-gemma-2b-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=BAAI/bge-reranker-v2-gemma
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=2
|
||||
volumes:
|
||||
- rerank_cand_bge_v2_gemma_2b_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
profiles: ["rerank-cand"]
|
||||
|
||||
volumes:
|
||||
rerank_cand_gte_ml_base_cache:
|
||||
rerank_cand_mxbai_large_cache:
|
||||
rerank_cand_bge_v2_gemma_2b_cache:
|
||||
@@ -149,7 +149,7 @@ services:
|
||||
# → 32 한도 초과 → 413. 64 로 늘림.
|
||||
# GPU VRAM free 6199MiB 충분. baseline path (MAX_RERANK_INPUT=200) 영향 0.
|
||||
- MAX_BATCH_TOKENS=16384
|
||||
- MAX_CLIENT_BATCH_SIZE=64
|
||||
- MAX_CLIENT_BATCH_SIZE=256 # 2026-06-18 fix: 64→256, MAX_RERANK_INPUT=200 커버 (batch>64 ERROR=RRF silent fallback 해소; MAX_BATCH_TOKENS가 VRAM 상한이라 entries 증가는 VRAM 무관)
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- reranker_cache:/data
|
||||
@@ -168,19 +168,6 @@ services:
|
||||
start_period: 120s
|
||||
restart: unless-stopped
|
||||
|
||||
ai-gateway:
|
||||
build: ./gpu-server/services/ai-gateway
|
||||
ports:
|
||||
- "127.0.0.1:8081:8080"
|
||||
environment:
|
||||
- PRIMARY_ENDPOINT=http://100.76.254.116:8801/v1/chat/completions
|
||||
- FALLBACK_ENDPOINT=http://ollama:11434/v1/chat/completions
|
||||
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
|
||||
- DAILY_BUDGET_USD=${DAILY_BUDGET_USD:-5.00}
|
||||
# depends_on: ollama 제거 (2026-06-08) — ollama 서비스가 standalone 으로 이관됨.
|
||||
# FALLBACK_ENDPOINT 의 ollama:11434 는 standalone(동일 hostname, DS 망 부착)으로 해소.
|
||||
restart: unless-stopped
|
||||
|
||||
fastapi:
|
||||
build: ./app
|
||||
ports:
|
||||
|
||||
@@ -213,3 +213,14 @@ body {
|
||||
|
||||
/* Phase 1C: frontmatter 박스 — 본문 위 메타 표시 */
|
||||
.md-frontmatter dt { font-weight: 500; }
|
||||
|
||||
/* AI 요약(TL;DR 등) 마크다운 렌더 — 좁은 카드에 맞게 문단/리스트 마진 압축 */
|
||||
.summary-md > :first-child { margin-top: 0; }
|
||||
.summary-md > :last-child { margin-bottom: 0; }
|
||||
.summary-md p { margin: 0 0 0.45em; }
|
||||
.summary-md ul, .summary-md ol { margin: 0.25em 0; padding-left: 1.2em; }
|
||||
.summary-md ul { list-style: disc; }
|
||||
.summary-md ol { list-style: decimal; }
|
||||
.summary-md li { margin: 0.1em 0; }
|
||||
.summary-md strong { font-weight: 700; }
|
||||
.summary-md code { background: rgba(0, 0, 0, 0.05); padding: 0 0.3em; border-radius: 3px; }
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { renderDocMarkdown } from '$lib/utils/docMarkdown';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
@@ -104,9 +105,7 @@
|
||||
</div>
|
||||
|
||||
{#if tldr}
|
||||
<p class="text-xs font-medium text-text leading-relaxed mb-2">
|
||||
{tldr}
|
||||
</p>
|
||||
<div class="summary-md text-xs font-medium text-text leading-relaxed mb-2">{@html renderDocMarkdown(tldr)}</div>
|
||||
{/if}
|
||||
|
||||
{#if bullets && bullets.length > 0}
|
||||
|
||||
@@ -212,6 +212,10 @@
|
||||
|
||||
// ─── 백그라운드 작업 (큐 밖 스크립트 backfill) — processing_queue 사각지대 노출 ───
|
||||
const bgJobs = $derived(overview.background_jobs ?? []);
|
||||
const runningBg = $derived(bgJobs.filter((j) => j.state === 'running'));
|
||||
function bgForMachine(key: string) {
|
||||
return runningBg.filter((j) => j.machine === key);
|
||||
}
|
||||
function fmtElapsed(s: number): string {
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||||
@@ -333,10 +337,11 @@
|
||||
{#each lanes as lane (lane.key)}
|
||||
<div class="bg-surface border border-default rounded-card px-3.5 py-2.5">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {dotClass(lane.card?.state ?? 'idle')}"></span>
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {dotClass(bgForMachine(lane.key).length > 0 ? 'active' : (lane.card?.state ?? 'idle'))}"></span>
|
||||
<span class="text-[9px] font-bold rounded px-1.5 py-px mtag-{lane.key}">{lane.meta.label}</span>
|
||||
<span class="text-[10px] text-faint font-mono">{lane.meta.model}</span>
|
||||
<span class="text-[11px] text-dim tabular-nums ml-1">{formatRate(lane.card?.done_1h ?? 0)}/h</span>
|
||||
{#each bgForMachine(lane.key) as j (j.id)}<span class="text-[10px] font-semibold text-success tabular-nums ml-1">생성 중: {j.label ?? j.kind}{#if j.total} {j.processed}/{j.total}{/if}</span>{/each}
|
||||
{#if lane.key === 'macbook' && (lane.card?.deferred_pending ?? 0) > 0}
|
||||
<span class="text-[10px] font-semibold text-warning tabular-nums">보류 {lane.card?.deferred_pending}</span>
|
||||
{/if}
|
||||
|
||||