Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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<String> tags = validateTags(result.tags());
String recommendedFolderName = normalizeRecommendedFolder(result.recommendedFolder(), folderNames);
Expand All @@ -96,6 +103,301 @@ private MaterialAiAnalysisResult readResult(String rawContent) {
}
}

private String ensureMinimumMarkdownImage(
String longAnalysis,
List<MaterialImageCandidate> imageCandidates
) {
List<MaterialImageCandidate> 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<MaterialImageCandidate> validImageCandidates(List<MaterialImageCandidate> 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<MaterialImageCandidate> imageCandidates
) {
List<MarkdownSection> 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<String> 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<String> left, Set<String> right) {
int count = 0;
for (String token : left) {
if (right.contains(token)) {
count++;
}
}
return count;
}

private Set<String> semanticTokens(String value) {
Set<String> 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<MarkdownSection> parseMarkdownSections(String longAnalysis) {
List<MarkdownSection> 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());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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");
Expand Down Expand Up @@ -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
) {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ Constraints (반드시 준수):
- *(리스트), **(강조)** 문법을 적극 사용하여 가독성과 몰입감을 높이십시오.

- Image Selection:
- 제공된 이미지 후보 목록 중 본문의 핵심 내용을 이해하거나 시각적으로 보완하는 데 도움이 되는 관련 이미지가 존재한다면, long_analysis에 관련성이 높은 이미지 1~3개를 우선적으로 선별하여 사용하십시오.
- 본문 이해에 도움이 되는 관련 이미지 후보가 없다면 이미지를 삽입하지 않아도 됩니다.
- 제공된 이미지 후보 목록이 비어 있지 않다면, long_analysis에 최소 1개의 이미지를 반드시 사용하십시오.
- 제공된 이미지 후보 중 본문의 핵심 내용을 이해하거나 시각적으로 보완하는 데 도움이 되는 관련성이 높은 이미지를 1~3개 우선적으로 선별하여 사용하십시오.
- 서로 다른 핵심 내용이나 개념을 각각 보완할 수 있는 관련 이미지가 여러 개 존재한다면, 가능한 범위에서 복수의 이미지를 활용하십시오.
- 서로 동일하거나 의미가 거의 중복되는 이미지는 반복해서 사용하지 마십시오.
- 로고, 프로필 사진, 아바타, 광고, 배너, 버튼, 아이콘, 사이트 UI, 단순 장식 이미지처럼 본문 이해에 의미 있는 정보를 추가하지 않는 이미지는 사용하지 마십시오.
- 이미지를 삽입할 경우 해당 이미지가 설명하거나 보완하는 내용이 등장하는 long_analysis의 관련 문단 또는 섹션 바로 뒤에 표준 Markdown 형식 (`![이미지 설명](이미지 URL)`)으로 삽입하십시오.
Expand Down
Loading
Loading