diff --git a/src/main/java/com/teaching/backend/domain/material/service/MaterialAiAnalysisResponseParser.java b/src/main/java/com/teaching/backend/domain/material/service/MaterialAiAnalysisResponseParser.java index a3305dd..59f2c42 100644 --- a/src/main/java/com/teaching/backend/domain/material/service/MaterialAiAnalysisResponseParser.java +++ b/src/main/java/com/teaching/backend/domain/material/service/MaterialAiAnalysisResponseParser.java @@ -9,8 +9,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -29,9 +31,12 @@ public class MaterialAiAnalysisResponseParser { Pattern.compile("\\s[-*]\\s+"); private static final Pattern MARKDOWN_SECTION_HEADING_PATTERN = Pattern.compile("(?m)^\\s{0,3}##\\s+\\S+"); + private static final Pattern SECTION_HEADING_LINE_PATTERN = + Pattern.compile("^\\s{0,3}##\\s+(.+?)\\s*$"); private static final Pattern MARKDOWN_IMAGE_PATTERN = Pattern.compile("!\\[[^\\]]*]\\(([^)\\s]+)(?:\\s+\"[^\"]*\")?\\)"); private static final int MIN_LONG_ANALYSIS_LENGTH = 500; + private static final int FALLBACK_IMAGE_DESCRIPTION_LIMIT = 80; private static final int RAW_RESPONSE_LOG_LIMIT = 500; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -71,6 +76,8 @@ public MaterialUrlAnalysisParseResult parseUrlAnalysis( String longAnalysis = requiredTrimmed(result.longAnalysis()); validateMarkdownAnalysis(longAnalysis); validateMarkdownImageUrls(longAnalysis, imageCandidates); + longAnalysis = ensureMinimumMarkdownImage(longAnalysis, imageCandidates); + validateMarkdownImageUrls(longAnalysis, imageCandidates); List tags = validateTags(result.tags()); String recommendedFolderName = normalizeRecommendedFolder(result.recommendedFolder(), folderNames); @@ -96,6 +103,301 @@ private MaterialAiAnalysisResult readResult(String rawContent) { } } + private String ensureMinimumMarkdownImage( + String longAnalysis, + List imageCandidates + ) { + List validCandidates = validImageCandidates(imageCandidates); + if (validCandidates.isEmpty() || hasMarkdownImage(longAnalysis)) { + return longAnalysis; + } + + FallbackImagePlacement placement = selectFallbackImagePlacement(longAnalysis, validCandidates); + String markdownImage = fallbackMarkdownImage(placement.candidate()); + return insertMarkdownImage(longAnalysis, placement.section(), markdownImage); + } + + private List validImageCandidates(List imageCandidates) { + if (imageCandidates == null || imageCandidates.isEmpty()) { + return List.of(); + } + return imageCandidates.stream() + .filter(candidate -> candidate != null + && candidate.url() != null + && isMarkdownSafeImageUrl(candidate.url())) + .toList(); + } + + private boolean isMarkdownSafeImageUrl(String url) { + if (url == null || url.isBlank()) { + return false; + } + String normalized = url.trim(); + return normalized.indexOf('(') < 0 + && normalized.indexOf(')') < 0 + && normalized.chars().noneMatch(Character::isWhitespace); + } + + private boolean hasMarkdownImage(String longAnalysis) { + return MARKDOWN_IMAGE_PATTERN.matcher(longAnalysis).find(); + } + + private FallbackImagePlacement selectFallbackImagePlacement( + String longAnalysis, + List imageCandidates + ) { + List sections = parseMarkdownSections(longAnalysis); + FallbackImagePlacement best = null; + + for (int candidateIndex = 0; candidateIndex < imageCandidates.size(); candidateIndex++) { + MaterialImageCandidate candidate = imageCandidates.get(candidateIndex); + for (int sectionIndex = 0; sectionIndex < sections.size(); sectionIndex++) { + MarkdownSection section = sections.get(sectionIndex); + int score = scoreCandidateForSection(candidate, section); + FallbackImagePlacement current = new FallbackImagePlacement( + candidate, + section, + score, + candidateIndex, + sectionIndex + ); + if (isBetterPlacement(current, best)) { + best = current; + } + } + } + + if (best == null || best.score() <= 0) { + return new FallbackImagePlacement( + imageCandidates.get(0), + sections.get(0), + 0, + 0, + 0 + ); + } + return best; + } + + private boolean isBetterPlacement(FallbackImagePlacement current, FallbackImagePlacement best) { + if (best == null) { + return true; + } + if (current.score() != best.score()) { + return current.score() > best.score(); + } + if (current.candidateIndex() != best.candidateIndex()) { + return current.candidateIndex() < best.candidateIndex(); + } + return current.sectionIndex() < best.sectionIndex(); + } + + private int scoreCandidateForSection(MaterialImageCandidate candidate, MarkdownSection section) { + int score = 0; + score += scoreField(candidate.sectionHeading(), section, 12); + score += scoreField(candidate.context(), section, 7); + score += scoreField(candidate.caption(), section, 5); + score += scoreField(candidate.alt(), section, 3); + score += scoreField(candidate.title(), section, 2); + return score; + } + + private int scoreField(String value, MarkdownSection section, int weight) { + String normalizedValue = normalizeSemanticText(value); + if (normalizedValue.isBlank()) { + return 0; + } + + String normalizedHeading = normalizeSemanticText(section.heading()); + String normalizedBody = normalizeSemanticText(section.body()); + int score = 0; + + if (containsMeaningfulPhrase(normalizedHeading, normalizedValue)) { + score += weight * 12; + } + if (containsMeaningfulPhrase(normalizedBody, normalizedValue)) { + score += weight * 6; + } + + Set valueTokens = semanticTokens(normalizedValue); + if (!valueTokens.isEmpty()) { + score += commonTokenCount(valueTokens, semanticTokens(normalizedHeading)) * weight * 4; + score += commonTokenCount(valueTokens, semanticTokens(normalizedBody)) * weight; + } + + return score; + } + + private boolean containsMeaningfulPhrase(String left, String right) { + return left.length() >= 4 + && right.length() >= 4 + && (left.contains(right) || right.contains(left)); + } + + private int commonTokenCount(Set left, Set right) { + int count = 0; + for (String token : left) { + if (right.contains(token)) { + count++; + } + } + return count; + } + + private Set semanticTokens(String value) { + Set tokens = new LinkedHashSet<>(); + for (String token : value.split("\\s+")) { + if (token.length() >= 2) { + tokens.add(token); + } + } + return tokens; + } + + private String normalizeSemanticText(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return value.toLowerCase(Locale.ROOT) + .replaceAll("[`*_#>!\\[\\](){}|.,:;\"'“”‘’]", " ") + .replaceAll("[^\\p{L}\\p{N}]+", " ") + .replaceAll("\\s+", " ") + .trim(); + } + + private List parseMarkdownSections(String longAnalysis) { + List sections = new ArrayList<>(); + String[] lines = longAnalysis.split("(?<=\\n)", -1); + boolean fencedCode = false; + PendingSection current = null; + int offset = 0; + + for (String line : lines) { + String lineWithoutNewline = stripTrailingNewline(line); + String trimmed = lineWithoutNewline.trim(); + if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { + fencedCode = !fencedCode; + } + + Matcher headingMatcher = SECTION_HEADING_LINE_PATTERN.matcher(lineWithoutNewline); + if (!fencedCode && headingMatcher.matches()) { + if (current != null) { + sections.add(current.toSection(offset, longAnalysis)); + } + current = new PendingSection( + sections.size(), + cleanHeadingText(headingMatcher.group(1)), + offset, + offset + line.length() + ); + } + offset += line.length(); + } + + if (current != null) { + sections.add(current.toSection(longAnalysis.length(), longAnalysis)); + } + if (sections.isEmpty()) { + sections.add(new MarkdownSection(0, "", longAnalysis, 0, 0, longAnalysis.length())); + } + return sections; + } + + private String stripTrailingNewline(String value) { + return value.replaceFirst("\\r?\\n$", ""); + } + + private String cleanHeadingText(String heading) { + return heading == null ? "" : heading.trim(); + } + + private String fallbackMarkdownImage(MaterialImageCandidate candidate) { + String description = firstNonBlank(candidate.alt(), candidate.caption(), candidate.title(), "이미지"); + description = description.replaceAll("[\\r\\n\\t ]+", " ").trim(); + if (description.length() > FALLBACK_IMAGE_DESCRIPTION_LIMIT) { + description = description.substring(0, FALLBACK_IMAGE_DESCRIPTION_LIMIT).trim(); + } + description = description.replace("[", "").replace("]", ""); + return "![%s](%s)".formatted(description, candidate.url().trim()); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return "이미지"; + } + + private String insertMarkdownImage( + String longAnalysis, + MarkdownSection section, + String markdownImage + ) { + int insertOffset = findInsertionOffset(longAnalysis, section); + String prefix = longAnalysis.substring(0, insertOffset).stripTrailing(); + String suffix = longAnalysis.substring(insertOffset).stripLeading(); + if (suffix.isBlank()) { + return prefix + System.lineSeparator() + System.lineSeparator() + markdownImage; + } + return prefix + + System.lineSeparator() + + System.lineSeparator() + + markdownImage + + System.lineSeparator() + + System.lineSeparator() + + suffix; + } + + private int findInsertionOffset(String longAnalysis, MarkdownSection section) { + String sectionText = longAnalysis.substring(section.contentStart(), section.end()); + String[] lines = sectionText.split("(?<=\\n)", -1); + boolean fencedCode = false; + int offset = section.contentStart(); + int paragraphStart = -1; + int paragraphEnd = -1; + + for (String line : lines) { + String lineWithoutNewline = stripTrailingNewline(line); + String trimmed = lineWithoutNewline.trim(); + boolean fenceLine = trimmed.startsWith("```") || trimmed.startsWith("~~~"); + if (fenceLine) { + if (paragraphStart >= 0) { + return paragraphEnd; + } + fencedCode = !fencedCode; + offset += line.length(); + continue; + } + + if (fencedCode || trimmed.isBlank() || isUnsafeInsertionLine(trimmed)) { + if (paragraphStart >= 0) { + return paragraphEnd; + } + offset += line.length(); + continue; + } + + if (paragraphStart < 0) { + paragraphStart = offset; + } + paragraphEnd = offset + line.length(); + offset += line.length(); + } + + if (paragraphStart >= 0) { + return paragraphEnd; + } + return section.end(); + } + + private boolean isUnsafeInsertionLine(String trimmedLine) { + return trimmedLine.startsWith("|") + || trimmedLine.startsWith("## ") + || trimmedLine.startsWith("!["); + } + private String requiredTrimmed(String value) { if (value == null || value.isBlank()) { throw parseFailed("required_value_blank"); @@ -226,4 +528,41 @@ private String truncate(String value) { return normalized.substring(0, RAW_RESPONSE_LOG_LIMIT) + "..."; } + private record PendingSection( + int index, + String heading, + int headingStart, + int contentStart + ) { + private MarkdownSection toSection(int end, String longAnalysis) { + return new MarkdownSection( + index, + heading, + longAnalysis.substring(contentStart, end), + headingStart, + contentStart, + end + ); + } + } + + private record MarkdownSection( + int index, + String heading, + String body, + int headingStart, + int contentStart, + int end + ) { + } + + private record FallbackImagePlacement( + MaterialImageCandidate candidate, + MarkdownSection section, + int score, + int candidateIndex, + int sectionIndex + ) { + } + } diff --git a/src/main/resources/prompts/material/url-analysis-system-prompt.md b/src/main/resources/prompts/material/url-analysis-system-prompt.md index 1fe7c10..6536674 100644 --- a/src/main/resources/prompts/material/url-analysis-system-prompt.md +++ b/src/main/resources/prompts/material/url-analysis-system-prompt.md @@ -60,8 +60,9 @@ Constraints (반드시 준수): - *(리스트), **(강조)** 문법을 적극 사용하여 가독성과 몰입감을 높이십시오. - Image Selection: - - 제공된 이미지 후보 목록 중 본문의 핵심 내용을 이해하거나 시각적으로 보완하는 데 도움이 되는 관련 이미지가 존재한다면, long_analysis에 관련성이 높은 이미지 1~3개를 우선적으로 선별하여 사용하십시오. - - 본문 이해에 도움이 되는 관련 이미지 후보가 없다면 이미지를 삽입하지 않아도 됩니다. + - 제공된 이미지 후보 목록이 비어 있지 않다면, long_analysis에 최소 1개의 이미지를 반드시 사용하십시오. + - 제공된 이미지 후보 중 본문의 핵심 내용을 이해하거나 시각적으로 보완하는 데 도움이 되는 관련성이 높은 이미지를 1~3개 우선적으로 선별하여 사용하십시오. + - 서로 다른 핵심 내용이나 개념을 각각 보완할 수 있는 관련 이미지가 여러 개 존재한다면, 가능한 범위에서 복수의 이미지를 활용하십시오. - 서로 동일하거나 의미가 거의 중복되는 이미지는 반복해서 사용하지 마십시오. - 로고, 프로필 사진, 아바타, 광고, 배너, 버튼, 아이콘, 사이트 UI, 단순 장식 이미지처럼 본문 이해에 의미 있는 정보를 추가하지 않는 이미지는 사용하지 마십시오. - 이미지를 삽입할 경우 해당 이미지가 설명하거나 보완하는 내용이 등장하는 long_analysis의 관련 문단 또는 섹션 바로 뒤에 표준 Markdown 형식 (`![이미지 설명](이미지 URL)`)으로 삽입하십시오. diff --git a/src/test/java/com/teaching/backend/domain/material/service/MaterialAiAnalysisResponseParserTest.java b/src/test/java/com/teaching/backend/domain/material/service/MaterialAiAnalysisResponseParserTest.java index 09d098e..b6cce58 100644 --- a/src/test/java/com/teaching/backend/domain/material/service/MaterialAiAnalysisResponseParserTest.java +++ b/src/test/java/com/teaching/backend/domain/material/service/MaterialAiAnalysisResponseParserTest.java @@ -229,16 +229,287 @@ void rejectsMarkdownImageWhenNoCandidatesWereProvided() { } @Test - void acceptsNoMarkdownImageEvenWhenCandidatesExist() { + void leavesLongAnalysisWithoutImageWhenCandidatesAreEmpty() { MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( validUrlAnalysisJson("Backend"), List.of("Backend"), - List.of(new MaterialImageCandidate("https://cdn.example.com/chart.png", null, null, null, null, null)) + List.of() + ); + + assertThat(result.analysisResult().longAnalysis()).doesNotContain("!["); + } + + @Test + void insertsOneFallbackMarkdownImageWhenCandidatesExistAndAiUsesNoImage() { + String imageUrl = "https://cdn.example.com/chart.png"; + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + validUrlAnalysisJson("Backend"), + List.of("Backend"), + List.of(new MaterialImageCandidate(imageUrl, "성장률 그래프", null, null, "Application", null)) + ); + + assertThat(result.analysisResult().longAnalysis()) + .contains("![성장률 그래프](%s)".formatted(imageUrl)); + assertThat(markdownImageCount(result.analysisResult().longAnalysis())).isEqualTo(1); + } + + @Test + void skipsFallbackCandidateWithWhitespaceUrlAndUsesNextSafeCandidate() { + String unsafeUrl = "https://cdn.example.com/image space.png"; + String safeUrl = "https://cdn.example.com/safe.png"; + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + validUrlAnalysisJson("Backend"), + List.of("Backend"), + List.of( + new MaterialImageCandidate(unsafeUrl, "unsafe", null, null, "Application", "상세 분석 검색 보조 맥락 활용"), + new MaterialImageCandidate(safeUrl, "safe", null, null, null, null) + ) + ); + + assertThat(result.analysisResult().longAnalysis()) + .contains("![safe](%s)".formatted(safeUrl)) + .doesNotContain(unsafeUrl); + } + + @Test + void skipsFallbackCandidateWithParenthesesUrlAndUsesNextSafeCandidate() { + String unsafeUrl = "https://cdn.example.com/a(1).png"; + String safeUrl = "https://cdn.example.com/safe.png"; + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + validUrlAnalysisJson("Backend"), + List.of("Backend"), + List.of( + new MaterialImageCandidate(unsafeUrl, "unsafe", null, null, "Application", "상세 분석 검색 보조 맥락 활용"), + new MaterialImageCandidate(safeUrl, "safe", null, null, null, null) + ) + ); + + assertThat(result.analysisResult().longAnalysis()) + .contains("![safe](%s)".formatted(safeUrl)) + .doesNotContain(unsafeUrl); + } + + @Test + void succeedsWithoutFallbackImageWhenAllCandidatesAreMarkdownUnsafe() { + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + validUrlAnalysisJson("Backend"), + List.of("Backend"), + List.of( + new MaterialImageCandidate("https://cdn.example.com/a(1).png", "first", null, null, "Application", null), + new MaterialImageCandidate("https://cdn.example.com/image space.png", "second", null, null, null, null) + ) ); assertThat(result.analysisResult().longAnalysis()).doesNotContain("!["); } + @Test + void ignoresHighScoringUnsafeCandidateAndSelectsLowerScoringSafeCandidate() { + String unsafeUrl = "https://cdn.example.com/best(1).png"; + String safeUrl = "https://cdn.example.com/safe.png"; + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + validUrlAnalysisJson("Backend"), + List.of("Backend"), + List.of( + new MaterialImageCandidate(unsafeUrl, "unsafe", null, null, "Application", "상세 분석 검색 보조 맥락 활용"), + new MaterialImageCandidate(safeUrl, "safe", null, null, null, null) + ) + ); + + assertThat(result.analysisResult().longAnalysis()) + .contains("![safe](%s)".formatted(safeUrl)) + .doesNotContain(unsafeUrl); + } + + @Test + void keepsAiMarkdownImageUnchangedWhenOneCandidateImageAlreadyExists() { + String imageUrl = "https://cdn.example.com/chart.png"; + String longAnalysis = validLongAnalysis() + "\n![성장률 그래프](%s)".formatted(imageUrl); + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + urlAnalysisJson("Backend", longAnalysis), + List.of("Backend"), + List.of(new MaterialImageCandidate(imageUrl, "fallback alt", null, null, null, null)) + ); + + assertThat(result.analysisResult().longAnalysis()).isEqualTo(longAnalysis); + assertThat(markdownImageCount(result.analysisResult().longAnalysis())).isEqualTo(1); + } + + @Test + void keepsAiMarkdownImagesUnchangedWhenMultipleCandidateImagesAlreadyExist() { + String firstUrl = "https://cdn.example.com/first.png"; + String secondUrl = "https://cdn.example.com/second.png"; + String longAnalysis = validLongAnalysis() + + "\n![첫 이미지](%s)\n![두 번째 이미지](%s)".formatted(firstUrl, secondUrl); + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + urlAnalysisJson("Backend", longAnalysis), + List.of("Backend"), + List.of( + new MaterialImageCandidate(firstUrl, "first", null, null, null, null), + new MaterialImageCandidate(secondUrl, "second", null, null, null, null) + ) + ); + + assertThat(result.analysisResult().longAnalysis()).isEqualTo(longAnalysis); + assertThat(markdownImageCount(result.analysisResult().longAnalysis())).isEqualTo(2); + } + + @Test + void insertsFallbackImageIntoBestMatchingLaterSection() { + String imageUrl = "https://cdn.example.com/application.png"; + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + validUrlAnalysisJson("Backend"), + List.of("Backend"), + List.of(new MaterialImageCandidate( + imageUrl, + "적용 방안 이미지", + null, + null, + "Application", + "저장된 상세 분석과 검색 보조 맥락에서 활용되는 흐름" + )) + ); + + String longAnalysis = result.analysisResult().longAnalysis(); + assertThat(longAnalysis.indexOf("![적용 방안 이미지](%s)".formatted(imageUrl))) + .isGreaterThan(longAnalysis.indexOf("## Application")) + .isLessThan(longAnalysis.indexOf("## Conclusion")); + } + + @Test + void choosesLaterCandidateWhenItMatchesSectionBetterThanFirstCandidate() { + String weakUrl = "https://cdn.example.com/weak.png"; + String strongUrl = "https://cdn.example.com/strong.png"; + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + validUrlAnalysisJson("Backend"), + List.of("Backend"), + List.of( + new MaterialImageCandidate(weakUrl, "일반 이미지", null, null, null, null), + new MaterialImageCandidate(strongUrl, "인덱싱 흐름", null, null, "Application", "상세 분석 검색 보조 맥락 활용") + ) + ); + + assertThat(result.analysisResult().longAnalysis()) + .contains("![인덱싱 흐름](%s)".formatted(strongUrl)) + .doesNotContain(weakUrl); + } + + @Test + void prioritizesSectionHeadingMatchOverWeakContextOrAltMatch() { + String weakUrl = "https://cdn.example.com/weak.png"; + String strongUrl = "https://cdn.example.com/section.png"; + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + validUrlAnalysisJson("Backend"), + List.of("Backend"), + List.of( + new MaterialImageCandidate(weakUrl, "Spring", null, null, null, "Spring"), + new MaterialImageCandidate(strongUrl, "섹션 이미지", null, null, "Application", null) + ) + ); + + assertThat(result.analysisResult().longAnalysis()) + .contains("![섹션 이미지](%s)".formatted(strongUrl)) + .doesNotContain(weakUrl); + } + + @Test + void fallsBackToFirstCandidateAndSafeFirstParagraphWhenNoMetadataMatches() { + String firstUrl = "https://cdn.example.com/first.png"; + String secondUrl = "https://cdn.example.com/second.png"; + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + validUrlAnalysisJson("Backend"), + List.of("Backend"), + List.of( + new MaterialImageCandidate(firstUrl, null, null, null, null, null), + new MaterialImageCandidate(secondUrl, "unmatched", null, null, null, null) + ) + ); + + String longAnalysis = result.analysisResult().longAnalysis(); + assertThat(longAnalysis) + .contains("![이미지](%s)".formatted(firstUrl)) + .doesNotContain(secondUrl); + assertThat(longAnalysis.indexOf("![이미지](%s)".formatted(firstUrl))) + .isGreaterThan(longAnalysis.indexOf("## Overview")) + .isLessThan(longAnalysis.indexOf("## Background")); + } + + @Test + void usesCaptionForFallbackDescriptionWhenAltIsBlank() { + String imageUrl = "https://cdn.example.com/caption.png"; + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + validUrlAnalysisJson("Backend"), + List.of("Backend"), + List.of(new MaterialImageCandidate(imageUrl, " ", "캡션 설명", "제목 설명", null, null)) + ); + + assertThat(result.analysisResult().longAnalysis()) + .contains("![캡션 설명](%s)".formatted(imageUrl)); + } + + @Test + void usesTitleForFallbackDescriptionWhenAltAndCaptionAreBlank() { + String imageUrl = "https://cdn.example.com/title.png"; + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + validUrlAnalysisJson("Backend"), + List.of("Backend"), + List.of(new MaterialImageCandidate(imageUrl, null, null, "제목 설명", null, null)) + ); + + assertThat(result.analysisResult().longAnalysis()) + .contains("![제목 설명](%s)".formatted(imageUrl)); + } + + @Test + void doesNotInsertFallbackInsideCodeBlockOrMarkdownTable() { + String imageUrl = "https://cdn.example.com/safe.png"; + String longAnalysis = """ + ## Overview + 첫 문단은 안전한 삽입 위치입니다. + + ```java + ## Not a heading + String value = "code"; + ``` + + | A | B | + |---|---| + | 1 | 2 | + + ## Background + * **본문 추출** 단계는 정적 HTML과 렌더링된 HTML을 사용합니다. + ## Key Points + * **요약**은 짧게 유지하고 상세 분석은 여러 섹션으로 나눕니다. + ## Application + * **저장된 상세 분석**은 자료 상세 화면에서 활용됩니다. + ## Conclusion + * 전체 흐름은 원문 정제, AI 분석, 태그 저장, 인덱싱을 분리합니다. + """; + longAnalysis = longAnalysis + " 상세한 설명을 충분히 채우기 위해 ".repeat(30); + + MaterialUrlAnalysisParseResult result = parser.parseUrlAnalysis( + urlAnalysisJson("Backend", longAnalysis), + List.of("Backend"), + List.of(new MaterialImageCandidate(imageUrl, "안전 이미지", null, null, null, null)) + ); + + String parsedLongAnalysis = result.analysisResult().longAnalysis(); + assertThat(parsedLongAnalysis).contains("![안전 이미지](%s)".formatted(imageUrl)); + assertThat(parsedLongAnalysis.indexOf("![안전 이미지](%s)".formatted(imageUrl))) + .isLessThan(parsedLongAnalysis.indexOf("```java")); + } + @Test void rejectsInvalidTagsInUrlAnalysis() { assertUrlAnalysisParseFailed(validUrlAnalysisJson("Backend").replace( @@ -290,6 +561,10 @@ private String validUrlAnalysisJson(String recommendedFolder) { } private String validUrlAnalysisJson(String recommendedFolder, String extraMarkdown) { + return urlAnalysisJson(recommendedFolder, validLongAnalysis() + extraMarkdown); + } + + private String urlAnalysisJson(String recommendedFolder, String longAnalysis) { String folderValue = recommendedFolder == null ? "null" : "\"" + recommendedFolder + "\""; return """ { @@ -298,7 +573,17 @@ private String validUrlAnalysisJson(String recommendedFolder, String extraMarkdo "tags": ["Spring", "JPA", "Web"], "recommended_folder": %s } - """.formatted(jsonEscape(validLongAnalysis() + extraMarkdown), folderValue); + """.formatted(jsonEscape(longAnalysis), folderValue); + } + + private int markdownImageCount(String value) { + int count = 0; + int index = 0; + while ((index = value.indexOf("![", index)) >= 0) { + count++; + index += 2; + } + return count; } private String validLongAnalysis() { diff --git a/src/test/java/com/teaching/backend/domain/material/service/ai/MaterialUrlAnalysisPromptBuilderTest.java b/src/test/java/com/teaching/backend/domain/material/service/ai/MaterialUrlAnalysisPromptBuilderTest.java index cba9f9a..4035248 100644 --- a/src/test/java/com/teaching/backend/domain/material/service/ai/MaterialUrlAnalysisPromptBuilderTest.java +++ b/src/test/java/com/teaching/backend/domain/material/service/ai/MaterialUrlAnalysisPromptBuilderTest.java @@ -52,7 +52,9 @@ void systemPromptPrioritizesOneToThreeRelevantImageSelections() throws Exception assertThat(systemPrompt) .contains("- Image Selection:") - .contains("본문의 핵심 내용을 이해하거나 시각적으로 보완하는 데 도움이 되는 관련 이미지가 존재한다면, long_analysis에 관련성이 높은 이미지 1~3개를 우선적으로 선별하여 사용하십시오.") + .contains("제공된 이미지 후보 목록이 비어 있지 않다면, long_analysis에 최소 1개의 이미지를 반드시 사용하십시오.") + .contains("제공된 이미지 후보 중 본문의 핵심 내용을 이해하거나 시각적으로 보완하는 데 도움이 되는 관련성이 높은 이미지를 1~3개 우선적으로 선별하여 사용하십시오.") + .contains("서로 다른 핵심 내용이나 개념을 각각 보완할 수 있는 관련 이미지가 여러 개 존재한다면, 가능한 범위에서 복수의 이미지를 활용하십시오.") .contains("로고, 프로필 사진, 아바타, 광고, 배너, 버튼, 아이콘, 사이트 UI, 단순 장식 이미지처럼 본문 이해에 의미 있는 정보를 추가하지 않는 이미지는 사용하지 마십시오.") .contains("해당 이미지가 설명하거나 보완하는 내용이 등장하는 long_analysis의 관련 문단 또는 섹션 바로 뒤에 표준 Markdown 형식") .contains("여러 이미지를 사용하는 경우 각 이미지를 관련 내용이 등장하는 서로 적절한 위치에 분산하여 배치하십시오.")