Files
Hyungi Ahn 0cbba0ceeb 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>
2026-05-15 21:23:16 +09:00

11 KiB

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 dateYYYY-MM-DD (KST 또는 UTC, 일관 유지)
  • slug: 파일명 안전한 형태로 변환 (영숫자/하이픈/언더스코어만)

Sidecar 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 메뉴 → ToolsSmart Rules+ (새 규칙).

  • Name: Web → NAS for GPU ingest
  • Trigger:
    • On Adding Item to (Inbox) — Inbox 자동 처리
    • 또는 On Tagging Itemweb/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 만 수정.)

-- 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 확인:
    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

검증 (운영 후)

-- 도메인 분포 (어느 사이트가 많이 들어오는지)
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 에서 정책 결정.