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:
parent
13dd61d80c
commit
b48ab6494c
@ -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": "<Output 원문>", "step1Text": "<Step 1 원문>"}`. step1Text는 추출 실패 시 생략 가능(비파괴). 응답의 clips가 5개 미만이면 원문과 함께 보고.
|
||||
4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음).
|
||||
5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약.
|
||||
|
||||
|
||||
@ -35,12 +35,12 @@ public class ShortformController {
|
||||
|
||||
@PostMapping("/jobs/{id}/result")
|
||||
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")
|
||||
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}")
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<ParsedClip> 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)
|
||||
|
||||
@ -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<ClipDto> clips) {
|
||||
String rawOutput, String step1Output, List<ClipDto> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,10 +107,19 @@
|
||||
<button class="btn btn-primary" data-copy="${encodeURIComponent(detail.rawOutput || '')}"
|
||||
data-label="전체 Output 복사 (capcut2 오팔 JSON 탭용)"
|
||||
${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 || '')}"
|
||||
data-label="영상 URL 복사">영상 URL 복사</button>
|
||||
</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('')
|
||||
: '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>');
|
||||
el.classList.add('open');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user