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:
@@ -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