From 88179bb620083a7901268722ecaa75ceece8826f Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Fri, 7 Aug 2026 17:04:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=8F=ED=8F=BC=20=ED=81=90=20?= =?UTF-8?q?=ED=8C=8C=EC=84=9C=20-=201=EB=8B=A8=EA=B3=84=20candidates=20JSO?= =?UTF-8?q?N=EB=8F=84=20=ED=81=B4=EB=A6=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Opal "숏폼 생성기 1단계" 출력(candidates JSON)을 N===== 구분자 매칭 실패 시 폴백으로 파싱해 DONE 처리 - 후보 원본 JSON은 capcutJson 자리에 보존, 시간·경계·선정이유는 durationTable 요약으로 표시 - 실전 픽스처 opal-step1-candidates-sample.txt 추가 - 낡은 테스트 정리: ChannelRole.acceptsShorts 제거 반영, PersonPicks 쇼츠 컷 180초 반영 Co-Authored-By: Claude Fable 5 --- .../shortform/ShortformOutputParser.java | 40 +++++++++++++ .../domain/channel/ChannelRoleTest.java | 6 -- .../domain/channel/PersonPicksTest.java | 4 +- .../shortform/ShortformOutputParserTest.java | 21 +++++++ .../opal-step1-candidates-sample.txt | 59 +++++++++++++++++++ 5 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 src/test/resources/shortform/opal-step1-candidates-sample.txt diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParser.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParser.java index 49a75c4..5114f28 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParser.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParser.java @@ -40,9 +40,49 @@ public class ShortformOutputParser { ParsedClip clip = parseSection(nos.get(i), raw.substring(starts.get(i), to)); if (clip != null) result.add(clip); } + if (result.isEmpty()) result.addAll(parseCandidates(raw)); return result; } + /** + * "숏폼 생성기 1단계" 앱 출력: N===== 구분자 없이 {"candidates":[...]} JSON 하나. + * 후보마다 원본 JSON을 capcutJson 자리에 보존하고, 시간 정보는 durationTable 로 요약한다. + */ + private List parseCandidates(String raw) { + List result = new ArrayList<>(); + String json = extractJson(raw); + if (json == null) return result; + try { + JsonNode arr = OM.readTree(json).path("candidates"); + int fallbackNo = 0; + for (JsonNode c : arr) { + fallbackNo++; + String table = candidateTable(c); + result.add(new ParsedClip( + c.path("id").asInt(fallbackNo), + OM.writerWithDefaultPrettyPrinter().writeValueAsString(c), + null, + table, + c.path("title_top").asText(null), + c.path("title_main").asText(null))); + } + } catch (Exception ignored) { + // candidates 형식이 아니면 빈 리스트 — 원문은 rawOutput에 보존된다 + } + return result; + } + + private String candidateTable(JsonNode c) { + StringBuilder sb = new StringBuilder(); + sb.append("구간 ").append(c.path("start_time").asText("?")) + .append(" ~ ").append(c.path("end_time").asText("?")) + .append(" (").append(c.path("duration_sec").asText("?")).append("초)") + .append(" · 훅 ").append(c.path("hook_point").asText("?")); + if (c.hasNonNull("boundary_check")) sb.append("\n경계: ").append(c.path("boundary_check").asText()); + if (c.hasNonNull("reason")) sb.append("\n선정 이유: ").append(c.path("reason").asText()); + return sb.toString(); + } + private ParsedClip parseSection(int no, String body) { String json = extractJson(body); String titles = extractBetween(body, "타이틀 후보", "검산표"); diff --git a/src/test/java/com/hlab/yanalyst/domain/channel/ChannelRoleTest.java b/src/test/java/com/hlab/yanalyst/domain/channel/ChannelRoleTest.java index e637a5f..6bee256 100644 --- a/src/test/java/com/hlab/yanalyst/domain/channel/ChannelRoleTest.java +++ b/src/test/java/com/hlab/yanalyst/domain/channel/ChannelRoleTest.java @@ -28,12 +28,6 @@ class ChannelRoleTest { assertThat(ChannelRole.isFeed(null)).isFalse(); } - @Test - void acceptsShorts_경쟁채널만_쇼츠_소스채널은_롱폼() { - assertThat(ChannelRole.acceptsShorts(ChannelRole.RIVAL)).isTrue(); - assertThat(ChannelRole.acceptsShorts(ChannelRole.SOURCE)).isFalse(); - } - @Test void OWN은_별도_역할이며_isFeed가_아니다() { assertThat(ChannelRole.normalize("own")).isEqualTo(ChannelRole.OWN); diff --git a/src/test/java/com/hlab/yanalyst/domain/channel/PersonPicksTest.java b/src/test/java/com/hlab/yanalyst/domain/channel/PersonPicksTest.java index dbe0d76..87e020d 100644 --- a/src/test/java/com/hlab/yanalyst/domain/channel/PersonPicksTest.java +++ b/src/test/java/com/hlab/yanalyst/domain/channel/PersonPicksTest.java @@ -20,8 +20,8 @@ class PersonPicksTest { void 쇼츠는_버리고_롱폼만_남긴다() { List found = List.of( f("a", 30, "C1", NOW), // 쇼츠 - f("b", 65, "C1", NOW), // 경계값 65초 = 쇼츠 - f("c", 66, "C1", NOW), // 롱폼 + f("b", 180, "C1", NOW), // 경계값 180초(유튜브 쇼츠 최대) = 쇼츠 + f("c", 181, "C1", NOW), // 롱폼 f("d", 4300, "C1", NOW)); // 롱폼 List kept = PersonPicks.keep(found, Set.of(), null, 0); diff --git a/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParserTest.java b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParserTest.java index d1a1d3b..d82fccb 100644 --- a/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParserTest.java +++ b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParserTest.java @@ -52,6 +52,27 @@ class ShortformOutputParserTest { assertTrue(clips.get(0).durationTable().contains("총 13컷 / 총 길이: 46.5초")); } + /** "숏폼 생성기 1단계" 앱 출력: N===== 구분자 없이 candidates JSON 하나 (실전 픽스처). */ + private String step1Sample() throws Exception { + return Files.readString(Path.of("src/test/resources/shortform/opal-step1-candidates-sample.txt"), + StandardCharsets.UTF_8); + } + + @Test + void candidates_JSON_출력은_후보를_클립으로_파싱한다() throws Exception { + List clips = parser.parse(step1Sample()); + + assertEquals(5, clips.size()); + ParsedClip first = clips.get(0); + assertEquals(1, first.clipNo()); + assertEquals("냉동창고 알바생들의 현실 꼼수", first.titleTop()); + assertEquals("극한직업 뷔페 알바 첫 미션", first.titleMain()); + assertTrue(first.capcutJson().contains("\"start_time\""), "후보 원본 JSON 보존"); + assertTrue(first.durationTable().contains("02:35")); + assertTrue(first.durationTable().contains("05:07")); + assertEquals(5, clips.get(4).clipNo()); + } + @Test void 빈_입력은_빈_리스트를_반환한다() { assertTrue(parser.parse(null).isEmpty()); diff --git a/src/test/resources/shortform/opal-step1-candidates-sample.txt b/src/test/resources/shortform/opal-step1-candidates-sample.txt new file mode 100644 index 0000000..f2ec8a8 --- /dev/null +++ b/src/test/resources/shortform/opal-step1-candidates-sample.txt @@ -0,0 +1,59 @@ +{ +"candidates": [ +{ +"id": 1, +"start_time": "02:35", +"end_time": "05:07", +"duration_sec": 152, +"hook_point": "02:49", +"boundary_check": "인사하며 냉동창고에 들어가는 시점에서 시작, 추위에 떨며 밖으로 도망쳐 나오는 상황 종료 시점에서 끝", +"reason": "영케이와 츄의 티키타카가 돋보이는 구간으로, 냉동창고라는 극한의 환경 속에서 꼼수(?)를 부리는 알바생들의 현실적인 리액션과 몸개그가 큰 웃음을 유발함.", +"title_top": "냉동창고 알바생들의 현실 꼼수", +"title_main": "극한직업 뷔페 알바 첫 미션" +}, +{ +"id": 2, +"start_time": "08:28", +"end_time": "10:33", +"duration_sec": 125, +"hook_point": "08:34", +"boundary_check": "오픈 전 인사를 연습하는 시점에서 시작, 손님 응대 후 한숨 돌리는 문장 완결 지점에서 끝", +"reason": "오픈런 손님들을 맞이하기 위해 만반의 준비(이상한 포즈)를 했으나, 음식에 직진하는 손님들에게 철저히 투명인간 취급을 당하는 굴욕적인 상황이 완벽한 코미디를 완성함.", +"title_top": "알바생이 투명인간이 되는 마법", +"title_main": "오픈런 손님들의 뷔페 돌진" +}, +{ +"id": 3, +"start_time": "11:42", +"end_time": "13:14", +"duration_sec": 92, +"hook_point": "11:53", +"boundary_check": "테이블 번호를 못 찾아 헤매는 시점에서 시작, 주방으로 다시 쫓겨 들어가는 혼미한 상황 종료 지점에서 끝", +"reason": "복잡한 뷔페 홀에서 테이블 번호를 찾지 못해 넓은 홀 한가운데서 생목으로 샤우팅을 지르는 영케이의 다급함과 민망함이 시청자들에게 폭소를 안김.", +"title_top": "뷔페 한가운데서 생목 샤우팅", +"title_main": "길 잃은 신입 알바의 최후" +}, +{ +"id": 4, +"start_time": "15:43", +"end_time": "17:24", +"duration_sec": 101, +"hook_point": "15:52", +"boundary_check": "손님의 레시피를 구경하는 시점에서 시작, 얻어먹은 멜론을 숨기려다 점장에게 걸리는 완벽한 콩트 완결 후 끝", +"reason": "손님이 만든 기발한 디저트를 얻어먹기 위해 데이식스 영케이가 직접 라이브 라이브 헌정쇼를 펼치고, 이후 남은 멜론을 은닉하려다 걸리는 반전까지 버릴 곳 없는 알짜배기 구간.", +"title_top": "손님 디저트 뺏어먹는 아이돌", +"title_main": "멜론 한 조각을 위한 미니 콘서트" +}, +{ +"id": 5, +"start_time": "19:48", +"end_time": "21:22", +"duration_sec": 94, +"hook_point": "19:59", +"boundary_check": "영케이가 군대 이야기를 꺼내는 시점에서 시작, JYP 연습생 시절 댄스 배틀에 대한 회상을 마무리하는 지점에서 끝", +"reason": "설거지 지옥에 빠져 멘탈이 나간 상태에서 해병대 vs 공군 드립을 치는 웃픈 상황과 더불어, 대형 기획사(JYP) 연습생 시절 댄스 배틀을 피해 화장실에 숨었던 과거 썰이 흥미를 자극함.", +"title_top": "JYP 전설의 연습생(?)의 비밀", +"title_main": "설거지 지옥에서 풀린 과거 썰" +} +] +}