From fff993298270705bb1971441abfad179580e6415 Mon Sep 17 00:00:00 2001 From: choihooo Date: Fri, 22 May 2026 15:38:14 +0900 Subject: [PATCH 01/14] docs: add embedding troubleshooting guide --- .gitignore | 6 + ...-05-22-embedding-server-troubleshooting.md | 193 +++ ...5-22-curation-vector-recommendations-ko.md | 766 +++++++++++ ...6-05-22-curation-vector-recommendations.md | 1213 +++++++++++++++++ 4 files changed, 2178 insertions(+) create mode 100644 docs/26-05-22-embedding-server-troubleshooting.md create mode 100644 docs/superpowers/plans/26-05-22-curation-vector-recommendations-ko.md create mode 100644 docs/superpowers/plans/26-05-22-curation-vector-recommendations.md diff --git a/.gitignore b/.gitignore index f8d0997..f9fb3bc 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,12 @@ firebase-service-account.json packages/shared/drizzle/*.sql .vercel scripts +!packages/bot/src/scripts/ +!packages/bot/src/scripts/*.ts # Superpowers brainstorming artifacts (local only) .superpowers + +# Local agent artifacts +.agents/ +skills-lock.json diff --git a/docs/26-05-22-embedding-server-troubleshooting.md b/docs/26-05-22-embedding-server-troubleshooting.md new file mode 100644 index 0000000..08af2db --- /dev/null +++ b/docs/26-05-22-embedding-server-troubleshooting.md @@ -0,0 +1,193 @@ +# 임베딩 서버 트러블슈팅 + +작성일: 2026-05-22 + +이번 구성은 다음 구조다. + +```text +봇/로컬 개발 환경 + -> https://embedding.hozorica.com + -> Cloudflare Access Service Token + -> Cloudflare Tunnel + -> GCE VM localhost:11434 + -> Ollama nomic-embed-text +``` + +## 1. Ollama 로컬 확인 + +GCE VM 안에서 먼저 확인한다. + +```bash +curl http://localhost:11434/api/tags +``` + +정상 예시: + +```json +{ + "models": [ + { + "name": "nomic-embed-text:latest" + } + ] +} +``` + +임베딩 확인: + +```bash +curl http://localhost:11434/api/embed \ + -H "Content-Type: application/json" \ + -d '{ + "model": "nomic-embed-text", + "input": "React 성능 최적화" + }' +``` + +정상 조건: + +```text +embeddings[0].length = 768 +``` + +첫 요청은 모델 로딩 때문에 10~30초 걸릴 수 있다. 두 번째 요청부터 빨라지는지 확인한다. + +## 2. Cloudflare Tunnel 확인 + +VM에서 foreground로 테스트: + +```bash +sudo cloudflared tunnel --config /etc/cloudflared/config.yml run +``` + +정상 로그: + +```text +Registered tunnel connection +``` + +서비스 상태: + +```bash +sudo systemctl status cloudflared --no-pager +sudo journalctl -u cloudflared -n 80 --no-pager +``` + +## 3. Cloudflare Access 403 확인 + +외부에서 service token 없이 호출하면 403이 정상이다. + +```bash +curl -i https://embedding.hozorica.com/api/tags +``` + +service token을 붙였는데도 403이면 Cloudflare Access 정책을 확인한다. + +정책 설정: + +```text +Application: embedding.hozorica.com +Policy action: Service Auth +Include: Service Token +``` + +Cloudflare 로그 위치: + +```text +Zero Trust -> Logs -> Access -> Access 인증 로그 +``` + +주의: + +- 로그 화면에서 `서비스 인증 같음 제외` 필터가 켜져 있으면 service token 요청이 숨겨진다. +- 필터를 지우고 확인한다. +- Access 로그에 `Allowed`가 뜨면 Access는 통과한 것이다. + +## 4. Access는 Allowed인데 curl이 403인 경우 + +이 경우 Cloudflare Access가 아니라 origin인 Ollama가 막았을 가능성이 높다. + +원인: + +```text +Cloudflare Tunnel이 Host: embedding.hozorica.com 헤더를 origin에 전달 +Ollama가 예상하지 않은 Host 헤더를 403 처리 +``` + +해결: + +```bash +sudo vim /etc/cloudflared/config.yml +``` + +`originRequest.httpHostHeader`를 추가한다. + +```yaml +tunnel: +credentials-file: /etc/cloudflared/.json + +ingress: + - hostname: embedding.hozorica.com + service: http://localhost:11434 + originRequest: + httpHostHeader: localhost:11434 + - service: http_status:404 +``` + +재시작: + +```bash +sudo systemctl restart cloudflared +sudo systemctl status cloudflared --no-pager +``` + +확인: + +```bash +curl -i https://embedding.hozorica.com/api/tags \ + -H "CF-Access-Client-Id: $EMBEDDING_ACCESS_CLIENT_ID" \ + -H "CF-Access-Client-Secret: $EMBEDDING_ACCESS_CLIENT_SECRET" +``` + +정상: + +```text +HTTP/2 200 +``` + +## 5. 최종 외부 임베딩 테스트 + +```bash +curl -i https://embedding.hozorica.com/api/embed \ + -H "CF-Access-Client-Id: $EMBEDDING_ACCESS_CLIENT_ID" \ + -H "CF-Access-Client-Secret: $EMBEDDING_ACCESS_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "nomic-embed-text", + "input": "React 성능 최적화" + }' +``` + +정상 조건: + +```text +HTTP 200 +model = nomic-embed-text +embedding_count = 1 +dimensions = 768 +``` + +## 6. 운영 env + +로컬 개발 `.env`와 운영 봇 EC2 `.env`에 같은 값이 필요하다. + +```bash +EMBEDDING_PROVIDER=ollama +EMBEDDING_BASE_URL=https://embedding.hozorica.com +EMBEDDING_MODEL=nomic-embed-text +EMBEDDING_DIMENSIONS=768 +EMBEDDING_ACCESS_CLIENT_ID=... +EMBEDDING_ACCESS_CLIENT_SECRET=... +``` + +`EMBEDDING_ACCESS_CLIENT_ID`와 `EMBEDDING_ACCESS_CLIENT_SECRET`은 Cloudflare Access service token이다. 채팅이나 로그에 노출되면 rotate한다. diff --git a/docs/superpowers/plans/26-05-22-curation-vector-recommendations-ko.md b/docs/superpowers/plans/26-05-22-curation-vector-recommendations-ko.md new file mode 100644 index 0000000..83588ea --- /dev/null +++ b/docs/superpowers/plans/26-05-22-curation-vector-recommendations-ko.md @@ -0,0 +1,766 @@ +# 큐레이션 벡터 추천 구현 계획 + +**목표:** `/curation`의 `sort=recommended`를 지금처럼 `관심사 태그 ∩ 아이템 태그`로 정렬하는 방식에서, 사용자 취향 임베딩과 글 임베딩의 유사도로 빠르게 랭킹하는 방식으로 바꾼다. + +**큰 방향:** +RSS나 관리자 크롤링으로 큐레이션 글이 들어오면 `title + description + tags + sourceName`을 하나의 문장으로 만들고 임베딩을 저장한다. 사용자도 `part + interests + bio`를 취향 문장으로 만들고 임베딩을 저장한다. 추천 피드는 두 벡터의 코사인 유사도에 최신성과 기존 `relevanceScore`를 섞어 정렬한다. + +--- + +## 지금 상태 + +- 코드에는 `curation_items` 테이블이 있다고 되어 있다. +- 그런데 실제 Supabase에는 `curation_items`가 없고, 비슷한 역할의 `original_articles` 테이블이 있다. +- Supabase에 `vector` 확장이 아직 켜져 있지 않다. +- 현재 `/api/curation?sort=recommended`는 `members.interests`와 `curation_items.tags`의 겹치는 개수로 정렬한다. +- 실제 DB의 큐레이션 글은 대부분 `tags`가 비어 있어서, 지금 방식은 추천 품질이 잘 나오기 어렵다. +- Supabase에서 RLS가 꺼져 있다는 보안 경고가 있다. 다만 RLS는 잘못 켜면 서비스가 막힐 수 있어서 이번 추천 기능과 분리해서 별도 작업으로 다룬다. + +--- + +## 추천 점수 + +기본 점수식은 이렇게 간다. + +```text +최종 추천 점수 = + 의미 유사도 65% + + 최신성 20% + + 기존 relevanceScore 15% +``` + +SQL 개념은 이렇다. + +```sql +semantic_score = 1 - (item.embedding <=> user.embedding) +freshness_score = exp(-age_days / 14.0) +relevance_score = relevance_score를 0~1 사이로 정규화 + +final_score = + semantic_score * 0.65 + + freshness_score * 0.20 + + relevance_score * 0.15 +``` + +추천 모델은 로컬에서 돌릴 수 있는 Ollama `nomic-embed-text`를 기준으로 잡는다. + +- 차원: `768` +- DB 타입: `vector(768)` +- 외부 LLM API 호출 비용이 없다. +- 로컬/사내망에서 임베딩을 만들 수 있다. +- Supabase `pgvector`의 HNSW 인덱스를 쓰기 좋다. + +필요한 환경변수: + +```bash +EMBEDDING_PROVIDER=ollama +EMBEDDING_BASE_URL=http://localhost:11434 +EMBEDDING_MODEL=nomic-embed-text +EMBEDDING_DIMENSIONS=768 +``` + +로컬 준비 예시: + +```bash +ollama pull nomic-embed-text +ollama serve +``` + +운영에서 Docker 봇이 Ollama에 접근해야 하면 `localhost`가 아니라 같은 네트워크에서 접근 가능한 URL을 넣는다. + +--- + +## 추천 이유 + +추천 이유는 “AI가 매번 문장을 생성”하지 않는다. 대신 추천 점수를 계산할 때 이미 알 수 있는 근거를 구조화해서 내려준다. + +예시: + +```text +관심사 React와 성능 최적화에 잘 맞아요. +최근 7일 안에 올라온 글이에요. +Frontend Weekly에서 가져온 글이에요. +``` + +추천 이유 후보: + +1. **관심사 기반 이유** + - 사용자 `interests`와 아이템 `tags/title/description`에서 겹치거나 가까운 키워드를 찾는다. + - 예: `React`, `성능 최적화`, `Next.js` + +2. **파트 기반 이유** + - 사용자 `part`가 `Frontend`이고 글이 React/브라우저/UI/성능 쪽이면 표시한다. + - 예: `프론트엔드 파트 관심 주제와 가까워요.` + +3. **최신성 이유** + - `publishedAt`이 최근이면 표시한다. + - 예: `최근 3일 안에 올라온 글이에요.` + +4. **기존 relevanceScore 이유** + - `relevanceScore`가 높으면 표시한다. + - 예: `스터디 키워드 관련도가 높은 글이에요.` + +5. **소스 이유** + - 신뢰할 수 있는 큐레이션 소스명이 있으면 표시한다. + - 예: `Frontend Weekly에서 가져온 글이에요.` + +API 응답에는 이렇게 붙인다. + +```ts +recommendationReason: { + summary: string; + reasons: string[]; + matchedKeywords: string[]; + semanticScore: number | null; + freshnessScore: number | null; + relevanceScore: number | null; +} +``` + +프론트에서는 카드에 한 줄 요약만 보여주고, 필요하면 작은 tooltip/drawer에서 세부 이유를 보여준다. + +예시 응답: + +```json +{ + "summary": "React와 성능 최적화 관심사에 잘 맞아요.", + "reasons": [ + "관심사 React와 연결돼요.", + "최근 7일 안에 올라온 글이에요.", + "스터디 키워드 관련도가 높은 글이에요." + ], + "matchedKeywords": ["React", "성능 최적화"], + "semanticScore": 0.82, + "freshnessScore": 0.71, + "relevanceScore": 0.64 +} +``` + +이 방식의 장점: + +- 매 요청마다 LLM을 부르지 않아서 빠르고 싸다. +- 같은 조건에서는 같은 추천 이유가 나온다. +- 추천 이유가 실제 점수 계산과 연결되어 있어서 납득 가능하다. +- 나중에 클릭/저장/숨김 데이터를 섞어도 이유 규칙을 확장하기 쉽다. + +--- + +## 작업 1. 큐레이션 테이블 이름 정리 + +**왜 필요한가:** +코드는 `curation_items`를 보는데, 실제 DB는 `original_articles`를 쓰고 있다. 이 상태에서 추천 기능을 얹으면 로컬과 운영이 계속 어긋난다. + +**할 일:** + +1. 실제 DB에 `curation_items`가 없는지 확인한다. +2. `original_articles`를 `curation_items`로 이름 변경한다. +3. 혹시 기존 운영 코드가 `original_articles`를 보고 있을 가능성을 대비해서 임시 view를 둔다. + +예상 SQL: + +```sql +alter table if exists public.original_articles rename to curation_items; + +alter index if exists idx_original_articles_is_shared rename to idx_curation_items_is_shared; +alter index if exists idx_original_articles_published_at rename to idx_curation_items_published_at; + +create or replace view public.original_articles as +select * from public.curation_items; +``` + +검증: + +```sql +select to_regclass('public.curation_items'), count(*) +from public.curation_items; +``` + +--- + +## 작업 2. pgvector 스키마 추가 + +**왜 필요한가:** +글과 사용자 취향을 벡터로 저장해야 추천 정렬을 빠르게 할 수 있다. + +**할 일:** + +1. Supabase에 `vector` 확장을 켠다. +2. `curation_items`에 임베딩 컬럼을 추가한다. +3. 사용자 취향 임베딩용 새 테이블을 만든다. +4. HNSW 인덱스를 만든다. + +추가할 컬럼: + +```text +curation_items.embedding +curation_items.embedding_text_hash +curation_items.embedding_model +curation_items.embedded_at +``` + +새 테이블: + +```text +member_preference_embeddings +- member_id +- preference_text +- preference_text_hash +- embedding +- embedding_model +- refreshed_at +``` + +핵심 SQL: + +```sql +create extension if not exists vector with schema extensions; + +create index if not exists idx_curation_items_embedding_hnsw +on public.curation_items +using hnsw (embedding extensions.vector_cosine_ops) +where embedding is not null; + +create index if not exists idx_member_preference_embeddings_embedding_hnsw +on public.member_preference_embeddings +using hnsw (embedding extensions.vector_cosine_ops); +``` + +--- + +## 작업 3. 임베딩용 텍스트 생성 함수 만들기 + +**왜 필요한가:** +임베딩 품질은 “어떤 문장을 임베딩하느냐”에 크게 좌우된다. 매번 제멋대로 만들지 말고 같은 규칙으로 만들어야 한다. + +**파일:** + +```text +packages/shared/src/ai/embedding-text.ts +``` + +아이템 임베딩 문장 예시: + +```text +Title: React Server Components 성능 최적화 +Description: Streaming과 cache 전략 정리 +Tags: React, Performance +Source: Frontend Weekly +``` + +사용자 취향 문장 예시: + +```text +Backend 개발자. 관심사: React, 성능 최적화. 소개: API와 DB 성능을 좋아합니다. +``` + +같은 문장이면 다시 임베딩하지 않도록 `sha256` 해시도 같이 만든다. + +--- + +## 작업 4. 임베딩 생성 서비스 만들기 + +**왜 필요한가:** +크롤링, 백필, 프로필 수정 등 여러 곳에서 임베딩 생성이 필요하므로 공통 서비스로 빼야 한다. + +**파일:** + +```text +packages/bot/src/services/embedding.service.ts +``` + +기능: + +- 글 하나의 임베딩 생성/저장 +- 멤버 한 명의 취향 임베딩 생성/저장 +- 임베딩이 없는 큐레이션 글 일괄 백필 + +주의: + +- 로컬 임베딩 서버에 연결할 수 없으면 실패하지 않고 그냥 skip한다. +- 외부 API 호출이므로 크롤링 요청을 오래 붙잡지 않게 비동기로 처리한다. + +--- + +## 작업 5. 크롤링 후 새 글 임베딩 생성 + +**왜 필요한가:** +새 큐레이션 글이 들어왔는데 임베딩이 없으면 추천 피드에 바로 반영되지 않는다. + +**수정할 곳:** + +```text +packages/bot/src/services/curation.service.ts +packages/web/src/app/api/admin/curation/crawl/route.ts +``` + +방식: + +- 봇 크롤링 경로에서는 새 글 insert 후 `refreshCurationItem(item.id)`를 비동기로 호출한다. +- 관리자 수동 크롤링 경로는 SSE 응답을 막지 않도록, 우선 insert된 ID만 모으고 백필 스크립트로 보완한다. +- 나중에 필요하면 내부 API/큐로 관리자 크롤링도 즉시 임베딩 처리한다. + +--- + +## 작업 6. 사용자 취향 임베딩 갱신 + +**왜 필요한가:** +사용자가 온보딩하거나 프로필에서 관심사/bio/part를 바꾸면 추천 취향도 바뀌어야 한다. + +**수정할 곳:** + +```text +packages/web/src/app/api/profile/onboarding/route.ts +packages/web/src/app/api/profile/edit/route.ts +``` + +방식: + +- 프로필 저장 후 내부 API나 봇 API를 통해 해당 멤버의 취향 임베딩을 갱신한다. +- 즉시 갱신이 실패해도 서비스는 계속 동작한다. +- 실패/누락분은 백필 스크립트로 보완한다. + +--- + +## 작업 7. `/api/curation?sort=recommended` 정렬 교체 + +**왜 필요한가:** +실제 사용자에게 보이는 추천 품질이 바뀌는 핵심 작업이다. + +**파일:** + +```text +packages/web/src/app/api/curation/route.ts +``` + +처리 흐름: + +1. 로그인한 사용자의 Discord ID를 찾는다. +2. `members`에서 멤버를 찾는다. +3. `member_preference_embeddings`에서 사용자 취향 벡터를 찾는다. +4. 벡터가 있으면 벡터 추천 정렬을 쓴다. +5. 벡터가 없으면 기존 태그 오버랩 추천을 쓴다. +6. 관심사도 없으면 최신순으로 fallback한다. + +정렬: + +```sql +order by final_score desc, published_at desc nulls last, id desc +``` + +커서: + +```text +final_score|published_at|id +``` + +기존 기능 유지: + +- 카테고리 필터 유지 +- 태그 필터 유지 +- 검색 유지 +- 무한 스크롤 유지 +- 최신순 정렬 유지 + +--- + +## 작업 8. 추천 이유 생성 및 응답 추가 + +**왜 필요한가:** +사용자는 “왜 이 글이 나한테 추천됐는지”를 알아야 추천을 신뢰할 수 있다. 다만 매번 AI 추천문을 생성하면 느리고 비싸므로, 점수 계산에 사용한 근거로 설명을 만든다. + +**파일:** + +```text +packages/shared/src/ai/recommendation-reason.ts +packages/shared/src/ai/recommendation-reason.property.test.ts +packages/web/src/app/api/curation/route.ts +packages/web/src/app/(user)/curation/page.tsx +``` + +생성 규칙: + +```text +1순위: matchedKeywords가 있으면 “React와 성능 최적화 관심사에 잘 맞아요.” +2순위: semanticScore가 높으면 “프로필 관심사와 의미적으로 가까운 글이에요.” +3순위: freshnessScore가 높으면 “최근 올라온 글이에요.” +4순위: relevanceScore가 높으면 “스터디 키워드 관련도가 높은 글이에요.” +5순위: sourceName이 있으면 “{sourceName}에서 가져온 글이에요.” +``` + +API 응답 필드: + +```ts +recommendationReason: { + summary: string; + reasons: string[]; + matchedKeywords: string[]; + semanticScore: number | null; + freshnessScore: number | null; + relevanceScore: number | null; +} +``` + +화면 표시: + +- 큐레이션 카드/리스트에 한 줄만 작게 표시한다. +- 예: `React와 성능 최적화 관심사에 잘 맞아요.` +- 세부 점수는 기본 UI에 노출하지 않는다. +- 디버깅이 필요하면 개발 모드에서만 console이나 hidden data로 확인한다. + +현재 UI 기준 구체 위치: + +- 모바일/태블릿 카드: 제목 아래, 설명 위에 추천 이유 한 줄을 넣는다. +- 데스크톱 리스트: 설명 아래, 메타 정보 줄 위에 추천 이유 한 줄을 넣는다. +- `sort=recommended`일 때만 보여준다. +- `전체`, `컨퍼런스`, `아티클`, 검색 결과처럼 추천 정렬이 아닌 화면에서는 숨긴다. +- 아이콘은 기존 `Sparkles`를 재사용한다. +- 색은 과하게 강조하지 않고 `text-primary` 또는 `text-muted-foreground` 안에서 처리한다. +- 추천 이유가 길면 `line-clamp-1`로 한 줄 처리한다. + +예상 UI: + +```tsx +{ + showRecommendationReason && item.recommendationReason?.summary && ( +

+

+ ); +} +``` + +타입도 같이 확장한다. + +```ts +interface RecommendationReason { + summary: string; + reasons: string[]; + matchedKeywords: string[]; + semanticScore: number | null; + freshnessScore: number | null; + relevanceScore: number | null; +} + +interface CurationItemResponse { + // 기존 필드 유지 + recommendationReason: RecommendationReason | null; +} +``` + +fallback: + +- 벡터 추천이 아니면 `semanticScore`는 `null`로 내려준다. +- 태그 오버랩 추천에서는 `matchedKeywords` 중심으로 이유를 만든다. +- 최신순 fallback에서는 `최근 올라온 글이에요.` 정도만 표시하거나 이유를 생략한다. + +--- + +## 작업 9. 기존 데이터 백필 스크립트 추가 + +**왜 필요한가:** +이미 DB에 들어가 있는 글과 멤버는 크롤링/프로필 저장 이벤트가 다시 발생하지 않으므로 따로 임베딩을 채워야 한다. + +**파일:** + +```text +packages/bot/src/scripts/backfill-curation-embeddings.ts +packages/bot/src/scripts/backfill-member-preference-embeddings.ts +``` + +명령어: + +```bash +pnpm --filter @blog-study/bot backfill-member-preference-embeddings +pnpm --filter @blog-study/bot backfill-curation-embeddings 100 +``` + +검증: + +```sql +select count(*) filter (where embedding is not null) +from public.curation_items; + +select count(*) +from public.member_preference_embeddings; +``` + +--- + +## 작업 10. 검증 + +**타입 검사:** + +```bash +pnpm --filter @blog-study/shared typecheck +pnpm --filter @blog-study/bot typecheck +pnpm --filter @blog-study/web typecheck +``` + +**DB 확인:** + +```sql +select extname, extversion +from pg_extension +where extname = 'vector'; +``` + +**추천 쿼리 확인:** + +```sql +select + ci.id, + ci.title, + 1 - (ci.embedding <=> mpe.embedding) as semantic_score +from public.curation_items ci +join public.member_preference_embeddings mpe + on mpe.member_id = ''::uuid +where ci.embedding is not null +order by semantic_score desc +limit 10; +``` + +**웹 확인:** + +```text +/curation?sort=recommended +``` + +확인할 것: + +- 추천 피드가 뜬다. +- 무한 스크롤이 된다. +- 검색이 된다. +- 카테고리/태그 필터가 된다. +- 임베딩 없는 유저도 오류 없이 fallback된다. + +--- + +## 배포 순서 + +1. DB 테이블명 정리 +2. `vector` 확장과 임베딩 컬럼 추가 +3. fallback 포함한 코드 배포 +4. 멤버 취향 임베딩 백필 +5. 큐레이션 글 임베딩 백필 +6. 추천 이유 응답과 화면 표시 배포 +7. `/curation?sort=recommended` 모니터링 +8. 오류/지연 시간이 괜찮으면 벡터 추천을 기본 추천으로 유지 + +--- + +## 임베딩 서버 트러블슈팅 + +이번 구성은 다음 구조다. + +```text +봇/로컬 개발 환경 + -> https://embedding.hozorica.com + -> Cloudflare Access Service Token + -> Cloudflare Tunnel + -> GCE VM localhost:11434 + -> Ollama nomic-embed-text +``` + +### 1. Ollama 로컬 확인 + +GCE VM 안에서 먼저 확인한다. + +```bash +curl http://localhost:11434/api/tags +``` + +정상 예시: + +```json +{ + "models": [ + { + "name": "nomic-embed-text:latest" + } + ] +} +``` + +임베딩 확인: + +```bash +curl http://localhost:11434/api/embed \ + -H "Content-Type: application/json" \ + -d '{ + "model": "nomic-embed-text", + "input": "React 성능 최적화" + }' +``` + +정상 조건: + +```text +embeddings[0].length = 768 +``` + +첫 요청은 모델 로딩 때문에 10~30초 걸릴 수 있다. 두 번째 요청부터 빨라지는지 확인한다. + +### 2. Cloudflare Tunnel 확인 + +VM에서 foreground로 테스트: + +```bash +sudo cloudflared tunnel --config /etc/cloudflared/config.yml run +``` + +정상 로그: + +```text +Registered tunnel connection +``` + +서비스 상태: + +```bash +sudo systemctl status cloudflared --no-pager +sudo journalctl -u cloudflared -n 80 --no-pager +``` + +### 3. Cloudflare Access 403 확인 + +외부에서 service token 없이 호출하면 403이 정상이다. + +```bash +curl -i https://embedding.hozorica.com/api/tags +``` + +service token을 붙였는데도 403이면 Cloudflare Access 정책을 확인한다. + +정책 설정: + +```text +Application: embedding.hozorica.com +Policy action: Service Auth +Include: Service Token +``` + +Cloudflare 로그 위치: + +```text +Zero Trust -> Logs -> Access -> Access 인증 로그 +``` + +주의: + +- 로그 화면에서 `서비스 인증 같음 제외` 필터가 켜져 있으면 service token 요청이 숨겨진다. +- 필터를 지우고 확인한다. +- Access 로그에 `Allowed`가 뜨면 Access는 통과한 것이다. + +### 4. Access는 Allowed인데 curl이 403인 경우 + +이 경우 Cloudflare Access가 아니라 origin인 Ollama가 막았을 가능성이 높다. + +원인: + +```text +Cloudflare Tunnel이 Host: embedding.hozorica.com 헤더를 origin에 전달 +Ollama가 예상하지 않은 Host 헤더를 403 처리 +``` + +해결: + +```bash +sudo vim /etc/cloudflared/config.yml +``` + +`originRequest.httpHostHeader`를 추가한다. + +```yaml +tunnel: baa77ccf-c3ea-43a6-b102-505c4f53edb0 +credentials-file: /etc/cloudflared/baa77ccf-c3ea-43a6-b102-505c4f53edb0.json + +ingress: + - hostname: embedding.hozorica.com + service: http://localhost:11434 + originRequest: + httpHostHeader: localhost:11434 + - service: http_status:404 +``` + +재시작: + +```bash +sudo systemctl restart cloudflared +sudo systemctl status cloudflared --no-pager +``` + +확인: + +```bash +curl -i https://embedding.hozorica.com/api/tags \ + -H "CF-Access-Client-Id: $EMBEDDING_ACCESS_CLIENT_ID" \ + -H "CF-Access-Client-Secret: $EMBEDDING_ACCESS_CLIENT_SECRET" +``` + +정상: + +```text +HTTP/2 200 +``` + +### 5. 최종 외부 임베딩 테스트 + +```bash +curl -i https://embedding.hozorica.com/api/embed \ + -H "CF-Access-Client-Id: $EMBEDDING_ACCESS_CLIENT_ID" \ + -H "CF-Access-Client-Secret: $EMBEDDING_ACCESS_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "nomic-embed-text", + "input": "React 성능 최적화" + }' +``` + +정상 조건: + +```text +HTTP 200 +model = nomic-embed-text +embedding_count = 1 +dimensions = 768 +``` + +### 6. 운영 env + +로컬 개발 `.env`와 운영 봇 EC2 `.env`에 같은 값이 필요하다. + +```bash +EMBEDDING_PROVIDER=ollama +EMBEDDING_BASE_URL=https://embedding.hozorica.com +EMBEDDING_MODEL=nomic-embed-text +EMBEDDING_DIMENSIONS=768 +EMBEDDING_ACCESS_CLIENT_ID=... +EMBEDDING_ACCESS_CLIENT_SECRET=... +``` + +`EMBEDDING_ACCESS_CLIENT_ID`와 `EMBEDDING_ACCESS_CLIENT_SECRET`은 Cloudflare Access service token이다. 채팅이나 로그에 노출되면 rotate한다. + +--- + +## 완료 기준 + +- 최신순 정렬은 기존과 동일하게 동작한다. +- 추천 정렬은 임베딩이 있으면 벡터 유사도 기반으로 동작한다. +- 임베딩이 없으면 기존 방식이나 최신순으로 안전하게 fallback한다. +- 새로 크롤링된 글은 임베딩 생성 대상이 된다. +- 프로필 변경 후 사용자 취향 임베딩이 갱신된다. +- 추천 이유가 API 응답에 포함되고 화면에 한 줄로 표시된다. +- 기존 데이터 백필 스크립트가 동작한다. +- Supabase에 HNSW 벡터 인덱스가 있다. +- shared, bot, web 타입 체크가 통과한다. + +--- + +## 별도 보안 작업 + +이번 추천 기능과 별개로 Supabase RLS를 꼭 정리해야 한다. + +추천 기능은 `members.bio`, `members.interests`, 취향 문장 같은 개인 데이터를 더 적극적으로 쓰게 된다. 지금 Supabase advisory 기준으로는 public 테이블 RLS가 꺼져 있으므로, 운영 확장 전에 별도 계획으로 처리하는 게 좋다. + +다만 RLS는 정책 없이 켜면 앱이 바로 막힐 수 있으니 추천 기능 마이그레이션에 섞지 않는다. diff --git a/docs/superpowers/plans/26-05-22-curation-vector-recommendations.md b/docs/superpowers/plans/26-05-22-curation-vector-recommendations.md new file mode 100644 index 0000000..0c556be --- /dev/null +++ b/docs/superpowers/plans/26-05-22-curation-vector-recommendations.md @@ -0,0 +1,1213 @@ +# Curation Vector Recommendations Implementation Plan + +> Note: The Korean plan is the authoritative current version for implementation. The embedding provider was changed from OpenAI SDK to a local Ollama-compatible embedding endpoint after this draft was written. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `/api/curation?sort=recommended` tag-overlap sorting with fast vector similarity ranking using Supabase pgvector, while keeping latest/tag/search filters and a safe fallback path. + +**Architecture:** Store embeddings for curation items and member preference profiles in Postgres via `pgvector`. Generate item embeddings when RSS/admin crawlers insert new items, generate member preference embeddings from `interests + bio + part`, and rank recommended feed items by semantic similarity blended with freshness and existing `relevanceScore`. Keep the existing latest sort unchanged and fall back to tag-overlap/latest when vectors are missing. + +**Tech Stack:** Next.js 16 route handlers, Drizzle ORM, Supabase Postgres + pgvector, Node 22, pnpm, Vitest, OpenAI-compatible embeddings provider. + +--- + +## Current Findings + +- Local code expects `curation_items` in `packages/shared/src/db/schema.ts`. +- Connected Supabase currently has `public.original_articles` with curation-item-shaped columns, not `public.curation_items`. +- Connected Supabase does not have the `vector` extension enabled yet. +- Existing `/api/curation?sort=recommended` reads `members.interests` and ranks by `interests ∩ curation_items.tags`. +- Production DB has many curation rows with empty `tags`, so semantic embedding is the right upgrade path. +- Supabase advisory reports RLS is disabled on public tables. Do not auto-enable RLS in the recommendation migration because it can block the app without policies; handle as a separate security task. + +## File Map + +- Modify: `packages/shared/src/db/schema.ts` + - Add vector custom type if Drizzle version lacks native vector support. + - Add embedding metadata fields to curation items. + - Add `member_preference_embeddings` table. +- Create: `packages/shared/src/ai/embedding-text.ts` + - Pure helpers that build deterministic embedding input text for items and members. +- Create: `packages/shared/src/ai/recommendation-score.ts` + - Shared constants and score blend formula documentation/helpers. +- Modify: `packages/shared/src/index.ts` + - Export AI helpers. +- Modify: `packages/shared/src/config/env.ts` + - Add server-only embedding provider env vars. +- Create: `packages/bot/src/services/embedding.service.ts` + - Generate embeddings, update item/member embedding rows, and backfill missing embeddings. +- Modify: `packages/bot/src/services/curation.service.ts` + - Queue or directly trigger item embedding generation after new items are inserted. +- Modify: `packages/web/src/app/api/admin/curation/crawl/route.ts` + - Trigger item embedding generation after admin crawl inserts. +- Modify: `packages/web/src/app/api/profile/onboarding/route.ts` + - Refresh member preference embedding after onboarding. +- Modify: `packages/web/src/app/api/profile/edit/route.ts` + - Refresh member preference embedding after profile edits. +- Modify: `packages/web/src/app/api/curation/route.ts` + - Replace recommended sort with vector score query and cursor pagination. +- Create: `packages/bot/src/scripts/backfill-curation-embeddings.ts` + - Backfill missing curation item embeddings. +- Create: `packages/bot/src/scripts/backfill-member-preference-embeddings.ts` + - Backfill missing member preference embeddings. +- Test: `packages/shared/src/ai/embedding-text.property.test.ts` + - Verify stable text construction. +- Test: `packages/shared/src/ai/recommendation-score.property.test.ts` + - Verify bounded score behavior. +- Test: `packages/web/src/app/api/curation/route.test.ts` + - Verify fallback and SQL cursor behavior using mocked db calls, if route test harness exists; otherwise use a focused helper test. + +## Recommended Embedding Model Decision + +Use local Ollama `nomic-embed-text` by default: + +- Dimension: `768` +- Good enough for short article/member profile text. +- Lower cost than larger embedding models. +- Fits pgvector HNSW index limit for standard `vector`. + +Environment variables: + +```bash +EMBEDDING_PROVIDER=ollama +EMBEDDING_BASE_URL=http://localhost:11434 +EMBEDDING_MODEL=nomic-embed-text +EMBEDDING_DIMENSIONS=768 +``` + +If a non-OpenAI provider is preferred later, keep the service interface provider-neutral and only swap the API client implementation. + +## Score Formula + +Use cosine similarity from pgvector: + +```sql +semantic_score = 1 - (item.embedding <=> member_pref.embedding) +freshness_score = exp(-age_days / 14.0) +normalized_relevance_score = least(greatest(coalesce(relevance_score, 0), 0), 100) / 100.0 + +final_score = + semantic_score * 0.65 + + freshness_score * 0.20 + + normalized_relevance_score * 0.15 +``` + +Reasoning: + +- Semantic match should dominate. +- Freshness prevents excellent but stale items from pinning the top forever. +- Existing relevance score remains useful as a weak prior. + +Cursor fields: + +```text +final_score|published_at|id +``` + +Use `id` as deterministic tie-breaker. + +--- + +### Task 1: Reconcile Curation Table Name Before Feature Work + +**Files:** + +- Inspect: `packages/shared/src/db/schema.ts` +- Inspect: Supabase tables `public.original_articles`, `public.curation_items` +- Modify only if needed: generated Drizzle migration under `packages/shared/drizzle/` + +- [ ] **Step 1: Verify local schema and remote table mismatch** + +Run: + +```bash +pnpm --filter @blog-study/shared typecheck +``` + +Expected: + +```text +No TypeScript errors, or unrelated existing errors only. +``` + +Run against Supabase: + +```sql +select to_regclass('public.curation_items') as curation_items, + to_regclass('public.original_articles') as original_articles; +``` + +Expected current result: + +```text +curation_items = null +original_articles = original_articles +``` + +- [ ] **Step 2: Decide the canonical table** + +Use `curation_items` as canonical because repo code already imports `curationItems` and the user-facing feature is `/curation`. + +Migration SQL for production convergence: + +```sql +alter table if exists public.original_articles rename to curation_items; + +alter index if exists idx_original_articles_is_shared rename to idx_curation_items_is_shared; +alter index if exists idx_original_articles_published_at rename to idx_curation_items_published_at; +``` + +If any existing production-only code still references `original_articles`, create a temporary compatibility view after rename: + +```sql +create or replace view public.original_articles as +select * from public.curation_items; +``` + +- [ ] **Step 3: Verify convergence** + +Run: + +```sql +select to_regclass('public.curation_items') as curation_items, + count(*)::int as rows +from public.curation_items; +``` + +Expected: + +```text +curation_items = curation_items +rows > 0 +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/shared/drizzle packages/shared/src/db/schema.ts +git commit -m "chore: align curation table naming" +``` + +--- + +### Task 2: Add pgvector Schema + +**Files:** + +- Modify: `packages/shared/src/db/schema.ts` +- Generate: `packages/shared/drizzle/*` + +- [ ] **Step 1: Add Drizzle vector custom type** + +In `packages/shared/src/db/schema.ts`, extend imports: + +```ts +import { + boolean, + customType, + date, + index, + integer, + jsonb, + pgEnum, + pgTable, + real, + serial, + text, + timestamp, + unique, + uniqueIndex, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; +``` + +Add near table declarations: + +```ts +const vector = customType<{ data: number[]; driverData: string }>({ + dataType() { + return 'vector(768)'; + }, + toDriver(value: number[]) { + return `[${value.join(',')}]`; + }, + fromDriver(value: string) { + return value + .replace(/^\[|\]$/g, '') + .split(',') + .filter(Boolean) + .map(Number); + }, +}); +``` + +- [ ] **Step 2: Add curation item embedding columns** + +In `curationItems`, add: + +```ts + embedding: vector('embedding'), + embeddingTextHash: varchar('embedding_text_hash', { length: 64 }), + embeddingModel: varchar('embedding_model', { length: 100 }), + embeddedAt: timestamp('embedded_at', { withTimezone: true }), +``` + +Add indexes: + +```ts + embeddedAtIdx: index('idx_curation_items_embedded_at').on(table.embeddedAt), +``` + +The HNSW vector index should be added with raw SQL in the generated migration because Drizzle index helpers may not support pgvector operator classes cleanly: + +```sql +create extension if not exists vector with schema extensions; + +create index if not exists idx_curation_items_embedding_hnsw +on public.curation_items +using hnsw (embedding extensions.vector_cosine_ops) +where embedding is not null; +``` + +- [ ] **Step 3: Add member preference embeddings table** + +Add after `members`: + +```ts +export const memberPreferenceEmbeddings = pgTable( + 'member_preference_embeddings', + { + memberId: uuid('member_id') + .primaryKey() + .references(() => members.id, { onDelete: 'cascade' }), + preferenceText: text('preference_text').notNull(), + preferenceTextHash: varchar('preference_text_hash', { length: 64 }).notNull(), + embedding: vector('embedding').notNull(), + embeddingModel: varchar('embedding_model', { length: 100 }).notNull(), + refreshedAt: timestamp('refreshed_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + refreshedAtIdx: index('idx_member_preference_embeddings_refreshed_at').on(table.refreshedAt), + }) +); +``` + +Add HNSW index SQL: + +```sql +create index if not exists idx_member_preference_embeddings_embedding_hnsw +on public.member_preference_embeddings +using hnsw (embedding extensions.vector_cosine_ops); +``` + +- [ ] **Step 4: Generate and inspect migration** + +Run: + +```bash +pnpm --filter @blog-study/shared db:generate +``` + +Expected: + +```text +New migration file created under packages/shared/drizzle/ +``` + +Inspect the generated migration and manually add `create extension` plus HNSW index SQL if missing. + +- [ ] **Step 5: Verify typecheck** + +Run: + +```bash +pnpm --filter @blog-study/shared typecheck +``` + +Expected: + +```text +No TypeScript errors. +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/shared/src/db/schema.ts packages/shared/drizzle +git commit -m "feat: add curation embedding schema" +``` + +--- + +### Task 3: Add Pure Embedding Text Helpers + +**Files:** + +- Create: `packages/shared/src/ai/embedding-text.ts` +- Create: `packages/shared/src/ai/embedding-text.property.test.ts` +- Modify: `packages/shared/src/index.ts` + +- [ ] **Step 1: Write tests** + +Create `packages/shared/src/ai/embedding-text.property.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { + buildCurationItemEmbeddingText, + buildMemberPreferenceText, + sha256Text, +} from './embedding-text'; + +describe('embedding text helpers', () => { + it('builds curation item text from stable fields', () => { + expect( + buildCurationItemEmbeddingText({ + title: 'React Server Components 성능 최적화', + description: 'Streaming과 cache 전략 정리', + tags: ['React', 'Performance'], + sourceName: 'Frontend Weekly', + }) + ).toBe( + 'Title: React Server Components 성능 최적화\nDescription: Streaming과 cache 전략 정리\nTags: React, Performance\nSource: Frontend Weekly' + ); + }); + + it('builds member preference text from profile fields', () => { + expect( + buildMemberPreferenceText({ + part: 'Backend', + bio: 'API와 DB 성능을 좋아합니다.', + interests: ['React', '성능 최적화'], + }) + ).toBe('Backend 개발자. 관심사: React, 성능 최적화. 소개: API와 DB 성능을 좋아합니다.'); + }); + + it('hashes text deterministically', () => { + expect(sha256Text('same')).toBe(sha256Text('same')); + expect(sha256Text('same')).not.toBe(sha256Text('different')); + }); +}); +``` + +- [ ] **Step 2: Run test and verify failure** + +Run: + +```bash +pnpm --filter @blog-study/shared test embedding-text.property.test.ts +``` + +Expected: + +```text +FAIL because embedding-text module does not exist. +``` + +- [ ] **Step 3: Implement helper** + +Create `packages/shared/src/ai/embedding-text.ts`: + +```ts +import { createHash } from 'node:crypto'; + +function clean(value: string | null | undefined): string { + return (value ?? '').replace(/\s+/g, ' ').trim(); +} + +export function sha256Text(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +export function buildCurationItemEmbeddingText(input: { + title: string; + description?: string | null; + tags?: string[] | null; + sourceName?: string | null; +}): string { + const lines = [`Title: ${clean(input.title)}`]; + const description = clean(input.description); + if (description) lines.push(`Description: ${description}`); + if (input.tags?.length) lines.push(`Tags: ${input.tags.map(clean).filter(Boolean).join(', ')}`); + const sourceName = clean(input.sourceName); + if (sourceName) lines.push(`Source: ${sourceName}`); + return lines.join('\n'); +} + +export function buildMemberPreferenceText(input: { + part: string; + bio?: string | null; + interests?: string[] | null; +}): string { + const part = clean(input.part); + const interests = input.interests?.map(clean).filter(Boolean) ?? []; + const bio = clean(input.bio); + + const segments = [`${part || '스터디'} 개발자.`]; + if (interests.length) segments.push(`관심사: ${interests.join(', ')}.`); + if (bio) segments.push(`소개: ${bio}`); + return segments.join(' '); +} +``` + +Modify `packages/shared/src/index.ts`: + +```ts +export * from './ai/embedding-text'; +``` + +- [ ] **Step 4: Verify tests** + +Run: + +```bash +pnpm --filter @blog-study/shared test embedding-text.property.test.ts +pnpm --filter @blog-study/shared typecheck +``` + +Expected: + +```text +PASS embedding-text.property.test.ts +No TypeScript errors. +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/shared/src/ai/embedding-text.ts packages/shared/src/ai/embedding-text.property.test.ts packages/shared/src/index.ts +git commit -m "feat: add embedding text helpers" +``` + +--- + +### Task 4: Add Embedding Service + +**Files:** + +- Modify: `packages/shared/src/config/env.ts` +- Create: `packages/bot/src/services/embedding.service.ts` +- Modify: `packages/bot/package.json` + +- [ ] **Step 1: Add env validation** + +In `packages/shared/src/config/env.ts`, add optional bot env vars: + +```ts +EMBEDDING_PROVIDER: z.enum(['ollama', 'openai-compatible']).default('ollama'), +EMBEDDING_BASE_URL: z.string().url().default('http://localhost:11434'), +EMBEDDING_MODEL: z.string().min(1).default('nomic-embed-text'), +EMBEDDING_DIMENSIONS: z.coerce.number().int().positive().default(768), +EMBEDDING_API_KEY: z.string().min(1).optional(), +``` + +- [ ] **Step 2: Add OpenAI SDK dependency** + +Run: + +```bash +pnpm --filter @blog-study/bot add openai +``` + +Expected: + +```text +openai added to @blog-study/bot dependencies. +``` + +- [ ] **Step 3: Implement service** + +Create `packages/bot/src/services/embedding.service.ts`: + +```ts +import OpenAI from 'openai'; +import { eq, isNull, or } from 'drizzle-orm'; +import { + buildCurationItemEmbeddingText, + buildMemberPreferenceText, + sha256Text, +} from '@blog-study/shared'; +import { + curationItems, + curationSources, + getDb, + memberPreferenceEmbeddings, + members, +} from '@blog-study/shared/db'; + +const DEFAULT_MODEL = process.env.EMBEDDING_MODEL || 'text-embedding-3-small'; + +export class EmbeddingService { + private db = getDb(); + private client = process.env.OPENAI_API_KEY + ? new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) + : null; + + private async embed(text: string): Promise { + if (!this.client) return null; + const response = await this.client.embeddings.create({ + model: DEFAULT_MODEL, + input: text, + }); + return response.data[0]?.embedding ?? null; + } + + async refreshCurationItem(itemId: string): Promise { + const [row] = await this.db + .select({ + id: curationItems.id, + title: curationItems.title, + description: curationItems.description, + tags: curationItems.tags, + sourceName: curationSources.name, + }) + .from(curationItems) + .leftJoin(curationSources, eq(curationItems.sourceId, curationSources.id)) + .where(eq(curationItems.id, itemId)) + .limit(1); + + if (!row) return false; + + const text = buildCurationItemEmbeddingText(row); + const hash = sha256Text(text); + const embedding = await this.embed(text); + if (!embedding) return false; + + await this.db + .update(curationItems) + .set({ + embedding, + embeddingTextHash: hash, + embeddingModel: DEFAULT_MODEL, + embeddedAt: new Date(), + }) + .where(eq(curationItems.id, itemId)); + + return true; + } + + async refreshMemberPreference(memberId: string): Promise { + const [member] = await this.db + .select({ + id: members.id, + part: members.part, + bio: members.bio, + interests: members.interests, + }) + .from(members) + .where(eq(members.id, memberId)) + .limit(1); + + if (!member) return false; + + const preferenceText = buildMemberPreferenceText(member); + const preferenceTextHash = sha256Text(preferenceText); + const embedding = await this.embed(preferenceText); + if (!embedding) return false; + + await this.db + .insert(memberPreferenceEmbeddings) + .values({ + memberId, + preferenceText, + preferenceTextHash, + embedding, + embeddingModel: DEFAULT_MODEL, + refreshedAt: new Date(), + }) + .onConflictDoUpdate({ + target: memberPreferenceEmbeddings.memberId, + set: { + preferenceText, + preferenceTextHash, + embedding, + embeddingModel: DEFAULT_MODEL, + refreshedAt: new Date(), + }, + }); + + return true; + } + + async backfillMissingCurationItems(limit = 50): Promise { + const rows = await this.db + .select({ id: curationItems.id }) + .from(curationItems) + .where(or(isNull(curationItems.embedding), isNull(curationItems.embeddedAt))) + .limit(limit); + + let updated = 0; + for (const row of rows) { + if (await this.refreshCurationItem(row.id)) updated++; + } + return updated; + } +} + +let embeddingService: EmbeddingService | null = null; + +export function getEmbeddingService(): EmbeddingService { + embeddingService ??= new EmbeddingService(); + return embeddingService; +} +``` + +- [ ] **Step 4: Verify typecheck** + +Run: + +```bash +pnpm --filter @blog-study/bot typecheck +``` + +Expected: + +```text +No TypeScript errors. +``` + +- [ ] **Step 5: Commit** + +```bash +git add package.json pnpm-lock.yaml packages/shared/src/config/env.ts packages/bot/package.json packages/bot/src/services/embedding.service.ts +git commit -m "feat: add embedding generation service" +``` + +--- + +### Task 5: Trigger Item Embeddings During Crawls + +**Files:** + +- Modify: `packages/bot/src/services/curation.service.ts` +- Modify: `packages/web/src/app/api/admin/curation/crawl/route.ts` + +- [ ] **Step 1: Update bot curation service insert path** + +In `packages/bot/src/services/curation.service.ts`, import: + +```ts +import { getEmbeddingService } from './embedding.service'; +``` + +After `const created = await this.addItem(...)`, call: + +```ts +void getEmbeddingService() + .refreshCurationItem(created.id) + .catch((error) => { + logger.warn( + { itemId: created.id, error: serializeError(error) }, + '[CurationService] Failed to refresh curation item embedding' + ); + }); +``` + +- [ ] **Step 2: Update admin crawl route** + +In `packages/web/src/app/api/admin/curation/crawl/route.ts`, avoid importing the bot service into web. Instead, after insert, collect inserted item IDs: + +```ts +const [created] = await database + .insert(curationItems) + .values({ + sourceId: source.id, + title: item.title!, + url: item.link!, + description, + thumbnailUrl, + publishedAt, + category: source.category, + tags: mergedTags.length > 0 ? mergedTags : null, + relevanceScore: 0, + isShared: false, + }) + .returning({ id: curationItems.id }); + +if (created) { + insertedItemIds.push(created.id); +} +``` + +Add a follow-up internal API or queue in a later task if web-triggered admin crawls must embed immediately. For first release, the backfill script plus bot crawl path is enough to avoid blocking the SSE route on external API calls. + +- [ ] **Step 3: Verify typecheck** + +Run: + +```bash +pnpm --filter @blog-study/bot typecheck +pnpm --filter @blog-study/web typecheck +``` + +Expected: + +```text +No TypeScript errors. +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/bot/src/services/curation.service.ts packages/web/src/app/api/admin/curation/crawl/route.ts +git commit -m "feat: refresh curation embeddings after crawl" +``` + +--- + +### Task 6: Refresh Member Preference Embeddings + +**Files:** + +- Modify: `packages/web/src/app/api/profile/onboarding/route.ts` +- Modify: `packages/web/src/app/api/profile/edit/route.ts` +- Optional Create: `packages/web/src/app/api/internal/member-preference-embedding/route.ts` + +- [ ] **Step 1: Add internal route for preference refresh** + +Create `packages/web/src/app/api/internal/member-preference-embedding/route.ts`: + +```ts +import { NextRequest, NextResponse } from 'next/server'; + +const BOT_API_URL = process.env.BOT_API_URL || 'http://localhost:3001'; +const BOT_API_SECRET = process.env.BOT_API_SECRET; + +export async function POST(request: NextRequest) { + const expectedKey = process.env.INTERNAL_API_KEY; + const providedKey = request.headers.get('x-api-key'); + + if (!expectedKey || providedKey !== expectedKey) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const memberId = typeof body.memberId === 'string' ? body.memberId : ''; + if (!memberId) { + return NextResponse.json({ error: 'memberId is required' }, { status: 400 }); + } + + if (!BOT_API_SECRET) { + return NextResponse.json({ skipped: true, reason: 'BOT_API_SECRET missing' }); + } + + const response = await fetch(`${BOT_API_URL}/api/internal/member-preference-embedding`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${BOT_API_SECRET}`, + }, + body: JSON.stringify({ memberId }), + }); + + return NextResponse.json({ ok: response.ok }, { status: response.ok ? 200 : 502 }); +} +``` + +If adding a bot endpoint is too much for this release, skip the internal route and rely on the backfill script after profile changes. The product behavior remains correct after backfill, but not immediately personalized. + +- [ ] **Step 2: Trigger refresh after profile writes** + +In onboarding/edit routes, after member update succeeds: + +```ts +const webUrl = process.env.WEB_URL; +const apiKey = process.env.INTERNAL_API_KEY; +if (webUrl && apiKey) { + fetch(`${webUrl}/api/internal/member-preference-embedding`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + }, + body: JSON.stringify({ memberId: updatedMember.id }), + }).catch(() => undefined); +} +``` + +- [ ] **Step 3: Verify typecheck** + +Run: + +```bash +pnpm --filter @blog-study/web typecheck +``` + +Expected: + +```text +No TypeScript errors. +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/web/src/app/api/profile/onboarding/route.ts packages/web/src/app/api/profile/edit/route.ts packages/web/src/app/api/internal/member-preference-embedding/route.ts +git commit -m "feat: refresh member recommendation profile" +``` + +--- + +### Task 7: Replace Recommended Sort Query + +**Files:** + +- Modify: `packages/web/src/app/api/curation/route.ts` +- Create or Modify test helper around recommendation SQL if route tests are not practical. + +- [ ] **Step 1: Keep existing fallback branch** + +Preserve current overlap/latest logic under this condition: + +```ts +const useVectorRecommendedSort = isRecommended && Boolean(memberPreferenceEmbedding); +``` + +If no preference embedding exists, keep the current `userInterests.length > 0` overlap path. If no interests exist, fall back to latest. + +- [ ] **Step 2: Fetch member preference embedding** + +Add `memberPreferenceEmbeddings` import from shared db. + +Fetch: + +```ts +const [memberProfile] = await database + .select({ + memberId: members.id, + interests: members.interests, + embedding: memberPreferenceEmbeddings.embedding, + }) + .from(members) + .leftJoin(memberPreferenceEmbeddings, eq(memberPreferenceEmbeddings.memberId, members.id)) + .where(eq(members.discordId, discordId)) + .limit(1); +``` + +- [ ] **Step 3: Add vector score expressions** + +Use SQL expressions: + +```ts +const semanticScoreExpr = sql`1 - (${curationItems.embedding} <=> ${memberProfile.embedding})`; +const ageDaysExpr = sql`greatest(extract(epoch from (now() - coalesce(${curationItems.publishedAt}, ${curationItems.collectedAt}, now()))) / 86400.0, 0)`; +const freshnessExpr = sql`exp(-(${ageDaysExpr}) / 14.0)`; +const normalizedRelevanceExpr = sql`least(greatest(coalesce(${curationItems.relevanceScore}, 0), 0), 100) / 100.0`; +const finalScoreExpr = sql`((${semanticScoreExpr}) * 0.65 + (${freshnessExpr}) * 0.20 + (${normalizedRelevanceExpr}) * 0.15)`; +``` + +Add filter: + +```ts +queryConditions.push(sql`${curationItems.embedding} is not null`); +``` + +- [ ] **Step 4: Add cursor condition** + +For cursor `score|date|id`: + +```ts +queryConditions.push( + sql`((${finalScoreExpr}) < ${cursorScore} + OR ((${finalScoreExpr}) = ${cursorScore} AND ${curationItems.publishedAt} < ${cursorIso}::timestamptz) + OR ((${finalScoreExpr}) = ${cursorScore} AND ${curationItems.publishedAt} = ${cursorIso}::timestamptz AND ${curationItems.id} < ${cursorId}))` +); +``` + +- [ ] **Step 5: Query order** + +Use: + +```ts +.select({ ...BASE_SELECT, finalScore: finalScoreExpr.as('final_score') }) +.from(curationItems) +.leftJoin(curationSources, eq(curationItems.sourceId, curationSources.id)) +.where(whereClause) +.orderBy(sql`final_score DESC`, sql`${curationItems.publishedAt} DESC NULLS LAST`, desc(curationItems.id)) +.limit(limit + 1); +``` + +- [ ] **Step 6: Verify API behavior manually** + +Run web: + +```bash +pnpm --filter @blog-study/web dev +``` + +Request after login: + +```bash +curl 'http://localhost:3300/api/curation?sort=recommended&limit=12' +``` + +Expected: + +```text +200 JSON response with items, nextCursor, hasMore, totalCount. +``` + +- [ ] **Step 7: Verify typecheck** + +Run: + +```bash +pnpm --filter @blog-study/web typecheck +``` + +Expected: + +```text +No TypeScript errors. +``` + +- [ ] **Step 8: Commit** + +```bash +git add packages/web/src/app/api/curation/route.ts +git commit -m "feat: rank curation feed by vector similarity" +``` + +--- + +### Task 8: Add Backfill Scripts + +**Files:** + +- Create: `packages/bot/src/scripts/backfill-curation-embeddings.ts` +- Create: `packages/bot/src/scripts/backfill-member-preference-embeddings.ts` +- Modify: `packages/bot/package.json` + +- [ ] **Step 1: Add curation backfill script** + +Create `packages/bot/src/scripts/backfill-curation-embeddings.ts`: + +```ts +import { config } from 'dotenv'; +import { resolve } from 'node:path'; +import { getEmbeddingService } from '../services/embedding.service'; + +config({ path: resolve(process.cwd(), '../../.env.local') }); +config({ path: resolve(process.cwd(), '../../.env') }); + +async function main() { + const limit = Number(process.argv[2] ?? 50); + const updated = await getEmbeddingService().backfillMissingCurationItems(limit); + console.log(`Updated ${updated} curation item embeddings`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +- [ ] **Step 2: Add member backfill script** + +Create `packages/bot/src/scripts/backfill-member-preference-embeddings.ts`: + +```ts +import { config } from 'dotenv'; +import { resolve } from 'node:path'; +import { eq } from 'drizzle-orm'; +import { getDb, members, MemberStatus } from '@blog-study/shared/db'; +import { getEmbeddingService } from '../services/embedding.service'; + +config({ path: resolve(process.cwd(), '../../.env.local') }); +config({ path: resolve(process.cwd(), '../../.env') }); + +async function main() { + const db = getDb(); + const rows = await db + .select({ id: members.id }) + .from(members) + .where(eq(members.status, MemberStatus.ACTIVE)); + + let updated = 0; + for (const row of rows) { + if (await getEmbeddingService().refreshMemberPreference(row.id)) updated++; + } + console.log(`Updated ${updated} member preference embeddings`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +- [ ] **Step 3: Add package scripts** + +In `packages/bot/package.json`: + +```json +"backfill-curation-embeddings": "tsx src/scripts/backfill-curation-embeddings.ts", +"backfill-member-preference-embeddings": "tsx src/scripts/backfill-member-preference-embeddings.ts" +``` + +- [ ] **Step 4: Run dry backfill with missing API key** + +Run: + +```bash +pnpm --filter @blog-study/bot backfill-curation-embeddings 5 +``` + +Expected if the local embedding server is unavailable: + +```text +Updated 0 curation item embeddings +``` + +Expected if the local embedding server is available: + +```text +Updated N curation item embeddings +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/bot/src/scripts/backfill-curation-embeddings.ts packages/bot/src/scripts/backfill-member-preference-embeddings.ts packages/bot/package.json +git commit -m "feat: add embedding backfill scripts" +``` + +--- + +### Task 9: Production Verification + +**Files:** + +- No code changes unless verification finds defects. + +- [ ] **Step 1: Apply migration in staging or branch first** + +Run migration against a Supabase branch or local DB first. + +Verify: + +```sql +select extname, extversion +from pg_extension +where extname = 'vector'; +``` + +Expected: + +```text +vector row exists +``` + +- [ ] **Step 2: Verify columns** + +Run: + +```sql +select column_name, data_type, udt_name +from information_schema.columns +where table_schema = 'public' + and table_name in ('curation_items', 'member_preference_embeddings') + and column_name in ('embedding', 'embedding_model', 'embedded_at', 'preference_text'); +``` + +Expected: + +```text +embedding columns exist; udt_name is vector for vector columns. +``` + +- [ ] **Step 3: Backfill** + +Run: + +```bash +pnpm --filter @blog-study/bot backfill-member-preference-embeddings +pnpm --filter @blog-study/bot backfill-curation-embeddings 100 +``` + +Expected: + +```text +Updated N member preference embeddings +Updated N curation item embeddings +``` + +- [ ] **Step 4: Verify vector ranking** + +Run SQL with a real member: + +```sql +select ci.id, + ci.title, + 1 - (ci.embedding <=> mpe.embedding) as semantic_score +from public.curation_items ci +join public.member_preference_embeddings mpe on mpe.member_id = ''::uuid +where ci.embedding is not null +order by semantic_score desc +limit 10; +``` + +Expected: + +```text +10 rows ordered by semantic_score descending. +``` + +- [ ] **Step 5: Verify API** + +Open: + +```text +http://localhost:3300/curation?sort=recommended +``` + +Expected: + +```text +Feed loads, infinite scroll works, category/tag/search filters still work. +``` + +- [ ] **Step 6: Commit verification fixes** + +If fixes were needed: + +```bash +git add +git commit -m "fix: stabilize vector curation recommendations" +``` + +--- + +## Security Follow-Up + +Do not bundle RLS enablement into this feature migration. Create a separate security plan for: + +- Enabling RLS on exposed public tables. +- Adding ownership policies for member-private data. +- Keeping admin/server-only write paths working through service-role or server DB connection. +- Verifying `/curation`, profile, admin, and bot jobs after RLS. + +The recommendation feature increases use of `members.bio`, `members.interests`, and derived preference text, so this follow-up should happen before broad production rollout. + +## Rollout Plan + +1. Deploy schema migration with `vector` extension and nullable embedding columns. +2. Deploy code with fallback still active. +3. Run member and item backfills. +4. Monitor `/api/curation?sort=recommended` latency and errors. +5. Once most active users and items have embeddings, treat vector path as primary. +6. Keep fallback permanently for new users, API-key outages, and not-yet-embedded items. + +## Definition of Done + +- `sort=latest` behavior unchanged. +- `sort=recommended` uses vector similarity when member and item embeddings exist. +- Recommended sort falls back gracefully when embeddings are missing. +- New bot-crawled items get embeddings asynchronously. +- Profile onboarding/edit can refresh member preference embeddings, or backfill covers the delay. +- Backfill scripts can populate current data. +- HNSW indexes exist for vector search. +- Typecheck passes for shared, bot, and web packages. +- Manual API verification succeeds on `/curation?sort=recommended`. From e90be8795a4d0b6d734e25a0d8a0c48147e4fccd Mon Sep 17 00:00:00 2001 From: choihooo Date: Fri, 22 May 2026 15:38:50 +0900 Subject: [PATCH 02/14] feat(shared): add embedding recommendation foundations --- packages/shared/package.json | 2 + .../src/ai/embedding-text.property.test.ts | 49 +++++ packages/shared/src/ai/embedding-text.ts | 76 +++++++ packages/shared/src/ai/index.ts | 3 + .../ai/recommendation-reason.property.test.ts | 67 +++++++ .../shared/src/ai/recommendation-reason.ts | 189 ++++++++++++++++++ .../shared/src/ai/recommendation-score.ts | 39 ++++ packages/shared/src/config/env.ts | 14 +- .../src/db/migrate-curation-embeddings.ts | 118 +++++++++++ .../shared/src/db/migrate-post-embeddings.ts | 68 +++++++ packages/shared/src/db/schema.ts | 97 ++++++++- packages/shared/src/index.ts | 1 + 12 files changed, 721 insertions(+), 2 deletions(-) create mode 100644 packages/shared/src/ai/embedding-text.property.test.ts create mode 100644 packages/shared/src/ai/embedding-text.ts create mode 100644 packages/shared/src/ai/index.ts create mode 100644 packages/shared/src/ai/recommendation-reason.property.test.ts create mode 100644 packages/shared/src/ai/recommendation-reason.ts create mode 100644 packages/shared/src/ai/recommendation-score.ts create mode 100644 packages/shared/src/db/migrate-curation-embeddings.ts create mode 100644 packages/shared/src/db/migrate-post-embeddings.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index ef2817f..43a762e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -40,6 +40,8 @@ "db:migrate:run": "tsx src/db/migrate.ts", "migrate:member-blogs:expand": "tsx src/db/migrate-member-blogs-expand.ts", "migrate:member-blogs:contract": "tsx src/db/migrate-member-blogs-contract.ts", + "migrate:curation-embeddings": "tsx src/db/migrate-curation-embeddings.ts", + "migrate:post-embeddings": "tsx src/db/migrate-post-embeddings.ts", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio" }, diff --git a/packages/shared/src/ai/embedding-text.property.test.ts b/packages/shared/src/ai/embedding-text.property.test.ts new file mode 100644 index 0000000..770bc97 --- /dev/null +++ b/packages/shared/src/ai/embedding-text.property.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + buildCurationItemEmbeddingText, + buildMemberPreferenceText, + buildPostEmbeddingText, + sha256Text, +} from './embedding-text'; + +describe('embedding text helpers', () => { + it('builds stable curation item text', () => { + expect( + buildCurationItemEmbeddingText({ + title: 'React Server Components 성능 최적화', + description: 'Streaming과 cache 전략 정리', + tags: ['React', 'Performance'], + sourceName: 'Frontend Weekly', + }) + ).toBe( + 'Title: React Server Components 성능 최적화\nDescription: Streaming과 cache 전략 정리\nTags: React, Performance\nSource: Frontend Weekly' + ); + }); + + it('builds stable member preference text', () => { + expect( + buildMemberPreferenceText({ + part: 'Backend', + bio: 'API와 DB 성능을 좋아합니다.', + interests: ['React', '성능 최적화'], + }) + ).toBe('Backend 개발자. 관심사: React, 성능 최적화. 소개: API와 DB 성능을 좋아합니다.'); + }); + + it('hashes text deterministically', () => { + expect(sha256Text('same')).toBe(sha256Text('same')); + expect(sha256Text('same')).not.toBe(sha256Text('different')); + }); + + it('builds stable post text with author context', () => { + expect( + buildPostEmbeddingText({ + title: 'React 성능 최적화', + description: 'memo와 서버 컴포넌트 이야기', + authorPart: 'frontend', + authorInterests: ['React', 'Next.js'], + roundNumber: 4, + }) + ).toContain('Author interests: React, Next.js'); + }); +}); diff --git a/packages/shared/src/ai/embedding-text.ts b/packages/shared/src/ai/embedding-text.ts new file mode 100644 index 0000000..12200aa --- /dev/null +++ b/packages/shared/src/ai/embedding-text.ts @@ -0,0 +1,76 @@ +import { createHash } from 'node:crypto'; + +function clean(value: string | null | undefined): string { + return (value ?? '').replace(/\s+/g, ' ').trim(); +} + +function cleanExcerpt(value: string | null | undefined, maxLength: number): string { + const cleaned = clean(value).replace(/<[^>]*>/g, ' '); + return cleaned.length > maxLength ? `${cleaned.slice(0, maxLength).trim()}...` : cleaned; +} + +export function sha256Text(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +export function buildCurationItemEmbeddingText(input: { + title: string; + description?: string | null; + tags?: string[] | null; + sourceName?: string | null; +}): string { + const lines = [`Title: ${clean(input.title)}`]; + const description = clean(input.description); + const tags = input.tags?.map(clean).filter(Boolean) ?? []; + const sourceName = clean(input.sourceName); + + if (description) lines.push(`Description: ${description}`); + if (tags.length > 0) lines.push(`Tags: ${tags.join(', ')}`); + if (sourceName) lines.push(`Source: ${sourceName}`); + + return lines.join('\n'); +} + +export function buildPostEmbeddingText(input: { + title: string; + description?: string | null; + authorPart?: string | null; + authorBio?: string | null; + authorInterests?: string[] | null; + authorNickname?: string | null; + roundNumber?: number | null; +}): string { + const lines = [`Title: ${clean(input.title)}`]; + const description = cleanExcerpt(input.description, 1000); + const authorPart = clean(input.authorPart); + const authorBio = cleanExcerpt(input.authorBio, 300); + const authorNickname = clean(input.authorNickname); + const authorInterests = input.authorInterests?.map(clean).filter(Boolean) ?? []; + + if (description) lines.push(`Description: ${description}`); + if (authorPart) lines.push(`Author part: ${authorPart}`); + if (authorNickname) lines.push(`Author: ${authorNickname}`); + if (authorInterests.length > 0) lines.push(`Author interests: ${authorInterests.join(', ')}`); + if (authorBio) lines.push(`Author bio: ${authorBio}`); + if (input.roundNumber !== null && input.roundNumber !== undefined) { + lines.push(`Round: ${input.roundNumber}`); + } + + return lines.join('\n'); +} + +export function buildMemberPreferenceText(input: { + part: string; + bio?: string | null; + interests?: string[] | null; +}): string { + const part = clean(input.part); + const bio = clean(input.bio); + const interests = input.interests?.map(clean).filter(Boolean) ?? []; + + const segments = [`${part || '스터디'} 개발자.`]; + if (interests.length > 0) segments.push(`관심사: ${interests.join(', ')}.`); + if (bio) segments.push(`소개: ${bio}`); + + return segments.join(' '); +} diff --git a/packages/shared/src/ai/index.ts b/packages/shared/src/ai/index.ts new file mode 100644 index 0000000..1e653db --- /dev/null +++ b/packages/shared/src/ai/index.ts @@ -0,0 +1,3 @@ +export * from './embedding-text'; +export * from './recommendation-reason'; +export * from './recommendation-score'; diff --git a/packages/shared/src/ai/recommendation-reason.property.test.ts b/packages/shared/src/ai/recommendation-reason.property.test.ts new file mode 100644 index 0000000..580a592 --- /dev/null +++ b/packages/shared/src/ai/recommendation-reason.property.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + buildPostRecommendationReason, + buildRecommendationReason, + findMatchedKeywords, +} from './recommendation-reason'; + +describe('recommendation reasons', () => { + it('extracts matched keywords from title, description, and tags', () => { + expect( + findMatchedKeywords({ + interests: ['React', '성능 최적화', 'Database'], + tags: ['Frontend'], + title: 'React 성능 최적화 가이드', + description: 'Next.js 캐시 전략', + }) + ).toEqual(['React', '성능 최적화']); + }); + + it('builds a concise summary from matched keywords', () => { + const reason = buildRecommendationReason({ + interests: ['React', '성능 최적화'], + title: 'React 성능 최적화 가이드', + sourceName: 'Frontend Weekly', + semanticScore: 0.8, + freshnessScore: 0.7, + relevanceScore: 70, + }); + + expect(reason?.summary).toBe('React, 성능 최적화 관심사에 잘 맞아요.'); + expect(reason?.matchedKeywords).toEqual(['React', '성능 최적화']); + expect(reason?.semanticScore).toBe(0.8); + }); + + it('builds post recommendation reason with score fields', () => { + const reason = buildPostRecommendationReason({ + interests: ['React'], + title: 'React 서버 컴포넌트 정리', + authorPart: 'frontend', + currentMemberPart: 'frontend', + semanticScore: 0.75, + freshnessScore: 0.4, + popularityScore: 0.2, + authorAffinityScore: 1, + }); + + expect(reason?.summary).toBe('React 관심사와 가까워요.'); + expect(reason?.authorAffinityScore).toBe(1); + }); + + it('does not match short ascii interests inside longer words', () => { + expect( + findMatchedKeywords({ + interests: ['AI'], + title: '브라우저 렌더링 파이프라인', + description: '매일메일을 참고해 HTML 파서와 DOM 생성 과정을 정리합니다.', + }) + ).toEqual([]); + + expect( + findMatchedKeywords({ + interests: ['AI'], + title: '생성형 AI가 뭐에요?', + }) + ).toEqual(['AI']); + }); +}); diff --git a/packages/shared/src/ai/recommendation-reason.ts b/packages/shared/src/ai/recommendation-reason.ts new file mode 100644 index 0000000..0168d35 --- /dev/null +++ b/packages/shared/src/ai/recommendation-reason.ts @@ -0,0 +1,189 @@ +import { calculateFreshnessScore, normalizeRelevanceScore } from './recommendation-score'; + +export interface RecommendationReason { + summary: string; + reasons: string[]; + matchedKeywords: string[]; + semanticScore: number | null; + freshnessScore: number | null; + relevanceScore: number | null; +} + +export interface PostRecommendationReason { + summary: string; + reasons: string[]; + matchedKeywords: string[]; + semanticScore: number | null; + freshnessScore: number | null; + popularityScore: number | null; + authorAffinityScore: number | null; +} + +function normalizeKeyword(value: string): string { + return value.trim().toLowerCase(); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function isAsciiKeyword(value: string): boolean { + return /^[a-z0-9+#.]+$/i.test(value); +} + +function includesKeyword(haystack: string, keyword: string): boolean { + const normalizedKeyword = normalizeKeyword(keyword); + if (!normalizedKeyword) return false; + + if (isAsciiKeyword(normalizedKeyword)) { + const pattern = new RegExp( + `(^|[^a-z0-9+#.])${escapeRegExp(normalizedKeyword)}(?=$|[^a-z0-9+#.])`, + 'i' + ); + return pattern.test(haystack); + } + + return haystack.includes(normalizedKeyword); +} + +export function findMatchedKeywords(input: { + interests?: string[] | null; + tags?: string[] | null; + title?: string | null; + description?: string | null; +}): string[] { + const haystack = [input.title, input.description, ...(input.tags ?? [])] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + const seen = new Set(); + const matches: string[] = []; + + for (const interest of input.interests ?? []) { + const trimmed = interest.trim(); + if (!trimmed) continue; + + const key = normalizeKeyword(trimmed); + if (seen.has(key)) continue; + + if (includesKeyword(haystack, trimmed)) { + seen.add(key); + matches.push(trimmed); + } + } + + return matches.slice(0, 3); +} + +export function buildRecommendationReason(input: { + interests?: string[] | null; + tags?: string[] | null; + title?: string | null; + description?: string | null; + sourceName?: string | null; + publishedAt?: Date | string | null; + semanticScore?: number | null; + freshnessScore?: number | null; + relevanceScore?: number | null; +}): RecommendationReason | null { + const matchedKeywords = findMatchedKeywords(input); + const freshnessScore = input.freshnessScore ?? calculateFreshnessScore(input.publishedAt ?? null); + const relevanceScore = normalizeRelevanceScore(input.relevanceScore); + const semanticScore = input.semanticScore ?? null; + const reasons: string[] = []; + + if (matchedKeywords.length > 0) { + reasons.push(`관심사 ${matchedKeywords.join(', ')}와 연결돼요.`); + } + if (semanticScore !== null && semanticScore >= 0.6) { + reasons.push('프로필 관심사와 의미적으로 가까운 글이에요.'); + } + if (freshnessScore >= 0.6) { + reasons.push('최근 올라온 글이에요.'); + } + if (relevanceScore >= 0.6) { + reasons.push('스터디 키워드 관련도가 높은 글이에요.'); + } + if (input.sourceName) { + reasons.push(`${input.sourceName}에서 가져온 글이에요.`); + } + + if (reasons.length === 0) return null; + + const summary = + matchedKeywords.length > 0 ? `${matchedKeywords.join(', ')} 관심사에 잘 맞아요.` : reasons[0]!; + + return { + summary, + reasons: reasons.slice(0, 3), + matchedKeywords, + semanticScore, + freshnessScore, + relevanceScore, + }; +} + +export function buildPostRecommendationReason(input: { + interests?: string[] | null; + title?: string | null; + description?: string | null; + authorPart?: string | null; + currentMemberPart?: string | null; + publishedAt?: Date | string | null; + semanticScore?: number | null; + freshnessScore?: number | null; + popularityScore?: number | null; + authorAffinityScore?: number | null; +}): PostRecommendationReason | null { + const matchedKeywords = findMatchedKeywords(input); + const freshnessScore = input.freshnessScore ?? calculateFreshnessScore(input.publishedAt ?? null); + const semanticScore = input.semanticScore ?? null; + const popularityScore = input.popularityScore ?? null; + const authorAffinityScore = input.authorAffinityScore ?? null; + const reasons: string[] = []; + + if (matchedKeywords.length > 0) { + reasons.push(`관심사 ${matchedKeywords.join(', ')}와 연결돼요.`); + } + if (semanticScore !== null && semanticScore >= 0.6) { + reasons.push('프로필 관심사와 의미적으로 가까운 글이에요.'); + } + if ( + authorAffinityScore !== null && + authorAffinityScore >= 0.9 && + input.authorPart && + input.currentMemberPart && + input.authorPart === input.currentMemberPart + ) { + reasons.push(`같은 ${input.authorPart} 파트의 글이에요.`); + } + if (freshnessScore >= 0.6) { + reasons.push('최근 올라온 글이에요.'); + } + if (popularityScore !== null && popularityScore >= 0.3) { + reasons.push('조회, 댓글, 리액션 반응이 있는 글이에요.'); + } + + if (reasons.length === 0 && semanticScore !== null) { + reasons.push('내 프로필과 비교해 추천된 글이에요.'); + } + if (reasons.length === 0) return null; + + const summary = + matchedKeywords.length > 0 + ? `${matchedKeywords.join(', ')} 관심사와 가까워요.` + : semanticScore !== null + ? `관심도 ${Math.round(Math.max(0, Math.min(1, semanticScore)) * 100)}%로 추천됐어요.` + : reasons[0]!; + + return { + summary, + reasons: reasons.slice(0, 3), + matchedKeywords, + semanticScore, + freshnessScore, + popularityScore, + authorAffinityScore, + }; +} diff --git a/packages/shared/src/ai/recommendation-score.ts b/packages/shared/src/ai/recommendation-score.ts new file mode 100644 index 0000000..09a2136 --- /dev/null +++ b/packages/shared/src/ai/recommendation-score.ts @@ -0,0 +1,39 @@ +export const RECOMMENDATION_SCORE_WEIGHTS = { + semantic: 0.65, + freshness: 0.2, + relevance: 0.15, +} as const; + +export function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +export function normalizeRelevanceScore(score: number | null | undefined): number { + return clamp01((score ?? 0) / 100); +} + +export function calculateFreshnessScore( + publishedAt: Date | string | null | undefined, + now: Date = new Date() +): number { + if (!publishedAt) return 0; + + const published = publishedAt instanceof Date ? publishedAt : new Date(publishedAt); + if (Number.isNaN(published.getTime())) return 0; + + const ageDays = Math.max(0, (now.getTime() - published.getTime()) / 86_400_000); + return clamp01(Math.exp(-ageDays / 14)); +} + +export function calculateRecommendationScore(input: { + semanticScore: number; + freshnessScore: number; + relevanceScore: number; +}): number { + return ( + clamp01(input.semanticScore) * RECOMMENDATION_SCORE_WEIGHTS.semantic + + clamp01(input.freshnessScore) * RECOMMENDATION_SCORE_WEIGHTS.freshness + + clamp01(input.relevanceScore) * RECOMMENDATION_SCORE_WEIGHTS.relevance + ); +} diff --git a/packages/shared/src/config/env.ts b/packages/shared/src/config/env.ts index 9858fff..d673da6 100644 --- a/packages/shared/src/config/env.ts +++ b/packages/shared/src/config/env.ts @@ -43,12 +43,24 @@ const studyEnvSchema = z.object({ STUDY_ROLE_ID: z.string().optional(), }); +// Embedding provider configuration (server-side only) +const embeddingEnvSchema = z.object({ + EMBEDDING_PROVIDER: z.enum(['ollama', 'openai-compatible']).default('ollama'), + EMBEDDING_BASE_URL: z.string().url().default('http://localhost:11434'), + EMBEDDING_MODEL: z.string().min(1).default('nomic-embed-text'), + EMBEDDING_DIMENSIONS: z.coerce.number().int().positive().default(768), + EMBEDDING_API_KEY: z.string().min(1).optional(), + EMBEDDING_ACCESS_CLIENT_ID: z.string().min(1).optional(), + EMBEDDING_ACCESS_CLIENT_SECRET: z.string().min(1).optional(), +}); + // Combined environment schema const envSchema = z.object({ ...discordEnvSchema.shape, ...supabaseEnvSchema.shape, ...appEnvSchema.shape, ...studyEnvSchema.shape, + ...embeddingEnvSchema.shape, }); // Partial schema for bot-only usage @@ -57,6 +69,7 @@ const botEnvSchema = z.object({ ...supabaseEnvSchema.shape, ...appEnvSchema.shape, ...studyEnvSchema.shape, + ...embeddingEnvSchema.shape, DATABASE_URL_DIRECT: z.string().min(1, 'DATABASE_URL_DIRECT is required'), SENTRY_DSN: z.string().url().optional(), // Sentry DSN for error monitoring (optional) }); @@ -154,4 +167,3 @@ export function isDevelopment(): boolean { export function isTest(): boolean { return process.env.NODE_ENV === 'test'; } - diff --git a/packages/shared/src/db/migrate-curation-embeddings.ts b/packages/shared/src/db/migrate-curation-embeddings.ts new file mode 100644 index 0000000..8cc09c0 --- /dev/null +++ b/packages/shared/src/db/migrate-curation-embeddings.ts @@ -0,0 +1,118 @@ +/** + * 큐레이션 벡터 추천 마이그레이션 + * + * - original_articles 테이블만 있는 운영 DB를 curation_items 이름으로 정렬 + * - pgvector 확장 활성화 + * - curation_items.embedding vector(768) 컬럼 추가 + * - member_preference_embeddings 테이블 추가 + * - cosine HNSW 인덱스 추가 + * + * Usage: pnpm --filter @blog-study/shared migrate:curation-embeddings + */ + +import postgres from 'postgres'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../../../.env.local') }); +config({ path: resolve(__dirname, '../../../../.env') }); + +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + console.error('DATABASE_URL 환경변수가 설정되지 않았습니다.'); + process.exit(1); + } + + const sql = postgres(connectionString, { max: 1, prepare: false }); + + try { + await sql.begin(async (tx) => { + await tx.unsafe(`CREATE SCHEMA IF NOT EXISTS extensions`); + await tx.unsafe(`CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA extensions`); + + await tx.unsafe(` + DO $$ + BEGIN + IF to_regclass('public.curation_items') IS NULL + AND to_regclass('public.original_articles') IS NOT NULL THEN + ALTER TABLE public.original_articles RENAME TO curation_items; + END IF; + END $$; + `); + + await tx.unsafe(` + ALTER TABLE public.curation_items + ADD COLUMN IF NOT EXISTS embedding extensions.vector(768), + ADD COLUMN IF NOT EXISTS embedding_text_hash varchar(64), + ADD COLUMN IF NOT EXISTS embedding_model varchar(100), + ADD COLUMN IF NOT EXISTS embedded_at timestamp with time zone + `); + + await tx.unsafe(` + CREATE TABLE IF NOT EXISTS public.member_preference_embeddings ( + member_id uuid PRIMARY KEY REFERENCES public.members(id) ON DELETE cascade, + preference_text text NOT NULL, + preference_text_hash varchar(64) NOT NULL, + embedding extensions.vector(768) NOT NULL, + embedding_model varchar(100) NOT NULL, + refreshed_at timestamp with time zone DEFAULT now() + ) + `); + + await tx.unsafe(` + ALTER TABLE public.member_preference_embeddings ENABLE ROW LEVEL SECURITY + `); + + await tx.unsafe(` + CREATE INDEX IF NOT EXISTS idx_curation_items_embedded_at + ON public.curation_items (embedded_at) + `); + + await tx.unsafe(` + CREATE INDEX IF NOT EXISTS idx_member_preference_embeddings_refreshed_at + ON public.member_preference_embeddings (refreshed_at) + `); + + await tx.unsafe(` + CREATE INDEX IF NOT EXISTS idx_curation_items_embedding_hnsw + ON public.curation_items + USING hnsw (embedding extensions.vector_cosine_ops) + WHERE embedding IS NOT NULL + `); + + await tx.unsafe(` + CREATE INDEX IF NOT EXISTS idx_member_preference_embeddings_embedding_hnsw + ON public.member_preference_embeddings + USING hnsw (embedding extensions.vector_cosine_ops) + `); + + await tx.unsafe(` + DO $$ + DECLARE + original_articles_kind char; + BEGIN + SELECT relkind + INTO original_articles_kind + FROM pg_class + WHERE oid = to_regclass('public.original_articles'); + + IF original_articles_kind IS NULL THEN + EXECUTE 'CREATE VIEW public.original_articles WITH (security_invoker = true) AS SELECT * FROM public.curation_items'; + ELSIF original_articles_kind = 'v' THEN + EXECUTE 'CREATE OR REPLACE VIEW public.original_articles WITH (security_invoker = true) AS SELECT * FROM public.curation_items'; + END IF; + END $$; + `); + }); + + console.log('큐레이션 벡터 추천 마이그레이션 완료'); + } catch (error) { + console.error('큐레이션 벡터 추천 마이그레이션 실패:', error); + process.exitCode = 1; + } finally { + await sql.end(); + } +} + +main(); diff --git a/packages/shared/src/db/migrate-post-embeddings.ts b/packages/shared/src/db/migrate-post-embeddings.ts new file mode 100644 index 0000000..6f1400d --- /dev/null +++ b/packages/shared/src/db/migrate-post-embeddings.ts @@ -0,0 +1,68 @@ +/** + * 스터디 글 벡터 추천 마이그레이션 + * + * - pgvector 확장 활성화 + * - post_embeddings 테이블 추가 + * - cosine HNSW 인덱스 추가 + * + * Usage: pnpm --filter @blog-study/shared migrate:post-embeddings + */ + +import postgres from 'postgres'; +import { config } from 'dotenv'; +import { resolve } from 'node:path'; + +config({ path: resolve(__dirname, '../../../../.env.local') }); +config({ path: resolve(__dirname, '../../../../.env') }); + +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + console.error('DATABASE_URL 환경변수가 설정되지 않았습니다.'); + process.exit(1); + } + + const sql = postgres(connectionString, { max: 1, prepare: false }); + + try { + await sql.begin(async (tx) => { + await tx.unsafe(`CREATE SCHEMA IF NOT EXISTS extensions`); + await tx.unsafe(`CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA extensions`); + + await tx.unsafe(` + CREATE TABLE IF NOT EXISTS public.post_embeddings ( + post_id uuid PRIMARY KEY REFERENCES public.posts(id) ON DELETE cascade, + embedding extensions.vector(768) NOT NULL, + embedding_text text NOT NULL, + embedding_text_hash varchar(64) NOT NULL, + embedding_model varchar(100) NOT NULL, + embedded_at timestamp with time zone DEFAULT now() + ) + `); + + await tx.unsafe(` + ALTER TABLE public.post_embeddings ENABLE ROW LEVEL SECURITY + `); + + await tx.unsafe(` + CREATE INDEX IF NOT EXISTS idx_post_embeddings_embedded_at + ON public.post_embeddings (embedded_at) + `); + + await tx.unsafe(` + CREATE INDEX IF NOT EXISTS idx_post_embeddings_embedding_hnsw + ON public.post_embeddings + USING hnsw (embedding extensions.vector_cosine_ops) + `); + }); + + console.log('스터디 글 벡터 추천 마이그레이션 완료'); + } catch (error) { + console.error('스터디 글 벡터 추천 마이그레이션 실패:', error); + process.exitCode = 1; + } finally { + await sql.end(); + } +} + +main(); diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 96adb77..a52e133 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -1,5 +1,6 @@ import { boolean, + customType, date, index, integer, @@ -17,6 +18,22 @@ import { } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; +const vector768 = customType<{ data: number[]; driverData: string }>({ + dataType() { + return 'extensions.vector(768)'; + }, + toDriver(value: number[]) { + return `[${value.join(',')}]`; + }, + fromDriver(value: string) { + return value + .replace(/^\[|\]$/g, '') + .split(',') + .filter(Boolean) + .map(Number); + }, +}); + // ============================================ // Enums (as string literals for PostgreSQL) // ============================================ @@ -114,6 +131,27 @@ export const members = pgTable( }) ); +/** + * 멤버 취향 임베딩 + * interests + bio + part를 취향 문장으로 만든 뒤 임베딩 저장 + */ +export const memberPreferenceEmbeddings = pgTable( + 'member_preference_embeddings', + { + memberId: uuid('member_id') + .primaryKey() + .references(() => members.id, { onDelete: 'cascade' }), + preferenceText: text('preference_text').notNull(), + preferenceTextHash: varchar('preference_text_hash', { length: 64 }).notNull(), + embedding: vector768('embedding').notNull(), + embeddingModel: varchar('embedding_model', { length: 100 }).notNull(), + refreshedAt: timestamp('refreshed_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + refreshedAtIdx: index('idx_member_preference_embeddings_refreshed_at').on(table.refreshedAt), + }) +); + /** * 멤버당 등록 가능한 블로그 최대 개수 */ @@ -189,6 +227,27 @@ export const posts = pgTable( }) ); +/** + * 스터디 글 임베딩 + * posts 본 테이블을 추천 메타데이터로 오염시키지 않기 위해 분리 + */ +export const postEmbeddings = pgTable( + 'post_embeddings', + { + postId: uuid('post_id') + .primaryKey() + .references(() => posts.id, { onDelete: 'cascade' }), + embedding: vector768('embedding').notNull(), + embeddingText: text('embedding_text').notNull(), + embeddingTextHash: varchar('embedding_text_hash', { length: 64 }).notNull(), + embeddingModel: varchar('embedding_model', { length: 100 }).notNull(), + embeddedAt: timestamp('embedded_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + embeddedAtIdx: index('idx_post_embeddings_embedded_at').on(table.embeddedAt), + }) +); + /** * 출석 (Attendance) * 회차별 멤버의 출석 상태 @@ -290,6 +349,10 @@ export const curationItems = pgTable( category: varchar('category', { length: 50 }).notNull(), tags: text('tags').array(), relevanceScore: real('relevance_score').default(0), + embedding: vector768('embedding'), + embeddingTextHash: varchar('embedding_text_hash', { length: 64 }), + embeddingModel: varchar('embedding_model', { length: 100 }), + embeddedAt: timestamp('embedded_at', { withTimezone: true }), isShared: boolean('is_shared').default(false), sharedAt: timestamp('shared_at', { withTimezone: true }), collectedAt: timestamp('collected_at', { withTimezone: true }).defaultNow(), @@ -297,6 +360,7 @@ export const curationItems = pgTable( (table) => ({ isSharedIdx: index('idx_curation_items_is_shared').on(table.isShared), publishedAtIdx: index('idx_curation_items_published_at').on(table.publishedAt), + embeddedAtIdx: index('idx_curation_items_embedded_at').on(table.embeddedAt), }) ); @@ -668,8 +732,12 @@ export const discordNotificationLogs = pgTable( // Relations // ============================================ -export const membersRelations = relations(members, ({ many }) => ({ +export const membersRelations = relations(members, ({ many, one }) => ({ blogs: many(memberBlogs), + preferenceEmbedding: one(memberPreferenceEmbeddings, { + fields: [members.id], + references: [memberPreferenceEmbeddings.memberId], + }), posts: many(posts), attendance: many(attendance), fines: many(fines), @@ -684,6 +752,16 @@ export const membersRelations = relations(members, ({ many }) => ({ postReactions: many(postReactions), })); +export const memberPreferenceEmbeddingsRelations = relations( + memberPreferenceEmbeddings, + ({ one }) => ({ + member: one(members, { + fields: [memberPreferenceEmbeddings.memberId], + references: [members.id], + }), + }) +); + export const memberBlogsRelations = relations(memberBlogs, ({ one }) => ({ member: one(members, { fields: [memberBlogs.memberId], @@ -723,6 +801,17 @@ export const postsRelations = relations(posts, ({ one, many }) => ({ views: many(postViews), comments: many(postComments), reactions: many(postReactions), + embedding: one(postEmbeddings, { + fields: [posts.id], + references: [postEmbeddings.postId], + }), +})); + +export const postEmbeddingsRelations = relations(postEmbeddings, ({ one }) => ({ + post: one(posts, { + fields: [postEmbeddings.postId], + references: [posts.id], + }), })); export const attendanceRelations = relations(attendance, ({ one }) => ({ @@ -875,6 +964,9 @@ export const postReactionsRelations = relations(postReactions, ({ one }) => ({ export type Member = typeof members.$inferSelect; export type NewMember = typeof members.$inferInsert; +export type MemberPreferenceEmbedding = typeof memberPreferenceEmbeddings.$inferSelect; +export type NewMemberPreferenceEmbedding = typeof memberPreferenceEmbeddings.$inferInsert; + export type MemberBlog = typeof memberBlogs.$inferSelect; export type NewMemberBlog = typeof memberBlogs.$inferInsert; @@ -884,6 +976,9 @@ export type NewRound = typeof rounds.$inferInsert; export type Post = typeof posts.$inferSelect; export type NewPost = typeof posts.$inferInsert; +export type PostEmbedding = typeof postEmbeddings.$inferSelect; +export type NewPostEmbedding = typeof postEmbeddings.$inferInsert; + export type Attendance = typeof attendance.$inferSelect; export type NewAttendance = typeof attendance.$inferInsert; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ed471a9..2b74289 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,5 +2,6 @@ // 공유 유틸리티, 타입, 데이터베이스 스키마 export * from './config'; +export * from './ai'; export * as db from './db'; export * as utils from './utils'; From d09c606d638891c79e107baf31237216390000df Mon Sep 17 00:00:00 2001 From: choihooo Date: Fri, 22 May 2026 15:39:23 +0900 Subject: [PATCH 03/14] feat(bot): manage recommendation embeddings --- packages/bot/package.json | 5 +- packages/bot/src/api-server.ts | 100 ++++-- packages/bot/src/scheduler-registry.ts | 94 ++++-- .../scripts/backfill-curation-embeddings.ts | 24 ++ .../backfill-member-preference-embeddings.ts | 23 ++ .../src/scripts/backfill-post-embeddings.ts | 24 ++ packages/bot/src/services/curation.service.ts | 85 +++-- .../bot/src/services/embedding.service.ts | 290 ++++++++++++++++++ packages/bot/src/services/index.ts | 1 + 9 files changed, 549 insertions(+), 97 deletions(-) create mode 100644 packages/bot/src/scripts/backfill-curation-embeddings.ts create mode 100644 packages/bot/src/scripts/backfill-member-preference-embeddings.ts create mode 100644 packages/bot/src/scripts/backfill-post-embeddings.ts create mode 100644 packages/bot/src/services/embedding.service.ts diff --git a/packages/bot/package.json b/packages/bot/package.json index 9567c8e..6d51262 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -23,7 +23,10 @@ "seed-test-data": "tsx src/scripts/seed-test-data.ts", "check-keywords": "tsx src/scripts/check-keywords.ts", "seed-keywords": "tsx src/scripts/seed-keywords.ts", - "recalculate-relevance": "tsx src/scripts/recalculate-relevance.ts" + "recalculate-relevance": "tsx src/scripts/recalculate-relevance.ts", + "backfill-curation-embeddings": "tsx src/scripts/backfill-curation-embeddings.ts", + "backfill-post-embeddings": "tsx src/scripts/backfill-post-embeddings.ts", + "backfill-member-preference-embeddings": "tsx src/scripts/backfill-member-preference-embeddings.ts" }, "dependencies": { "@blog-study/shared": "workspace:*", diff --git a/packages/bot/src/api-server.ts b/packages/bot/src/api-server.ts index d385de9..bc06a52 100644 --- a/packages/bot/src/api-server.ts +++ b/packages/bot/src/api-server.ts @@ -18,11 +18,12 @@ import { getWeeklyRanking, } from './schedulers'; import { getAttendanceService } from './services/attendance.service'; -import { getFineService } from './services'; +import { getEmbeddingService, getFineService } from './services'; import { getCurrentRound, getRoundByNumber, isGracePeriodEnded } from './services/round.service'; import { AttendanceStatus } from '@blog-study/shared/db'; const BOT_API_SECRET = process.env.BOT_API_SECRET; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * Bearer token authentication middleware for trigger endpoints @@ -63,6 +64,64 @@ export function createBotApiServer(): Express { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); + app.post( + '/api/internal/embedding/member-preference', + authMiddleware, + triggerLimiter, + async (req, res) => { + try { + const { memberId } = req.body || {}; + if (typeof memberId !== 'string' || !UUID_RE.test(memberId)) { + return res.status(400).json({ error: '유효한 memberId가 필요합니다' }); + } + + const updated = await getEmbeddingService().refreshMemberPreference(memberId); + res.json({ success: true, updated }); + } catch (error) { + Sentry.captureException(error); + logger.error({ error }, '🌐 [API] 멤버 취향 임베딩 갱신 에러'); + res.status(500).json({ error: '내부 오류가 발생했습니다' }); + } + } + ); + + app.post( + '/api/internal/embedding/curation-item', + authMiddleware, + triggerLimiter, + async (req, res) => { + try { + const { itemId } = req.body || {}; + if (typeof itemId !== 'string' || !UUID_RE.test(itemId)) { + return res.status(400).json({ error: '유효한 itemId가 필요합니다' }); + } + + const updated = await getEmbeddingService().refreshCurationItem(itemId); + res.json({ success: true, updated }); + } catch (error) { + Sentry.captureException(error); + logger.error({ error }, '🌐 [API] 큐레이션 아이템 임베딩 갱신 에러'); + res.status(500).json({ error: '내부 오류가 발생했습니다' }); + } + } + ); + + app.post('/api/internal/embedding/post', authMiddleware, triggerLimiter, async (req, res) => { + try { + const { postId } = req.body || {}; + if (typeof postId !== 'string' || !UUID_RE.test(postId)) { + return res.status(400).json({ error: '유효한 postId가 필요합니다' }); + } + + const updated = await getEmbeddingService().refreshPost(postId); + res.json({ success: true, updated }); + } catch (error) { + Sentry.captureException(error); + logger.error({ error }, '🌐 [API] 포스트 임베딩 갱신 에러'); + res.status(500).json({ error: '내부 오류가 발생했습니다' }); + } + }); + // Operation trigger endpoints (auth + rate limiting) app.post('/api/trigger/rss-poll', authMiddleware, triggerLimiter, async (_req, res) => { try { @@ -99,18 +158,24 @@ export function createBotApiServer(): Express { // 이전 회차 PENDING → ABSENT 처리 const processedRecords = await attendanceService.processGracePeriodEnd(prevRound.id); - const absentRecords = processedRecords.filter(r => r.status === AttendanceStatus.ABSENT); + const absentRecords = processedRecords.filter((r) => r.status === AttendanceStatus.ABSENT); // 결석 벌금 부과 for (const record of absentRecords) { try { await fineService.create(record.memberId, prevRound.id, 'absent'); } catch (fineError) { - logger.error({ memberId: record.memberId, error: fineError }, '🌐 [API] 결석 벌금 부과 실패'); + logger.error( + { memberId: record.memberId, error: fineError }, + '🌐 [API] 결석 벌금 부과 실패' + ); } } - res.json({ success: true, result: { roundNumber: prevRound.roundNumber, processedCount: absentRecords.length } }); + res.json({ + success: true, + result: { roundNumber: prevRound.roundNumber, processedCount: absentRecords.length }, + }); } catch (error) { Sentry.captureException(error); logger.error({ error }, '🌐 [API] 출석 체크 에러'); @@ -216,9 +281,8 @@ export function createBotApiServer(): Express { // Convert Date objects to strings for JSON serialization const serializedResult = { ...result, - timestamp: result.timestamp instanceof Date - ? result.timestamp.toISOString() - : result.timestamp, + timestamp: + result.timestamp instanceof Date ? result.timestamp.toISOString() : result.timestamp, }; res.json({ success: true, result: serializedResult }); @@ -247,9 +311,8 @@ export function createBotApiServer(): Express { const serializedResult = { ...result, - timestamp: result.timestamp instanceof Date - ? result.timestamp.toISOString() - : result.timestamp, + timestamp: + result.timestamp instanceof Date ? result.timestamp.toISOString() : result.timestamp, }; res.json({ success: true, result: serializedResult }); @@ -277,9 +340,8 @@ export function createBotApiServer(): Express { const serializedResult = { ...result, - timestamp: result.timestamp instanceof Date - ? result.timestamp.toISOString() - : result.timestamp, + timestamp: + result.timestamp instanceof Date ? result.timestamp.toISOString() : result.timestamp, }; res.json({ success: true, result: serializedResult }); @@ -301,15 +363,15 @@ export function createBotApiServer(): Express { const { dDay } = req.body || {}; // dDay가 지정되면 수동 발송, 아니면 자동(오늘 날짜 기준) - const result = typeof dDay === 'number' - ? await deadlineReminder.sendManual(dDay) - : await deadlineReminder.sendReminders(); + const result = + typeof dDay === 'number' + ? await deadlineReminder.sendManual(dDay) + : await deadlineReminder.sendReminders(); const serializedResult = { ...result, - timestamp: result.timestamp instanceof Date - ? result.timestamp.toISOString() - : result.timestamp, + timestamp: + result.timestamp instanceof Date ? result.timestamp.toISOString() : result.timestamp, }; res.json({ success: true, result: serializedResult }); diff --git a/packages/bot/src/scheduler-registry.ts b/packages/bot/src/scheduler-registry.ts index e9eaf50..abcd8da 100644 --- a/packages/bot/src/scheduler-registry.ts +++ b/packages/bot/src/scheduler-registry.ts @@ -16,13 +16,19 @@ import { getDeadlineReminder } from './schedulers/deadline-reminder'; import { getPopularPosts } from './schedulers/popular-posts'; import type { CrawledContent } from './services/curation.service'; import { getPostService } from './services/post.service'; +import { getEmbeddingService } from './services/embedding.service'; import { getNotificationService } from './services/notification.service'; import { getScoreService } from './services/score.service'; import { getAttendanceService, getFineService } from './services'; import { ActivityScoreType, AttendanceStatus, curationSources, getDb } from '@blog-study/shared/db'; import { extractFirstImage, extractOgImage, formatKSTDate } from '@blog-study/shared/utils'; -import { getCurrentRound, getRoundByNumber, isGracePeriodEnded, setCurrentRound } from './services/round.service'; +import { + getCurrentRound, + getRoundByNumber, + isGracePeriodEnded, + setCurrentRound, +} from './services/round.service'; import { Sentry } from './lib/sentry'; import { eq } from 'drizzle-orm'; import logger from './lib/logger'; @@ -32,17 +38,17 @@ import logger from './lib/logger'; */ // pg-boss cron은 UTC 기준. KST = UTC+9 const JOB_DEFINITIONS = [ - { name: 'rss-poll', cron: '*/5 * * * *' }, // 5분마다 - { name: 'attendance-init', cron: '2 15 * * 0' }, // KST 월 00:02 (UTC 일 15:02) — 회차 시작일 출석 PENDING 생성 - { name: 'attendance-absent', cron: '2 15 * * 1' }, // KST 화 00:02 (UTC 월 15:02) — PENDING → ABSENT + 벌금 - { name: 'fine-reminder', cron: '0 0 * * *' }, // KST 매일 09:00 (UTC 00:00) - { name: 'round-report', cron: '0 23 * * 1' }, // KST 화 08:00 (UTC 월 23:00) - { name: 'round-start', cron: '0 23 * * 0' }, // KST 월 08:00 (UTC 일 23:00) - { name: 'curation-crawl', cron: '0 23 * * *' }, // 4기 미사용 - { name: 'curation-share', cron: '5 10 * * *' }, // 4기 미사용 - { name: 'weekly-ranking', cron: '0 1 * * 0' }, // KST 일 10:00 (UTC 일 01:00) - { name: 'deadline-reminder', cron: '0 23 * * *' }, // KST 매일 08:00 (UTC 23:00) - { name: 'popular-posts', cron: '5 23 * * 1' }, // KST 화 08:05 (UTC 월 23:05) + { name: 'rss-poll', cron: '*/5 * * * *' }, // 5분마다 + { name: 'attendance-init', cron: '2 15 * * 0' }, // KST 월 00:02 (UTC 일 15:02) — 회차 시작일 출석 PENDING 생성 + { name: 'attendance-absent', cron: '2 15 * * 1' }, // KST 화 00:02 (UTC 월 15:02) — PENDING → ABSENT + 벌금 + { name: 'fine-reminder', cron: '0 0 * * *' }, // KST 매일 09:00 (UTC 00:00) + { name: 'round-report', cron: '0 23 * * 1' }, // KST 화 08:00 (UTC 월 23:00) + { name: 'round-start', cron: '0 23 * * 0' }, // KST 월 08:00 (UTC 일 23:00) + { name: 'curation-crawl', cron: '0 23 * * *' }, // 4기 미사용 + { name: 'curation-share', cron: '5 10 * * *' }, // 4기 미사용 + { name: 'weekly-ranking', cron: '0 1 * * 0' }, // KST 일 10:00 (UTC 일 01:00) + { name: 'deadline-reminder', cron: '0 23 * * *' }, // KST 매일 08:00 (UTC 23:00) + { name: 'popular-posts', cron: '5 23 * * 1' }, // KST 화 08:05 (UTC 월 23:05) ] as const; /** @@ -69,6 +75,7 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise null) - ?? extractFirstImage(item.description); + const thumbnailUrl = + (await extractOgImage(item.link).catch(() => null)) ?? extractFirstImage(item.description); const result = await postService.create({ memberId: member.id, @@ -98,7 +105,14 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise { + logger.warn({ postId: result.post.id, error }, '📡 [RSS] 포스트 임베딩 갱신 실패'); + }); // P0 #3: 출석 상태 업데이트 (제출 또는 지각) — active 유저만 if (currentRound && member.status === 'active') { @@ -114,10 +128,13 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise r.status === AttendanceStatus.ABSENT); - logger.info(`✅ [결석 처리] ${prevRound.roundNumber}회차 ${absentRecords.length}명 결석 처리`); + const absentRecords = processedRecords.filter((r) => r.status === AttendanceStatus.ABSENT); + logger.info( + `✅ [결석 처리] ${prevRound.roundNumber}회차 ${absentRecords.length}명 결석 처리` + ); for (const record of absentRecords) { try { await fineService.create(record.memberId, prevRound.id, 'absent'); } catch (fineError) { Sentry.captureException(fineError); - logger.error({ memberId: record.memberId, error: fineError }, '✅ [결석 처리] 벌금 부과 실패'); + logger.error( + { memberId: record.memberId, error: fineError }, + '✅ [결석 처리] 벌금 부과 실패' + ); } } } catch (error) { @@ -335,7 +364,6 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise { @@ -349,7 +377,7 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); // THEN schedule all cron jobs (after queues are created) for (const job of JOB_DEFINITIONS) { diff --git a/packages/bot/src/scripts/backfill-curation-embeddings.ts b/packages/bot/src/scripts/backfill-curation-embeddings.ts new file mode 100644 index 0000000..0cc1ff9 --- /dev/null +++ b/packages/bot/src/scripts/backfill-curation-embeddings.ts @@ -0,0 +1,24 @@ +import { config } from 'dotenv'; +import { resolve } from 'node:path'; +import { closeDb } from '@blog-study/shared/db'; +import { getEmbeddingService } from '../services/embedding.service'; + +config({ path: resolve(process.cwd(), '../../.env.local') }); +config({ path: resolve(process.cwd(), '../../.env') }); +config({ path: resolve(process.cwd(), '.env.local') }); +config({ path: resolve(process.cwd(), '.env') }); + +async function main() { + const limit = Number(process.argv[2] ?? 50); + const updated = await getEmbeddingService().backfillMissingCurationItems(limit); + console.log(`Updated ${updated} curation item embeddings`); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await closeDb(); + }); diff --git a/packages/bot/src/scripts/backfill-member-preference-embeddings.ts b/packages/bot/src/scripts/backfill-member-preference-embeddings.ts new file mode 100644 index 0000000..eb5e5ae --- /dev/null +++ b/packages/bot/src/scripts/backfill-member-preference-embeddings.ts @@ -0,0 +1,23 @@ +import { config } from 'dotenv'; +import { resolve } from 'node:path'; +import { closeDb } from '@blog-study/shared/db'; +import { getEmbeddingService } from '../services/embedding.service'; + +config({ path: resolve(process.cwd(), '../../.env.local') }); +config({ path: resolve(process.cwd(), '../../.env') }); +config({ path: resolve(process.cwd(), '.env.local') }); +config({ path: resolve(process.cwd(), '.env') }); + +async function main() { + const updated = await getEmbeddingService().backfillActiveMemberPreferences(); + console.log(`Updated ${updated} member preference embeddings`); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await closeDb(); + }); diff --git a/packages/bot/src/scripts/backfill-post-embeddings.ts b/packages/bot/src/scripts/backfill-post-embeddings.ts new file mode 100644 index 0000000..e52192e --- /dev/null +++ b/packages/bot/src/scripts/backfill-post-embeddings.ts @@ -0,0 +1,24 @@ +import { config } from 'dotenv'; +import { resolve } from 'node:path'; +import { closeDb } from '@blog-study/shared/db'; +import { getEmbeddingService } from '../services/embedding.service'; + +config({ path: resolve(process.cwd(), '../../.env.local') }); +config({ path: resolve(process.cwd(), '../../.env') }); +config({ path: resolve(process.cwd(), '.env.local') }); +config({ path: resolve(process.cwd(), '.env') }); + +async function main() { + const limit = Number(process.argv[2] ?? 50); + const updated = await getEmbeddingService().backfillMissingPosts(limit); + console.log(`Updated ${updated} post embeddings`); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await closeDb(); + }); diff --git a/packages/bot/src/services/curation.service.ts b/packages/bot/src/services/curation.service.ts index 1fc8f1a..4229ccd 100644 --- a/packages/bot/src/services/curation.service.ts +++ b/packages/bot/src/services/curation.service.ts @@ -17,6 +17,7 @@ import { } from '@blog-study/shared/db'; import logger, { serializeError } from '../lib/logger'; import { getKeywordService } from './keyword.service'; +import { getEmbeddingService } from './embedding.service'; /** * Curation source with item count @@ -65,15 +66,17 @@ export class CurationService { * @param category Category (conference or article) * @returns Created curation source */ - async addSource( - url: string, - name: string, - category: string - ): Promise { + async addSource(url: string, name: string, category: string): Promise { // Validate category const validCategories = Object.values(CurationCategory); - if (!validCategories.includes(category as typeof CurationCategory[keyof typeof CurationCategory])) { - throw new Error(`Invalid category: ${category}. Must be one of: ${validCategories.join(', ')}`); + if ( + !validCategories.includes( + category as (typeof CurationCategory)[keyof typeof CurationCategory] + ) + ) { + throw new Error( + `Invalid category: ${category}. Must be one of: ${validCategories.join(', ')}` + ); } // Check for duplicate URL @@ -89,10 +92,7 @@ export class CurationService { isActive: true, }; - const [created] = await this.db - .insert(curationSources) - .values(newSource) - .returning(); + const [created] = await this.db.insert(curationSources).values(newSource).returning(); return created!; } @@ -103,14 +103,10 @@ export class CurationService { */ async removeSource(sourceId: string): Promise { // First delete all items from this source - await this.db - .delete(curationItems) - .where(eq(curationItems.sourceId, sourceId)); + await this.db.delete(curationItems).where(eq(curationItems.sourceId, sourceId)); // Then delete the source - await this.db - .delete(curationSources) - .where(eq(curationSources.id, sourceId)); + await this.db.delete(curationSources).where(eq(curationSources.id, sourceId)); } /** @@ -148,10 +144,7 @@ export class CurationService { * @returns Array of active sources */ async getAllActiveSources(): Promise { - return this.db - .select() - .from(curationSources) - .where(eq(curationSources.isActive, true)); + return this.db.select().from(curationSources).where(eq(curationSources.isActive, true)); } /** @@ -168,13 +161,9 @@ export class CurationService { * @param isActive New active status */ async setSourceActive(sourceId: string, isActive: boolean): Promise { - await this.db - .update(curationSources) - .set({ isActive }) - .where(eq(curationSources.id, sourceId)); + await this.db.update(curationSources).set({ isActive }).where(eq(curationSources.id, sourceId)); } - /** * Add a curation item * Requirements: 13.4 - Store content with extracted metadata @@ -188,10 +177,18 @@ export class CurationService { return existing; } - const [created] = await this.db - .insert(curationItems) - .values(item) - .returning(); + const [created] = await this.db.insert(curationItems).values(item).returning(); + + if (created) { + void getEmbeddingService() + .refreshCurationItem(created.id) + .catch((error) => { + logger.warn( + { itemId: created.id, error: serializeError(error) }, + '[CurationService] Failed to refresh curation item embedding' + ); + }); + } return created!; } @@ -264,10 +261,10 @@ export class CurationService { */ async calculateRelevanceScore(title: string, tags: string[]): Promise { const keywordService = getKeywordService(); - + // Combine title and tags for analysis const content = [title, ...tags].join(' '); - + return keywordService.calculateRelevanceScore(content); } @@ -299,7 +296,7 @@ export class CurationService { for (const source of sources) { try { let crawledItems: CrawledContent[] = []; - + if (crawlFunction) { crawledItems = await crawlFunction(source.url); } @@ -312,10 +309,7 @@ export class CurationService { if (existing) continue; // Calculate relevance score - const relevanceScore = await this.calculateRelevanceScore( - crawled.title, - crawled.tags - ); + const relevanceScore = await this.calculateRelevanceScore(crawled.title, crawled.tags); // Add new item await this.addItem({ @@ -343,8 +337,11 @@ export class CurationService { }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - logger.error({ source: source.name, error: serializeError(error) }, '[CurationService] Error crawling source'); - + logger.error( + { source: source.name, error: serializeError(error) }, + '[CurationService] Error crawling source' + ); + results.push({ sourceId: source.id, sourceName: source.name, @@ -367,7 +364,7 @@ export class CurationService { async selectDailyContent(): Promise { // Get unshared items sorted by relevance score (highest first) const items = await this.getUnsharedItems(1); - + if (items.length === 0) { return null; } @@ -430,11 +427,11 @@ export class CurationService { unsharedItems: number; }> { const allSources = await this.getAllSources(); - const activeSources = allSources.filter(s => s.isActive); - + const activeSources = allSources.filter((s) => s.isActive); + const allItems = await this.db.select().from(curationItems); - const sharedItems = allItems.filter(i => i.isShared); - + const sharedItems = allItems.filter((i) => i.isShared); + return { totalSources: allSources.length, activeSources: activeSources.length, diff --git a/packages/bot/src/services/embedding.service.ts b/packages/bot/src/services/embedding.service.ts new file mode 100644 index 0000000..4fe2108 --- /dev/null +++ b/packages/bot/src/services/embedding.service.ts @@ -0,0 +1,290 @@ +import { eq, isNull, or } from 'drizzle-orm'; +import { + buildCurationItemEmbeddingText, + buildMemberPreferenceText, + buildPostEmbeddingText, + sha256Text, +} from '@blog-study/shared'; +import { + curationItems, + curationSources, + getDb, + memberPreferenceEmbeddings, + members, + MemberStatus, + postEmbeddings, + posts, + rounds, +} from '@blog-study/shared/db'; +import logger, { serializeError } from '../lib/logger'; + +const DEFAULT_PROVIDER = process.env.EMBEDDING_PROVIDER || 'ollama'; +const DEFAULT_BASE_URL = process.env.EMBEDDING_BASE_URL || 'http://localhost:11434'; +const DEFAULT_MODEL = process.env.EMBEDDING_MODEL || 'nomic-embed-text'; +const DEFAULT_DIMENSIONS = Number(process.env.EMBEDDING_DIMENSIONS || 768); + +function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +function accessHeaders(): Record { + const headers: Record = {}; + if (process.env.EMBEDDING_ACCESS_CLIENT_ID) { + headers['CF-Access-Client-Id'] = process.env.EMBEDDING_ACCESS_CLIENT_ID; + } + if (process.env.EMBEDDING_ACCESS_CLIENT_SECRET) { + headers['CF-Access-Client-Secret'] = process.env.EMBEDDING_ACCESS_CLIENT_SECRET; + } + if (process.env.EMBEDDING_API_KEY) { + headers.Authorization = `Bearer ${process.env.EMBEDDING_API_KEY}`; + } + return headers; +} + +export class EmbeddingService { + private db = getDb(); + private baseUrl = normalizeBaseUrl(DEFAULT_BASE_URL); + + private async embed(text: string): Promise { + try { + if (DEFAULT_PROVIDER === 'openai-compatible') { + return await this.embedOpenAICompatible(text); + } + return await this.embedOllama(text); + } catch (error) { + logger.warn({ error: serializeError(error) }, '[EmbeddingService] Embedding request failed'); + return null; + } + } + + private async embedOllama(text: string): Promise { + const response = await fetch(`${this.baseUrl}/api/embed`, { + method: 'POST', + headers: { + ...accessHeaders(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: DEFAULT_MODEL, + input: text, + }), + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + throw new Error(`Ollama embedding failed: HTTP ${response.status}`); + } + + const data = (await response.json()) as { embeddings?: number[][] }; + const embedding = data.embeddings?.[0] ?? null; + return this.validateEmbedding(embedding); + } + + private async embedOpenAICompatible(text: string): Promise { + const response = await fetch(`${this.baseUrl}/embeddings`, { + method: 'POST', + headers: { + ...accessHeaders(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: DEFAULT_MODEL, + input: text, + }), + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + throw new Error(`OpenAI-compatible embedding failed: HTTP ${response.status}`); + } + + const data = (await response.json()) as { data?: Array<{ embedding?: number[] }> }; + const embedding = data.data?.[0]?.embedding ?? null; + return this.validateEmbedding(embedding); + } + + private validateEmbedding(embedding: number[] | null): number[] | null { + if (!embedding) return null; + if (embedding.length !== DEFAULT_DIMENSIONS) { + throw new Error( + `Embedding dimension mismatch: expected ${DEFAULT_DIMENSIONS}, got ${embedding.length}` + ); + } + return embedding; + } + + async refreshCurationItem(itemId: string): Promise { + const [row] = await this.db + .select({ + id: curationItems.id, + title: curationItems.title, + description: curationItems.description, + tags: curationItems.tags, + sourceName: curationSources.name, + }) + .from(curationItems) + .leftJoin(curationSources, eq(curationItems.sourceId, curationSources.id)) + .where(eq(curationItems.id, itemId)) + .limit(1); + + if (!row) return false; + + const embeddingText = buildCurationItemEmbeddingText(row); + const embeddingTextHash = sha256Text(embeddingText); + const embedding = await this.embed(embeddingText); + if (!embedding) return false; + + await this.db + .update(curationItems) + .set({ + embedding, + embeddingTextHash, + embeddingModel: DEFAULT_MODEL, + embeddedAt: new Date(), + }) + .where(eq(curationItems.id, itemId)); + + return true; + } + + async refreshMemberPreference(memberId: string): Promise { + const [member] = await this.db + .select({ + id: members.id, + part: members.part, + bio: members.bio, + interests: members.interests, + }) + .from(members) + .where(eq(members.id, memberId)) + .limit(1); + + if (!member) return false; + + const preferenceText = buildMemberPreferenceText(member); + const preferenceTextHash = sha256Text(preferenceText); + const embedding = await this.embed(preferenceText); + if (!embedding) return false; + + await this.db + .insert(memberPreferenceEmbeddings) + .values({ + memberId, + preferenceText, + preferenceTextHash, + embedding, + embeddingModel: DEFAULT_MODEL, + refreshedAt: new Date(), + }) + .onConflictDoUpdate({ + target: memberPreferenceEmbeddings.memberId, + set: { + preferenceText, + preferenceTextHash, + embedding, + embeddingModel: DEFAULT_MODEL, + refreshedAt: new Date(), + }, + }); + + return true; + } + + async refreshPost(postId: string): Promise { + const [row] = await this.db + .select({ + id: posts.id, + title: posts.title, + description: posts.description, + authorPart: members.part, + authorBio: members.bio, + authorInterests: members.interests, + authorNickname: members.nickname, + roundNumber: rounds.roundNumber, + }) + .from(posts) + .leftJoin(members, eq(posts.memberId, members.id)) + .leftJoin(rounds, eq(posts.roundId, rounds.id)) + .where(eq(posts.id, postId)) + .limit(1); + + if (!row) return false; + + const embeddingText = buildPostEmbeddingText(row); + const embeddingTextHash = sha256Text(embeddingText); + const embedding = await this.embed(embeddingText); + if (!embedding) return false; + + await this.db + .insert(postEmbeddings) + .values({ + postId, + embedding, + embeddingText, + embeddingTextHash, + embeddingModel: DEFAULT_MODEL, + embeddedAt: new Date(), + }) + .onConflictDoUpdate({ + target: postEmbeddings.postId, + set: { + embedding, + embeddingText, + embeddingTextHash, + embeddingModel: DEFAULT_MODEL, + embeddedAt: new Date(), + }, + }); + + return true; + } + + async backfillMissingCurationItems(limit = 50): Promise { + const rows = await this.db + .select({ id: curationItems.id }) + .from(curationItems) + .where(or(isNull(curationItems.embedding), isNull(curationItems.embeddedAt))) + .limit(limit); + + let updated = 0; + for (const row of rows) { + if (await this.refreshCurationItem(row.id)) updated++; + } + return updated; + } + + async backfillMissingPosts(limit = 50): Promise { + const rows = await this.db + .select({ id: posts.id }) + .from(posts) + .leftJoin(postEmbeddings, eq(postEmbeddings.postId, posts.id)) + .where(or(isNull(postEmbeddings.postId), isNull(postEmbeddings.embeddedAt))) + .limit(limit); + + let updated = 0; + for (const row of rows) { + if (await this.refreshPost(row.id)) updated++; + } + return updated; + } + + async backfillActiveMemberPreferences(): Promise { + const rows = await this.db + .select({ id: members.id }) + .from(members) + .where(eq(members.status, MemberStatus.ACTIVE)); + + let updated = 0; + for (const row of rows) { + if (await this.refreshMemberPreference(row.id)) updated++; + } + return updated; + } +} + +let embeddingService: EmbeddingService | null = null; + +export function getEmbeddingService(): EmbeddingService { + embeddingService ??= new EmbeddingService(); + return embeddingService; +} diff --git a/packages/bot/src/services/index.ts b/packages/bot/src/services/index.ts index 5fde562..7e1089d 100644 --- a/packages/bot/src/services/index.ts +++ b/packages/bot/src/services/index.ts @@ -11,5 +11,6 @@ export * from './fine.service'; export * from './notification.service'; export * from './keyword.service'; export * from './curation.service'; +export * from './embedding.service'; export * from './ranking.service'; export * from './score.service'; From 8446b00d9541f5a4bb45ff06338287a6083b2ef6 Mon Sep 17 00:00:00 2001 From: choihooo Date: Fri, 22 May 2026 15:40:19 +0900 Subject: [PATCH 04/14] feat(web): rank curation with embeddings --- packages/web/src/app/(user)/curation/page.tsx | 270 ++++++++++++------ .../src/app/api/admin/curation/crawl/route.ts | 36 ++- packages/web/src/app/api/curation/route.ts | 162 +++++++++-- .../web/src/app/api/profile/edit/route.ts | 2 + .../src/app/api/profile/onboarding/route.ts | 3 + packages/web/src/lib/embedding-refresh.ts | 62 ++++ 6 files changed, 417 insertions(+), 118 deletions(-) create mode 100644 packages/web/src/lib/embedding-refresh.ts diff --git a/packages/web/src/app/(user)/curation/page.tsx b/packages/web/src/app/(user)/curation/page.tsx index cc500e3..ea2bd39 100644 --- a/packages/web/src/app/(user)/curation/page.tsx +++ b/packages/web/src/app/(user)/curation/page.tsx @@ -35,6 +35,14 @@ interface CurationItemResponse { relevanceScore: number; sharedAt: string | null; sourceName: string | null; + recommendationReason: { + summary: string; + reasons: string[]; + matchedKeywords: string[]; + semanticScore: number | null; + freshnessScore: number | null; + relevanceScore: number | null; + } | null; } interface CurationData { @@ -69,6 +77,42 @@ function isSafeUrl(url: string): boolean { } } +function toPercent(score: number | null): number | null { + if (score === null || !Number.isFinite(score)) return null; + return Math.round(Math.min(1, Math.max(0, score)) * 100); +} + +function RecommendationScoreBadges({ item }: { item: CurationItemResponse }) { + const reason = item.recommendationReason; + if (!reason) return null; + + const scores = [ + { label: '관심도', value: toPercent(reason.semanticScore) }, + { label: '최신성', value: toPercent(reason.freshnessScore) }, + { label: '관련도', value: toPercent(reason.relevanceScore), hideWhenZero: true }, + ].filter( + (score): score is { label: string; value: number; hideWhenZero?: boolean } => + score.value !== null && (!score.hideWhenZero || score.value > 0) + ); + + if (scores.length === 0) return null; + + return ( +
+ {scores.map((score) => ( + + {score.label} + {score.value}% + + ))} +
+ ); +} + // ───────────────────────────────────────────── // Thumbnail — img with gradient fallback // ───────────────────────────────────────────── @@ -141,9 +185,10 @@ function TagFilterList({ className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors cursor-pointer shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 - ${isSelected - ? 'border-transparent bg-primary text-primary-foreground' - : 'border-border text-foreground hover:bg-accent hover:text-accent-foreground' + ${ + isSelected + ? 'border-transparent bg-primary text-primary-foreground' + : 'border-border text-foreground hover:bg-accent hover:text-accent-foreground' }`} > #{tag} @@ -204,9 +249,11 @@ function CurationCard({ item }: { item: CurationItemResponse }) { {catStyle.label} {item.sharedAt && ( - + dark:bg-emerald-500/20 dark:text-emerald-300 dark:ring-emerald-500/30" + > @@ -223,11 +270,21 @@ function CurationCard({ item }: { item: CurationItemResponse }) { {/* Title */} -

+

{item.title}

+ {item.recommendationReason?.summary && ( +

+

+ )} + + {/* Description */} {item.description && (

@@ -239,7 +296,10 @@ function CurationCard({ item }: { item: CurationItemResponse }) {

{item.sourceName && ( - + {item.sourceName} )} @@ -247,7 +307,10 @@ function CurationCard({ item }: { item: CurationItemResponse }) { {relativeDate && {relativeDate}}
(새 탭에서 열기) -
@@ -285,8 +348,10 @@ function CurationListRow({ item }: { item: CurationItemResponse }) { {/* Content */}
{/* Title */} -

+

{item.title}

@@ -297,6 +362,14 @@ function CurationListRow({ item }: { item: CurationItemResponse }) {

)} + {item.recommendationReason?.summary && ( +

+

+ )} + + {/* Footer meta */}
{item.sharedAt && ( - + dark:bg-emerald-500/20 dark:text-emerald-300 dark:ring-emerald-500/30" + > @@ -323,22 +398,32 @@ function CurationListRow({ item }: { item: CurationItemResponse }) { ))} {item.sourceName && ( - + {item.sourceName} )} {item.sourceName && relativeDate && ( - + )} {relativeDate && ( - {relativeDate} + + {relativeDate} + )}
{/* External link icon */} (새 탭에서 열기) -