diff --git a/.claude/skills/shortform-queue/SKILL.md b/.claude/skills/shortform-queue/SKILL.md index 47ab0c6..791a21b 100644 --- a/.claude/skills/shortform-queue/SKILL.md +++ b/.claude/skills/shortform-queue/SKILL.md @@ -17,7 +17,8 @@ h-lab 서버가 꺼져 있으면 먼저 `.\gradlew.bat bootRun`(JAVA_HOME=D:\Dev b. Preview 패널의 새로고침(리셋) 아이콘 → **Start** 클릭. c. **완료 폴링**: 40~60초 간격 스크린샷. "Thinking... Step N" 진행 표시가 사라지고 Output(1===== 형식)이 렌더링되면 완료. 전체 4~5분 소요(Step 3 다섯 개가 각 ~90초 병렬). d. **Output 추출**: 앱 UI는 접근성 트리에 안 잡힌다. javascript_tool로 `document.getElementById('opal-app').contentDocument`에서 shadow DOM을 재귀 관통해 `"cuts"`를 포함한 **가장 긴** innerText를 찾는다. 텍스트가 크면 window 변수에 저장 후 900자 단위로 나눠 회수한다. - e. **저장**: `POST http://localhost:8088/api/shortform/jobs/{id}/result` body `{"rawText": "<추출 원문>"}`. 응답의 clips가 5개 미만이면 원문과 함께 보고. + d-2. **Step 1 결과 추출**: Console 탭에서 "Step 1: 하이라이트 후보 구간 추출" 항목을 UI 클릭으로 펼친 뒤(lazy 렌더라 JS로 details.open만 하면 내용이 안 붙을 수 있음 — 안 되면 화면 좌표 클릭), shadow DOM 관통으로 `"candidates"`를 포함한 **가장 짧은** innerText(Model Response 부분)를 회수한다. + e. **저장**: `POST http://localhost:8088/api/shortform/jobs/{id}/result` body `{"rawText": "", "step1Text": ""}`. step1Text는 추출 실패 시 생략 가능(비파괴). 응답의 clips가 5개 미만이면 원문과 함께 보고. 4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음). 5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약. diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java index fd27a34..22c82a1 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java @@ -35,12 +35,12 @@ public class ShortformController { @PostMapping("/jobs/{id}/result") public ApiResponse saveResult(@PathVariable Long id, @RequestBody ResultRequest request) { - return ApiResponse.ok(shortformService.saveResult(id, request.rawText())); + return ApiResponse.ok(shortformService.saveResult(id, request.rawText(), request.step1Text())); } @PostMapping("/import") public ApiResponse importResult(@RequestBody ImportRequest request) { - return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText())); + return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText(), request.step1Text())); } @DeleteMapping("/jobs/{id}") diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java index 50f734d..627b766 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java @@ -43,6 +43,10 @@ public class ShortformJob { @Column(columnDefinition = "TEXT") private String rawOutput; + /** Step 1(하이라이트 후보 구간 추출) 원문 — 구간 선정 이유가 담겨 최종 Output과 별개로 보존한다. */ + @Column(columnDefinition = "TEXT") + private String step1Output; + @CreationTimestamp @Column(updatable = false) private LocalDateTime createdAt; @@ -71,6 +75,12 @@ public class ShortformJob { this.completedAt = LocalDateTime.now(); } + public void attachStep1(String step1Text) { + if (step1Text != null && !step1Text.isBlank()) { + this.step1Output = step1Text; + } + } + public void markFailed(String raw) { this.rawOutput = raw; this.status = ShortformJobStatus.FAILED; diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java index d3f89d2..c794687 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java @@ -27,7 +27,7 @@ public class ShortformService { /** Opal 출력 원문을 파싱해 저장. 클립이 하나도 안 나오면 FAILED로 남긴다. */ @Transactional - public JobDetail saveResult(Long jobId, String rawText) { + public JobDetail saveResult(Long jobId, String rawText, String step1Text) { ShortformJob job = jobRepository.findById(jobId) .orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId)); List parsed = parser.parse(rawText); @@ -36,14 +36,15 @@ public class ShortformService { } else { job.applyResult(rawText, parsed); } + job.attachStep1(step1Text); return JobDetail.from(job); } /** 등록 + 결과 저장 한 번에 (Opal 수동 실행 후 붙여넣기 경로). */ @Transactional - public JobDetail importResult(String youtubeUrl, String rawText) { + public JobDetail importResult(String youtubeUrl, String rawText, String step1Text) { JobDetail registered = register(youtubeUrl); - return saveResult(registered.id(), rawText); + return saveResult(registered.id(), rawText, step1Text); } @Transactional(readOnly = true) diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java b/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java index 1a0984d..420080c 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java @@ -12,9 +12,9 @@ public final class ShortformDtos { public record RegisterRequest(String youtubeUrl) {} - public record ResultRequest(String rawText) {} + public record ResultRequest(String rawText, String step1Text) {} - public record ImportRequest(String youtubeUrl, String rawText) {} + public record ImportRequest(String youtubeUrl, String rawText, String step1Text) {} public record JobSummary(Long id, String youtubeUrl, String videoId, String title, String status, int clipCount, @@ -36,11 +36,11 @@ public final class ShortformDtos { public record JobDetail(Long id, String youtubeUrl, String videoId, String title, String status, LocalDateTime createdAt, LocalDateTime completedAt, - String rawOutput, List clips) { + String rawOutput, String step1Output, List clips) { public static JobDetail from(ShortformJob job) { return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(), job.getTitle(), job.getStatus().name(), job.getCreatedAt(), - job.getCompletedAt(), job.getRawOutput(), + job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(), job.getClips().stream().map(ClipDto::from).toList()); } } diff --git a/src/main/resources/templates/shortform.html b/src/main/resources/templates/shortform.html index 2503730..3907f07 100644 --- a/src/main/resources/templates/shortform.html +++ b/src/main/resources/templates/shortform.html @@ -107,10 +107,19 @@ + `; - el.querySelector('.sf-clips').innerHTML = toolbar + (detail.clips.length + const step1Block = detail.step1Output + ? `
+ Step 1: 하이라이트 후보 구간 (선정 이유 포함) +
${esc(detail.step1Output)}
+
` + : ''; + el.querySelector('.sf-clips').innerHTML = toolbar + step1Block + (detail.clips.length ? detail.clips.map(clipHtml).join('') : '

저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.

'); el.classList.add('open');