feat: 숏폼 큐에 Step 1 후보구간 원문 저장·복사 추가

ShortformJob.step1Output(TEXT) + result/import API에 step1Text 옵션 필드.
카드 펼침에 'Step 1 후보구간 복사' 버튼과 접이식 원문 표시.
shortform-queue 스킬에 콘솔 Step 1 추출 절차(d-2) 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 10:31:14 +09:00
parent 13dd61d80c
commit b48ab6494c
6 changed files with 32 additions and 11 deletions

View File

@ -17,7 +17,8 @@ h-lab 서버가 꺼져 있으면 먼저 `.\gradlew.bat bootRun`(JAVA_HOME=D:\Dev
b. Preview 패널의 새로고침(리셋) 아이콘 → **Start** 클릭. b. Preview 패널의 새로고침(리셋) 아이콘 → **Start** 클릭.
c. **완료 폴링**: 40~60초 간격 스크린샷. "Thinking... Step N" 진행 표시가 사라지고 Output(1===== 형식)이 렌더링되면 완료. 전체 4~5분 소요(Step 3 다섯 개가 각 ~90초 병렬). 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자 단위로 나눠 회수한다. 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": "<Output 원문>", "step1Text": "<Step 1 원문>"}`. step1Text는 추출 실패 시 생략 가능(비파괴). 응답의 clips가 5개 미만이면 원문과 함께 보고.
4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음). 4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음).
5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약. 5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약.

View File

@ -35,12 +35,12 @@ public class ShortformController {
@PostMapping("/jobs/{id}/result") @PostMapping("/jobs/{id}/result")
public ApiResponse<JobDetail> saveResult(@PathVariable Long id, @RequestBody ResultRequest request) { public ApiResponse<JobDetail> 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") @PostMapping("/import")
public ApiResponse<JobDetail> importResult(@RequestBody ImportRequest request) { public ApiResponse<JobDetail> 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}") @DeleteMapping("/jobs/{id}")

View File

@ -43,6 +43,10 @@ public class ShortformJob {
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String rawOutput; private String rawOutput;
/** Step 1(하이라이트 후보 구간 추출) 원문 — 구간 선정 이유가 담겨 최종 Output과 별개로 보존한다. */
@Column(columnDefinition = "TEXT")
private String step1Output;
@CreationTimestamp @CreationTimestamp
@Column(updatable = false) @Column(updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -71,6 +75,12 @@ public class ShortformJob {
this.completedAt = LocalDateTime.now(); this.completedAt = LocalDateTime.now();
} }
public void attachStep1(String step1Text) {
if (step1Text != null && !step1Text.isBlank()) {
this.step1Output = step1Text;
}
}
public void markFailed(String raw) { public void markFailed(String raw) {
this.rawOutput = raw; this.rawOutput = raw;
this.status = ShortformJobStatus.FAILED; this.status = ShortformJobStatus.FAILED;

View File

@ -27,7 +27,7 @@ public class ShortformService {
/** Opal 출력 원문을 파싱해 저장. 클립이 하나도 안 나오면 FAILED로 남긴다. */ /** Opal 출력 원문을 파싱해 저장. 클립이 하나도 안 나오면 FAILED로 남긴다. */
@Transactional @Transactional
public JobDetail saveResult(Long jobId, String rawText) { public JobDetail saveResult(Long jobId, String rawText, String step1Text) {
ShortformJob job = jobRepository.findById(jobId) ShortformJob job = jobRepository.findById(jobId)
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId)); .orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
List<ParsedClip> parsed = parser.parse(rawText); List<ParsedClip> parsed = parser.parse(rawText);
@ -36,14 +36,15 @@ public class ShortformService {
} else { } else {
job.applyResult(rawText, parsed); job.applyResult(rawText, parsed);
} }
job.attachStep1(step1Text);
return JobDetail.from(job); return JobDetail.from(job);
} }
/** 등록 + 결과 저장 한 번에 (Opal 수동 실행 후 붙여넣기 경로). */ /** 등록 + 결과 저장 한 번에 (Opal 수동 실행 후 붙여넣기 경로). */
@Transactional @Transactional
public JobDetail importResult(String youtubeUrl, String rawText) { public JobDetail importResult(String youtubeUrl, String rawText, String step1Text) {
JobDetail registered = register(youtubeUrl); JobDetail registered = register(youtubeUrl);
return saveResult(registered.id(), rawText); return saveResult(registered.id(), rawText, step1Text);
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)

View File

@ -12,9 +12,9 @@ public final class ShortformDtos {
public record RegisterRequest(String youtubeUrl) {} 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, public record JobSummary(Long id, String youtubeUrl, String videoId, String title,
String status, int clipCount, String status, int clipCount,
@ -36,11 +36,11 @@ public final class ShortformDtos {
public record JobDetail(Long id, String youtubeUrl, String videoId, String title, public record JobDetail(Long id, String youtubeUrl, String videoId, String title,
String status, LocalDateTime createdAt, LocalDateTime completedAt, String status, LocalDateTime createdAt, LocalDateTime completedAt,
String rawOutput, List<ClipDto> clips) { String rawOutput, String step1Output, List<ClipDto> clips) {
public static JobDetail from(ShortformJob job) { public static JobDetail from(ShortformJob job) {
return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(), return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
job.getTitle(), job.getStatus().name(), job.getCreatedAt(), job.getTitle(), job.getStatus().name(), job.getCreatedAt(),
job.getCompletedAt(), job.getRawOutput(), job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(),
job.getClips().stream().map(ClipDto::from).toList()); job.getClips().stream().map(ClipDto::from).toList());
} }
} }

View File

@ -107,10 +107,19 @@
<button class="btn btn-primary" data-copy="${encodeURIComponent(detail.rawOutput || '')}" <button class="btn btn-primary" data-copy="${encodeURIComponent(detail.rawOutput || '')}"
data-label="전체 Output 복사 (capcut2 오팔 JSON 탭용)" data-label="전체 Output 복사 (capcut2 오팔 JSON 탭용)"
${detail.rawOutput ? '' : 'disabled'}>전체 Output 복사 (capcut2 오팔 JSON 탭용)</button> ${detail.rawOutput ? '' : 'disabled'}>전체 Output 복사 (capcut2 오팔 JSON 탭용)</button>
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.step1Output || '')}"
data-label="Step 1 후보구간 복사"
${detail.step1Output ? '' : 'disabled'}>Step 1 후보구간 복사</button>
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}" <button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}"
data-label="영상 URL 복사">영상 URL 복사</button> data-label="영상 URL 복사">영상 URL 복사</button>
</div>`; </div>`;
el.querySelector('.sf-clips').innerHTML = toolbar + (detail.clips.length const step1Block = detail.step1Output
? `<details class="sf-clip" style="margin-bottom:0.6rem;">
<summary style="cursor:pointer;font-weight:600;">Step 1: 하이라이트 후보 구간 (선정 이유 포함)</summary>
<pre>${esc(detail.step1Output)}</pre>
</details>`
: '';
el.querySelector('.sf-clips').innerHTML = toolbar + step1Block + (detail.clips.length
? detail.clips.map(clipHtml).join('') ? detail.clips.map(clipHtml).join('')
: '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>'); : '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>');
el.classList.add('open'); el.classList.add('open');