Files
hyungi_document_server/docs/devonthink-web-bridge.md
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

280 lines
11 KiB
Markdown

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