# 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 에서 정책 결정.
