Add knowledge structuring feature with incremental LLM processing
- Add structured_content column and STRUCTURING pipeline step - Split LLM structuring into TOC + per-section calls to avoid token limit - Save intermediate results to DB for real-time frontend polling (3s) - Add manual "정리하기" button with async processing - Fix browser login modal by customizing authentication entry point - Fix standalone build symlinks for server.js and static files - Add troubleshooting guide Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
212
docs/troubleshooting.md
Normal file
212
docs/troubleshooting.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# 트러블슈팅 가이드
|
||||||
|
|
||||||
|
## 1. 프론트엔드 502 Bad Gateway
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 브라우저에서 `502 Bad Gateway nginx/1.20.1` 표시
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
Next.js standalone 빌드 시 `server.js`가 `sundol-frontend/` 서브디렉토리에 생성되는 경우가 있음.
|
||||||
|
PM2가 `.next/standalone/server.js`를 찾지만 실제 파일은 `.next/standalone/sundol-frontend/server.js`에 위치.
|
||||||
|
|
||||||
|
### 확인 방법
|
||||||
|
```bash
|
||||||
|
pm2 list # sundol-frontend 상태 확인 (errored 여부)
|
||||||
|
pm2 logs sundol-frontend --err --lines 20 # 에러 로그 확인
|
||||||
|
# "Cannot find module .../standalone/server.js" 에러가 나면 이 문제
|
||||||
|
```
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
```bash
|
||||||
|
# server.js 심볼릭 링크 생성
|
||||||
|
ln -sf /home/opc/sundol/sundol-frontend/.next/standalone/sundol-frontend/server.js \
|
||||||
|
/home/opc/sundol/sundol-frontend/.next/standalone/server.js
|
||||||
|
|
||||||
|
pm2 restart sundol-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
> `build.sh`에 이미 자동 처리 로직 포함되어 있음. 문제가 반복되면 build.sh 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 프론트엔드 static 파일 404
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 페이지는 로드되지만 CSS/JS가 404
|
||||||
|
- 브라우저 콘솔에 `_next/static/chunks/...js net::ERR_ABORTED 404` 다수 표시
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
standalone 빌드에서 `.next/static` 심볼릭 링크가 올바른 위치에 걸리지 않음.
|
||||||
|
`server.js`가 `standalone/sundol-frontend/` 안에서 실행되므로 static도 그 안에 있어야 함.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
```bash
|
||||||
|
# 중첩 디렉토리에도 static 링크 생성
|
||||||
|
ln -sf /home/opc/sundol/sundol-frontend/.next/static \
|
||||||
|
/home/opc/sundol/sundol-frontend/.next/standalone/sundol-frontend/.next/static
|
||||||
|
|
||||||
|
pm2 restart sundol-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
> `build.sh`에 자동 처리 로직 포함되어 있음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 브라우저 로그인 모달 (HTTP Basic Auth 팝업)
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 페이지 리프레시 시 브라우저 기본 로그인 팝업(username/password)이 뜸
|
||||||
|
- 특히 JWT 토큰이 만료되었거나 없을 때 발생
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
Spring Security가 401 응답에 `WWW-Authenticate: Basic` 헤더를 포함.
|
||||||
|
브라우저가 이 헤더를 감지하면 자동으로 Basic Auth 로그인 다이얼로그를 표시.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
`SecurityConfig.java`에 커스텀 `authenticationEntryPoint` 설정:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.exceptionHandling(exceptions -> exceptions
|
||||||
|
.authenticationEntryPoint((exchange, ex) -> {
|
||||||
|
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||||
|
return exchange.getResponse().setComplete();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
이렇게 하면 `WWW-Authenticate` 헤더 없이 401만 반환되어 브라우저 팝업이 뜨지 않음.
|
||||||
|
프론트엔드의 axios 인터셉터가 401을 감지하여 자동으로 토큰 갱신 처리.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. YouTube 자막 가져오기 실패 (Caption XML 0 chars)
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- YouTube 트랜스크립트 가져오기 시 "자막 텍스트를 파싱할 수 없습니다" 에러
|
||||||
|
- 로그에 `Caption XML fetched: 0 chars` 표시
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
YouTube timedtext API의 caption URL이 서명 기반으로, 브라우저 세션 외부에서 요청하면 빈 응답 반환.
|
||||||
|
`HttpURLConnection`이나 `context.request().get()`으로는 쿠키/세션이 유지되지 않음.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
두 가지 방법으로 fallback 처리:
|
||||||
|
|
||||||
|
1. **방법 A (우선)**: YouTube 페이지에서 '스크립트 표시' 패널을 열어 DOM에서 직접 텍스트 추출
|
||||||
|
2. **방법 B**: caption URL에 `&fmt=json3` 추가 후 `page.evaluate(fetch())`로 브라우저 내에서 요청
|
||||||
|
|
||||||
|
자세한 내용은 [crawling-guide.md](crawling-guide.md) 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Playwright 브라우저 창이 사라짐
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- VNC에서 Playwright Chromium 창이 보이다가 사라짐
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
`page.close()` 호출 시 마지막 탭이 닫히면 브라우저 창이 빈 상태가 됨.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
`page.close()` 대신 `page.navigate("about:blank")`으로 변경하여 탭을 유지:
|
||||||
|
|
||||||
|
```java
|
||||||
|
try {
|
||||||
|
page.navigate("about:blank");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
page.close();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Playwright 의존 라이브러리 누락 경고
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 백엔드 로그에 `Host system is missing dependencies to run browsers` 경고
|
||||||
|
- `libicudata.so.66`, `libwoff2dec.so.1.0.2` 등 누락 표시
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
Playwright가 WebKit 브라우저용 의존성까지 검사함. Chromium만 사용하면 대부분 문제없음.
|
||||||
|
|
||||||
|
### 확인
|
||||||
|
```bash
|
||||||
|
# Chromium 실행 가능 여부 직접 테스트
|
||||||
|
/home/opc/.cache/ms-playwright/chromium-1161/chrome-linux/chrome --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 해결 (경고 제거하려면)
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y libicu woff2 harfbuzz-icu libjpeg-turbo libwebp enchant2 hyphen libffi
|
||||||
|
```
|
||||||
|
|
||||||
|
> `libx264`는 WebKit 전용이므로 Chromium만 사용 시 불필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Git Push 인증 실패
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- `git push origin main` 시 `could not read Username` 에러
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
`.netrc`이나 `.git-credentials`에 인증 정보가 없음.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
`.env`의 `GIT_USER`, `GIT_PASSWORD` 사용 (비밀번호에 특수문자 포함 시 URL 인코딩 필요):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a && source /home/opc/sundol/.env && set +a
|
||||||
|
ENCODED_PW=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$GIT_PASSWORD', safe=''))")
|
||||||
|
git push "https://${GIT_USER}:${ENCODED_PW}@gittea.cloud-handson.com/joungmin/sundol.git" main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. LLM 구조화 내용이 잘림
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 지식 정리(구조화) 결과가 중간에 끊김
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
OCI GenAI의 `maxTokens`가 4096으로 제한되어 있어 긴 콘텐츠 정리 시 응답이 잘림.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
한 번에 전체를 정리하지 않고, 나눠서 요청하는 방식으로 변경:
|
||||||
|
|
||||||
|
1. **1차 호출**: Abstract + 목차만 생성
|
||||||
|
2. **2차~ 호출**: 목차 항목별로 상세 정리 요청 (각각 maxTokens 4096 내에서 충분)
|
||||||
|
3. **최종 조합**: 모든 결과를 합침
|
||||||
|
|
||||||
|
각 섹션 완료 시 DB에 중간 저장하여 프론트엔드에서 실시간 확인 가능.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. VNC 접속 안 됨
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- VNC 클라이언트에서 접속 불가
|
||||||
|
|
||||||
|
### 확인
|
||||||
|
```bash
|
||||||
|
# VNC 서버 실행 확인
|
||||||
|
vncserver -list
|
||||||
|
|
||||||
|
# 방화벽 확인
|
||||||
|
sudo firewall-cmd --list-ports
|
||||||
|
|
||||||
|
# OCI 보안 목록에서 TCP 5901 인바운드 허용 여부 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
```bash
|
||||||
|
# VNC 서버 재시작
|
||||||
|
vncserver -kill :1
|
||||||
|
vncserver :1 -geometry 1920x1080 -depth 24
|
||||||
|
|
||||||
|
# 방화벽 포트 열기
|
||||||
|
sudo firewall-cmd --permanent --add-port=5901/tcp
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
자세한 내용은 [setup-xwindow.md](setup-xwindow.md) 참조.
|
||||||
@@ -9,6 +9,8 @@ import org.springframework.security.config.annotation.web.reactive.EnableWebFlux
|
|||||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||||
|
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.cors.reactive.CorsConfigurationSource;
|
import org.springframework.web.cors.reactive.CorsConfigurationSource;
|
||||||
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
||||||
@@ -41,6 +43,12 @@ public class SecurityConfig {
|
|||||||
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
|
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
|
||||||
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
||||||
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
||||||
|
.exceptionHandling(exceptions -> exceptions
|
||||||
|
.authenticationEntryPoint((exchange, ex) -> {
|
||||||
|
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||||
|
return exchange.getResponse().setComplete();
|
||||||
|
})
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,16 @@ public class KnowledgeController {
|
|||||||
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/structure")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> structure(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody(required = false) Map<String, String> body) {
|
||||||
|
String modelId = body != null ? body.get("modelId") : null;
|
||||||
|
return knowledgeService.structureContent(userId, id, modelId)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/chunks")
|
@GetMapping("/{id}/chunks")
|
||||||
public Mono<ResponseEntity<List<Map<String, Object>>>> getChunks(
|
public Mono<ResponseEntity<List<Map<String, Object>>>> getChunks(
|
||||||
@AuthenticationPrincipal String userId,
|
@AuthenticationPrincipal String userId,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public class KnowledgeRepository {
|
|||||||
|
|
||||||
public Map<String, Object> findById(String userId, String id) {
|
public Map<String, Object> findById(String userId, String id) {
|
||||||
var results = jdbcTemplate.queryForList(
|
var results = jdbcTemplate.queryForList(
|
||||||
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, status, created_at, updated_at " +
|
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, structured_content, status, created_at, updated_at " +
|
||||||
"FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
"FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
id, userId
|
id, userId
|
||||||
);
|
);
|
||||||
@@ -64,7 +64,7 @@ public class KnowledgeRepository {
|
|||||||
|
|
||||||
public Map<String, Object> findByIdInternal(String id) {
|
public Map<String, Object> findByIdInternal(String id) {
|
||||||
var results = jdbcTemplate.queryForList(
|
var results = jdbcTemplate.queryForList(
|
||||||
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, status, created_at, updated_at " +
|
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, structured_content, status, created_at, updated_at " +
|
||||||
"FROM knowledge_items WHERE RAWTOHEX(id) = ?",
|
"FROM knowledge_items WHERE RAWTOHEX(id) = ?",
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
@@ -85,6 +85,13 @@ public class KnowledgeRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void updateStructuredContent(String id, String structuredContent) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE knowledge_items SET structured_content = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
structuredContent, id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public void delete(String userId, String id) {
|
public void delete(String userId, String id) {
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"DELETE FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
"DELETE FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
|||||||
@@ -50,6 +50,140 @@ public class IngestPipelineService {
|
|||||||
|
|
||||||
private static final int TITLE_MAX_LENGTH = 80;
|
private static final int TITLE_MAX_LENGTH = 80;
|
||||||
private static final int TEXT_PREVIEW_LENGTH = 3000;
|
private static final int TEXT_PREVIEW_LENGTH = 3000;
|
||||||
|
private static final int STRUCTURING_MIN_LENGTH = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM으로 콘텐츠를 구조화: Abstract + 목차 + 목차별 상세 정리.
|
||||||
|
* 1000자 이상일 때만 실행.
|
||||||
|
* 1차 호출: Abstract + 목차 생성, 2차~ 호출: 목차별 상세 정리, 최종 조합.
|
||||||
|
*/
|
||||||
|
public String structureContent(String text, String modelId, String knowledgeItemId) {
|
||||||
|
if (!genAiService.isConfigured()) {
|
||||||
|
log.info("OCI GenAI not configured, skipping structuring");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length() < STRUCTURING_MIN_LENGTH) {
|
||||||
|
log.info("Content too short for structuring ({} chars), skipping", text.length());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String content = text.length() > 30000 ? text.substring(0, 30000) : text;
|
||||||
|
|
||||||
|
// === 1차 호출: Abstract + 목차 생성 ===
|
||||||
|
String tocSystemMsg =
|
||||||
|
"당신은 콘텐츠 분석 전문가입니다. 주어진 원본 텍스트를 분석하여 요약과 목차만 생성해주세요.\n\n" +
|
||||||
|
"## 규칙\n" +
|
||||||
|
"1. 원본 언어와 같은 언어로 작성하세요.\n" +
|
||||||
|
"2. Markdown 형식으로 작성하세요.\n" +
|
||||||
|
"3. 아래 구조를 반드시 따르세요:\n\n" +
|
||||||
|
"```\n" +
|
||||||
|
"# 요약 (Abstract)\n" +
|
||||||
|
"(핵심 내용을 3~5문장으로 요약)\n\n" +
|
||||||
|
"# 목차\n" +
|
||||||
|
"1. 첫 번째 주제\n" +
|
||||||
|
"2. 두 번째 주제\n" +
|
||||||
|
"...\n" +
|
||||||
|
"```\n\n" +
|
||||||
|
"4. 목차 항목은 원본 내용의 흐름에 맞게 논리적으로 나누세요.\n" +
|
||||||
|
"5. 목차는 5~15개 사이로 적절히 나누세요.\n" +
|
||||||
|
"6. 목차에는 번호와 제목만 넣고, 상세 내용은 넣지 마세요.\n" +
|
||||||
|
"7. 원본에 없는 내용을 추가하지 마세요.";
|
||||||
|
|
||||||
|
String tocUserMsg = "아래 원본 텍스트의 요약과 목차를 생성해주세요:\n\n" + content;
|
||||||
|
String tocResult = genAiService.chat(tocSystemMsg, tocUserMsg, modelId).strip();
|
||||||
|
log.info("Phase 1 - TOC generated: {} chars", tocResult.length());
|
||||||
|
|
||||||
|
// 1차 결과 중간 저장
|
||||||
|
if (knowledgeItemId != null) {
|
||||||
|
knowledgeRepository.updateStructuredContent(knowledgeItemId, tocResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목차 항목 파싱
|
||||||
|
List<String> tocItems = parseTocItems(tocResult);
|
||||||
|
if (tocItems.isEmpty()) {
|
||||||
|
log.warn("No TOC items parsed, returning TOC-only result");
|
||||||
|
return tocResult;
|
||||||
|
}
|
||||||
|
log.info("Parsed {} TOC items: {}", tocItems.size(), tocItems);
|
||||||
|
|
||||||
|
// === 2차~ 호출: 목차별 상세 정리 ===
|
||||||
|
StringBuilder fullResult = new StringBuilder(tocResult).append("\n\n");
|
||||||
|
|
||||||
|
String sectionSystemMsg =
|
||||||
|
"당신은 콘텐츠 정리 전문가입니다. 주어진 원본 텍스트에서 지정된 섹션에 해당하는 내용만 상세히 정리해주세요.\n\n" +
|
||||||
|
"## 규칙\n" +
|
||||||
|
"1. 원본의 의미를 절대 왜곡하거나 생략하지 마세요. 디테일을 최대한 살려주세요.\n" +
|
||||||
|
"2. 원본 언어와 같은 언어로 작성하세요.\n" +
|
||||||
|
"3. Markdown 형식으로 작성하세요.\n" +
|
||||||
|
"4. 불릿 포인트, 번호 매기기, 굵은 글씨 등을 활용하여 가독성을 높이세요.\n" +
|
||||||
|
"5. 원본에 없는 내용을 추가하지 마세요.\n" +
|
||||||
|
"6. 해당 섹션과 관련 없는 내용은 포함하지 마세요.\n" +
|
||||||
|
"7. 섹션 제목은 '# 번호. 제목' 형식으로 시작하세요.";
|
||||||
|
|
||||||
|
for (int i = 0; i < tocItems.size(); i++) {
|
||||||
|
String tocItem = tocItems.get(i);
|
||||||
|
String sectionUserMsg = "원본 텍스트에서 아래 섹션에 해당하는 내용을 상세히 정리해주세요.\n\n" +
|
||||||
|
"## 정리할 섹션\n" +
|
||||||
|
(i + 1) + ". " + tocItem + "\n\n" +
|
||||||
|
"## 원본 텍스트\n" + content;
|
||||||
|
|
||||||
|
try {
|
||||||
|
String sectionResult = genAiService.chat(sectionSystemMsg, sectionUserMsg, modelId).strip();
|
||||||
|
fullResult.append(sectionResult).append("\n\n");
|
||||||
|
log.info("Phase 2 - Section {} '{}' generated: {} chars", i + 1, tocItem, sectionResult.length());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to generate section {}: {}", i + 1, e.getMessage());
|
||||||
|
fullResult.append("# ").append(i + 1).append(". ").append(tocItem).append("\n\n")
|
||||||
|
.append("(정리 실패)\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 섹션 완료될 때마다 중간 저장 (프론트엔드 폴링용)
|
||||||
|
if (knowledgeItemId != null) {
|
||||||
|
knowledgeRepository.updateStructuredContent(knowledgeItemId, fullResult.toString().strip());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String result = fullResult.toString().strip();
|
||||||
|
log.info("Structured content generated: {} chars ({} sections)", result.length(), tocItems.size());
|
||||||
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Content structuring failed", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목차 텍스트에서 항목들을 파싱한다.
|
||||||
|
* "1. 첫 번째 주제" 형태의 줄을 추출.
|
||||||
|
*/
|
||||||
|
private List<String> parseTocItems(String tocText) {
|
||||||
|
List<String> items = new java.util.ArrayList<>();
|
||||||
|
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("^\\d+\\.\\s+(.+)$", java.util.regex.Pattern.MULTILINE);
|
||||||
|
java.util.regex.Matcher matcher = pattern.matcher(tocText);
|
||||||
|
|
||||||
|
// "# 목차" 이후의 내용만 파싱
|
||||||
|
int tocStart = tocText.indexOf("# 목차");
|
||||||
|
if (tocStart == -1) tocStart = tocText.indexOf("# Table of Contents");
|
||||||
|
if (tocStart == -1) tocStart = 0;
|
||||||
|
String tocSection = tocText.substring(tocStart);
|
||||||
|
|
||||||
|
// 목차 섹션 이후 다음 '#'이 나오기 전까지만 파싱
|
||||||
|
int nextSection = tocSection.indexOf("\n#", 2);
|
||||||
|
if (nextSection > 0) {
|
||||||
|
tocSection = tocSection.substring(0, nextSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher = pattern.matcher(tocSection);
|
||||||
|
while (matcher.find()) {
|
||||||
|
String item = matcher.group(1).strip();
|
||||||
|
if (!item.isBlank()) {
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LLM으로 내용 기반 제목 생성. 실패 시 텍스트 앞부분으로 폴백.
|
* LLM으로 내용 기반 제목 생성. 실패 시 텍스트 앞부분으로 폴백.
|
||||||
@@ -178,6 +312,24 @@ public class IngestPipelineService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수동 구조화 요청 (비동기). 프론트엔드 버튼에서 호출.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void runStructuring(String knowledgeItemId, String text, String modelId) {
|
||||||
|
try {
|
||||||
|
String structured = structureContent(text, modelId, knowledgeItemId);
|
||||||
|
if (structured != null && !structured.isBlank()) {
|
||||||
|
knowledgeRepository.updateStructuredContent(knowledgeItemId, structured);
|
||||||
|
}
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "READY");
|
||||||
|
log.info("Manual structuring complete for item {}", knowledgeItemId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Manual structuring failed for item {}", knowledgeItemId, e);
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "READY");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Async
|
@Async
|
||||||
public void runPipeline(String knowledgeItemId, String modelId) {
|
public void runPipeline(String knowledgeItemId, String modelId) {
|
||||||
try {
|
try {
|
||||||
@@ -243,7 +395,19 @@ public class IngestPipelineService {
|
|||||||
knowledgeRepository.updateTitle(knowledgeItemId, autoTitle);
|
knowledgeRepository.updateTitle(knowledgeItemId, autoTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Chunk
|
// Step 2: Structure content (1000자 이상일 때만)
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "STRUCTURING");
|
||||||
|
try {
|
||||||
|
String structured = structureContent(extractedText, modelId, knowledgeItemId);
|
||||||
|
if (structured != null && !structured.isBlank()) {
|
||||||
|
knowledgeRepository.updateStructuredContent(knowledgeItemId, structured);
|
||||||
|
log.info("Item {} structured: {} chars", knowledgeItemId, structured.length());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Structuring failed for item {}, continuing pipeline", knowledgeItemId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Chunk
|
||||||
knowledgeRepository.updateStatus(knowledgeItemId, "CHUNKING");
|
knowledgeRepository.updateStatus(knowledgeItemId, "CHUNKING");
|
||||||
List<String> chunks = chunkingService.chunk(extractedText);
|
List<String> chunks = chunkingService.chunk(extractedText);
|
||||||
log.info("Item {} chunked into {} pieces", knowledgeItemId, chunks.size());
|
log.info("Item {} chunked into {} pieces", knowledgeItemId, chunks.size());
|
||||||
@@ -254,11 +418,11 @@ public class IngestPipelineService {
|
|||||||
chunkRepository.insertChunk(knowledgeItemId, i, chunkContent, tokenCount);
|
chunkRepository.insertChunk(knowledgeItemId, i, chunkContent, tokenCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Categorize
|
// Step 4: Categorize
|
||||||
knowledgeRepository.updateStatus(knowledgeItemId, "CATEGORIZING");
|
knowledgeRepository.updateStatus(knowledgeItemId, "CATEGORIZING");
|
||||||
categorize(knowledgeItemId, (String) item.get("USER_ID"), extractedText, modelId);
|
categorize(knowledgeItemId, (String) item.get("USER_ID"), extractedText, modelId);
|
||||||
|
|
||||||
// Step 4: Embedding
|
// Step 5: Embedding
|
||||||
knowledgeRepository.updateStatus(knowledgeItemId, "EMBEDDING");
|
knowledgeRepository.updateStatus(knowledgeItemId, "EMBEDDING");
|
||||||
embedChunks(knowledgeItemId, chunks);
|
embedChunks(knowledgeItemId, chunks);
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,42 @@ public class KnowledgeService {
|
|||||||
}).subscribeOn(Schedulers.boundedElastic());
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Mono<Map<String, Object>> structureContent(String userId, String id, String modelId) {
|
||||||
|
return Mono.fromCallable(() -> {
|
||||||
|
Map<String, Object> item = knowledgeRepository.findById(userId, id);
|
||||||
|
if (item == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw_text 또는 청크에서 원본 텍스트 가져오기
|
||||||
|
Object rawTextObj = item.get("RAW_TEXT");
|
||||||
|
String text = rawTextObj != null ? rawTextObj.toString() : null;
|
||||||
|
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
// WEB 타입은 raw_text가 없을 수 있으므로 청크에서 조합
|
||||||
|
var chunks = chunkRepository.findByKnowledgeItemId(id);
|
||||||
|
if (!chunks.isEmpty()) {
|
||||||
|
text = chunks.stream()
|
||||||
|
.map(c -> c.get("CONTENT").toString())
|
||||||
|
.collect(java.util.stream.Collectors.joining("\n\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
throw new AppException(HttpStatus.BAD_REQUEST, "No content to structure");
|
||||||
|
}
|
||||||
|
|
||||||
|
// STRUCTURING 상태로 변경 (프론트엔드 폴링에서 진행 중 표시)
|
||||||
|
knowledgeRepository.updateStatus(id, "STRUCTURING");
|
||||||
|
|
||||||
|
// 비동기로 구조화 실행 (중간 결과는 pipelineService가 DB에 직접 저장)
|
||||||
|
final String finalText = text;
|
||||||
|
pipelineService.runStructuring(id, finalText, modelId);
|
||||||
|
|
||||||
|
return knowledgeRepository.findById(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
public Mono<Void> delete(String userId, String id) {
|
public Mono<Void> delete(String userId, String id) {
|
||||||
return Mono.fromRunnable(() -> knowledgeRepository.delete(userId, id))
|
return Mono.fromRunnable(() -> knowledgeRepository.delete(userId, id))
|
||||||
.subscribeOn(Schedulers.boundedElastic()).then();
|
.subscribeOn(Schedulers.boundedElastic()).then();
|
||||||
|
|||||||
@@ -25,16 +25,33 @@ echo "=== [1/3] Next.js 빌드 ==="
|
|||||||
npx next build
|
npx next build
|
||||||
|
|
||||||
echo "=== [2/3] 심볼릭 링크 생성 ==="
|
echo "=== [2/3] 심볼릭 링크 생성 ==="
|
||||||
STATIC_SRC="$SCRIPT_DIR/.next/static"
|
STANDALONE_DIR="$SCRIPT_DIR/.next/standalone"
|
||||||
STATIC_DST="$SCRIPT_DIR/.next/standalone/.next/static"
|
|
||||||
|
|
||||||
|
# static 링크
|
||||||
|
STATIC_SRC="$SCRIPT_DIR/.next/static"
|
||||||
|
STATIC_DST="$STANDALONE_DIR/.next/static"
|
||||||
|
mkdir -p "$STANDALONE_DIR/.next"
|
||||||
if [ -L "$STATIC_DST" ] || [ -e "$STATIC_DST" ]; then
|
if [ -L "$STATIC_DST" ] || [ -e "$STATIC_DST" ]; then
|
||||||
rm -rf "$STATIC_DST"
|
rm -rf "$STATIC_DST"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ln -s "$STATIC_SRC" "$STATIC_DST"
|
ln -s "$STATIC_SRC" "$STATIC_DST"
|
||||||
echo "링크 생성 완료: $STATIC_DST -> $STATIC_SRC"
|
echo "링크 생성 완료: $STATIC_DST -> $STATIC_SRC"
|
||||||
|
|
||||||
|
# standalone output이 subdirectory에 생성되는 경우 대응
|
||||||
|
if [ -d "$STANDALONE_DIR/sundol-frontend" ]; then
|
||||||
|
# server.js 링크
|
||||||
|
if [ ! -f "$STANDALONE_DIR/server.js" ] && [ -f "$STANDALONE_DIR/sundol-frontend/server.js" ]; then
|
||||||
|
ln -sf "$STANDALONE_DIR/sundol-frontend/server.js" "$STANDALONE_DIR/server.js"
|
||||||
|
echo "server.js 링크 생성 완료"
|
||||||
|
fi
|
||||||
|
# nested static 링크
|
||||||
|
NESTED_STATIC="$STANDALONE_DIR/sundol-frontend/.next/static"
|
||||||
|
if [ ! -L "$NESTED_STATIC" ] && [ ! -e "$NESTED_STATIC" ]; then
|
||||||
|
ln -s "$STATIC_SRC" "$NESTED_STATIC"
|
||||||
|
echo "nested static 링크 생성 완료"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=== [3/3] PM2 재시작 ==="
|
echo "=== [3/3] PM2 재시작 ==="
|
||||||
pm2 restart sundol-frontend
|
pm2 restart sundol-frontend
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface KnowledgeItem {
|
|||||||
TITLE: string;
|
TITLE: string;
|
||||||
SOURCE_URL: string;
|
SOURCE_URL: string;
|
||||||
RAW_TEXT: string;
|
RAW_TEXT: string;
|
||||||
|
STRUCTURED_CONTENT: string | null;
|
||||||
STATUS: string;
|
STATUS: string;
|
||||||
CREATED_AT: string;
|
CREATED_AT: string;
|
||||||
UPDATED_AT: string;
|
UPDATED_AT: string;
|
||||||
@@ -37,6 +38,7 @@ const statusColors: Record<string, string> = {
|
|||||||
EXTRACTING: "bg-blue-500/20 text-blue-400",
|
EXTRACTING: "bg-blue-500/20 text-blue-400",
|
||||||
CHUNKING: "bg-purple-500/20 text-purple-400",
|
CHUNKING: "bg-purple-500/20 text-purple-400",
|
||||||
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
|
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
|
||||||
|
STRUCTURING: "bg-orange-500/20 text-orange-400",
|
||||||
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
|
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
|
||||||
READY: "bg-green-500/20 text-green-400",
|
READY: "bg-green-500/20 text-green-400",
|
||||||
FAILED: "bg-red-500/20 text-red-400",
|
FAILED: "bg-red-500/20 text-red-400",
|
||||||
@@ -74,6 +76,8 @@ export default function KnowledgeDetailPage() {
|
|||||||
const [showChunks, setShowChunks] = useState(false);
|
const [showChunks, setShowChunks] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [structuring, setStructuring] = useState(false);
|
||||||
|
const [showStructured, setShowStructured] = useState(true);
|
||||||
|
|
||||||
const fetchItem = async () => {
|
const fetchItem = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -103,7 +107,7 @@ export default function KnowledgeDetailPage() {
|
|||||||
// Poll while processing
|
// Poll while processing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const processing = ["PENDING", "EXTRACTING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS);
|
const processing = ["PENDING", "EXTRACTING", "STRUCTURING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS);
|
||||||
if (!processing) return;
|
if (!processing) return;
|
||||||
const interval = setInterval(fetchItem, 3000);
|
const interval = setInterval(fetchItem, 3000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
@@ -278,12 +282,13 @@ export default function KnowledgeDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{["PENDING", "EXTRACTING", "CHUNKING", "CATEGORIZING"].includes(item.STATUS) && (
|
{["PENDING", "EXTRACTING", "STRUCTURING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS) && (
|
||||||
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-center gap-3">
|
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-center gap-3">
|
||||||
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
|
||||||
<span className="text-sm text-blue-400">
|
<span className="text-sm text-blue-400">
|
||||||
{item.STATUS === "PENDING" && "파이프라인 대기 중..."}
|
{item.STATUS === "PENDING" && "파이프라인 대기 중..."}
|
||||||
{item.STATUS === "EXTRACTING" && "텍스트 추출 중..."}
|
{item.STATUS === "EXTRACTING" && "텍스트 추출 중..."}
|
||||||
|
{item.STATUS === "STRUCTURING" && "내용 정리 중..."}
|
||||||
{item.STATUS === "CHUNKING" && "청킹 처리 중..."}
|
{item.STATUS === "CHUNKING" && "청킹 처리 중..."}
|
||||||
{item.STATUS === "CATEGORIZING" && "카테고리 분류 중..."}
|
{item.STATUS === "CATEGORIZING" && "카테고리 분류 중..."}
|
||||||
{item.STATUS === "EMBEDDING" && "벡터 임베딩 중..."}
|
{item.STATUS === "EMBEDDING" && "벡터 임베딩 중..."}
|
||||||
@@ -291,6 +296,34 @@ export default function KnowledgeDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Structured Content */}
|
||||||
|
{item.STRUCTURED_CONTENT && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowStructured(!showStructured)}
|
||||||
|
className="text-sm text-[var(--color-primary)] hover:underline mb-3"
|
||||||
|
>
|
||||||
|
{showStructured ? "▼ 정리된 내용 숨기기" : "▶ 정리된 내용 보기"}
|
||||||
|
</button>
|
||||||
|
{showStructured && (
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] prose prose-invert max-w-none">
|
||||||
|
<div
|
||||||
|
className="text-sm leading-relaxed whitespace-pre-wrap"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: item.STRUCTURED_CONTENT
|
||||||
|
.replace(/^### (.+)$/gm, '<h3 class="text-base font-bold mt-4 mb-2 text-[var(--color-text)]">$1</h3>')
|
||||||
|
.replace(/^## (.+)$/gm, '<h2 class="text-lg font-bold mt-5 mb-2 text-[var(--color-text)]">$1</h2>')
|
||||||
|
.replace(/^# (.+)$/gm, '<h1 class="text-xl font-bold mt-6 mb-3 text-[var(--color-text)]">$1</h1>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
|
||||||
|
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Chunks toggle */}
|
{/* Chunks toggle */}
|
||||||
{item.STATUS === "READY" && (
|
{item.STATUS === "READY" && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -331,6 +364,34 @@ export default function KnowledgeDetailPage() {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="pt-4 border-t border-[var(--color-border)] flex items-center gap-4">
|
<div className="pt-4 border-t border-[var(--color-border)] flex items-center gap-4">
|
||||||
{item.STATUS === "READY" && (
|
{item.STATUS === "READY" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setStructuring(true);
|
||||||
|
setShowStructured(true);
|
||||||
|
try {
|
||||||
|
// 비동기 요청: 서버가 STRUCTURING 상태로 변경 후 백그라운드 처리
|
||||||
|
// 폴링이 자동으로 진행 상태를 갱신함
|
||||||
|
await request({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/knowledge/${id}/structure`,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
// 완료 후 최종 상태 갱신
|
||||||
|
await fetchItem();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to structure:", err);
|
||||||
|
alert("내용 정리에 실패했습니다.");
|
||||||
|
await fetchItem();
|
||||||
|
} finally {
|
||||||
|
setStructuring(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={structuring}
|
||||||
|
className="text-sm text-[var(--color-primary)] hover:underline disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{structuring ? "정리 중..." : item.STRUCTURED_CONTENT ? "다시 정리하기" : "내용 정리하기"}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
@@ -352,6 +413,7 @@ export default function KnowledgeDetailPage() {
|
|||||||
>
|
>
|
||||||
{generating ? "카드 생성 중..." : "스터디 카드 생성"}
|
{generating ? "카드 생성 중..." : "스터디 카드 생성"}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
|
|||||||
Reference in New Issue
Block a user