feat(ingest): devonagent 트랙 Phase 1 ingest 활성화
DEVONagent/DEVONthink 가 발견한 웹페이지를 NAS Web/ drop → file_watcher
ingest → extract 4-tier fallback (trafilatura/sibling-md/readability/bs4)
→ embed + chunk 까지. classify/preview/markdown SKIP.
- source_channel='devonagent' (migration 001 dormant 활성화)
- file_watcher: SCAN_TARGETS 통합 + Web/ rglob + canonical_url dedup +
sidecar 누락 정책 (skip 안 함, web_meta.sidecar_missing=true flag)
- extract_worker: HTML+devonagent 분기 + md_extraction_engine 4-tier 구분
(trafilatura → sibling .md ≥200char → readability+markdownify → bs4_text)
- queue_consumer: enqueue_next_stage 의 extract stage 만 source_channel-
aware override (devonagent → [embed, chunk])
- classify_worker: devonagent safety skip (law_monitor 패턴 mirror,
ai_domain='Web', ai_tags=['Web/{host}'])
- requirements: trafilatura/readability-lxml/markdownify 추가
- docs: devonthink-web-bridge.md 설치 가이드 + first-wins 정책 명시
Phase 1 closure 기준 = 재료 품질 (검색 가능 + 노이즈율 + dedup + 엔진 분포).
활용처(ai_tldr/digest/PKM 회고)는 1-2주 OR 30-50건 관찰 후 별 PR 에서 결정.
Plan: ~/.claude/plans/db-snuggly-petal.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,3 +17,7 @@ python-multipart>=0.0.9
|
|||||||
jinja2>=3.1.0
|
jinja2>=3.1.0
|
||||||
feedparser>=6.0.0
|
feedparser>=6.0.0
|
||||||
pymupdf>=1.24.0
|
pymupdf>=1.24.0
|
||||||
|
# Web/Blog ingest (devonagent 트랙) — HTML 본문 정화 4-tier fallback
|
||||||
|
trafilatura>=1.12.0
|
||||||
|
readability-lxml>=0.8.1
|
||||||
|
markdownify>=0.13.1
|
||||||
|
|||||||
@@ -373,6 +373,22 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
|||||||
logger.info(f"doc {document_id}: law_monitor → classify skip")
|
logger.info(f"doc {document_id}: law_monitor → classify skip")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Web/Blog ingest (devonagent 트랙) — plan db-snuggly-petal.md
|
||||||
|
# queue_consumer override 가 classify 를 skip 시키지만, 우회 경로 (예: 수동 enqueue)
|
||||||
|
# 로 들어왔을 때 안전망. ai_tldr/ai_bullets 같은 LLM 가공은 별 PR (Mac mini derived-worker).
|
||||||
|
if doc.source_channel == "devonagent":
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
if not doc.ai_domain:
|
||||||
|
doc.ai_domain = "Web"
|
||||||
|
if not doc.ai_tags:
|
||||||
|
host = (urlparse(doc.edit_url or "").hostname or "web").lower()
|
||||||
|
doc.ai_tags = [f"Web/{host}"]
|
||||||
|
if not doc.importance:
|
||||||
|
doc.importance = "medium"
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"doc {document_id}: devonagent → classify skip")
|
||||||
|
return
|
||||||
|
|
||||||
if not doc.extracted_text:
|
if not doc.extracted_text:
|
||||||
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
|
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""텍스트 추출 워커 — kordoc / PyMuPDF / Surya OCR / LibreOffice / 직접 읽기"""
|
"""텍스트 추출 워커 — kordoc / PyMuPDF / Surya OCR / LibreOffice / 직접 읽기 / 웹 HTML"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -101,6 +102,137 @@ async def _call_ocr(file_path: Path, is_image: bool, max_pages: int = 200) -> st
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Web/Blog ingest (devonagent 트랙) — HTML → markdown 4-tier ────────────
|
||||||
|
|
||||||
|
_WEB_MIN_BODY_LEN = 200 # 4-tier fallback 전환 임계
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_web_with_trafilatura(html: str) -> tuple[str, str | None]:
|
||||||
|
"""trafilatura 로 본문 markdown 추출. (body, engine_version) 반환. 실패 시 ("", None)."""
|
||||||
|
try:
|
||||||
|
import trafilatura
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("[web] trafilatura 미설치 — 다음 fallback 시도")
|
||||||
|
return "", None
|
||||||
|
try:
|
||||||
|
body = trafilatura.extract(
|
||||||
|
html,
|
||||||
|
output_format="markdown",
|
||||||
|
include_comments=False,
|
||||||
|
include_tables=True,
|
||||||
|
with_metadata=True,
|
||||||
|
deduplicate=True,
|
||||||
|
favor_precision=True,
|
||||||
|
)
|
||||||
|
return (body or "", getattr(trafilatura, "__version__", "unknown"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[web] trafilatura 실패: {e}")
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_web_with_readability(html: str) -> tuple[str, str | None]:
|
||||||
|
"""readability-lxml 로 본문 추출 + markdownify 로 markdown 변환."""
|
||||||
|
try:
|
||||||
|
from readability import Document as ReadabilityDocument
|
||||||
|
from markdownify import markdownify
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("[web] readability/markdownify 미설치 — 다음 fallback 시도")
|
||||||
|
return "", None
|
||||||
|
try:
|
||||||
|
rd = ReadabilityDocument(html)
|
||||||
|
body_html = rd.summary() or ""
|
||||||
|
if not body_html:
|
||||||
|
return "", None
|
||||||
|
body_md = markdownify(body_html, heading_style="ATX")
|
||||||
|
return (body_md or "", "readability+markdownify")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[web] readability 실패: {e}")
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_web_with_bs4(html: str) -> tuple[str, str | None]:
|
||||||
|
"""최종 fallback — BeautifulSoup 으로 script/style 제거 후 get_text."""
|
||||||
|
try:
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("[web] beautifulsoup4 미설치 — 빈 본문 반환")
|
||||||
|
return "", None
|
||||||
|
try:
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
for tag in soup(["script", "style", "noscript", "nav", "footer", "aside"]):
|
||||||
|
tag.decompose()
|
||||||
|
text = soup.get_text(" ", strip=True)
|
||||||
|
return (text or "", "bs4_text")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[web] bs4 실패: {e}")
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_web_html(doc: Document, html_path: Path) -> None:
|
||||||
|
"""devonagent HTML → markdown 4-tier fallback. md_* 컬럼 전체 채움."""
|
||||||
|
html_bytes = html_path.read_bytes()
|
||||||
|
html_text = html_bytes.decode("utf-8", errors="replace")
|
||||||
|
src_hash = hashlib.sha256(html_bytes).hexdigest()
|
||||||
|
|
||||||
|
# 1) trafilatura
|
||||||
|
body, engine_ver = _extract_web_with_trafilatura(html_text)
|
||||||
|
engine = "trafilatura" if body and len(body) >= _WEB_MIN_BODY_LEN else None
|
||||||
|
|
||||||
|
# 2) sibling .md (DEVONthink rendered)
|
||||||
|
if not engine:
|
||||||
|
md_path = html_path.with_suffix(".md")
|
||||||
|
if md_path.is_file():
|
||||||
|
try:
|
||||||
|
md_body = md_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
if md_body and len(md_body) >= _WEB_MIN_BODY_LEN:
|
||||||
|
body = md_body
|
||||||
|
engine = "devonthink_export"
|
||||||
|
engine_ver = "smart_rule"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[web] sibling .md 읽기 실패 {md_path}: {e}")
|
||||||
|
|
||||||
|
# 3) readability + markdownify
|
||||||
|
if not engine:
|
||||||
|
body2, ver2 = _extract_web_with_readability(html_text)
|
||||||
|
if body2 and len(body2) >= _WEB_MIN_BODY_LEN:
|
||||||
|
body = body2
|
||||||
|
engine = "readability"
|
||||||
|
engine_ver = ver2
|
||||||
|
|
||||||
|
# 4) bs4 get_text (최종 fallback)
|
||||||
|
if not engine:
|
||||||
|
body3, ver3 = _extract_web_with_bs4(html_text)
|
||||||
|
if body3:
|
||||||
|
body = body3
|
||||||
|
engine = "bs4_text"
|
||||||
|
engine_ver = ver3
|
||||||
|
else:
|
||||||
|
body = ""
|
||||||
|
engine = "empty"
|
||||||
|
engine_ver = None
|
||||||
|
|
||||||
|
clean_body = (body or "").replace("\x00", "")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
doc.extracted_text = clean_body
|
||||||
|
doc.extracted_at = now
|
||||||
|
doc.extractor_version = f"web@{engine}"
|
||||||
|
doc.md_content = clean_body
|
||||||
|
doc.md_status = "ready" if clean_body else "failed"
|
||||||
|
doc.md_extraction_engine = engine
|
||||||
|
doc.md_extraction_engine_version = engine_ver
|
||||||
|
doc.md_format_version = "1.0"
|
||||||
|
doc.md_generated_at = now
|
||||||
|
doc.md_source_hash = src_hash
|
||||||
|
doc.md_content_hash = hashlib.sha256(clean_body.encode("utf-8")).hexdigest()
|
||||||
|
doc.content_origin = "extracted"
|
||||||
|
|
||||||
|
# extract_meta 의 web_meta 는 file_watcher 가 박은 그대로 유지 (sidecar 출처)
|
||||||
|
logger.info(
|
||||||
|
f"[web/{engine}] {doc.file_path} ({len(clean_body)}자, engine_ver={engine_ver})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ─── 메인 처리 ───
|
# ─── 메인 처리 ───
|
||||||
|
|
||||||
async def process(document_id: int, session: AsyncSession) -> None:
|
async def process(document_id: int, session: AsyncSession) -> None:
|
||||||
@@ -112,6 +244,19 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
|||||||
fmt = doc.file_format.lower()
|
fmt = doc.file_format.lower()
|
||||||
full_path = Path(settings.nas_mount_path) / doc.file_path
|
full_path = Path(settings.nas_mount_path) / doc.file_path
|
||||||
|
|
||||||
|
# ─── Web/Blog ingest (devonagent 트랙) — HTML 본문 정화 4-tier fallback ───
|
||||||
|
# plan: ~/.claude/plans/db-snuggly-petal.md
|
||||||
|
# 1) trafilatura (markdown body)
|
||||||
|
# 2) sibling .md (DEVONthink rendered, >= 200 char)
|
||||||
|
# 3) readability-lxml + markdownify
|
||||||
|
# 4) BeautifulSoup get_text
|
||||||
|
# md_extraction_engine 으로 어느 경로로 추출됐는지 기록 → 품질 모니터링용
|
||||||
|
if fmt == "html" and doc.source_channel == "devonagent":
|
||||||
|
if not full_path.exists():
|
||||||
|
raise FileNotFoundError(f"파일 없음: {full_path}")
|
||||||
|
await _extract_web_html(doc, full_path)
|
||||||
|
return
|
||||||
|
|
||||||
# ─── 텍스트 파일 — 직접 읽기 ───
|
# ─── 텍스트 파일 — 직접 읽기 ───
|
||||||
if fmt in TEXT_FORMATS:
|
if fmt in TEXT_FORMATS:
|
||||||
if not full_path.exists():
|
if not full_path.exists():
|
||||||
|
|||||||
+132
-6
@@ -1,4 +1,4 @@
|
|||||||
"""파일 감시 워커 — Inbox/Recordings/Videos 스캔, 새/변경 파일 자동 등록.
|
"""파일 감시 워커 — PKM(Inbox/Recordings/Videos) + Web(devonagent) 스캔, 자동 등록.
|
||||||
|
|
||||||
§3 확장:
|
§3 확장:
|
||||||
- 스캔 대상: PKM/Inbox (문서) + PKM/Recordings (오디오) + PKM/Videos (비디오)
|
- 스캔 대상: PKM/Inbox (문서) + PKM/Recordings (오디오) + PKM/Videos (비디오)
|
||||||
@@ -8,9 +8,19 @@
|
|||||||
- Roon 음원 경로(prefix match) skip — settings.roon_library_path
|
- Roon 음원 경로(prefix match) skip — settings.roon_library_path
|
||||||
- 파이프 분기: audio → stage='stt', video direct-play → stage='thumbnail',
|
- 파이프 분기: audio → stage='stt', video direct-play → stage='thumbnail',
|
||||||
video quarantine → stage 없음 (처리 안 함, UI 에서 재생 불가 안내)
|
video quarantine → stage 없음 (처리 안 함, UI 에서 재생 불가 안내)
|
||||||
|
|
||||||
|
Web/Blog ingest (devonagent 트랙, plan db-snuggly-petal.md):
|
||||||
|
- 스캔 대상: NAS/Web/{domain}/{YYYY-MM-DD}/{slug}.{html,md,json}
|
||||||
|
- DEVONthink Smart Rule 이 3종 export → 여기서 .html 만 진입 (sidecar 는 메타 소스)
|
||||||
|
- source_channel='devonagent', dedup = file_hash = sha256(canonical_url)
|
||||||
|
- first-wins 정책: 같은 canonical_url 재저장은 ingest 안 함
|
||||||
|
- sidecar (.json) 누락 시: skip 안 하고 ingest, web_meta.sidecar_missing=true
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -34,7 +44,14 @@ VIDEO_QUARANTINE_EXTS = {".mov", ".mkv", ".avi"} # 변환 필요, 보관만
|
|||||||
# library (외부 작성 학습 자료) 폴더 — md/pdf/docx 등 문서 확장자만 수락
|
# library (외부 작성 학습 자료) 폴더 — md/pdf/docx 등 문서 확장자만 수락
|
||||||
LIBRARY_DOC_EXTS = {".md", ".pdf", ".docx", ".doc", ".txt", ".rtf", ".html", ".odt"}
|
LIBRARY_DOC_EXTS = {".md", ".pdf", ".docx", ".doc", ".txt", ".rtf", ".html", ".odt"}
|
||||||
|
|
||||||
# 스캔 대상: (하위경로, 예상 category) — None 은 문서함(카테고리 미지정)
|
# Web ingest — canonical URL 정규화 시 strip 할 추적 파라미터
|
||||||
|
TRACKING_PARAMS = {
|
||||||
|
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
|
||||||
|
"fbclid", "gclid", "msclkid", "ref", "ref_src", "ref_url", "mc_cid", "mc_eid",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 스캔 대상: (PKM 상대 하위경로, 예상 category) — None 은 문서함(카테고리 미지정)
|
||||||
|
# 모든 PKM 스캔은 source_channel='drive_sync'. Web 트랙은 별도 처리 (watch_inbox 안).
|
||||||
SCAN_TARGETS: list[tuple[str, str | None]] = [
|
SCAN_TARGETS: list[tuple[str, str | None]] = [
|
||||||
("Inbox", None),
|
("Inbox", None),
|
||||||
("Recordings", "audio"),
|
("Recordings", "audio"),
|
||||||
@@ -95,10 +112,109 @@ def _route_media(path: Path, expected_category: str | None) -> tuple[str | None,
|
|||||||
return (None, False, "extract")
|
return (None, False, "extract")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Web/Blog ingest (devonagent 트랙) 헬퍼 ──────────────────────────────────
|
||||||
|
|
||||||
|
def _canonicalize_url(url: str) -> str:
|
||||||
|
"""URL 정규화 — UTM/fbclid/fragment/trailing-slash 제거. dedup 의 진짜 기준.
|
||||||
|
|
||||||
|
같은 글의 utm 변형 (`?utm_source=foo`) 과 fragment 변형 (`#section`) 을
|
||||||
|
한 row 로 수렴시키기 위해 file_hash 산출 전 반드시 거친다.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
p = urlparse(url.strip())
|
||||||
|
clean_qs = [
|
||||||
|
(k, v) for k, v in parse_qsl(p.query, keep_blank_values=True)
|
||||||
|
if k.lower() not in TRACKING_PARAMS
|
||||||
|
]
|
||||||
|
clean_qs.sort()
|
||||||
|
path = p.path.rstrip("/") or "/"
|
||||||
|
netloc = p.netloc.lower()
|
||||||
|
return urlunparse((p.scheme.lower(), netloc, path, "", urlencode(clean_qs), ""))
|
||||||
|
except Exception:
|
||||||
|
return url.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_web_sidecar(html_path: Path) -> dict | None:
|
||||||
|
"""sibling .json sidecar 읽기. 부재/파싱실패 시 None."""
|
||||||
|
json_path = html_path.with_suffix(".json")
|
||||||
|
if not json_path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(json_path.read_text(encoding="utf-8", errors="replace"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[devonagent] sidecar parse 실패 {json_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _ingest_web_file(session, file_path: Path, rel_path: str) -> tuple[int, int]:
|
||||||
|
"""devonagent 트랙: .html 1건을 documents row + extract enqueue 로 등록.
|
||||||
|
|
||||||
|
- .md/.json 은 sidecar 라 caller 가 skip (여기 진입 안 함)
|
||||||
|
- sidecar (.json) 있으면: canonical_url 기반 dedup, web_meta 풍부
|
||||||
|
- sidecar 없으면: ingest 하되 web_meta.sidecar_missing=true (조용한 누락 방지)
|
||||||
|
- first-wins: 같은 canonical_url 재저장 시 변경 ingest 안 함
|
||||||
|
"""
|
||||||
|
sidecar = _load_web_sidecar(file_path)
|
||||||
|
if sidecar and sidecar.get("url"):
|
||||||
|
raw_url = str(sidecar["url"])
|
||||||
|
canonical_url = _canonicalize_url(raw_url)
|
||||||
|
fhash = hashlib.sha256(canonical_url.encode("utf-8")).hexdigest()
|
||||||
|
title = str(sidecar.get("title") or file_path.stem)
|
||||||
|
web_meta = {
|
||||||
|
"raw_url": raw_url,
|
||||||
|
"devonthink_uuid": sidecar.get("devonthink_uuid"),
|
||||||
|
"pub_date": sidecar.get("pub_date"),
|
||||||
|
"author": sidecar.get("author"),
|
||||||
|
"source_agent": sidecar.get("source_agent"),
|
||||||
|
}
|
||||||
|
edit_url = canonical_url
|
||||||
|
else:
|
||||||
|
canonical_url = None
|
||||||
|
fhash = hashlib.sha256(f"NO_URL:{rel_path}".encode("utf-8")).hexdigest()
|
||||||
|
title = file_path.stem
|
||||||
|
web_meta = {"sidecar_missing": True}
|
||||||
|
edit_url = None
|
||||||
|
|
||||||
|
# devonagent dedup: file_path OR file_hash (URL identity 우선, path re-slug 흡수)
|
||||||
|
result = await session.execute(
|
||||||
|
select(Document).where(
|
||||||
|
(Document.file_path == rel_path) | (Document.file_hash == fhash)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
# first-wins: 변경 ingest 안 함 (Phase 1 정책. 업데이트는 별 PR)
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
doc = Document(
|
||||||
|
file_path=rel_path,
|
||||||
|
file_hash=fhash,
|
||||||
|
file_format="html",
|
||||||
|
file_size=file_path.stat().st_size,
|
||||||
|
file_type="immutable",
|
||||||
|
title=title,
|
||||||
|
source_channel="devonagent",
|
||||||
|
category="document",
|
||||||
|
data_origin="external",
|
||||||
|
import_source="devonthink",
|
||||||
|
edit_url=edit_url,
|
||||||
|
extract_meta={"web_meta": web_meta},
|
||||||
|
)
|
||||||
|
session.add(doc)
|
||||||
|
await session.flush()
|
||||||
|
await enqueue_stage(session, doc.id, "extract")
|
||||||
|
return (1, 0)
|
||||||
|
|
||||||
|
|
||||||
async def watch_inbox():
|
async def watch_inbox():
|
||||||
"""PKM 하위 디렉토리를 스캔하여 새/변경 파일을 DB 등록 + 파이프 투입."""
|
"""PKM 하위 디렉토리 + Web/ 를 스캔하여 새/변경 파일을 DB 등록 + 파이프 투입."""
|
||||||
pkm_root = Path(settings.nas_mount_path) / "PKM"
|
nas_root = Path(settings.nas_mount_path)
|
||||||
if not pkm_root.exists():
|
pkm_root = nas_root / "PKM"
|
||||||
|
web_root = nas_root / "Web"
|
||||||
|
|
||||||
|
if not pkm_root.exists() and not web_root.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
new_count = 0
|
new_count = 0
|
||||||
@@ -111,6 +227,16 @@ async def watch_inbox():
|
|||||||
targets.append((extra_path, "library"))
|
targets.append((extra_path, "library"))
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
# ─── Web/ 트랙 (devonagent) — DEVONthink Smart Rule 이 떨군 .html 만 진입 ───
|
||||||
|
if web_root.exists():
|
||||||
|
for file_path in web_root.rglob("*.html"):
|
||||||
|
if not file_path.is_file() or should_skip(file_path):
|
||||||
|
continue
|
||||||
|
rel_path = str(file_path.relative_to(nas_root))
|
||||||
|
added, _ = await _ingest_web_file(session, file_path, rel_path)
|
||||||
|
new_count += added
|
||||||
|
|
||||||
|
# ─── PKM 트랙 (기존 drive_sync) ─────────────────────────────────────────
|
||||||
for sub, expected_category in targets:
|
for sub, expected_category in targets:
|
||||||
scan_root = pkm_root / sub
|
scan_root = pkm_root / sub
|
||||||
if not scan_root.exists():
|
if not scan_root.exists():
|
||||||
@@ -129,7 +255,7 @@ async def watch_inbox():
|
|||||||
if category is None and next_stage is None:
|
if category is None and next_stage is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rel_path = str(file_path.relative_to(Path(settings.nas_mount_path)))
|
rel_path = str(file_path.relative_to(nas_root))
|
||||||
fhash = file_hash(file_path)
|
fhash = file_hash(file_path)
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|||||||
@@ -103,13 +103,36 @@ async def enqueue_next_stage(document_id: int, current_stage: str):
|
|||||||
§3 추가:
|
§3 추가:
|
||||||
stt → [classify] (audio 는 extract 건너뛰고 stt 가 extracted_text 를 채움)
|
stt → [classify] (audio 는 extract 건너뛰고 stt 가 extracted_text 를 채움)
|
||||||
thumbnail → [] (video 는 leaf — classify/embed 없음)
|
thumbnail → [] (video 는 leaf — classify/embed 없음)
|
||||||
|
|
||||||
|
Web/Blog ingest (devonagent 트랙) — plan db-snuggly-petal.md:
|
||||||
|
source_channel='devonagent' 인 doc 의 extract 완료 시
|
||||||
|
classify/preview/markdown 전부 SKIP → [embed, chunk] 만 enqueue.
|
||||||
|
AI 가공 (ai_tldr/ai_bullets 등) 은 별 PR (Mac mini derived-worker).
|
||||||
"""
|
"""
|
||||||
|
# source_channel-aware override (extract stage 만). source_channel 누락 시 _default.
|
||||||
|
extract_override_by_channel = {
|
||||||
|
"devonagent": ["embed", "chunk"],
|
||||||
|
}
|
||||||
|
|
||||||
next_stages = {
|
next_stages = {
|
||||||
"extract": ["classify", "preview"],
|
"extract": ["classify", "preview"],
|
||||||
"classify": ["embed", "chunk", "markdown"],
|
"classify": ["embed", "chunk", "markdown"],
|
||||||
"stt": ["classify"],
|
"stt": ["classify"],
|
||||||
}
|
}
|
||||||
stages = next_stages.get(current_stage, [])
|
|
||||||
|
# extract 의 경우만 doc.source_channel 을 lookup 해서 override 적용
|
||||||
|
if current_stage == "extract":
|
||||||
|
from models.document import Document
|
||||||
|
async with async_session() as lookup_session:
|
||||||
|
doc = await lookup_session.get(Document, document_id)
|
||||||
|
sc = doc.source_channel if doc else None
|
||||||
|
if sc in extract_override_by_channel:
|
||||||
|
stages = extract_override_by_channel[sc]
|
||||||
|
else:
|
||||||
|
stages = next_stages.get(current_stage, [])
|
||||||
|
else:
|
||||||
|
stages = next_stages.get(current_stage, [])
|
||||||
|
|
||||||
if not stages:
|
if not stages:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# DEVONthink → Document Server Web Bridge (devonagent 트랙)
|
||||||
|
|
||||||
|
DEVONagent / DEVONthink 가 발견·저장한 웹페이지를 Document Server 의 검색 가능한 재료로
|
||||||
|
보내기 위한 수동 설치 가이드. Plan: `~/.claude/plans/db-snuggly-petal.md`.
|
||||||
|
|
||||||
|
## 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
DEVONagent (smart agent — 사용자 운영)
|
||||||
|
↓
|
||||||
|
DEVONthink Inbox / tagged group (web/ingest)
|
||||||
|
↓ Smart Rule (AppleScript)
|
||||||
|
NAS /volume4/Document_Server/Web/{domain}/{YYYY-MM-DD}/{slug}.{html,md,json}
|
||||||
|
↓ NFS → GPU file_watcher (5분 간격)
|
||||||
|
documents row (source_channel='devonagent') + extract → embed → chunk
|
||||||
|
↓
|
||||||
|
/api/search + bge-reranker-v2-m3 검색 가능 상태
|
||||||
|
```
|
||||||
|
|
||||||
|
## 정책 (Phase 1)
|
||||||
|
|
||||||
|
- **첫 ingest 만 유지 (first-wins)**: 같은 `canonical_url` 은 한 번만 documents row 생성.
|
||||||
|
DEVONthink 에서 같은 글을 다시 저장해도 **내용이 갱신되지 않는다**. UTM 파라미터 변형
|
||||||
|
(`?utm_source=foo`) 과 fragment (`#section`) 도 정규화되어 한 row 로 수렴.
|
||||||
|
업데이트 버전 관리는 추후 별 PR (`PR-Web-Update-Policy`) 에서 다룬다.
|
||||||
|
- **AI 가공 미적용**: 이 단계는 "검색 가능한 재료" 까지만. ai_tldr / ai_bullets / 카테고리
|
||||||
|
자동 태깅은 별 PR (Mac mini derived-worker) 에서 결정.
|
||||||
|
- **Sidecar (.json) 누락 시**: skip 안 하고 ingest. `extract_meta.web_meta.sidecar_missing=true`
|
||||||
|
로 표시. URL 정보가 없어 검색 evidence 가치는 줄지만 침묵 누락보다 낫다.
|
||||||
|
|
||||||
|
## NAS 경로 규칙
|
||||||
|
|
||||||
|
```
|
||||||
|
/volume4/Document_Server/Web/
|
||||||
|
├── example.com/
|
||||||
|
│ ├── 2026-05-15/
|
||||||
|
│ │ ├── sample-post.html # 본문 HTML
|
||||||
|
│ │ ├── sample-post.md # DEVONthink rendered markdown (fallback 용)
|
||||||
|
│ │ └── sample-post.json # 메타 sidecar
|
||||||
|
│ └── 2026-05-14/
|
||||||
|
│ └── another-post.html
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- 도메인: `urlparse(url).hostname` 의 lowercase
|
||||||
|
- 날짜: `creation date` 의 `YYYY-MM-DD` (KST 또는 UTC, 일관 유지)
|
||||||
|
- slug: 파일명 안전한 형태로 변환 (영숫자/하이픈/언더스코어만)
|
||||||
|
|
||||||
|
## Sidecar JSON 스키마
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Sample Blog Post Title",
|
||||||
|
"url": "https://example.com/sample-post?utm_source=newsletter#main",
|
||||||
|
"author": "Author Name",
|
||||||
|
"pub_date": "2026-05-15T09:00:00Z",
|
||||||
|
"devonthink_uuid": "DEADBEEF-1234-5678-90AB-CDEF12345678",
|
||||||
|
"source_agent": "web-ingest"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `title`, `url` **필수** (둘 다 없으면 sidecar_missing 처리)
|
||||||
|
- `pub_date` 는 ISO 8601 UTC 권장 (한국 시간이면 명시적 +09:00)
|
||||||
|
- `source_agent` 는 어떤 smart agent 가 수집했는지 (분석용 메타, 옵션)
|
||||||
|
|
||||||
|
## DEVONthink Smart Rule 설치
|
||||||
|
|
||||||
|
### 1. Smart Rule 생성
|
||||||
|
|
||||||
|
DEVONthink 3 메뉴 → `Tools` → `Smart Rules` → `+` (새 규칙).
|
||||||
|
|
||||||
|
- **Name**: `Web → NAS for GPU ingest`
|
||||||
|
- **Trigger**:
|
||||||
|
- `On Adding Item to` (Inbox) — Inbox 자동 처리
|
||||||
|
- 또는 `On Tagging Item` — `web/ingest` 태그 붙으면 발동 (수동 큐레이션 선호 시)
|
||||||
|
- **Conditions** (옵션):
|
||||||
|
- `Kind` is `WebArchive` or `HTML` or `Markdown`
|
||||||
|
- `URL` is not empty
|
||||||
|
|
||||||
|
### 2. Action: `Execute Script`
|
||||||
|
|
||||||
|
다음 AppleScript 본문을 `Action Scripts` 영역에 붙여넣는다. NAS 경로
|
||||||
|
`/Volumes/Document_Server` 는 macOS 가 마운트한 SMB/AFP volume 이라고 가정한다.
|
||||||
|
(다른 mount point 면 `kBaseDir` 만 수정.)
|
||||||
|
|
||||||
|
```applescript
|
||||||
|
-- DEVONthink Smart Rule: Web → NAS for GPU ingest
|
||||||
|
-- Plan: ~/.claude/plans/db-snuggly-petal.md
|
||||||
|
|
||||||
|
property kBaseDir : "/Volumes/Document_Server/Web"
|
||||||
|
|
||||||
|
on slugify(theText)
|
||||||
|
set theResult to ""
|
||||||
|
repeat with c in theText
|
||||||
|
set ch to c as string
|
||||||
|
set asciiVal to (id of ch)
|
||||||
|
if (asciiVal ≥ 48 and asciiVal ≤ 57) or ¬
|
||||||
|
(asciiVal ≥ 65 and asciiVal ≤ 90) or ¬
|
||||||
|
(asciiVal ≥ 97 and asciiVal ≤ 122) or ¬
|
||||||
|
ch is "-" or ch is "_" then
|
||||||
|
set theResult to theResult & ch
|
||||||
|
else if ch is " " or ch is "." or ch is "/" then
|
||||||
|
set theResult to theResult & "-"
|
||||||
|
end if
|
||||||
|
end repeat
|
||||||
|
if theResult is "" then set theResult to "untitled"
|
||||||
|
if (length of theResult) > 80 then ¬
|
||||||
|
set theResult to text 1 thru 80 of theResult
|
||||||
|
return theResult
|
||||||
|
end slugify
|
||||||
|
|
||||||
|
on hostnameFromURL(theURL)
|
||||||
|
try
|
||||||
|
set delim to "://"
|
||||||
|
set AppleScript's text item delimiters to delim
|
||||||
|
set tail to text item 2 of theURL
|
||||||
|
set AppleScript's text item delimiters to "/"
|
||||||
|
set host to text item 1 of tail
|
||||||
|
set AppleScript's text item delimiters to ""
|
||||||
|
-- strip port + 소문자
|
||||||
|
set AppleScript's text item delimiters to ":"
|
||||||
|
set host to text item 1 of host
|
||||||
|
set AppleScript's text item delimiters to ""
|
||||||
|
return do shell script "echo " & quoted form of host & " | tr 'A-Z' 'a-z'"
|
||||||
|
on error
|
||||||
|
return "unknown"
|
||||||
|
end try
|
||||||
|
end hostnameFromURL
|
||||||
|
|
||||||
|
on isoDate(theDate)
|
||||||
|
set y to year of theDate as string
|
||||||
|
set m to month of theDate as integer
|
||||||
|
set d to day of theDate as integer
|
||||||
|
if m < 10 then set m to "0" & m
|
||||||
|
if d < 10 then set d to "0" & d
|
||||||
|
return y & "-" & m & "-" & d
|
||||||
|
end isoDate
|
||||||
|
|
||||||
|
on performSmartRule(theRecords)
|
||||||
|
tell application id "DNtp"
|
||||||
|
repeat with theRecord in theRecords
|
||||||
|
try
|
||||||
|
set theURL to URL of theRecord
|
||||||
|
if theURL is missing value or theURL is "" then
|
||||||
|
log message "Web→NAS: URL 없음, skip — " & (name of theRecord)
|
||||||
|
-- continue
|
||||||
|
else
|
||||||
|
set theName to name of theRecord
|
||||||
|
set theUUID to uuid of theRecord
|
||||||
|
set theAuthor to ""
|
||||||
|
try
|
||||||
|
set theAuthor to (meta data of theRecord)'s |author|
|
||||||
|
end try
|
||||||
|
set theDate to (creation date of theRecord)
|
||||||
|
set dateStr to my isoDate(theDate)
|
||||||
|
set host to my hostnameFromURL(theURL)
|
||||||
|
set slug to my slugify(theName)
|
||||||
|
|
||||||
|
set targetDir to kBaseDir & "/" & host & "/" & dateStr
|
||||||
|
do shell script "mkdir -p " & quoted form of targetDir
|
||||||
|
|
||||||
|
set htmlPath to targetDir & "/" & slug & ".html"
|
||||||
|
set mdPath to targetDir & "/" & slug & ".md"
|
||||||
|
set jsonPath to targetDir & "/" & slug & ".json"
|
||||||
|
|
||||||
|
-- 1) HTML export
|
||||||
|
try
|
||||||
|
export record theRecord to htmlPath as HTML
|
||||||
|
on error errMsg
|
||||||
|
log message "Web→NAS HTML export 실패 (" & theName & "): " & errMsg
|
||||||
|
end try
|
||||||
|
|
||||||
|
-- 2) Markdown export (DEVONthink rendered, trafilatura fallback)
|
||||||
|
try
|
||||||
|
export record theRecord to mdPath as markdown
|
||||||
|
end try
|
||||||
|
|
||||||
|
-- 3) JSON sidecar
|
||||||
|
set pubISO to do shell script ¬
|
||||||
|
"date -u +%Y-%m-%dT%H:%M:%SZ -r " & ¬
|
||||||
|
(do shell script "stat -f %m " & quoted form of htmlPath)
|
||||||
|
set jsonText to "{" & ¬
|
||||||
|
"\"title\":" & my jsonEsc(theName) & "," & ¬
|
||||||
|
"\"url\":" & my jsonEsc(theURL) & "," & ¬
|
||||||
|
"\"author\":" & my jsonEsc(theAuthor) & "," & ¬
|
||||||
|
"\"pub_date\":\"" & pubISO & "\"," & ¬
|
||||||
|
"\"devonthink_uuid\":\"" & theUUID & "\"," & ¬
|
||||||
|
"\"source_agent\":\"smart-rule:web-ingest\"" & ¬
|
||||||
|
"}"
|
||||||
|
do shell script "cat > " & quoted form of jsonPath & ¬
|
||||||
|
" <<'EOF'" & linefeed & jsonText & linefeed & "EOF"
|
||||||
|
|
||||||
|
log message "Web→NAS: " & theName & " → " & host & "/" & dateStr
|
||||||
|
end if
|
||||||
|
on error errMsg
|
||||||
|
log message "Web→NAS 처리 실패: " & errMsg
|
||||||
|
end try
|
||||||
|
end repeat
|
||||||
|
end tell
|
||||||
|
end performSmartRule
|
||||||
|
|
||||||
|
on jsonEsc(theText)
|
||||||
|
if theText is missing value then return "\"\""
|
||||||
|
set s to theText as string
|
||||||
|
-- 최소 escape: backslash 와 따옴표
|
||||||
|
set AppleScript's text item delimiters to "\\"
|
||||||
|
set parts to text items of s
|
||||||
|
set AppleScript's text item delimiters to "\\\\"
|
||||||
|
set s to parts as string
|
||||||
|
set AppleScript's text item delimiters to "\""
|
||||||
|
set parts to text items of s
|
||||||
|
set AppleScript's text item delimiters to "\\\""
|
||||||
|
set s to parts as string
|
||||||
|
set AppleScript's text item delimiters to ""
|
||||||
|
return "\"" & s & "\""
|
||||||
|
end jsonEsc
|
||||||
|
```
|
||||||
|
|
||||||
|
**참고**: 위 스크립트는 시작점이다. 실제 사용 시 다음을 점검하라.
|
||||||
|
|
||||||
|
- `kBaseDir` 경로가 실제 NAS mount 와 일치하는지
|
||||||
|
- `creation date` 가 글의 실제 발행일이 아닐 수 있음 (DEVONthink 가 저장한 시점) —
|
||||||
|
필요하면 `meta data → date` 사용
|
||||||
|
- JSON escape 가 한국어/특수문자에서 깨지는지 → `do shell script "python3 -c ..."` 로
|
||||||
|
대체하는 게 안전
|
||||||
|
|
||||||
|
### 3. 동작 확인
|
||||||
|
|
||||||
|
1. DEVONthink 에서 웹페이지를 Inbox 에 저장 (단축키 `^⌥⌘)` 또는 Clip to DEVONthink)
|
||||||
|
2. Smart Rule 이 자동 발동 (혹은 우클릭 → `Apply Rule`)
|
||||||
|
3. `/Volumes/Document_Server/Web/{host}/{date}/{slug}.{html,md,json}` 3종 생성 확인
|
||||||
|
4. 최대 5분 내 GPU file_watcher 가 ingest. SQL 확인:
|
||||||
|
```sql
|
||||||
|
SELECT id, title, edit_url, md_extraction_engine, md_status
|
||||||
|
FROM documents WHERE source_channel='devonagent'
|
||||||
|
ORDER BY created_at DESC LIMIT 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
## file_watcher 동작 요약
|
||||||
|
|
||||||
|
- `nas_mount_path / "Web"` 하위를 5분 간격 rglob 으로 `.html` 만 수집
|
||||||
|
- 각 `.html` 마다 sibling `.json` 읽어 canonical URL 산출
|
||||||
|
- `file_hash = sha256(canonical_url)` → URL identity dedup
|
||||||
|
- documents row 생성 + `processing_queue.stage='extract'` 등록
|
||||||
|
- extract_worker 의 4-tier fallback 으로 md_content 채움
|
||||||
|
- `source_channel='devonagent'` 인 doc 은 `classify`/`preview`/`markdown` SKIP →
|
||||||
|
`embed` + `chunk` 만 enqueue
|
||||||
|
|
||||||
|
## 검증 (운영 후)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 도메인 분포 (어느 사이트가 많이 들어오는지)
|
||||||
|
SELECT split_part(edit_url, '/', 3) host, count(*) cnt
|
||||||
|
FROM documents WHERE source_channel='devonagent' AND edit_url IS NOT NULL
|
||||||
|
GROUP BY host ORDER BY cnt DESC;
|
||||||
|
|
||||||
|
-- 추출 엔진 분포 (bs4_text 비율 모니터링)
|
||||||
|
SELECT md_extraction_engine, count(*) cnt,
|
||||||
|
ROUND(100.0 * count(*) / sum(count(*)) OVER (), 1) pct
|
||||||
|
FROM documents WHERE source_channel='devonagent'
|
||||||
|
GROUP BY md_extraction_engine ORDER BY cnt DESC;
|
||||||
|
|
||||||
|
-- Sidecar 누락 분 (조용한 누락 가시화)
|
||||||
|
SELECT id, title, file_path
|
||||||
|
FROM documents
|
||||||
|
WHERE source_channel='devonagent'
|
||||||
|
AND extract_meta->'web_meta'->>'sidecar_missing' = 'true';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 알려진 한계 (Phase 1)
|
||||||
|
|
||||||
|
- **JS-rendered 페이지**: SPA / React / Vue 로 본문이 client-side 렌더되는 사이트는
|
||||||
|
HTML 안에 본문 텍스트가 없어 trafilatura 가 빈 결과를 낸다. DEVONthink WebArchive
|
||||||
|
export 가 렌더 결과를 잡아주면 OK, 아니면 bs4_text fallback 도 빈약하다.
|
||||||
|
Playwright 컨테이너는 별 PR.
|
||||||
|
- **로그인/페이월 콘텐츠**: DEVONthink 가 로그인 세션으로 capture 한 경우만 본문 보유.
|
||||||
|
- **canonical_url 정책**: 같은 글의 reprint (Medium → 본인 블로그) 는 다른 row 로 ingest 됨.
|
||||||
|
URL identity 만 dedup 기준이다.
|
||||||
|
- **첫 ingest 만 유지**: 글이 후속 편집되어도 갱신 안 됨. 별 PR 에서 정책 결정.
|
||||||
Reference in New Issue
Block a user