Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96bd849bcb | |||
| db7ede04b7 | |||
| ac7de71ecd | |||
| a6d5734f6c | |||
| fe8235d726 | |||
| b0a73f8506 | |||
| 2d6d1b8e8a | |||
| 4c111ca7f2 | |||
| f325bd0509 | |||
| d4e1f76e81 | |||
| a82b0724df | |||
| b2949d26ff | |||
| 151c1ee518 | |||
| ebbcaf86d8 | |||
| 6d978289b8 | |||
| 73c6f123b8 | |||
| 57c1805a8d | |||
| cbdd4a3df7 | |||
| c5bc1f773d | |||
| d007ad5492 | |||
| b6a4821cac | |||
| b461559d2f | |||
| 9b9790f05d | |||
| b49596135e | |||
| 0a82a5b1bc | |||
| 74e29e510e | |||
| c1555fd6ab |
+1
-1
@@ -264,7 +264,7 @@ class AIClient:
|
||||
"""벡터 임베딩 — GPU 서버 전용"""
|
||||
response = await self._http.post(
|
||||
self.ai.embedding.endpoint,
|
||||
json={"model": self.ai.embedding.model, "prompt": text},
|
||||
json={"model": self.ai.embedding.model, "prompt": text, "keep_alive": -1}, # bge-m3 GPU 상주(홈랩 sparse 검색 cold reload ~6s 방지)
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["embedding"]
|
||||
|
||||
@@ -680,7 +680,12 @@ class SectionItem(BaseModel):
|
||||
level: int | None = None
|
||||
node_type: str | None = None # window | chapter_split | clause_split | section_split | null
|
||||
is_leaf: bool
|
||||
parent_id: int | None = None # 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent.
|
||||
# 프런트 collapseWindows 가 비인접 window 를 split-parent 에 흡수할 때 사용.
|
||||
char_start: int | None = None # md_content 내 heading offset(UTF-16). jump-target 만 값, 그 외 None (Path B)
|
||||
text: str | None = None # 절 본문 = 청크 원문. 대형 split 문서는 md_content 가 앞 5만 자만 보존
|
||||
# (marker LARGE_DOC_MD_CONTENT_HEAD_CHARS)이고 char_start 도 NULL 이라
|
||||
# md_content 슬라이스로는 본문이 비므로, 청크 text 를 직접 렌더한다.
|
||||
section_type: str | None = None
|
||||
summary: str | None = None # status='summarized' 인 분석행에만, 그 외 None
|
||||
confidence: float | None = None
|
||||
@@ -719,12 +724,12 @@ async def get_document_sections(
|
||||
await session.execute(
|
||||
sql_text(
|
||||
"""
|
||||
SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, char_start,
|
||||
section_type, summary, confidence
|
||||
SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, parent_id, char_start,
|
||||
text, section_type, summary, confidence
|
||||
FROM (
|
||||
SELECT DISTINCT ON (c.id)
|
||||
c.id AS chunk_id, c.chunk_index, c.section_title, c.heading_path,
|
||||
c.level, c.node_type, c.is_leaf, c.char_start,
|
||||
c.level, c.node_type, c.is_leaf, c.parent_id, c.char_start, c.text,
|
||||
a.section_type,
|
||||
CASE WHEN a.status = 'summarized' THEN a.summary ELSE NULL END AS summary,
|
||||
a.confidence
|
||||
|
||||
@@ -688,6 +688,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 ───
|
||||
|
||||
|
||||
|
||||
@@ -103,6 +103,21 @@ class StageRow(BaseModel):
|
||||
oldest_pending_age_sec: int | None
|
||||
|
||||
|
||||
class BackgroundJobItem(BaseModel):
|
||||
"""큐 밖 관리 스크립트(백필 등) 작업 — processing_queue 가 못 보는 사각지대 노출.
|
||||
stale = running 인데 heartbeat 가 오래 끊김(프로세스 사망 추정)."""
|
||||
id: int
|
||||
kind: str
|
||||
machine: str
|
||||
label: str | None
|
||||
state: Literal["running", "done", "failed"]
|
||||
processed: int
|
||||
total: int | None
|
||||
elapsed_sec: int
|
||||
stale: bool
|
||||
error: str | None
|
||||
|
||||
|
||||
class QueueOverviewResponse(BaseModel):
|
||||
machines: list[MachineCard]
|
||||
stages: list[StageRow]
|
||||
@@ -110,6 +125,7 @@ class QueueOverviewResponse(BaseModel):
|
||||
summarize_by_machine: SummarizeByMachine
|
||||
trend_24h: list[TrendBucket]
|
||||
totals: Totals
|
||||
background_jobs: list[BackgroundJobItem] = []
|
||||
|
||||
|
||||
class FailedItem(BaseModel):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+9
-1
@@ -64,7 +64,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 +77,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 +103,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")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""off-queue 관리 스크립트(백필 등) 진행 가시화 — background_jobs (migration 357).
|
||||
|
||||
processing_queue 는 파이프라인 stage 전용이라 hier_overnight_backfill /
|
||||
section_summary_pilot 같은 스크립트 작업은 대시보드 보드에 안 잡힌다. 이 모듈로
|
||||
스크립트가 진행상황을 남기면 queue_overview 가 "백그라운드 작업" 패널로 노출한다.
|
||||
|
||||
설계 불변식:
|
||||
- **자율 트랜잭션**: 각 기록은 engine.begin() 짧은 트랜잭션으로 즉시 commit한다.
|
||||
스크립트 본 작업은 별도 세션(긴 트랜잭션)이라, 같이 묶으면 commit 전까지 안 보여
|
||||
실시간 가시화가 깨진다. 그래서 전용 connection 으로 독립 commit.
|
||||
- **best-effort**: 관측 기록 실패가 본 작업을 깨면 안 된다 — 모든 함수 try/except,
|
||||
실패 시 warning 로그만. job_id=None 이면 조용히 no-op (start 실패해도 이어서 동작).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def start_job(
|
||||
engine: AsyncEngine, kind: str, label: str | None = None, total: int | None = None
|
||||
) -> int | None:
|
||||
"""작업 시작 기록 → background_jobs.id (실패 시 None — 호출측은 그대로 진행)."""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
row = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO background_jobs (kind, label, total) "
|
||||
"VALUES (:k, :l, :t) RETURNING id"
|
||||
),
|
||||
{"k": kind, "l": label, "t": total},
|
||||
)
|
||||
).first()
|
||||
return int(row[0]) if row else None
|
||||
except Exception as exc: # noqa: BLE001 — 관측은 부가, 본작업 보호
|
||||
logger.warning(f"[background_jobs] start 실패(무시): {type(exc).__name__}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
async def heartbeat(
|
||||
engine: AsyncEngine,
|
||||
job_id: int | None,
|
||||
*,
|
||||
processed: int | None = None,
|
||||
total: int | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""진행 갱신(processed/total/detail). job_id=None 또는 실패 시 no-op."""
|
||||
if job_id is None:
|
||||
return
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE background_jobs SET "
|
||||
"processed = COALESCE(:p, processed), "
|
||||
"total = COALESCE(:t, total), "
|
||||
"detail = COALESCE(CAST(:d AS jsonb), detail), "
|
||||
"updated_at = now() WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"id": job_id,
|
||||
"p": processed,
|
||||
"t": total,
|
||||
"d": json.dumps(detail, ensure_ascii=False) if detail is not None else None,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"[background_jobs] heartbeat 실패(무시): {type(exc).__name__}: {exc}")
|
||||
|
||||
|
||||
async def finish_job(
|
||||
engine: AsyncEngine, job_id: int | None, *, state: str = "done", error: str | None = None
|
||||
) -> None:
|
||||
"""종료 기록(done/failed). job_id=None 또는 실패 시 no-op."""
|
||||
if job_id is None:
|
||||
return
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE background_jobs SET state = :s, error = :e, "
|
||||
"finished_at = now(), updated_at = now() WHERE id = :id"
|
||||
),
|
||||
{"id": job_id, "s": state, "e": (error or None)},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"[background_jobs] finish 실패(무시): {type(exc).__name__}: {exc}")
|
||||
@@ -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:
|
||||
|
||||
@@ -412,7 +412,7 @@ async def build_overview(session: AsyncSession) -> dict:
|
||||
for row in current_result
|
||||
]
|
||||
|
||||
return compose_overview(
|
||||
result = compose_overview(
|
||||
rows_to_stage_stats(stage_rows),
|
||||
rows_to_summarize_split(split_rows),
|
||||
{row[0]: int(row[1]) for row in inflow_rows},
|
||||
@@ -421,6 +421,55 @@ async def build_overview(session: AsyncSession) -> dict:
|
||||
deep_enabled=deep_enabled,
|
||||
now_kst=now_kst,
|
||||
)
|
||||
# 큐 밖 관리 스크립트(백필 등) = background_jobs (migration 357). 테이블 부재 시 graceful([]).
|
||||
result["background_jobs"] = await _fetch_background_jobs(session)
|
||||
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,
|
||||
(state = 'running' AND updated_at < now() - interval '5 minutes') AS stale,
|
||||
error
|
||||
FROM background_jobs
|
||||
WHERE state = 'running' OR finished_at > now() - interval '6 hours'
|
||||
ORDER BY (state = 'running') DESC, started_at DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
|
||||
|
||||
async def _fetch_background_jobs(session: AsyncSession) -> list[dict]:
|
||||
"""running + 최근 6h 완료 background_jobs. 테이블 없거나 오류면 [] (보드 무영향).
|
||||
|
||||
요청 세션과 **별도 connection**으로 조회한다 — 테이블 부재(마이그 357 미적용 등) 시
|
||||
SELECT 실패가 요청 세션의 트랜잭션을 오염시키지 않도록 물리적으로 분리(실패 시 그
|
||||
임시 connection만 폐기). 관측은 부가 기능이라 보드 본체를 절대 깨면 안 된다.
|
||||
"""
|
||||
try:
|
||||
async with session.bind.connect() as conn: # 풀에서 독립 connection
|
||||
rows = (await conn.execute(text(_BACKGROUND_JOBS_SQL))).mappings().all()
|
||||
except Exception: # noqa: BLE001 — 관측 부가, 보드 본체 보호
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"id": r["id"], "kind": r["kind"], "label": r["label"], "state": r["state"],
|
||||
"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
|
||||
]
|
||||
|
||||
|
||||
# ─── 실패 처리 (plan ds-board-engines-1) ─────────────────────────────────────
|
||||
|
||||
@@ -361,7 +361,7 @@ async def search_text(
|
||||
+ similarity(coalesce(d.ai_tags::text, ''), :q) * 2.5
|
||||
+ similarity(coalesce(d.user_note, ''), :q) * 2.0
|
||||
+ similarity(coalesce(d.ai_summary, ''), :q) * 1.5
|
||||
+ similarity(coalesce(d.extracted_text, ''), :q) * 1.0
|
||||
+ similarity(left(coalesce(d.extracted_text, ''), 2000), :q) * 1.0
|
||||
-- FTS 보너스 (idx_documents_fts_full 활용)
|
||||
+ coalesce(ts_rank(
|
||||
to_tsvector('simple',
|
||||
@@ -369,7 +369,7 @@ async def search_text(
|
||||
coalesce(d.ai_tags::text, '') || ' ' ||
|
||||
coalesce(d.ai_summary, '') || ' ' ||
|
||||
coalesce(d.user_note, '') || ' ' ||
|
||||
coalesce(d.extracted_text, '')
|
||||
left(coalesce(d.extracted_text, ''), 2000)
|
||||
),
|
||||
plainto_tsquery('simple', :q)
|
||||
), 0) * 2.0
|
||||
@@ -380,7 +380,7 @@ async def search_text(
|
||||
WHEN similarity(coalesce(d.ai_tags::text, ''), :q) >= 0.3 THEN 'tags'
|
||||
WHEN similarity(coalesce(d.user_note, ''), :q) >= 0.3 THEN 'note'
|
||||
WHEN similarity(coalesce(d.ai_summary, ''), :q) >= 0.3 THEN 'summary'
|
||||
WHEN similarity(coalesce(d.extracted_text, ''), :q) >= 0.3 THEN 'content'
|
||||
WHEN similarity(left(coalesce(d.extracted_text, ''), 2000), :q) >= 0.3 THEN 'content'
|
||||
ELSE 'fts'
|
||||
END AS match_reason,
|
||||
d.material_type, d.jurisdiction, d.published_date
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -411,6 +411,15 @@ async def process(
|
||||
logger.info(f"doc {document_id}: devonagent → classify skip")
|
||||
return
|
||||
|
||||
# 논문(material_type='paper') — 요약/분류 LLM 스킵(맥미니 큐 무접촉, B-3 signal-only 유지).
|
||||
# embed/chunk/markdown 은 queue_consumer 가 chain (early-return 후에도 다음 stage enqueue).
|
||||
if doc.material_type == "paper":
|
||||
if not doc.ai_domain:
|
||||
doc.ai_domain = "논문"
|
||||
await session.commit()
|
||||
logger.info(f"doc {document_id}: paper → classify skip (no summarize)")
|
||||
return
|
||||
|
||||
if not doc.extracted_text:
|
||||
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
|
||||
|
||||
|
||||
@@ -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,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()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""논문 arXiv 전문 승격 (in-place) — B-3 Phase-2 P2-PR1 (plan safety-library-b3-1).
|
||||
|
||||
arXiv 프리프린트 초록 행(file_format='article', signal-only)을 전문 PDF로 **in-place 승격**:
|
||||
PDF 다운로드 → file_format/file_type/file_path/md_status 갱신 → 'extract' enqueue → 기존 파이프라인
|
||||
(extract → classify[paper skip summarize] → embed/chunk/markdown)이 전문 검색 청크 + md_content(marker 표시)
|
||||
+ hier 절구조를 생성. 1-Document(2행 분리 회피, 기존 display 스택 재사용).
|
||||
|
||||
- arXiv = 공개 프리프린트(arxiv.org/pdf/{id}, friendly host) → 전문 검색/RAG 무난, restricted 불요.
|
||||
(유료 구매 논문은 Papers_Purchased 경로가 restricted=true 로 별개 처리.)
|
||||
- per-run cap (marker GPU ~10GB + embed 부하 보호, 4070 16GB 빡빡 → idle-unload·증분). keyless.
|
||||
- 요약 0 (classify paper-skip 가드). file_hash·extract_meta.paper 보존(수집기 dedup 무영향).
|
||||
- CLI 전용(Phase-2 deliberate 승격, GPU 부하 사용자 통제). 스케줄 잡 미등록.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from core.config import settings
|
||||
from core.crawl_politeness import CRAWL_UA
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.queue import enqueue_stage
|
||||
|
||||
logger = setup_logger("paper_fulltext_promote")
|
||||
|
||||
_ARXIV_PDF = "https://arxiv.org/pdf/{id}"
|
||||
_MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
_DOWNLOAD_DELAY = (2.0, 5.0)
|
||||
_RUN_CAP = 10 # 1회 승격 상한(marker/embed GPU 보호). bulk 시 해제.
|
||||
|
||||
_ARXIV_ID_EXPR = Document.extract_meta[("paper", "arxiv_id")].astext
|
||||
_OA_URL_EXPR = Document.extract_meta[("paper", "oa_url")].astext
|
||||
_OA_STATUS_EXPR = Document.extract_meta[("paper", "oa_status")].astext
|
||||
_REAL_OA = ("gold", "hybrid", "green", "diamond")
|
||||
|
||||
|
||||
async def _download(url: str, dest: Path) -> int:
|
||||
"""arXiv PDF 다운로드 — 크기 cap + PDF 헤더 검증 + 연속 간격(kosha 패턴)."""
|
||||
await asyncio.sleep(random.uniform(*_DOWNLOAD_DELAY))
|
||||
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers={"User-Agent": CRAWL_UA})
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"arXiv PDF {resp.status_code}: {url}")
|
||||
if len(resp.content) > _MAX_FILE_BYTES:
|
||||
raise RuntimeError(f"크기 초과 {len(resp.content)}b: {url}")
|
||||
if resp.content[:5] != b"%PDF-":
|
||||
raise RuntimeError(f"PDF 아님(헤더 {resp.content[:8]!r}): {url}")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(resp.content)
|
||||
return len(resp.content)
|
||||
|
||||
|
||||
async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
"""미승격 arXiv 논문(file_format='article')을 전문 PDF로 in-place 승격."""
|
||||
cap = (limit or 10**9) if bulk else (min(limit, _RUN_CAP) if limit else _RUN_CAP)
|
||||
async with async_session() as session:
|
||||
q = (
|
||||
select(Document.id)
|
||||
.where(
|
||||
Document.material_type == "paper",
|
||||
Document.file_format == "article",
|
||||
or_(
|
||||
_ARXIV_ID_EXPR.isnot(None),
|
||||
Document.extract_meta[("paper", "oa_url")].astext.isnot(None),
|
||||
),
|
||||
)
|
||||
.order_by(Document.id.desc())
|
||||
.limit(cap)
|
||||
)
|
||||
ids = [r[0] for r in (await session.execute(q)).all()]
|
||||
|
||||
promoted = failed = 0
|
||||
for doc_id in ids:
|
||||
async with async_session() as session:
|
||||
doc = await session.get(Document, doc_id)
|
||||
if doc is None or doc.file_format != "article":
|
||||
continue
|
||||
paper = (doc.extract_meta or {}).get("paper") or {}
|
||||
arxiv_id = paper.get("arxiv_id")
|
||||
oa_status = (paper.get("oa_status") or "").lower()
|
||||
if arxiv_id:
|
||||
url = _ARXIV_PDF.format(id=arxiv_id)
|
||||
key = arxiv_id.replace("/", "_")
|
||||
elif paper.get("oa_url") and oa_status in _REAL_OA:
|
||||
url = paper["oa_url"] # doi.org/KISTI/PMC (friendly OA). 비-OA·paywall 은 헤더검증서 skip
|
||||
key = (paper.get("openalex_id") or paper.get("doi") or "oa").replace("/", "_")
|
||||
else:
|
||||
continue
|
||||
rel_path = f"crawl_raw/papers/{key}.pdf"
|
||||
dest = Path(settings.nas_mount_path) / rel_path
|
||||
try:
|
||||
size = await _download(url, dest)
|
||||
except Exception as e: # noqa: BLE001 — 다운로드 실패 격리
|
||||
logger.error(f"[promote] {key} 다운로드 실패: {e}")
|
||||
failed += 1
|
||||
continue
|
||||
# in-place 승격: 초록 행 → 전문 PDF 행 (file_hash·extract_meta.paper 보존)
|
||||
doc.file_path = rel_path
|
||||
doc.file_format = "pdf"
|
||||
doc.file_type = "immutable"
|
||||
doc.file_size = size
|
||||
doc.md_status = "pending" # marker 재실행(기존 'skipped' 해제)
|
||||
doc.md_extraction_error = None
|
||||
await enqueue_stage(session, doc.id, "extract")
|
||||
await session.commit()
|
||||
promoted += 1
|
||||
logger.info(f"[promote] {key} → 전문 PDF in-place (doc {doc.id}, {size}b)")
|
||||
|
||||
logger.info(f"[paper_fulltext_promote] 승격 {promoted} · 실패 {failed} (cap {cap})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="논문 arXiv 전문 승격 (in-place, keyless)")
|
||||
parser.add_argument("--bulk", action="store_true", help="cap 해제(전건 백필 — GPU 부하 주의)")
|
||||
parser.add_argument("--limit", type=int, default=0, help="승격 상한(0=기본 cap 10)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(bulk=args.bulk, limit=args.limit))
|
||||
@@ -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)와 동일 패턴.
|
||||
@@ -405,3 +410,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])
|
||||
|
||||
+13
-7
@@ -13,7 +13,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 +22,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 +72,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 +199,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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -210,6 +210,23 @@
|
||||
// 맥북이 요약을 실제로 가져가는 중인가 (합류 표식 게이트)
|
||||
const offloadActive = $derived(split.macbook.done_1h > 0);
|
||||
|
||||
// ─── 백그라운드 작업 (큐 밖 스크립트 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`;
|
||||
return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
|
||||
}
|
||||
function bgDot(j: { state: string; stale: boolean }): string {
|
||||
if (j.state === 'running') return j.stale ? 'bg-warning' : 'bg-success';
|
||||
if (j.state === 'failed') return 'bg-error';
|
||||
return 'bg-faint';
|
||||
}
|
||||
|
||||
// ─── 지배 백로그 = 요약. 정직 ETA(유입 차감) — summarize_eta ───
|
||||
const eta = $derived(overview.summarize_eta);
|
||||
// 정직 ETA 라벨: eta_minutes null = 유입이 소화를 앞섬(소진 불가)
|
||||
@@ -320,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}
|
||||
@@ -466,6 +484,32 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 백그라운드 작업 (큐 밖 스크립트 backfill 등 — processing_queue 가 못 보는 사각지대) -->
|
||||
{#if bgJobs.length > 0}
|
||||
<div class="mt-3">
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wider mb-2">백그라운드 작업</div>
|
||||
<div class="grid gap-2">
|
||||
{#each bgJobs as j (j.id)}
|
||||
<div class="bg-surface border rounded-card px-3.5 py-2.5 {j.stale ? 'border-warning' : j.state === 'failed' ? 'border-error' : 'border-default'}">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {bgDot(j)}"></span>
|
||||
<span class="text-[9px] font-bold rounded px-1.5 py-px bg-default text-dim font-mono">{j.kind}</span>
|
||||
<span class="text-xs font-semibold text-text truncate">{j.label ?? '작업'}</span>
|
||||
<span class="text-[11px] text-dim tabular-nums ml-auto">
|
||||
{#if j.total}{j.processed.toLocaleString()}/{j.total.toLocaleString()}{:else}{j.processed.toLocaleString()}건{/if} · {fmtElapsed(j.elapsed_sec)}
|
||||
</span>
|
||||
</div>
|
||||
{#if j.stale}
|
||||
<div class="text-[10px] text-warning mt-1.5">heartbeat 끊김 — 프로세스 중단 추정 (재개 필요할 수 있음)</div>
|
||||
{:else if j.state === 'failed'}
|
||||
<div class="text-[10px] text-error mt-1.5 truncate">실패{#if j.error} · {j.error}{/if}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 실패 처리 드로어 -->
|
||||
{#if failOpen}
|
||||
<div class="border border-error/40 rounded-card mt-3 overflow-hidden bg-surface">
|
||||
|
||||
@@ -75,6 +75,21 @@ export interface QueueStageRow {
|
||||
oldest_pending_age_sec: number | null;
|
||||
}
|
||||
|
||||
/** 큐 밖 관리 스크립트(백필 등) 작업 — processing_queue 가 못 보는 사각지대.
|
||||
* stale = running 인데 heartbeat 끊김(프로세스 사망 추정). */
|
||||
export interface BackgroundJob {
|
||||
id: number;
|
||||
kind: string;
|
||||
label: string | null;
|
||||
state: 'running' | 'done' | 'failed';
|
||||
machine: string;
|
||||
processed: number;
|
||||
total: number | null;
|
||||
elapsed_sec: number;
|
||||
stale: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface QueueOverview {
|
||||
machines: MachineOverview[];
|
||||
summarize_eta: SummarizeEta;
|
||||
@@ -82,6 +97,7 @@ export interface QueueOverview {
|
||||
trend_24h: TrendPoint[];
|
||||
stages: QueueStageRow[];
|
||||
totals: QueueTotals;
|
||||
background_jobs?: BackgroundJob[];
|
||||
}
|
||||
|
||||
/** ─── 실패 처리 (ds-board-engines-1) — GET /api/queue/failed · POST /retry|/skip ─── */
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
import { Marked } from 'marked';
|
||||
import katex from 'katex';
|
||||
// @ts-ignore — 타입 정의 누락 시 무시
|
||||
import markedKatex from 'marked-katex-extension';
|
||||
// @ts-ignore — 타입 정의 누락 시 무시
|
||||
@@ -88,10 +89,97 @@ const SANITIZE_OPTS = {
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
} as const;
|
||||
|
||||
// ── 수식 pre-render ──────────────────────────────────────────────────────────
|
||||
// marked-katex-extension 의 토크나이저는 `$$` 가 블록 선두에 있어야 발화하는데,
|
||||
// (1) 개요 anchor splice 가 `$$` 직전에 <span id="sec-N"> 를 끼우면 `$$` 가 문단 중간으로
|
||||
// 밀려 블록 규칙이 깨지고, (2) 빌드/런타임 환경에 따라 확장 토크나이저가 발화하지 않으면
|
||||
// `$$` 가 평문으로 새어 marked 의 백슬래시 이스케이프(\% → %, \, → ,)에 망가진다.
|
||||
// → marked 가 손대기 *전에* 수식을 katex 로 직접 렌더해 placeholder 로 보호한 뒤 복원한다.
|
||||
// 위치·인접 상황과 무관(전역 정규식)하므로 위 두 경우를 모두 우회한다.
|
||||
const _MATH_SLOT = (i: number) => `KX0MATHSLOT${i}MATHKX0`; // marked-안전(영숫자) + 충돌 불가
|
||||
const _MATH_SLOT_RE = /KX0MATHSLOT(\d+)MATHKX0/g;
|
||||
const _BLOCK_MATH_RE = /\$\$([\s\S]+?)\$\$/g;
|
||||
// 인라인 $...$ — 통화($5)·이스케이프(\$)·`$$` 회피. $ 직후 비공백, $ 직전 비공백.
|
||||
const _INLINE_MATH_RE = /(?<![\\$\d])\$(?!\s)([^$\n]*?[^$\n\s])\$(?!\d)/g;
|
||||
|
||||
function _protectMath(text: string, slots: string[]): string {
|
||||
const render = (tex: string, displayMode: boolean): string => {
|
||||
slots.push(
|
||||
katex.renderToString(tex.trim(), { displayMode, throwOnError: false, output: 'html' }),
|
||||
);
|
||||
return _MATH_SLOT(slots.length - 1);
|
||||
};
|
||||
return text
|
||||
.replace(_BLOCK_MATH_RE, (m, tex) => {
|
||||
try {
|
||||
return render(String(tex), true);
|
||||
} catch {
|
||||
return m;
|
||||
}
|
||||
})
|
||||
.replace(_INLINE_MATH_RE, (m, tex) => {
|
||||
try {
|
||||
return render(String(tex), false);
|
||||
} catch {
|
||||
return m;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 이미지 pre-render ─────────────────────────────────────────────────────────
|
||||
// docMarked 의 image 렌더러(.use renderer)가 런타임에 미발화하면 `` 가
|
||||
// 기본 `<img src="docimg:..">` 로 떨어지고, DOMPurify(ALLOW_UNKNOWN_PROTOCOLS:false)가
|
||||
// `docimg:` 를 미지원 프로토콜로 제거 → placeholder 도 이미지도 둘 다 사라진다(수식 토크나이저
|
||||
// 미발화와 동형 증상). → marked 가 손대기 전에 image ref 를 placeholder figure 로 직접 변환해
|
||||
// 슬롯 보호(렌더러 발화 여부와 무관). 슬롯/복원 메커니즘은 수식과 공유.
|
||||
const _IMG_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
|
||||
|
||||
function _imagePlaceholder(alt: string, href: string): string {
|
||||
const isInternal = href.startsWith('docimg:');
|
||||
const basename = href.split('/').pop() ?? href;
|
||||
const labelSrc = alt || basename || '이미지';
|
||||
const safeHref = escAttr(href);
|
||||
const safeAlt = escAttr(alt);
|
||||
const safeLabel = escText(`[이미지: ${labelSrc} — 아직 표시되지 않음]`);
|
||||
const internalFlag = isInternal ? '1' : '0';
|
||||
return (
|
||||
`<figure class="md-image-placeholder" data-md-img="1" data-md-image-src="${safeHref}" data-md-image-internal="${internalFlag}" data-md-image-alt="${safeAlt}">` +
|
||||
`<div class="md-image-placeholder-card">` +
|
||||
`<span class="md-image-placeholder-icon" aria-hidden="true">🖼️</span>` +
|
||||
`<span class="md-image-placeholder-label">${safeLabel}</span>` +
|
||||
`</div>` +
|
||||
`</figure>`
|
||||
);
|
||||
}
|
||||
|
||||
function _protectImages(text: string, slots: string[]): string {
|
||||
return text.replace(_IMG_RE, (m, alt, href) => {
|
||||
try {
|
||||
slots.push(_imagePlaceholder(String(alt ?? ''), String(href ?? '')));
|
||||
return _MATH_SLOT(slots.length - 1);
|
||||
} catch {
|
||||
return m;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function renderDocMarkdown(text: string | null | undefined): string {
|
||||
if (!text) return '';
|
||||
try {
|
||||
const html = docMarked.parse(text) as string;
|
||||
const slots: string[] = [];
|
||||
// 이미지 먼저 placeholder 로 pre-render(렌더러 우회) → 그 다음 수식. 슬롯 공유.
|
||||
const protectedText = _protectMath(_protectImages(text, slots), slots);
|
||||
let html = docMarked.parse(protectedText) as string;
|
||||
if (slots.length) {
|
||||
// 블록 수식이 단독 문단이면 marked 가 <p> 로 감싸므로 그 <p> 를 벗겨 블록 수식이 문단에
|
||||
// 매몰되지 않게 한다. (katex-display 는 block 이라 <p> 안에 두면 브라우저가 자동 분리.)
|
||||
html = html
|
||||
.replace(
|
||||
new RegExp(`<p>\\s*KX0MATHSLOT(\\d+)MATHKX0\\s*</p>`, 'g'),
|
||||
(m, i) => slots[Number(i)] ?? m,
|
||||
)
|
||||
.replace(_MATH_SLOT_RE, (m, i) => slots[Number(i)] ?? m);
|
||||
}
|
||||
return DOMPurify.sanitize(html, SANITIZE_OPTS);
|
||||
} catch {
|
||||
// 마지막 안전망: 모든 태그 제거 후 escape
|
||||
|
||||
@@ -83,6 +83,74 @@ test('[C2] collapseWindows: split-parent + window 들 → rail 1행, 대표=spli
|
||||
assert.equal(out[0].fragmentCount, 2, 'window 조각 수 = 2 (split-parent 자신 제외)');
|
||||
});
|
||||
|
||||
test('collapseWindows: bodyText — 정상 leaf 는 자기 본문, split-parent 는 window 본문만 이어붙임', () => {
|
||||
// 정상 leaf → 자기 text 가 본문
|
||||
const leaf = collapseWindows([sec({ heading_path: 'Intro', node_type: null, text: '서론 본문' })]);
|
||||
assert.equal(leaf[0].bodyText, '서론 본문');
|
||||
|
||||
// split-parent(heading 줄뿐) + window 2개 → window 본문만 순서대로 합침(헤딩 제외)
|
||||
const split = collapseWindows([
|
||||
sec({ heading_path: 'Article 5', node_type: 'chapter_split', is_leaf: false, char_start: 120, text: '# Article 5' }),
|
||||
sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각1' }),
|
||||
sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각2' }),
|
||||
]);
|
||||
assert.equal(split.length, 1);
|
||||
assert.equal(split[0].bodyText, '본문 조각1\n\n본문 조각2', 'split-parent heading 제외, window 본문만 합침');
|
||||
|
||||
// legacy window 런(선행 split-parent 없음) → 첫 window 자기 본문 + 흡수 조각
|
||||
const legacy = collapseWindows([
|
||||
sec({ heading_path: 'Pearson', node_type: 'window', text: 'p1' }),
|
||||
sec({ heading_path: 'Pearson', node_type: 'window', text: 'p2' }),
|
||||
]);
|
||||
assert.equal(legacy.length, 1);
|
||||
assert.equal(legacy[0].bodyText, 'p1\n\np2');
|
||||
});
|
||||
|
||||
test('collapseWindows: 절-레벨 분석 집계 — windowed 절은 window 멤버에서 type 다수결/conf 평균/summaries 합본', () => {
|
||||
// split-parent(분석 없음) + window 3개(요약·유형·신뢰도 보유) → 대표에 집계
|
||||
const out = collapseWindows([
|
||||
sec({ heading_path: 'Sec A', node_type: 'section_split', is_leaf: false, char_start: 10, text: '# Sec A', section_type: null, summary: null, confidence: null }),
|
||||
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b1', section_type: 'requirement', summary: '요약1', confidence: 0.9 }),
|
||||
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b2', section_type: 'requirement', summary: '요약2', confidence: 0.8 }),
|
||||
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b3', section_type: 'overview', summary: '', confidence: 1.0 }),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].sectionType, 'requirement', '다수결 = requirement(2) > overview(1)');
|
||||
assert.ok(Math.abs(out[0].confidence! - 0.9) < 1e-9, '평균 (0.9+0.8+1.0)/3 = 0.9');
|
||||
assert.deepEqual(out[0].summaries, ['요약1', '요약2'], '빈 요약 제외, 순서 유지');
|
||||
|
||||
// 단일 leaf 는 대표 자신의 분석
|
||||
const single = collapseWindows([sec({ heading_path: 'X', node_type: null, text: 'body', section_type: 'definition', summary: '정의 요약', confidence: 0.7 })]);
|
||||
assert.equal(single[0].sectionType, 'definition');
|
||||
assert.equal(single[0].confidence, 0.7);
|
||||
assert.deepEqual(single[0].summaries, ['정의 요약']);
|
||||
|
||||
// 분석 전혀 없는 절 → null/빈
|
||||
const none = collapseWindows([sec({ heading_path: 'Y', node_type: null, text: 'body' })]);
|
||||
assert.equal(none[0].sectionType, null);
|
||||
assert.equal(none[0].confidence, null);
|
||||
assert.deepEqual(none[0].summaries, []);
|
||||
});
|
||||
|
||||
test('collapseWindows: 비인접 window 도 parent_id 로 split-parent 에 흡수 (빈 split 행 방지)', () => {
|
||||
// 실데이터 버그: split-parent(chunk_index 1143)와 그 window(1233~)가 비인접 → 인접 흡수 실패로
|
||||
// 빈 split 행 + 별도 window-그룹 행 2개로 쪼개짐. parent_id 링크로 정확히 합친다.
|
||||
const out = collapseWindows([
|
||||
sec({ chunk_id: 10, heading_path: 'FOREWORD', node_type: 'section_split', is_leaf: false, char_start: 5, text: '# FOREWORD' }),
|
||||
sec({ chunk_id: 11, heading_path: 'POLICY', node_type: null, text: '정책 본문' }), // 사이에 낀 다른 절
|
||||
sec({ chunk_id: 12, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각1', section_type: 'overview', summary: '요약A', confidence: 0.9 }),
|
||||
sec({ chunk_id: 13, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각2', section_type: 'overview', summary: '요약B', confidence: 0.8 }),
|
||||
]);
|
||||
assert.equal(out.length, 2, 'FOREWORD(split, window 흡수) + POLICY = 2행 (빈 split 행 없음)');
|
||||
assert.equal(out[0].section.chunk_id, 10, '대표 = split-parent(char_start 보유)');
|
||||
assert.equal(out[0].bodyText, '서문 조각1\n\n서문 조각2', '비인접 window 본문을 split-parent 에 흡수');
|
||||
assert.equal(out[0].fragmentCount, 2);
|
||||
assert.equal(out[0].sectionType, 'overview');
|
||||
assert.deepEqual(out[0].summaries, ['요약A', '요약B']);
|
||||
assert.equal(out[1].section.chunk_id, 11, '사이 낀 절은 별도 행 유지');
|
||||
assert.equal(out[1].bodyText, '정책 본문');
|
||||
});
|
||||
|
||||
test('groupOrFlat: 적은 그룹 + 낮은 기타% → group (5140-류)', () => {
|
||||
// 3 top segment × 4 = 12절, window 없음 → group_count 3, 기타 0%
|
||||
const sections: DocumentSection[] = [];
|
||||
|
||||
@@ -14,8 +14,12 @@ export interface DocumentSection {
|
||||
level: number | null;
|
||||
node_type: string | null; // 'window' | 'chapter_split' | 'clause_split' | 'section_split' | null
|
||||
is_leaf: boolean;
|
||||
/** 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent (비인접 흡수에 사용). */
|
||||
parent_id?: number | null;
|
||||
/** md_content 내 heading offset(UTF-16). jump-target 만 값, window-child/preamble/Path A = null (Path B). */
|
||||
char_start?: number | null;
|
||||
/** 절 본문 = 청크 원문. split-parent 는 heading 줄뿐, window child 가 실 본문 보유. */
|
||||
text?: string | null;
|
||||
section_type: string | null;
|
||||
summary: string | null;
|
||||
confidence: number | null;
|
||||
@@ -25,6 +29,17 @@ export interface DocumentSection {
|
||||
export interface OutlineItem {
|
||||
section: DocumentSection;
|
||||
fragmentCount: number; // >1 이면 "(n조각)" 배지
|
||||
/** 대표 + 흡수된 window child 들의 본문을 순서대로 이어붙인 논리 절 전체 본문.
|
||||
* split-parent 는 heading 줄(text)을 본문에서 제외(제목과 중복) — window 본문만 합친다. */
|
||||
bodyText: string;
|
||||
/** 집계된 절-레벨 분석. windowed 절은 분석이 window child(chunk_section_analysis)에 붙고
|
||||
* 대표=split-parent 엔 없으므로 멤버에서 집계한다. 단일 절은 대표 자신의 값.
|
||||
* - sectionType: 멤버 section_type 다수결(동률=첫 등장)
|
||||
* - confidence: 멤버 confidence 평균
|
||||
* - summaries: 멤버 요약(빈 것 제외, chunk_index 순) — 단일=1개, windowed=N개(부분별 요약) */
|
||||
sectionType: string | null;
|
||||
confidence: number | null;
|
||||
summaries: string[];
|
||||
}
|
||||
|
||||
export interface OutlineGroup {
|
||||
@@ -107,22 +122,78 @@ function topSegment(s: DocumentSection): string {
|
||||
* fragmentCount: split-parent 대표는 0 에서 시작(자신은 조각 아님) + 흡수 child 수 = 실제 조각 수;
|
||||
* legacy window 대표는 1 에서 시작(자신이 첫 조각).
|
||||
*/
|
||||
/** 멤버 section_type 다수결(동률은 첫 등장 우선). 비어있으면 null. */
|
||||
function majorityType(types: (string | null)[]): string | null {
|
||||
const vals = types.filter((t): t is string => !!t);
|
||||
if (!vals.length) return null;
|
||||
const count = new Map<string, number>();
|
||||
for (const t of vals) count.set(t, (count.get(t) ?? 0) + 1);
|
||||
let best: string | null = null;
|
||||
let bestN = -1;
|
||||
for (const t of vals) {
|
||||
const n = count.get(t)!;
|
||||
if (n > bestN) { bestN = n; best = t; } // 첫 등장 우선 tie-break
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function collapseWindows(sections: DocumentSection[]): OutlineItem[] {
|
||||
const out: OutlineItem[] = [];
|
||||
const members: DocumentSection[][] = []; // out[i] 의 멤버(대표 + 흡수된 window child)
|
||||
const repByChunkId = new Map<number, number>(); // split-parent chunk_id → out index (window 가 parent_id 로 흡수)
|
||||
|
||||
// window child 본문/멤버를 out[idx] 대표에 흡수.
|
||||
const absorb = (idx: number, s: DocumentSection) => {
|
||||
out[idx].fragmentCount += 1;
|
||||
const t = (s.text ?? '').trim();
|
||||
if (t) out[idx].bodyText = out[idx].bodyText ? `${out[idx].bodyText}\n\n${t}` : t;
|
||||
members[idx].push(s);
|
||||
};
|
||||
|
||||
for (const s of sections) {
|
||||
const prev = out[out.length - 1];
|
||||
const h = cleanHeading(s.heading_path);
|
||||
const prevAbsorbs =
|
||||
prev &&
|
||||
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
|
||||
h !== '' &&
|
||||
cleanHeading(prev.section.heading_path) === h;
|
||||
if (s.node_type === 'window' && prevAbsorbs) {
|
||||
prev!.fragmentCount += 1; // window child 흡수 — 대표(split-parent 우선)는 그대로 유지
|
||||
if (s.node_type === 'window') {
|
||||
// 1) parent_id 로 split-parent 대표에 흡수 — split-parent 와 window 가 chunk_index 상 비인접일 수
|
||||
// 있으므로(예: 헤딩 1143, window 1233) 인접 가정 대신 트리 부모 링크로 정확히 연결한다.
|
||||
let idx = s.parent_id != null ? repByChunkId.get(s.parent_id) ?? -1 : -1;
|
||||
// 2) fallback: 인접 대표(legacy window run / 같은 heading split)면 흡수
|
||||
if (idx < 0) {
|
||||
const prev = out[out.length - 1];
|
||||
const h = cleanHeading(s.heading_path);
|
||||
if (
|
||||
prev &&
|
||||
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
|
||||
h !== '' &&
|
||||
cleanHeading(prev.section.heading_path) === h
|
||||
) {
|
||||
idx = out.length - 1;
|
||||
}
|
||||
}
|
||||
if (idx >= 0) {
|
||||
absorb(idx, s);
|
||||
continue;
|
||||
}
|
||||
// 3) legacy: 부모 없는 window → 자기 대표(자기 본문으로 시작)
|
||||
out.push({ section: s, fragmentCount: 1, bodyText: s.text ?? '', sectionType: null, confidence: null, summaries: [] });
|
||||
members.push([s]);
|
||||
} else {
|
||||
out.push({ section: s, fragmentCount: s.node_type?.endsWith('_split') ? 0 : 1 });
|
||||
const isSplit = !!s.node_type?.endsWith('_split');
|
||||
// split-parent 의 text 는 heading 줄뿐 → 본문에서 제외(window 가 본문 보유). 그 외엔 자기 본문으로 시작.
|
||||
out.push({
|
||||
section: s, fragmentCount: isSplit ? 0 : 1, bodyText: isSplit ? '' : (s.text ?? ''),
|
||||
sectionType: null, confidence: null, summaries: [],
|
||||
});
|
||||
members.push([s]);
|
||||
if (isSplit) repByChunkId.set(s.chunk_id, out.length - 1); // window 가 parent_id 로 찾아 흡수
|
||||
}
|
||||
}
|
||||
// 멤버에서 절-레벨 분석 집계 (windowed 절: 대표 split-parent 엔 분석 없고 window 들이 보유).
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const mem = members[i];
|
||||
out[i].sectionType = majorityType(mem.map((m) => m.section_type));
|
||||
const confs = mem.map((m) => m.confidence).filter((c): c is number => c != null);
|
||||
out[i].confidence = confs.length ? confs.reduce((a, b) => a + b, 0) / confs.length : null;
|
||||
out[i].summaries = mem.map((m) => (m.summary ?? '').trim()).filter((x) => x !== '');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Menu, EllipsisVertical, ChevronDown, FileText, Newspaper, HelpCircle, StickyNote, Inbox, PanelLeft, MessageCircle } from 'lucide-svelte';
|
||||
import { Menu, EllipsisVertical, ChevronDown, FileText, Newspaper, StickyNote, Inbox, PanelLeft } from 'lucide-svelte';
|
||||
import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth';
|
||||
import { toasts, removeToast } from '$lib/stores/toast';
|
||||
import { refresh as refreshPublicConfig } from '$lib/stores/config';
|
||||
@@ -151,8 +151,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<a href="/ask" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/ask') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">질문</a>
|
||||
<a href="/chat" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/chat') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">이드</a>
|
||||
<a href="/memos" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/memos') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">메모</a>
|
||||
<SystemStatusDot />
|
||||
</div>
|
||||
|
||||
@@ -212,8 +211,6 @@
|
||||
<nav class="lg:hidden shrink-0 flex border-t border-default bg-sidebar" aria-label="하단 탭">
|
||||
<a href="/documents" aria-current={docsActive ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {docsActive ? 'text-accent' : 'text-dim'}"><FileText size={18} strokeWidth={1.9} /> 문서</a>
|
||||
<a href="/news" aria-current={newsActive ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {newsActive ? 'text-accent' : 'text-dim'}"><Newspaper size={18} strokeWidth={1.9} /> 뉴스</a>
|
||||
<a href="/ask" aria-current={isActive('/ask') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/ask') ? 'text-accent' : 'text-dim'}"><HelpCircle size={18} strokeWidth={1.9} /> 질문</a>
|
||||
<a href="/chat" aria-current={isActive('/chat') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/chat') ? 'text-accent' : 'text-dim'}"><MessageCircle size={18} strokeWidth={1.9} /> 이드</a>
|
||||
<a href="/memos" aria-current={isActive('/memos') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/memos') ? 'text-accent' : 'text-dim'}"><StickyNote size={18} strokeWidth={1.9} /> 메모</a>
|
||||
<button onclick={() => ui.openDrawer('sidebar')} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold text-dim"><Menu size={18} strokeWidth={1.9} /> 더보기</button>
|
||||
</nav>
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { Info, X, Plus, Trash2, Tag, FolderTree, Sparkles, ChevronLeft, ArrowUpDown } from 'lucide-svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import { X, Plus, Trash2, Tag, FolderTree, Sparkles, ArrowUpDown } from 'lucide-svelte';
|
||||
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
|
||||
import { isMdStatusVisible } from '$lib/utils/mdStatus';
|
||||
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
|
||||
@@ -233,15 +232,12 @@
|
||||
goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
|
||||
}
|
||||
|
||||
async function selectDoc(doc) {
|
||||
if (selectedDoc?.id === doc.id) { selectedDoc = null; return; }
|
||||
selectedDoc = doc; // 즉시 표시(리더 + 기본 인스펙터)
|
||||
// 인스펙터 풀 메타 하이드레이션 — 검색 결과(SearchResult)는 메타가 빈약(태그/크기/하위/md상태/읽음 없음).
|
||||
// 풀 문서를 조회해 채운다(기존 GET /documents/{id}, 백엔드 무변). 리스트 모드도 md상태 등 보강.
|
||||
try {
|
||||
const full = await api(`/documents/${doc.id}`);
|
||||
if (selectedDoc?.id === doc.id) selectedDoc = { ...doc, ...full };
|
||||
} catch { /* 실패 시 기본 정보 유지 */ }
|
||||
// 문서 열기 = 개선된 상세 페이지(D3 절 구조 탐색기)로 이동.
|
||||
// 사용자 결정: "개선된 페이지가 앞으로 표시되야지" — 인라인 미리보기 폐기.
|
||||
// /documents = 브라우즈/검색/필터/일괄 목록, 문서 열기 = /documents/[id] D3 리더.
|
||||
function selectDoc(doc) {
|
||||
if (!doc) return;
|
||||
goto(`/documents/${doc.id}`);
|
||||
}
|
||||
|
||||
// bulk 선택
|
||||
@@ -386,8 +382,8 @@
|
||||
|
||||
<div class="flex h-full min-h-0">
|
||||
|
||||
<!-- ═══ 좌: 리스트 컬럼 ═══ -->
|
||||
<div class="{selectedDoc ? 'hidden lg:flex' : 'flex'} flex-col w-full lg:w-[340px] lg:shrink-0 lg:border-r border-default min-h-0">
|
||||
<!-- ═══ 문서 목록 (풀폭 중앙) — 클릭 시 D3 상세로 이동 ═══ -->
|
||||
<div class="flex flex-col w-full max-w-5xl mx-auto min-h-0">
|
||||
<UploadDropzone onupload={loadDocuments} />
|
||||
|
||||
<!-- 검색바 -->
|
||||
@@ -487,6 +483,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- AI 답변 (질문형 검색) — 목록 상단 고정, 아래로 목록 스크롤 -->
|
||||
{#if showAskCard}
|
||||
<div class="px-3 py-2 shrink-0 border-b border-default max-h-[55vh] overflow-y-auto">
|
||||
<AskAnswerCard
|
||||
data={askData}
|
||||
loading={askLoading}
|
||||
error={askError}
|
||||
onCitationClick={(docId) => goto(`/documents/${docId}`)}
|
||||
onDismiss={() => { askDismissed = true; }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 선택 toolbar -->
|
||||
{#if selectionCount > 0}
|
||||
<div class="flex flex-wrap items-center gap-2 px-3 py-2 shrink-0 bg-accent/10 border-y border-accent/30">
|
||||
@@ -587,47 +596,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 중앙: 리더 ═══ -->
|
||||
<div class="{selectedDoc ? 'flex' : 'hidden lg:flex'} flex-1 min-w-0 flex-col min-h-0">
|
||||
{#if selectedDoc}
|
||||
<!-- 리더 상단 바: (모바일) 뒤로 / (lg) 인스펙터 토글 -->
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 shrink-0 border-b border-default bg-sidebar">
|
||||
<button type="button" onclick={() => { selectedDoc = null; if (ui.isDrawerOpen('meta')) ui.closeDrawer(); }}
|
||||
class="lg:hidden flex items-center gap-1 text-xs text-accent-hover font-medium" aria-label="목록으로">
|
||||
<ChevronLeft size={15} /> 문서
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button type="button" onclick={toggleInfoPanel} aria-pressed={isPanelActive} title="문서 정보"
|
||||
class="p-1.5 rounded-lg border transition-colors {isPanelActive ? 'border-accent text-accent bg-accent/10' : 'border-default text-dim hover:text-accent hover:border-accent'}">
|
||||
<Info size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<DocumentViewer doc={selectedDoc} />
|
||||
</div>
|
||||
{:else if showAskCard}
|
||||
<div class="p-4 lg:p-6 overflow-y-auto">
|
||||
<AskAnswerCard
|
||||
data={askData}
|
||||
loading={askLoading}
|
||||
error={askError}
|
||||
onCitationClick={(docId) => goto(`/documents/${docId}`)}
|
||||
onDismiss={() => { askDismissed = true; }}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="hidden lg:flex flex-1 items-center justify-center text-dim text-sm">
|
||||
왼쪽에서 문서를 선택하세요
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ═══ 우: 인스펙터 (xl+ inline) ═══ -->
|
||||
{#if selectedDoc && inspectorOpen}
|
||||
<aside class="hidden xl:flex flex-col w-[300px] shrink-0 border-l border-default bg-sidebar overflow-y-auto" aria-label="문서 정보">
|
||||
{@render inspector(selectedDoc)}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- < xl 폴백: Drawer (정보 하단/측면 시트) -->
|
||||
|
||||
@@ -1,118 +1,78 @@
|
||||
<script>
|
||||
// Phase E.2 — detail 페이지 inline 편집.
|
||||
// 기존 read-only 메타 패널(L138–201)을 editors/* 스택으로 교체.
|
||||
// + E.3 관련 문서 stub, + 헤더 affordance row.
|
||||
// 문서 상세 /documents/[id] — 확정 시안(d3-deepened) 스타일을 그대로 포팅, 데이터만 바인딩.
|
||||
// 데스크탑: 상단 헤더 띠 + [좌 절 트리(색바+연결선)][중 절 집중 뷰][우 슬림 레일]. 절 없으면 fallback.
|
||||
// 모바일: 헤더 + 나란한 토글 pill(절구조|인사이트) + 본문 절 카드 연속(+탭 이동). 편집/필기/네비 보존.
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, getAccessToken } from '$lib/api';
|
||||
import { isMdSuccess } from '$lib/utils/mdStatus';
|
||||
import { resolveAnchorMap } from '$lib/utils/resolveAnchorMap';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { ExternalLink, Download, Link2, FileText, PenLine, X, ChevronLeft, ChevronRight, Check } from 'lucide-svelte';
|
||||
import { ChevronRight, FileText } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte';
|
||||
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
|
||||
import { renderDocMarkdown } from '$lib/utils/docMarkdown';
|
||||
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
|
||||
import NoteEditor from '$lib/components/editors/NoteEditor.svelte';
|
||||
import EditUrlEditor from '$lib/components/editors/EditUrlEditor.svelte';
|
||||
import TagsEditor from '$lib/components/editors/TagsEditor.svelte';
|
||||
import AIClassificationEditor from '$lib/components/editors/AIClassificationEditor.svelte';
|
||||
import FileInfoView from '$lib/components/editors/FileInfoView.svelte';
|
||||
import ProcessingStatusView from '$lib/components/editors/ProcessingStatusView.svelte';
|
||||
import LibraryPathEditor from '$lib/components/editors/LibraryPathEditor.svelte';
|
||||
import DocumentDangerZone from '$lib/components/editors/DocumentDangerZone.svelte';
|
||||
import AnalysisPanel from '$lib/components/AnalysisPanel.svelte';
|
||||
import ReadCounter from '$lib/components/ReadCounter.svelte';
|
||||
import SectionOutline from '$lib/components/SectionOutline.svelte';
|
||||
import { cleanHeading, pathSegments, sectionTypeLabel, collapseWindows } from '$lib/utils/headingPath';
|
||||
import { domainLabel } from '$lib/utils/domainSlug';
|
||||
|
||||
marked.use({ mangle: false, headerIds: false });
|
||||
function renderMd(text) {
|
||||
return DOMPurify.sanitize(marked(text), {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['style', 'script'],
|
||||
FORBID_ATTR: ['onerror', 'onclick'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
return DOMPurify.sanitize(marked(text || ''), {
|
||||
USE_PROFILES: { html: true }, FORBID_TAGS: ['style', 'script'], FORBID_ATTR: ['onerror', 'onclick'], ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
});
|
||||
}
|
||||
|
||||
let doc = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state(null); // 'not_found' | 'network' | null
|
||||
let rawMarkdown = $state(''); // fallback: extracted_text 없을 때 원본 .md
|
||||
|
||||
let error = $state(null);
|
||||
let rawMarkdown = $state('');
|
||||
let docId = $derived($page.params.id);
|
||||
|
||||
// 손글씨 노트 (자료별 1:1) — "필기" 토글 시 사이드 캔버스 띄움.
|
||||
// 필기
|
||||
let noteOpen = $state(false);
|
||||
let noteStrokes = $state(null); // { version, strokes }
|
||||
let noteStrokes = $state(null);
|
||||
let noteLoaded = $state(false);
|
||||
async function ensureNoteLoaded() {
|
||||
if (noteLoaded) return;
|
||||
try {
|
||||
const r = await api(`/documents/${docId}/note`);
|
||||
noteStrokes = r.strokes_json && r.strokes_json.strokes ? r.strokes_json : { version: 1, strokes: [] };
|
||||
} catch {
|
||||
noteStrokes = { version: 1, strokes: [] };
|
||||
}
|
||||
try { const r = await api(`/documents/${docId}/note`); noteStrokes = r.strokes_json && r.strokes_json.strokes ? r.strokes_json : { version: 1, strokes: [] }; }
|
||||
catch { noteStrokes = { version: 1, strokes: [] }; }
|
||||
noteLoaded = true;
|
||||
}
|
||||
async function saveNote(strokesJson) {
|
||||
try {
|
||||
await api(`/documents/${docId}/note`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ strokes_json: strokesJson }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('필기 저장 실패', err);
|
||||
}
|
||||
}
|
||||
async function toggleNote() {
|
||||
if (!noteOpen) await ensureNoteLoaded();
|
||||
noteOpen = !noteOpen;
|
||||
}
|
||||
async function saveNote(s) { try { await api(`/documents/${docId}/note`, { method: 'PUT', body: JSON.stringify({ strokes_json: s }) }); } catch (e) { console.warn(e); } }
|
||||
async function toggleNote() { if (!noteOpen) await ensureNoteLoaded(); noteOpen = !noteOpen; }
|
||||
|
||||
// 인접 자료 (같은 library_path 내 이전/다음) — 학습 흐름 네비게이션
|
||||
// 인접 자료
|
||||
let neighbors = $state({ prev: null, next: null });
|
||||
async function loadNeighbors() {
|
||||
try {
|
||||
neighbors = await api(`/documents/${docId}/library-neighbors`);
|
||||
} catch {
|
||||
neighbors = { prev: null, next: null };
|
||||
}
|
||||
async function loadNeighbors() { try { neighbors = await api(`/documents/${docId}/library-neighbors`); } catch { neighbors = { prev: null, next: null }; } }
|
||||
async function readAndGoNext() {
|
||||
try { await api(`/documents/${docId}/read`, { method: 'POST' }); addToast('success', '1회독 완료'); }
|
||||
catch (err) { addToast('error', err?.detail || '회독 기록 실패'); return; }
|
||||
if (neighbors.next) goto(`/documents/${neighbors.next.id}`);
|
||||
}
|
||||
|
||||
// 절(hier section) 목차 — 본문 로드와 독립, 실패(404 포함) 무해.
|
||||
// reqId guard: 문서 전환 race 시 stale 결과가 새 문서에 붙지 않게.
|
||||
// 절 목차
|
||||
let sections = $state([]);
|
||||
let hasSections = $derived(sections.length > 0);
|
||||
// 과대 절은 builder 가 window 조각(같은 제목·is_leaf)으로 분해하고 부모를 heading 만 남긴 split-parent 로
|
||||
// 강등한다(예: 5180 = 27개 논리 절 → 562 window). raw sections 를 그대로 그리면 동일 제목 수백 행으로
|
||||
// 파편화되므로, collapseWindows 로 논리 절 1개(대표=split-parent, bodyText=window 본문 합본)로 합친다.
|
||||
let outline = $derived(collapseWindows(sections));
|
||||
async function loadSections() {
|
||||
const reqId = docId;
|
||||
try {
|
||||
const r = await api(`/documents/${reqId}/sections`);
|
||||
if (reqId === docId) sections = r?.sections ?? [];
|
||||
} catch {
|
||||
if (reqId === docId) sections = []; // Phase 1 미배포 시 404 → 목차 숨김(graceful)
|
||||
}
|
||||
}
|
||||
|
||||
// "1회독 완료 + 다음 자료로" 한 번에
|
||||
async function readAndGoNext() {
|
||||
try {
|
||||
await api(`/documents/${docId}/read`, { method: 'POST' });
|
||||
addToast('success', '1회독 완료');
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '회독 기록 실패');
|
||||
return;
|
||||
}
|
||||
if (neighbors.next) {
|
||||
goto(`/documents/${neighbors.next.id}`);
|
||||
}
|
||||
try { const r = await api(`/documents/${reqId}/sections`); if (reqId === docId) sections = r?.sections ?? []; }
|
||||
catch { if (reqId === docId) sections = []; }
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -120,87 +80,26 @@
|
||||
doc = await api(`/documents/${docId}`);
|
||||
const vt = doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format);
|
||||
if ((vt === 'markdown' || vt === 'hwp-markdown') && !doc.extracted_text) {
|
||||
try {
|
||||
const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`);
|
||||
if (resp.ok) rawMarkdown = await resp.text();
|
||||
} catch (e) {
|
||||
rawMarkdown = '';
|
||||
}
|
||||
try { const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`); if (resp.ok) rawMarkdown = await resp.text(); } catch { rawMarkdown = ''; }
|
||||
}
|
||||
} catch (err) {
|
||||
error = err?.status === 404 ? 'not_found' : 'network';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
// 자료실 자료면 인접 자료 미리 fetch (학습 흐름 네비)
|
||||
} catch (err) { error = err?.status === 404 ? 'not_found' : 'network'; }
|
||||
finally { loading = false; }
|
||||
if (doc && doc.category === 'library') loadNeighbors();
|
||||
if (doc) loadSections();
|
||||
});
|
||||
|
||||
let viewerType = $derived(
|
||||
doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none'
|
||||
);
|
||||
let viewerType = $derived(doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none');
|
||||
let canShowMarkdown = $derived(!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim()));
|
||||
// 절 본문은 청크 text(절별 원문)에서 오므로 md_content 성공/존재와 무관.
|
||||
// hasSections 만으로 절뷰 사용 → partial / 대형 split(md_content 5만 자 절단) 문서도 절뷰 표시.
|
||||
let useSectionView = $derived(hasSections);
|
||||
|
||||
// PDF 분기 전용: marker_worker 가 만든 canonical markdown 이 있으면 기본으로 그것을 보여줌.
|
||||
// Phase 1B 산출물의 95% 가 PDF 라 1D pilot 평가가 실사용 화면 기반이 되도록 markdown-first.
|
||||
// 사용자가 "PDF 원본" 토글하면 iframe. lastDocId 로 문서 전환만 감지해서 사용자 토글이
|
||||
// reactive cycle 에 덮이지 않도록 보호.
|
||||
let pdfViewMode = $state('markdown'); // 'markdown' | 'pdf'
|
||||
let pdfViewMode = $state('markdown');
|
||||
let lastDocId = $state(null);
|
||||
let canShowMarkdown = $derived(
|
||||
!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim())
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!doc) return;
|
||||
if (doc.id !== lastDocId) {
|
||||
lastDocId = doc.id;
|
||||
pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf';
|
||||
}
|
||||
// 같은 문서 안에서 markdown 이 사라지면 (success → failed 재처리 등) PDF 로 보호.
|
||||
if (!canShowMarkdown && pdfViewMode === 'markdown') {
|
||||
pdfViewMode = 'pdf';
|
||||
}
|
||||
});
|
||||
|
||||
// ── 개요 점프 (경로 B: BE char_start primary + string-match 폴백) ──
|
||||
// 이 사이트는 항상 md_content basis(canShowMarkdown && doc.md_content) → trustBE=true.
|
||||
// BE char_start 가 있으면 채택, 비면(non-PASS/미백필) resolveAnchorMap 내부에서 buildAnchorMap 로 폴백.
|
||||
let anchorMap = $derived(
|
||||
hasSections && canShowMarkdown && doc?.md_content
|
||||
? resolveAnchorMap(doc.md_content, sections, { trustBE: true }).anchors
|
||||
: {}
|
||||
);
|
||||
let activeKey = $state(null);
|
||||
function jumpToSection(chunkId) {
|
||||
const el = document.getElementById(`sec-${chunkId}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
// scroll-spy: 화면 상단(120px)을 지난 마지막 .md-anchor = 현재 절. [id] 는 window 스크롤.
|
||||
$effect(() => {
|
||||
void anchorMap; // 문서/섹션 변화 시 재바인딩
|
||||
if (typeof window === 'undefined') return;
|
||||
let raf = 0;
|
||||
const onScroll = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
let cur = null;
|
||||
document.querySelectorAll('.md-anchor').forEach((a) => {
|
||||
if (a.getBoundingClientRect().top <= 120) cur = a;
|
||||
});
|
||||
if (cur) {
|
||||
const m = cur.id.match(/^sec-(\d+)$/);
|
||||
if (m) activeKey = Number(m[1]);
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
if (doc.id !== lastDocId) { lastDocId = doc.id; pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf'; }
|
||||
if (!canShowMarkdown && pdfViewMode === 'markdown') pdfViewMode = 'pdf';
|
||||
});
|
||||
|
||||
function getViewerType(format) {
|
||||
@@ -212,353 +111,373 @@
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
// E.2 affordance row 핸들러
|
||||
// 절 집중/모바일 상태
|
||||
let selectedSectionId = $state(null);
|
||||
let mTree = $state(false);
|
||||
let mIns = $state(false);
|
||||
let manageOpen = $state(false);
|
||||
$effect(() => { if (outline.length && !outline.some((it) => it.section.chunk_id === selectedSectionId)) selectedSectionId = outline[0].section.chunk_id; });
|
||||
let selectedItem = $derived(outline.find((it) => it.section.chunk_id === selectedSectionId) ?? outline[0] ?? null);
|
||||
let selectedSection = $derived(selectedItem?.section ?? null);
|
||||
let selIdx = $derived(outline.findIndex((it) => it.section.chunk_id === selectedItem?.section?.chunk_id));
|
||||
// 절 본문 = 청크 원문(it.bodyText, window 조각 합본) 직접 렌더. 과거 char_start 로 md_content 를
|
||||
// 슬라이스했으나, 대형 split 문서는 md_content 가 앞 5만 자만 보존되고 char_start 도 NULL 이라 본문이
|
||||
// 비었다. 청크 text 는 절 전체를 담으므로(절 보유 문서 344개, 본문 합 평균 68KB·max 1.6MB) 그대로 렌더.
|
||||
function bodyHtml(it) { return it?.bodyText ? renderMd(it.bodyText) : ''; }
|
||||
let selectedBodyHtml = $derived(bodyHtml(selectedItem));
|
||||
// 모바일 연속 카드: 본문은 '본문 보기' 펼칠 때만 파싱(논리 절 수백 개 × marked 즉시 파싱 회피).
|
||||
let mBodyOpen = $state({});
|
||||
|
||||
// 절 유형 색 (시안: 정의 청 / 절차 올리브 / 요건 황)
|
||||
const TYPE_META = {
|
||||
definition: { label: '정의', en: 'definition', color: '#2f7d8f' },
|
||||
procedure: { label: '절차', en: 'procedure', color: '#7a8b3f' },
|
||||
requirement: { label: '요건', en: 'requirement', color: '#b5840a' },
|
||||
};
|
||||
function typeMeta(t) { return TYPE_META[t] ?? { label: sectionTypeLabel(t) || '', en: t || '', color: '#9aa090' }; }
|
||||
function isLowConf(c) { return c != null && c < 0.5; }
|
||||
function isMidLow(c) { return c != null && c < 0.6; }
|
||||
function confColor(c) { return c == null ? '#9aa090' : c < 0.6 ? '#b5840a' : '#1f9d6b'; }
|
||||
function secTitle(s) { return cleanHeading(s.section_title) || pathSegments(s.heading_path).at(-1) || '(제목 없음)'; }
|
||||
function secDepth(s) { return Math.max(0, (s.level ?? 1) - 1); }
|
||||
function confPct(c) { return c == null ? 0 : Math.round(c * 100); }
|
||||
|
||||
// 도메인 색 (시안 도메인 팔레트)
|
||||
const DOMAIN_COLOR = { Industrial_Safety: '#b5840a', Engineering: '#2f7d8f', Programming: '#3d7256', General: '#7a8b3f', Reference: '#8a6a3f', Philosophy: '#7a6a9b' };
|
||||
function domainColor(d) { return DOMAIN_COLOR[(d || '').split('/')[0]] ?? '#697061'; }
|
||||
function fmtColor(f) { return f === 'pdf' ? '#c0564a' : f === 'md' ? '#5a8f7a' : ['m4a', 'mp3', 'wav'].includes(f) ? '#8a6aa5' : f === 'html' ? '#c2911f' : '#697061'; }
|
||||
|
||||
let quality = $derived(doc?.md_extraction_quality?.metrics ?? doc?.md_extraction_quality ?? null);
|
||||
|
||||
function copyLink() {
|
||||
const url = `${window.location.origin}/documents/${docId}`;
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => addToast('success', '링크 복사됨'))
|
||||
.catch(() => addToast('error', '복사 실패'));
|
||||
}
|
||||
|
||||
function downloadOriginal() {
|
||||
window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
function downloadPdf() {
|
||||
window.open(`/api/documents/${docId}/preview?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
function handleDocDelete() {
|
||||
addToast('success', '문서가 삭제되어 목록으로 이동합니다.');
|
||||
goto('/documents');
|
||||
navigator.clipboard.writeText(`${window.location.origin}/documents/${docId}`).then(() => addToast('success', '링크 복사됨')).catch(() => addToast('error', '복사 실패'));
|
||||
}
|
||||
function downloadOriginal() { window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`); }
|
||||
function handleDocDelete() { addToast('success', '문서가 삭제되어 목록으로 이동합니다.'); goto('/documents'); }
|
||||
</script>
|
||||
|
||||
<div class="p-4 lg:p-6">
|
||||
<!-- ════ 좌 트리 (시안: 색바 + 연결선 + 활성 + 저신뢰 경고) ════ -->
|
||||
{#snippet treeNav(jumpMode)}
|
||||
<div class="d3tree" style="font-size:14px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:9px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#697061;letter-spacing:.4px;">절 구조</div>
|
||||
<span style="font-size:10.5px;color:#9aa090;font-variant-numeric:tabular-nums;">{outline.length}절</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px 8px;margin-bottom:11px;padding-bottom:10px;border-bottom:1px solid #dde3d6;">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#2f7d8f;"></span>정의</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#7a8b3f;"></span>절차</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#b5840a;"></span>요건</span>
|
||||
</div>
|
||||
{#each outline as it (it.section.chunk_id)}
|
||||
{@const s = it.section}
|
||||
{@const tm = typeMeta(it.sectionType)}
|
||||
{@const active = !jumpMode && s.chunk_id === selectedSection?.chunk_id}
|
||||
{@const child = secDepth(s) > 0}
|
||||
{@const low = isMidLow(it.confidence)}
|
||||
<svelte:element this={jumpMode ? 'a' : 'div'} href={jumpMode ? `#m-sec-${s.chunk_id}` : undefined} role="button" tabindex="0"
|
||||
onclick={() => !jumpMode && (selectedSectionId = s.chunk_id)}
|
||||
onkeydown={(e) => { if (!jumpMode && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectedSectionId = s.chunk_id; } }}
|
||||
class="d3node {child ? 'd3child' : ''} {active ? 'd3active' : ''}"
|
||||
style="display:block;border:1px solid {active ? '#4f8a6b' : low ? '#e7d49a' : 'transparent'};border-radius:9px;padding:{child ? '6px 8px' : '7px 8px'};margin-bottom:2px;{low ? 'background:#fbf6e6;' : ''}text-decoration:none;cursor:pointer;">
|
||||
<div style="display:flex;align-items:center;gap:7px;">
|
||||
<span style="width:3px;height:{child ? '13px' : '16px'};border-radius:2px;background:{tm.color};flex-shrink:0;"></span>
|
||||
<span class="d3title" style="font-size:{child ? '11.5px' : '12.5px'};flex:1;min-width:0;{child ? 'color:#697061;' : ''}{active ? 'color:#3d7256;font-weight:600;' : ''}overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{secTitle(s)}</span>
|
||||
{#if low}
|
||||
<span class="d3warn" title="저신뢰 절" style="display:inline-flex;width:14px;height:14px;border-radius:50%;background:#b5840a;color:#fff;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;">!</span>
|
||||
{:else if !child}
|
||||
<span title="신뢰도 {it.confidence != null ? it.confidence.toFixed(2) : '—'}" style="width:7px;height:7px;border-radius:50%;background:{confColor(it.confidence)};flex-shrink:0;"></span>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:element>
|
||||
{/each}
|
||||
{#if quality}
|
||||
<div style="margin-top:12px;padding-top:10px;border-top:1px solid #dde3d6;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;margin-bottom:7px;letter-spacing:.3px;">추출 품질</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;font-size:10.5px;color:#697061;font-variant-numeric:tabular-nums;">
|
||||
{#if quality.headings != null}<span>headings <b style="color:#23291f;">{quality.headings}</b></span>{/if}
|
||||
{#if quality.tables != null}<span>tables <b style="color:#23291f;">{quality.tables}</b></span>{/if}
|
||||
{#if quality.images != null}<span>images <b style="color:#23291f;">{quality.images}</b></span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ════ 절 집중 뷰 (데스크탑 중앙) ════ -->
|
||||
{#snippet focusView()}
|
||||
{#if selectedSection}
|
||||
{@const tm = typeMeta(selectedItem?.sectionType)}
|
||||
{@const conf = selectedItem?.confidence ?? null}
|
||||
{@const summaries = selectedItem?.summaries ?? []}
|
||||
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:#9aa090;margin-bottom:12px;flex-wrap:wrap;">
|
||||
<span class="truncate" style="max-width:200px;">{doc.title}</span>
|
||||
{#each pathSegments(selectedSection.heading_path) as seg}<span style="color:#c8d6c0;">/</span><span style="color:#697061;font-weight:600;">{seg}</span>{/each}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:13px;">
|
||||
<h2 style="margin:0;font-size:22px;font-weight:700;color:#23291f;line-height:1.3;flex:1;min-width:180px;">{secTitle(selectedSection)}</h2>
|
||||
{#if tm.label}<span style="display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:{tm.color}1a;border:1px solid {tm.color}55;font-size:12px;color:{tm.color};font-weight:600;"><span style="width:8px;height:8px;border-radius:2px;background:{tm.color};"></span>{tm.label} {tm.en}</span>{/if}
|
||||
</div>
|
||||
{#if conf != null}
|
||||
<div style="display:flex;align-items:center;gap:9px;margin-bottom:18px;">
|
||||
<span style="font-size:11px;color:#697061;font-weight:600;flex-shrink:0;">신뢰도</span>
|
||||
<div style="flex:1;max-width:300px;height:7px;border-radius:999px;background:#e3ebdf;overflow:hidden;"><div style="width:{confPct(conf)}%;height:100%;background:{confColor(conf)};border-radius:999px;"></div></div>
|
||||
<span style="font-size:13px;font-weight:700;color:{confColor(conf)};font-variant-numeric:tabular-nums;flex-shrink:0;">{conf.toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLowConf(conf)}
|
||||
<div style="display:flex;align-items:flex-start;gap:8px;background:#faf3e2;border:1px solid #ecdca3;border-radius:10px;padding:10px 12px;margin-bottom:16px;font-size:12.5px;color:#8a6306;"><span style="flex-shrink:0;width:16px;height:16px;border-radius:50%;border:1.5px solid #b5840a;color:#b5840a;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;margin-top:1px;">!</span><span>저신뢰 절 — 표·수식 추출이 불완전할 수 있습니다. 정확한 내용은 원본을 확인하세요.</span></div>
|
||||
{/if}
|
||||
{#if summaries.length}
|
||||
<div style="background:#ecf0e8;border-left:3px solid #4f8a6b;border-radius:0 10px 10px 0;padding:14px 16px;margin-bottom:20px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#3d7256;letter-spacing:.6px;margin-bottom:6px;">절 요약{#if summaries.length > 1} · {summaries.length}개 부분{/if}</div>
|
||||
{#if summaries.length === 1}
|
||||
<div style="font-size:15.5px;line-height:1.6;color:#23291f;white-space:pre-line;">{summaries[0]}</div>
|
||||
{:else}
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:8px;">
|
||||
{#each summaries as sm, i}<li style="font-size:13.5px;line-height:1.55;color:#23291f;display:flex;gap:8px;"><span style="flex-shrink:0;color:#7a8b3f;font-weight:700;font-variant-numeric:tabular-nums;">{i + 1}</span><span style="white-space:pre-line;">{sm}</span></li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedItem?.bodyText}
|
||||
<MarkdownDoc documentId={doc.id} mdContent={selectedItem.bodyText} mdStatus={null} class="prose prose-base max-w-none text-text" />
|
||||
{:else}
|
||||
<p style="color:#9aa090;font-size:14px;font-style:italic;">이 절의 본문은 추출되지 않았습니다. 헤더의 '원본'에서 확인하세요.</p>
|
||||
{/if}
|
||||
<div style="display:flex;justify-content:space-between;gap:10px;margin-top:20px;padding-top:14px;border-top:1px solid #dde3d6;">
|
||||
{#if selIdx > 0}
|
||||
{@const pv = outline[selIdx - 1].section}
|
||||
<button type="button" onclick={() => (selectedSectionId = pv.chunk_id)} style="font-size:12px;color:#697061;border:1px solid #dde3d6;border-radius:9px;padding:8px 12px;background:#fff;cursor:pointer;">← {secTitle(pv)}</button>
|
||||
{:else}<span></span>{/if}
|
||||
{#if selIdx >= 0 && selIdx < outline.length - 1}
|
||||
{@const nxIt = outline[selIdx + 1]}
|
||||
{@const nx = nxIt.section}
|
||||
<button type="button" onclick={() => (selectedSectionId = nx.chunk_id)} style="font-size:12px;color:{isMidLow(nxIt.confidence) ? '#8a6306' : '#697061'};border:1px solid {isMidLow(nxIt.confidence) ? '#e7d49a' : '#dde3d6'};border-radius:9px;padding:8px 12px;background:#fff;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">{#if isMidLow(nxIt.confidence)}<span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#b5840a;color:#fff;align-items:center;justify-content:center;font-size:8px;font-weight:700;">!</span>{/if}{secTitle(nx)} →</button>
|
||||
{:else}<span></span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- ════ 우 슬림 레일 (시안 카드 스타일) ════ -->
|
||||
{#snippet rail()}
|
||||
<div style="display:flex;flex-direction:column;gap:11px;font-size:14px;">
|
||||
{#if doc.ai_tldr || doc.ai_summary}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:7px;">TL;DR</div>
|
||||
<div class="summary-md" style="font-size:12px;line-height:1.5;color:#23291f;">{@html renderDocMarkdown(doc.ai_tldr || doc.ai_summary)}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_bullets && doc.ai_bullets.length}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">핵심점</div>
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:7px;">
|
||||
{#each doc.ai_bullets as b}<li style="font-size:12px;line-height:1.4;display:flex;gap:6px;"><span style="color:#b5840a;font-weight:700;flex-shrink:0;">·</span><span style="flex:1;min-width:0;color:#23291f;">{b}</span></li>{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_detail_summary}
|
||||
<div style="background:#f4f7f1;border:1px solid #c8d6c0;border-radius:14px;padding:13px;">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:7px;">
|
||||
<span style="font-size:10.5px;font-weight:700;color:#3d7256;letter-spacing:.4px;">심층</span>
|
||||
{#if doc.ai_analysis_tier === 'deep'}<span style="font-size:9px;color:#fff;background:#4f8a6b;border-radius:999px;padding:1px 7px;font-weight:600;">DEEP</span>{/if}
|
||||
</div>
|
||||
<div style="font-size:11.5px;line-height:1.5;color:#23291f;white-space:pre-line;">{doc.ai_detail_summary}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_inconsistencies && doc.ai_inconsistencies.length}
|
||||
<div style="background:#fbf6e6;border:1px solid #e7d49a;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#8a6306;letter-spacing:.4px;margin-bottom:7px;">불일치 {doc.ai_inconsistencies.length}</div>
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:5px;">{#each doc.ai_inconsistencies as inc}<li style="font-size:11.5px;line-height:1.45;color:#23291f;">· {typeof inc === 'string' ? inc : inc.desc || inc.kind}</li>{/each}</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_domain}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">분류</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;font-size:11.5px;">
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">도메인</span><span style="display:inline-flex;align-items:center;gap:5px;color:#23291f;font-weight:600;text-align:right;"><span style="width:7px;height:7px;border-radius:50%;background:{domainColor(doc.ai_domain)};"></span>{domainLabel(doc.ai_domain)}</span></div>
|
||||
{#if doc.ai_sub_group}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">하위</span><span style="color:#23291f;font-weight:600;">{doc.ai_sub_group}</span></div>{/if}
|
||||
{#if doc.ai_analysis_tier}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">tier</span><span style="color:#3d7256;font-weight:600;">{doc.ai_analysis_tier}</span></div>{/if}
|
||||
{#if doc.ai_confidence != null}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">신뢰도</span><span style="color:#1f9d6b;font-weight:700;font-variant-numeric:tabular-nums;">{doc.ai_confidence.toFixed(2)}</span></div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_tags && doc.ai_tags.length}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">태그</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:5px;">{#each doc.ai_tags as t}<span style="font-size:11px;padding:3px 8px;border-radius:999px;background:#fff;border:1px solid #dde3d6;color:#697061;">{t}</span>{/each}</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:6px;">관련 문서</div>
|
||||
<div style="font-size:11px;color:#9aa090;line-height:1.5;">벡터 유사도 기반 — 준비 중</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ════ 절 카드 (모바일 연속 본문) ════ -->
|
||||
{#snippet sectionCard(it)}
|
||||
{@const s = it.section}
|
||||
{@const tm = typeMeta(it.sectionType)}
|
||||
<div id="m-sec-{s.chunk_id}" style="scroll-margin-top:12px;background:#f4f7f1;border:1px solid {isLowConf(it.confidence) ? '#e7d49a' : '#dde3d6'};border-radius:14px;padding:14px 15px;">
|
||||
<div style="display:flex;align-items:center;gap:7px;margin-bottom:7px;">
|
||||
<h2 style="margin:0;font-size:16px;font-weight:700;color:#23291f;flex:1;min-width:0;line-height:1.3;">{secTitle(s)}</h2>
|
||||
{#if tm.label}<span style="flex-shrink:0;font-size:10.5px;font-weight:650;padding:2px 8px;border-radius:999px;background:{tm.color}1a;color:{tm.color};white-space:nowrap;">{tm.label}</span>{/if}
|
||||
</div>
|
||||
{#if isLowConf(it.confidence)}
|
||||
<div style="display:flex;align-items:flex-start;gap:7px;background:#faf3e2;border:1px solid #ecdca3;border-radius:9px;padding:8px 10px;margin-bottom:10px;font-size:12px;color:#8a6306;"><span style="flex-shrink:0;width:15px;height:15px;border-radius:50%;border:1.5px solid #b5840a;color:#b5840a;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;margin-top:1px;">!</span><span>저신뢰 — 표·수식 추출 불완전, 원본 확인 권장</span></div>
|
||||
{/if}
|
||||
{#if it.summaries.length}
|
||||
<div style="border-left:3px solid #4f8a6b;background:#ecf0e8;border-radius:0 8px 8px 0;padding:9px 12px;margin-bottom:12px;">
|
||||
<div style="font-size:9.5px;font-weight:700;color:#3d7256;letter-spacing:.5px;margin-bottom:3px;">절 요약{#if it.summaries.length > 1} · {it.summaries.length}개 부분{/if}</div>
|
||||
{#if it.summaries.length === 1}
|
||||
<div style="font-size:13.5px;line-height:1.55;color:#23291f;white-space:pre-line;">{it.summaries[0]}</div>
|
||||
{:else}
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;">{#each it.summaries as sm, i}<li style="font-size:12.5px;line-height:1.5;color:#23291f;display:flex;gap:6px;"><span style="flex-shrink:0;color:#7a8b3f;font-weight:700;font-variant-numeric:tabular-nums;">{i + 1}</span><span style="white-space:pre-line;">{sm}</span></li>{/each}</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if it.bodyText}
|
||||
<details class="m-secbody" ontoggle={(e) => { if (e.currentTarget.open) mBodyOpen[s.chunk_id] = true; }}>
|
||||
<summary style="cursor:pointer;list-style:none;font-size:12px;color:#697061;padding:5px 0;user-select:none;display:flex;align-items:center;gap:5px;">본문 보기 <span class="m-chev" style="transition:transform .16s;color:#9aa090;">›</span></summary>
|
||||
{#if mBodyOpen[s.chunk_id]}<div style="margin-top:6px;"><MarkdownDoc documentId={doc.id} mdContent={it.bodyText} mdStatus={null} class="prose prose-sm max-w-none text-text" /></div>{/if}
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div style="background:#e7ebe4;min-height:100%;" class="p-4 lg:p-6">
|
||||
<div style="max-width:1360px;margin:0 auto;">
|
||||
<!-- breadcrumb -->
|
||||
<div class="flex items-center gap-2 text-sm mb-4 text-dim">
|
||||
<a href="/documents" class="hover:text-text">문서</a>
|
||||
<span class="text-faint">/</span>
|
||||
<div class="flex items-center gap-2 text-sm mb-3 text-dim">
|
||||
<a href="/documents" class="hover:text-text">문서</a><span class="text-faint">/</span>
|
||||
<span class="truncate max-w-md text-text">{doc?.title || '로딩...'}</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<Skeleton h="h-96" rounded="card" />
|
||||
</div>
|
||||
<Skeleton h="h-96" rounded="card" />
|
||||
{:else if error === 'not_found'}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="문서를 찾을 수 없습니다"
|
||||
description="삭제되었거나 접근 권한이 없을 수 있습니다."
|
||||
>
|
||||
<Button variant="ghost" size="sm" href="/documents">목록으로 돌아가기</Button>
|
||||
</EmptyState>
|
||||
<EmptyState icon={FileText} title="문서를 찾을 수 없습니다" description="삭제되었거나 접근 권한이 없을 수 있습니다."><Button variant="ghost" size="sm" href="/documents">목록으로</Button></EmptyState>
|
||||
{:else if error === 'network'}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="문서를 불러올 수 없습니다"
|
||||
description="네트워크 오류가 발생했습니다."
|
||||
>
|
||||
<Button variant="secondary" size="sm" onclick={() => location.reload()}>다시 시도</Button>
|
||||
</EmptyState>
|
||||
<EmptyState icon={FileText} title="문서를 불러올 수 없습니다" description="네트워크 오류"><Button variant="secondary" size="sm" onclick={() => location.reload()}>다시 시도</Button></EmptyState>
|
||||
{:else if doc}
|
||||
<div class="mx-auto grid grid-cols-1 gap-6 {hasSections ? 'max-w-7xl xl:grid-cols-[18rem_minmax(0,1fr)_20rem]' : 'max-w-6xl lg:grid-cols-3'}">
|
||||
{#if hasSections}
|
||||
<!-- 좌측 절 목차 — xl+ sticky rail (그 아래 viewport 는 본문 상단 collapsible) -->
|
||||
<aside class="hidden xl:block xl:sticky xl:top-6 xl:self-start xl:max-h-[calc(100vh-3rem)] xl:overflow-y-auto">
|
||||
<Card>
|
||||
<SectionOutline {sections} onJump={jumpToSection} {activeKey} />
|
||||
</Card>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<!-- 본문 (좌측 목차 없을 때 lg 2/3) -->
|
||||
<div class="{hasSections ? '' : 'lg:col-span-2'} space-y-4">
|
||||
{#if hasSections}
|
||||
<!-- xl 미만: 절 목차 접이식 -->
|
||||
<details class="xl:hidden">
|
||||
<summary class="cursor-pointer text-sm text-dim px-1 py-2 select-none">절 목차 ({sections.length})</summary>
|
||||
<Card class="mt-2"><SectionOutline {sections} onJump={jumpToSection} {activeKey} /></Card>
|
||||
</details>
|
||||
{/if}
|
||||
<!-- Affordance row -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if doc.edit_url}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={ExternalLink}
|
||||
href={doc.edit_url}
|
||||
target="_blank"
|
||||
>
|
||||
Synology 편집
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" icon={Download} onclick={downloadOriginal}>
|
||||
원본 다운로드
|
||||
</Button>
|
||||
{#if doc.preview_status === 'ready'}
|
||||
<Button variant="secondary" size="sm" icon={FileText} onclick={downloadPdf}>
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" icon={Link2} onclick={copyLink}>
|
||||
링크 복사
|
||||
</Button>
|
||||
{#if doc.category === 'library'}
|
||||
<Button
|
||||
variant={noteOpen ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
icon={noteOpen ? X : PenLine}
|
||||
onclick={toggleNote}
|
||||
>
|
||||
{noteOpen ? '필기 닫기' : '필기'}
|
||||
</Button>
|
||||
{/if}
|
||||
<!-- ════ 상단 띠: 문서 헤더 (시안) ════ -->
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:16px 18px;margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:flex-start;gap:13px;flex-wrap:wrap;">
|
||||
<div style="width:40px;height:40px;border-radius:10px;background:{fmtColor(doc.file_format)};color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:10.5px;letter-spacing:.5px;flex-shrink:0;text-transform:uppercase;">{doc.file_format}</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:17px;font-weight:700;line-height:1.35;color:#23291f;">{doc.title}</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:center;">
|
||||
{#if doc.ai_domain}<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:999px;background:#fff;border:1px solid #dde3d6;font-size:11.5px;color:#23291f;"><span style="width:7px;height:7px;border-radius:50%;background:{domainColor(doc.ai_domain)};"></span>{domainLabel(doc.ai_domain)}</span>{/if}
|
||||
{#if doc.ai_sub_group}<span style="padding:3px 9px;border-radius:999px;background:#fff;border:1px solid #dde3d6;font-size:11.5px;color:#697061;">{doc.ai_sub_group}</span>{/if}
|
||||
{#if doc.ai_analysis_tier === 'deep'}<span style="padding:3px 9px;border-radius:999px;background:#4f8a6b;color:#fff;font-size:11.5px;font-weight:600;letter-spacing:.3px;">tier DEEP</span>{/if}
|
||||
{#if doc.ai_confidence != null}<span style="padding:3px 9px;border-radius:999px;background:#e3ebdf;border:1px solid #c8d6c0;font-size:11.5px;color:#3d7256;font-variant-numeric:tabular-nums;">신뢰도 {doc.ai_confidence.toFixed(2)}</span>{/if}
|
||||
{#if canShowMarkdown}<span style="padding:3px 9px;border-radius:999px;background:#eafaf2;border:1px solid #b8e3cc;font-size:11.5px;color:#1f9d6b;">PDF→MD success</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;">
|
||||
{#if doc.edit_url}<button type="button" onclick={() => window.open(doc.edit_url, '_blank')} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">Synology</button>{/if}
|
||||
<button type="button" onclick={downloadOriginal} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">원본</button>
|
||||
<button type="button" onclick={copyLink} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">링크</button>
|
||||
{#if doc.category === 'library'}<button type="button" onclick={toggleNote} style="font-size:11.5px;color:{noteOpen ? '#fff' : '#697061'};border:1px solid {noteOpen ? '#4f8a6b' : '#dde3d6'};border-radius:8px;padding:5px 9px;background:{noteOpen ? '#4f8a6b' : '#fff'};cursor:pointer;">{noteOpen ? '필기 닫기' : '필기'}</button>{/if}
|
||||
<button type="button" onclick={() => (manageOpen = !manageOpen)} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">관리</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 뷰어 — 모바일 가독성: 본문 폰트 키우고 line-height 늘림 -->
|
||||
<Card class="min-h-[500px]">
|
||||
{#if useSectionView}
|
||||
<!-- 데스크탑(xl+): 3영역 -->
|
||||
<div class="hidden xl:grid" style="grid-template-columns:252px minmax(0,1fr) 336px;gap:13px;align-items:start;">
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px 11px;position:sticky;top:14px;max-height:calc(100vh-2rem);overflow-y:auto;">{@render treeNav(false)}</div>
|
||||
<div style="min-width:0;"><div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:20px 22px;">{@render focusView()}</div></div>
|
||||
<div style="position:sticky;top:14px;">{@render rail()}</div>
|
||||
</div>
|
||||
|
||||
<!-- 모바일(<xl): 나란한 토글 pill + 패널 + 본문 연속 -->
|
||||
<div class="xl:hidden">
|
||||
<div style="display:flex;gap:8px;margin-bottom:10px;position:sticky;top:0;z-index:5;background:#e7ebe4;padding:6px 0;">
|
||||
<button type="button" onclick={() => (mTree = !mTree)} style="flex:1;display:flex;align-items:center;justify-content:space-between;gap:6px;border-radius:10px;padding:9px 12px;font-size:12.5px;font-weight:600;cursor:pointer;background:{mTree ? '#e3ebdf' : '#f4f7f1'};border:1px solid {mTree ? '#4f8a6b' : '#dde3d6'};color:{mTree ? '#23291f' : '#697061'};">절 구조 <span style="font-size:10px;color:#9aa090;font-weight:500;">{outline.length}절</span><span style="transition:transform .16s;transform:rotate({mTree ? 90 : 0}deg);color:#9aa090;font-weight:700;">›</span></button>
|
||||
<button type="button" onclick={() => (mIns = !mIns)} style="flex:1;display:flex;align-items:center;justify-content:space-between;gap:6px;border-radius:10px;padding:9px 12px;font-size:12.5px;font-weight:600;cursor:pointer;background:{mIns ? '#e3ebdf' : '#f4f7f1'};border:1px solid {mIns ? '#4f8a6b' : '#dde3d6'};color:{mIns ? '#23291f' : '#697061'};">인사이트<span style="transition:transform .16s;transform:rotate({mIns ? 90 : 0}deg);color:#9aa090;font-weight:700;">›</span></button>
|
||||
</div>
|
||||
{#if mTree}<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:6px;margin-bottom:10px;">{@render treeNav(true)}</div>{/if}
|
||||
{#if mIns}<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:13px 14px;margin-bottom:10px;">{@render rail()}</div>{/if}
|
||||
<div style="display:flex;flex-direction:column;gap:10px;">{#each outline as it (it.section.chunk_id)}{@render sectionCard(it)}{/each}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 절 없음 fallback: 절이 없어도 인사이트는 항상 보이게 (모바일=인사이트 상단 / 데스크탑=우측 레일) -->
|
||||
{#snippet fbViewer()}
|
||||
<div style="min-width:0;background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:18px 20px;min-height:360px;">
|
||||
{#if !hasSections && canShowMarkdown}<p style="font-size:11px;color:#9aa090;margin-bottom:12px;">이 문서는 절 분석이 없어 전체 본문으로 표시합니다. 위/옆 인사이트는 그대로 제공됩니다.</p>{/if}
|
||||
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
anchorMap={anchorMap}
|
||||
extractedText={doc.extracted_text || rawMarkdown}
|
||||
class="prose prose-invert prose-base lg:prose-sm max-w-none"
|
||||
/>
|
||||
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text || rawMarkdown} class="prose prose-base max-w-none" />
|
||||
{:else if viewerType === 'pdf'}
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<MarkdownStatusBadge
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
/>
|
||||
{#if canShowMarkdown}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={pdfViewMode === 'markdown' ? 'primary' : 'secondary'}
|
||||
onclick={() => (pdfViewMode = 'markdown')}
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={pdfViewMode === 'pdf' ? 'primary' : 'secondary'}
|
||||
onclick={() => (pdfViewMode = 'pdf')}
|
||||
>
|
||||
PDF 원본
|
||||
</Button>
|
||||
{/if}
|
||||
<MarkdownStatusBadge mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} />
|
||||
{#if canShowMarkdown}<Button size="sm" variant={pdfViewMode === 'markdown' ? 'primary' : 'secondary'} onclick={() => (pdfViewMode = 'markdown')}>Markdown</Button><Button size="sm" variant={pdfViewMode === 'pdf' ? 'primary' : 'secondary'} onclick={() => (pdfViewMode = 'pdf')}>PDF 원본</Button>{/if}
|
||||
</div>
|
||||
{#if pdfViewMode === 'markdown' && canShowMarkdown}
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
extractedText={doc.extracted_text}
|
||||
class="prose prose-invert prose-base lg:prose-sm max-w-none"
|
||||
/>
|
||||
{:else}
|
||||
<iframe
|
||||
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
|
||||
class="w-full h-[80vh] rounded"
|
||||
title={doc.title}
|
||||
></iframe>
|
||||
{/if}
|
||||
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text} class="prose prose-base max-w-none" />
|
||||
{:else}<iframe src="/api/documents/{doc.id}/file?token={getAccessToken()}" class="w-full h-[80vh] rounded" title={doc.title}></iframe>{/if}
|
||||
{:else if viewerType === 'image'}
|
||||
<img
|
||||
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
|
||||
alt={doc.title}
|
||||
class="max-w-full rounded"
|
||||
/>
|
||||
<img src="/api/documents/{doc.id}/file?token={getAccessToken()}" alt={doc.title} class="max-w-full rounded" />
|
||||
{:else if viewerType === 'synology'}
|
||||
<EmptyState
|
||||
icon={ExternalLink}
|
||||
title="Synology Office 문서"
|
||||
description="외부 편집기에서 열어야 합니다."
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
href={doc.edit_url || 'https://link.hyungi.net'}
|
||||
target="_blank"
|
||||
>
|
||||
새 창에서 열기
|
||||
</Button>
|
||||
</EmptyState>
|
||||
<EmptyState icon={FileText} title="Synology Office 문서" description="외부 편집기에서 열어야 합니다."><Button variant="primary" size="sm" href={doc.edit_url || 'https://link.hyungi.net'} target="_blank">새 창에서 열기</Button></EmptyState>
|
||||
{:else if viewerType === 'article'}
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-text mb-3">{doc.title}</h1>
|
||||
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
|
||||
<span>출처: {doc.source_channel}</span>
|
||||
<span class="text-faint">·</span>
|
||||
<span>
|
||||
{new Date(doc.created_at).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{#if doc.md_content || doc.extracted_text}
|
||||
<!-- article = 텍스트 네이티브(markdown 변환 비대상). md_status='skipped' 라도
|
||||
"Markdown 제외" badge 를 띄우지 않도록 mdStatus 미전달(badge 는 mdStatus 로만 구동). -->
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={null}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
extractedText={doc.extracted_text}
|
||||
class="mb-6"
|
||||
/>
|
||||
{/if}
|
||||
{#if doc.edit_url}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={ExternalLink}
|
||||
href={doc.edit_url}
|
||||
target="_blank"
|
||||
>
|
||||
원문 보기
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="인앱 미리보기 미지원"
|
||||
description="포맷: {doc.file_format}"
|
||||
/>
|
||||
{/if}
|
||||
</Card>
|
||||
{#if doc.md_content || doc.extracted_text}<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={null} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text} class="prose prose-base max-w-none" />{/if}
|
||||
{#if doc.edit_url}<div class="mt-4"><Button variant="primary" size="sm" href={doc.edit_url} target="_blank">원문 보기</Button></div>{/if}
|
||||
{:else}<EmptyState icon={FileText} title="인앱 미리보기 미지원" description="포맷: {doc.file_format}" />{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- 손글씨 노트 패드 (자료실 자료, "필기" 토글 시) -->
|
||||
{#if noteOpen && doc.category === 'library' && noteLoaded}
|
||||
<Card class="overflow-hidden p-0">
|
||||
<div class="h-[60vh] min-h-[400px] flex flex-col">
|
||||
<HandwriteCanvas
|
||||
sessionId={doc.id}
|
||||
initialStrokes={noteStrokes}
|
||||
onChange={(strokes) => saveNote(strokes)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
<!-- 데스크탑: 본문 | 인사이트 레일 -->
|
||||
<div class="hidden xl:grid xl:grid-cols-[minmax(0,1fr)_336px] gap-3.5 items-start">
|
||||
{@render fbViewer()}
|
||||
<div style="position:sticky;top:14px;">{@render rail()}</div>
|
||||
</div>
|
||||
<!-- 모바일: 인사이트(상단 상시) + 본문 -->
|
||||
<div class="xl:hidden">
|
||||
<div style="margin-bottom:12px;">{@render rail()}</div>
|
||||
{@render fbViewer()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 오른쪽 — 슬림 전역 인사이트 레일 (D3: 탭 게이트 제거, 요약·심층·불일치 상시 노출).
|
||||
정보/관리는 접이(<details>) — 데스크탑은 인사이트 상시, 모바일은 본문 메인 + 열어서 확인. -->
|
||||
<aside class="min-w-0 space-y-3">
|
||||
{#if doc.category === 'library'}
|
||||
<Card>
|
||||
<ReadCounter
|
||||
documentId={doc.id}
|
||||
initialCount={doc.read_count ?? 0}
|
||||
initialLastReadAt={doc.last_read_at ?? null}
|
||||
/>
|
||||
</Card>
|
||||
{/if}
|
||||
<!-- 관리 (편집/삭제) — 헤더 '관리'로 토글 -->
|
||||
{#if manageOpen}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:16px 18px;margin-top:14px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#697061;margin-bottom:12px;letter-spacing:.3px;">관리 · 분류 편집</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<AIClassificationEditor {doc} />
|
||||
<LibraryPathEditor {doc} />
|
||||
<NoteEditor {doc} />
|
||||
<EditUrlEditor {doc} />
|
||||
<TagsEditor {doc} />
|
||||
</div>
|
||||
<div class="pt-3 mt-3 border-t border-default"><DocumentDangerZone {doc} ondelete={handleDocDelete} /></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 요약·분석 — 기본 펼침(데스크탑 상시감, 모바일 접기 가능) -->
|
||||
<details open class="bg-surface border border-default rounded-card overflow-hidden group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-3.5 py-2.5 text-xs font-semibold text-dim uppercase tracking-wide select-none">
|
||||
<span>요약 · 분석</span>
|
||||
<ChevronRight size={14} class="transition-transform group-open:rotate-90 text-faint" />
|
||||
</summary>
|
||||
<div class="px-3.5 pb-3.5 space-y-4">
|
||||
<AnalysisPanel docId={doc.id} doc={doc} />
|
||||
<AIClassificationEditor {doc} />
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">관련 문서</h4>
|
||||
<!-- TODO(backend): GET /documents/{id}/related?limit=10 (벡터 유사도) — v1 제외(자리만) -->
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="추후 지원"
|
||||
description="관련 문서 추천은 backend 연동 후 제공됩니다."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{#if noteOpen && doc.category === 'library' && noteLoaded}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;overflow:hidden;margin-top:14px;"><div class="h-[60vh] min-h-[400px] flex flex-col"><HandwriteCanvas sessionId={doc.id} initialStrokes={noteStrokes} onChange={(s) => saveNote(s)} /></div></div>
|
||||
{/if}
|
||||
|
||||
<!-- 문서 정보 — 접이(기본 닫힘) -->
|
||||
<details class="bg-surface border border-default rounded-card overflow-hidden group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-3.5 py-2.5 text-xs font-semibold text-dim uppercase tracking-wide select-none">
|
||||
<span>문서 정보</span>
|
||||
<ChevronRight size={14} class="transition-transform group-open:rotate-90 text-faint" />
|
||||
</summary>
|
||||
<div class="px-3.5 pb-3.5 space-y-3">
|
||||
<FileInfoView {doc} />
|
||||
<ProcessingStatusView {doc} />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 관리 — 접이(기본 닫힘) -->
|
||||
<details class="bg-surface border border-default rounded-card overflow-hidden group">
|
||||
<summary class="cursor-pointer list-none flex items-center justify-between px-3.5 py-2.5 text-xs font-semibold text-dim uppercase tracking-wide select-none">
|
||||
<span>관리</span>
|
||||
<ChevronRight size={14} class="transition-transform group-open:rotate-90 text-faint" />
|
||||
</summary>
|
||||
<div class="px-3.5 pb-3.5 space-y-3">
|
||||
<LibraryPathEditor {doc} />
|
||||
<NoteEditor {doc} />
|
||||
<EditUrlEditor {doc} />
|
||||
<TagsEditor {doc} />
|
||||
<div class="pt-2 border-t border-default">
|
||||
<DocumentDangerZone {doc} ondelete={handleDocDelete} />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- 모바일 sticky 하단 바 — 자료실 자료의 학습 흐름 네비게이션 -->
|
||||
{#if doc.category === 'library'}
|
||||
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 bg-surface border-t border-default px-3 py-2 flex items-center gap-2 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => neighbors.prev && goto(`/documents/${neighbors.prev.id}`)}
|
||||
disabled={!neighbors.prev}
|
||||
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="이전 자료"
|
||||
><ChevronLeft size={20} /></button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={readAndGoNext}
|
||||
disabled={!neighbors.next}
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Check size={16} />
|
||||
{#if neighbors.next}
|
||||
1회독 완료 + 다음
|
||||
{:else}
|
||||
1회독 완료 (마지막 자료)
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => neighbors.next && goto(`/documents/${neighbors.next.id}`)}
|
||||
disabled={!neighbors.next}
|
||||
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="다음 자료 (회독 카운트 안 함)"
|
||||
><ChevronRight size={20} /></button>
|
||||
<button type="button" onclick={() => neighbors.prev && goto(`/documents/${neighbors.prev.id}`)} disabled={!neighbors.prev} class="px-3 py-2 rounded text-dim disabled:opacity-30" aria-label="이전">‹</button>
|
||||
<button type="button" onclick={readAndGoNext} disabled={!neighbors.next} class="flex-1 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50">{#if neighbors.next}1회독 완료 + 다음{:else}1회독 완료 (마지막){/if}</button>
|
||||
<button type="button" onclick={() => neighbors.next && goto(`/documents/${neighbors.next.id}`)} disabled={!neighbors.next} class="px-3 py-2 rounded text-dim disabled:opacity-30" aria-label="다음">›</button>
|
||||
</div>
|
||||
<!-- 본문이 sticky 바 뒤에 가리지 않도록 패딩 -->
|
||||
<div class="lg:hidden h-20"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.d3node:hover { background: #ecf0e8; }
|
||||
.d3active:hover { background: #e3ebdf; }
|
||||
.d3child { position: relative; }
|
||||
.d3child::before { content: ""; position: absolute; left: 2px; top: -3px; bottom: 50%; width: 1px; background: #cdd6c4; }
|
||||
.d3child::after { content: ""; position: absolute; left: 2px; top: 50%; width: 7px; height: 1px; background: #cdd6c4; }
|
||||
.m-secbody[open] .m-chev { transform: rotate(90deg); }
|
||||
.d3warn { animation: d3pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes d3pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(181, 132, 10, .35); } 50% { box-shadow: 0 0 0 3px rgba(181, 132, 10, 0); } }
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { renderMemoHtml, todayIso, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer';
|
||||
import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays, Mic, Calendar, Activity, ArrowRight, FileText, BookOpen } from 'lucide-svelte';
|
||||
import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays, Mic, Calendar, Activity, ArrowRight, FileText, BookOpen, FolderInput } from 'lucide-svelte';
|
||||
import { getAccessToken } from '$lib/api';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
@@ -276,6 +276,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 자료로 보내기 — 메모를 문서함 정식 문서로 승격(이동) + AI 분류/요약/심층/도메인.
|
||||
async function promoteToDocument(memoId) {
|
||||
try {
|
||||
const res = await api(`/memos/${memoId}/promote-to-document`, { method: 'POST' });
|
||||
addToast('success', '문서함으로 보냈습니다 · AI 분석 진행 중');
|
||||
// in-place 승격이라 더는 메모가 아님 → 목록에서 제거
|
||||
memos = memos.filter((m) => m.id !== memoId);
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '자료로 보내기 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// voice 메모 audio URL — /api/documents/{id}/file?token= 패턴 재사용
|
||||
function voiceAudioUrl(memoId) {
|
||||
const token = getAccessToken();
|
||||
@@ -601,6 +613,17 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 자료로 보내기 — 모든 메모(지식 메모 포함)에서 항상 노출 → 문서함 승격 + AI 처리 -->
|
||||
{#if editingId !== memo.id && !showArchived}
|
||||
<div class="mt-2">
|
||||
<button onclick={() => promoteToDocument(memo.id)}
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] bg-surface text-dim hover:bg-accent hover:text-white transition-colors"
|
||||
title="이 메모를 문서함으로 보내고 AI가 확인·정리·요약·심층분석·도메인 부여를 진행합니다">
|
||||
<FolderInput size={11} /> 자료로 보내기
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 태그 + 하단 -->
|
||||
{#if editingId !== memo.id}
|
||||
{#if memo.user_tags?.length || memo.ai_tags?.length}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 2026-06-14 PR-Background-Jobs-Observability: 큐 밖 관리 스크립트(백필 등) 진행 가시화.
|
||||
-- processing_queue 는 파이프라인 stage 전용 — hier_overnight_backfill / section_summary_pilot
|
||||
-- 같은 off-queue 관리 스크립트는 여기에 진행상황을 남겨 대시보드 보드가 노출한다.
|
||||
-- worker_jobs(user_id NOT NULL, worker-pool 전용)와 별개 — 이건 owner 없는 관리 작업 heartbeat.
|
||||
-- 단일 statement (asyncpg multi-statement 불허 컨벤션). 인덱스는 소량 테이블이라 생략.
|
||||
CREATE TABLE IF NOT EXISTS background_jobs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- 'hier_redecompose' | 'section_summary' | ...
|
||||
label TEXT, -- 사람이 읽는 대상 표기 (예: 'doc 5210 (Sec VIII)')
|
||||
state TEXT NOT NULL DEFAULT 'running'
|
||||
CHECK (state IN ('running', 'done', 'failed')),
|
||||
processed INTEGER NOT NULL DEFAULT 0, -- 처리한 단위 수 (절/leaf 등)
|
||||
total INTEGER, -- 전체 단위 수 (미상이면 NULL)
|
||||
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 358: documents.embedding HNSW 벡터 인덱스 + hnsw.ef_search (검색 latency T3, 2026-06-15)
|
||||
-- PROD 적용 = CREATE INDEX CONCURRENTLY 로 수동 빌드(40k rows 무중단, /dev/shm 회피 위해 단일 스레드)
|
||||
-- + schema_migrations(358) 수동 기록 완료. runner 는 단일 트랜잭션이라 CONCURRENTLY 불가.
|
||||
-- 본 파일 = fresh-init/재현용: non-concurrent IF NOT EXISTS (빈 테이블 init 시 즉시, 기존 index 존재 시 no-op).
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_embedding_hnsw
|
||||
ON documents USING hnsw (embedding vector_cosine_ops)
|
||||
WHERE (deleted_at IS NULL AND embedding IS NOT NULL);
|
||||
|
||||
-- docs vector leg LIMIT = limit*4 (기본 80) → HNSW recall 위해 ef_search >= 80 필요.
|
||||
-- ivfflat.probes=20 과 동일하게 DB 레벨 GUC (ALTER DATABASE) 로 설정.
|
||||
ALTER DATABASE pkm SET hnsw.ef_search = 100;
|
||||
@@ -32,6 +32,7 @@ from core.config import settings
|
||||
from services.hier_decomp.builder import build_hier_tree
|
||||
from services.hier_decomp.persist import persist_hier_tree
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
from services.background_jobs import finish_job, heartbeat, start_job
|
||||
|
||||
# 단일 진실: 절 분석 상수/헬퍼 (PROMPT_VERSION 일치 = 멱등 보존)
|
||||
from section_summary_pilot import (
|
||||
@@ -140,8 +141,10 @@ def _make_engine():
|
||||
return create_async_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
|
||||
|
||||
|
||||
async def _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, stop_at):
|
||||
"""doc 의 미분석 hier leaf 분석 → upsert. stop_at(epoch) 넘으면 leaf 경계 중단."""
|
||||
async def _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, stop_at,
|
||||
engine=None, job_id=None, base_processed=0):
|
||||
"""doc 의 미분석 hier leaf 분석 → upsert. stop_at(epoch) 넘으면 leaf 경계 중단.
|
||||
engine/job_id 주어지면 background_jobs 에 ~10절마다 진행 heartbeat(보드 가시화)."""
|
||||
rows = (await session.execute(LEAF_SQL, {"doc": doc_id, "pv": PROMPT_VERSION})).mappings().all()
|
||||
ok = fail = skip = 0
|
||||
timings, types = [], []
|
||||
@@ -187,6 +190,8 @@ async def _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, s
|
||||
"content_hash": r["content_hash"], "error": err,
|
||||
})
|
||||
await session.commit()
|
||||
if job_id and (ok + fail + skip) % 10 == 0:
|
||||
await heartbeat(engine, job_id, processed=base_processed + ok + fail + skip)
|
||||
await session.commit()
|
||||
return {"ok": ok, "fail": fail, "skip": skip, "leaves": len(rows),
|
||||
"timings": timings, "types": types, "aborted": aborted}
|
||||
@@ -256,6 +261,12 @@ async def cmd_run(args):
|
||||
_candidate_params(allowlist, doc_ids))).mappings().all()
|
||||
_log(f"후보 doc {len(cands)} 선별. 시작.")
|
||||
|
||||
# 관측: 큐 밖 작업이라 대시보드 보드가 못 보므로 background_jobs 에 진행 노출(best-effort)
|
||||
_job_kind = "hier_redecompose" if reprocess else "hier_backfill"
|
||||
_job_label = (f"doc {args.doc} {'재분해' if reprocess else '분해'}" if doc_ids
|
||||
else f"{len(cands)}개 문서 {'재분해' if reprocess else '분해'}")
|
||||
job_id = await start_job(engine, _job_kind, _job_label, total=None)
|
||||
|
||||
for c in cands:
|
||||
if time.time() >= stop_at:
|
||||
_log(f"⏰ deadline 버퍼 도달 — doc 경계에서 중단 (처리 {tot_docs} doc)")
|
||||
@@ -272,7 +283,10 @@ async def cmd_run(args):
|
||||
"timings": [], "types": [], "aborted": False}
|
||||
else:
|
||||
async with sm() as session:
|
||||
astat = await _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, stop_at)
|
||||
astat = await _analyze_doc_leaves(
|
||||
session, client, doc_id, doc_domain, model_name, stop_at,
|
||||
engine=engine, job_id=job_id,
|
||||
base_processed=(tot_ok + tot_fail + tot_skip))
|
||||
except Exception as exc:
|
||||
_log(f" ✗ doc={doc_id} 처리 실패(건너뜀): {type(exc).__name__}: {repr(exc)[:160]}")
|
||||
continue
|
||||
@@ -280,6 +294,8 @@ async def cmd_run(args):
|
||||
tot_docs += 1
|
||||
tot_ok += astat["ok"]; tot_fail += astat["fail"]; tot_skip += astat["skip"]
|
||||
all_timings += astat["timings"]; all_types += astat["types"]
|
||||
await heartbeat(engine, job_id, processed=(tot_ok + tot_fail + tot_skip),
|
||||
total=tot_leaves_created)
|
||||
avg = statistics.mean(astat["timings"]) if astat["timings"] else 0
|
||||
_log(f" ✓ doc={doc_id} ({len(body):,}자 {doc_domain.split('/')[0]}) "
|
||||
f"leaf생성={leaves_created} 분석ok={astat['ok']} fail={astat['fail']} skip={astat['skip']} "
|
||||
@@ -287,6 +303,7 @@ async def cmd_run(args):
|
||||
if astat["aborted"]:
|
||||
_log("⏰ leaf 분석 중 deadline 도달 — 중단")
|
||||
break
|
||||
await finish_job(engine, job_id, state="done")
|
||||
finally:
|
||||
await client.close()
|
||||
await engine.dispose()
|
||||
|
||||
@@ -26,7 +26,8 @@ def _fake_consumer_env(monkeypatch, held):
|
||||
lambda: {
|
||||
s: object()
|
||||
for s in (queue_consumer.MAIN_QUEUE_STAGES
|
||||
+ queue_consumer.FAST_QUEUE_STAGES + ["markdown"])
|
||||
+ queue_consumer.FAST_QUEUE_STAGES
|
||||
+ queue_consumer.DEEP_QUEUE_STAGES + ["markdown"])
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(queue_consumer, "_hold_logged", False)
|
||||
@@ -83,13 +84,37 @@ async def test_fast_consumer_respects_hold(monkeypatch):
|
||||
assert processed == ["chunk"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deep_consumer_processes_deep_only(monkeypatch):
|
||||
"""deep 컨슈머(2026-06-15 분리) = deep_summary 전용 (메인 루프와 디커플)."""
|
||||
processed = _fake_consumer_env(monkeypatch, [])
|
||||
|
||||
await queue_consumer.consume_deep_queue()
|
||||
|
||||
assert processed == ["deep_summary"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deep_consumer_respects_hold(monkeypatch):
|
||||
"""deep_summary 홀드 시 deep 컨슈머가 claim 안 함."""
|
||||
processed = _fake_consumer_env(monkeypatch, ["deep_summary"])
|
||||
|
||||
await queue_consumer.consume_deep_queue()
|
||||
|
||||
assert processed == []
|
||||
|
||||
|
||||
def test_fast_split_invariants():
|
||||
"""세 컨슈머 stage 집합 disjoint + embed/chunk 배치 상향 회귀 가드."""
|
||||
"""네 컨슈머 stage 집합 disjoint + embed/chunk 배치 상향 + deep split 회귀 가드."""
|
||||
main = set(queue_consumer.MAIN_QUEUE_STAGES)
|
||||
fast = set(queue_consumer.FAST_QUEUE_STAGES)
|
||||
md = set(queue_consumer.MARKDOWN_QUEUE_STAGES)
|
||||
deep = set(queue_consumer.DEEP_QUEUE_STAGES)
|
||||
assert not (main & fast) and not (main & md) and not (fast & md)
|
||||
assert not (main & deep) and not (fast & deep) and not (md & deep)
|
||||
assert fast == {"embed", "chunk"}
|
||||
assert deep == {"deep_summary"}
|
||||
assert "deep_summary" not in main # 2026-06-15 split 회귀 가드
|
||||
assert queue_consumer.BATCH_SIZE["embed"] >= 10
|
||||
assert queue_consumer.BATCH_SIZE["chunk"] >= 10
|
||||
|
||||
|
||||
Reference in New Issue
Block a user