Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ed2dc1c
feat(프로젝트/스터디 상세글 ai 기능 추가): 프로젝트/스터디 상세글 ai 기능 추가 DP-392
vayaconChoi Sep 12, 2025
5d21cfe
feat(프로젝트 프롬프트 수정): 프로젝트 프롬프트 수정 DP-356
vayaconChoi Sep 12, 2025
bb42279
feat(프로젝트 프롬프트 반복 금지 추가): 프로젝트 프롬프트 반복금지 추가 DP-356
vayaconChoi Sep 12, 2025
2dbab17
feat(스터디 프롬프트 수정): 스터디 프롬프트 수정 DP-392
vayaconChoi Sep 12, 2025
c5f2ba8
fix(principal): 사용자 식별 커스텀 추가 / DP-409
Shin-Yu-1 Sep 13, 2025
19067e3
fix(service): 알림 서비스 추가 / DP-409
Shin-Yu-1 Sep 13, 2025
cc3b21f
fix(interceptor): 경로가 user일 경우 roomId 추출/권한 검사 안 하도록 수정 / DP-409
Shin-Yu-1 Sep 13, 2025
f9ea456
fix(repository): 마지막 메세지 읽음 여부 추가 / DP-409
Shin-Yu-1 Sep 13, 2025
09f401e
fix(repository): 마지막 메세지 읽음 여부 추가 / DP-409
Shin-Yu-1 Sep 13, 2025
6f73128
fix(util): response 데이터에 마지막 메세지 읽음 여부 추가 / DP-409
Shin-Yu-1 Sep 13, 2025
903c431
fix(response): 메세지 읽음 여부 추가 / DP-409
Shin-Yu-1 Sep 13, 2025
588e4aa
fix(controller): 메세지 전송 후 멤버들에게 새로운 메세지 알림 전송 / DP-409
Shin-Yu-1 Sep 13, 2025
830a900
fix(preferredAges): 응답 일관성 수정 (null 처리 통일) DP-350
sunsetkk Sep 13, 2025
3e7ab6d
fix(players): 지역명 표기 개선 DP-350
sunsetkk Sep 13, 2025
035af4f
feat(프롬프트 수정 및 반복 출력 문제 해결): 프롬프트 수정 및 반복 출력 문제 해결 DP-392
vayaconChoi Sep 13, 2025
929e30d
feat(스터디 부분 디테일 부분 추가): 스터디 부분 디테일 부분 추가 DP-392
vayaconChoi Sep 13, 2025
ab2e8fa
fix(service): 소켓 메세지에 메세지 생성 시간 추가 / DP-409
Shin-Yu-1 Sep 13, 2025
330074c
fix(service): 소켓 메세지에 메세지 생성 시간 이름 잘못작성해서 수정함 / DP-409
Shin-Yu-1 Sep 13, 2025
5bfe4f3
refactor(프로필 수정 부분 리팩토링): 프로필 수정 부분 리팩토링 DP-410
vayaconChoi Sep 13, 2025
d21dd96
refactor(포지션 수정 로직 수정): 프로필 수정 로직 수정 DP-410
vayaconChoi Sep 13, 2025
12be320
Merge pull request #226 from DeepDirect/feat/DP-409-chat
projectmiluju Sep 14, 2025
4284a30
Merge pull request #227 from DeepDirect/fix/DP-350-location
projectmiluju Sep 14, 2025
9a4bbe0
Merge pull request #229 from DeepDirect/feature/DP-392-detailAi
projectmiluju Sep 14, 2025
292edcc
Merge pull request #231 from DeepDirect/refactor/DP-410-profileEdit
projectmiluju Sep 14, 2025
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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
implementation 'co.elastic.clients:elasticsearch-java:8.11.0'

implementation 'org.json:json:20231013'

}

tasks.named('test') {
Expand Down
34 changes: 34 additions & 0 deletions src/main/java/goorm/ddok/ai/controller/AiTextController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package goorm.ddok.ai.controller;

import goorm.ddok.ai.dto.request.AiProjectRequest;
import goorm.ddok.ai.dto.request.AiStudyRequest;
import goorm.ddok.ai.dto.response.AiTextResponse;
import goorm.ddok.ai.service.AiTextService;
import goorm.ddok.global.response.ApiResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AiTextController {

private final AiTextService ai;

@PreAuthorize("isAuthenticated()")
@PostMapping("/projects/ai")
public ResponseEntity<ApiResponseDto<AiTextResponse>> projectAi(@RequestBody AiProjectRequest req) {
String detail = ai.generateProjectDetail(req);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponseDto.of(201, "상세 내용 생성이 성공했습니다.", new AiTextResponse(detail)));
}

@PreAuthorize("isAuthenticated()")
@PostMapping("/studies/ai")
public ResponseEntity<ApiResponseDto<AiTextResponse>> studyAi(@RequestBody AiStudyRequest req) {
String detail = ai.generateStudyDetail(req);
return ResponseEntity.ok(ApiResponseDto.of(200, "요청이 성공적으로 처리되었습니다.", new AiTextResponse(detail)));
}
}
23 changes: 23 additions & 0 deletions src/main/java/goorm/ddok/ai/dto/request/AiProjectRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package goorm.ddok.ai.dto.request;

import goorm.ddok.global.dto.LocationDto;
import goorm.ddok.global.dto.PreferredAgesDto;
import lombok.*;

import java.time.LocalDate;
import java.util.List;

@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class AiProjectRequest {
private String title;
private LocalDate expectedStart;
private Integer expectedMonth;
private String mode; // "online" / "offline"
private LocationDto location;
private PreferredAgesDto preferredAges;
private Integer capacity;
private List<String> traits;
private List<String> positions;
private String leaderPosition;
private String detail; // 사용자가 이미 쓴 텍스트(있으면 tone 참고)
}
22 changes: 22 additions & 0 deletions src/main/java/goorm/ddok/ai/dto/request/AiStudyRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package goorm.ddok.ai.dto.request;

import goorm.ddok.global.dto.LocationDto;
import goorm.ddok.global.dto.PreferredAgesDto;
import lombok.*;

import java.time.LocalDate;
import java.util.List;

@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class AiStudyRequest {
private String title;
private LocalDate expectedStart;
private Integer expectedMonth;
private String mode; // "online" / "offline"
private LocationDto location;
private PreferredAgesDto preferredAges;
private Integer capacity;
private List<String> traits;
private String studyType; // 예: "ALGORITHM", "자소서" 등
private String detail;
}
8 changes: 8 additions & 0 deletions src/main/java/goorm/ddok/ai/dto/response/AiTextResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package goorm.ddok.ai.dto.response;

import lombok.*;

@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class AiTextResponse {
private String detail;
}
101 changes: 101 additions & 0 deletions src/main/java/goorm/ddok/ai/service/AiTextService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package goorm.ddok.ai.service;

import goorm.ddok.ai.dto.request.AiProjectRequest;
import goorm.ddok.ai.dto.request.AiStudyRequest;
import goorm.ddok.ai.service.prompt.AiPromptFactory;
import goorm.ddok.ai.service.provider.AiModelClient;
import goorm.ddok.global.dto.LocationDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.util.List;

@Service
@RequiredArgsConstructor
public class AiTextService {

private final AiModelClient model; // ClovaStreamClient 또는 ClovaModelClient 주입

public String generateProjectDetail(AiProjectRequest req) {
String prompt = AiPromptFactory.buildProjectPrompt(
safe(req.getTitle()),
toDateString(req.getExpectedStart()),
req.getExpectedMonth(),
toModeString(req.getMode()),
toLocation(req.getLocation()),
req.getCapacity(),
safeList(req.getTraits()),
safeList(req.getPositions()),
safe(req.getLeaderPosition()),
safe(req.getDetail())
);
// 템플릿은 충분히 길 수 있으니 maxTokens 넉넉히
return model.generate(prompt, 800);
}

public String generateStudyDetail(AiStudyRequest req) {
String prompt = AiPromptFactory.buildStudyPrompt(
safe(req.getTitle()),
toDateString(req.getExpectedStart()),
req.getExpectedMonth(),
toModeString(req.getMode()),
toLocation(req.getLocation()),
req.getCapacity(),
safeList(req.getTraits()),
safe(req.getStudyType()),
safe(req.getDetail())
);
return model.generate(prompt, 800);
}

/* =========================
* helpers
* ========================= */

private static String safe(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}

private static List<String> safeList(List<String> list) {
return (list == null) ? List.of() : list.stream()
.filter(s -> s != null && !s.isBlank())
.map(String::trim)
.toList();
}

private static String toDateString(LocalDate d) {
return (d == null) ? null : d.toString(); // yyyy-MM-dd
}

/**
* enum(ONLINE/OFFLINE) 또는 문자열("online"/"offline") 모두 대응
*/
private static String toModeString(Object mode) {
if (mode == null) return null;
String v = mode.toString().trim().toLowerCase();
if (v.equals("online") || v.equals("offline")) return v;
// 혹시 다른 표현이면 그대로 전달
return v;
}

/**
* 요청 DTO의 location 타입이 goorm.ddok.global.dto.LocationDto와 동일하다면 그대로 캐스팅,
* 아니라면 address만 있는 간이 LocationDto로 변환.
*/
private static LocationDto toLocation(Object loc) {
if (loc == null) return null;
if (loc instanceof LocationDto l) return l;

// reflection 없이 address 필드만 얕게 시도 (필드명이 다르면 null 처리)
try {
var m = loc.getClass().getMethod("getAddress");
Object addr = m.invoke(loc);
String address = (addr == null) ? null : addr.toString();
return LocationDto.builder().address(address).build();
} catch (Exception ignore) {
// address가 없으면 null
return null;
}
}
}
141 changes: 141 additions & 0 deletions src/main/java/goorm/ddok/ai/service/prompt/AiPromptFactory.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package goorm.ddok.ai.service.prompt;

import goorm.ddok.global.dto.LocationDto;
import java.util.List;
import java.util.Objects;

public final class AiPromptFactory {

private AiPromptFactory() {}

private static String joinComma(List<String> list) {
if (list == null || list.isEmpty()) return "-";
return String.join(", ", list);
}

private static String orDash(String s) {
return (s == null || s.isBlank()) ? "-" : s.trim();
}

/* =========================
* 프로젝트 상세 프롬프트 (줄글, 반복 방지 강화)
* ========================= */
public static String buildProjectPrompt(
String title,
String expectedStart,
Integer expectedMonth,
String mode,
LocationDto loc,
Integer cap,
List<String> traits,
List<String> positions,
String leaderPosition,
String detail
) {
String address = (loc == null) ? null : loc.getAddress();
String scheduleStart = orDash(expectedStart);
String scheduleMonths = (expectedMonth == null) ? "-" : expectedMonth + "개월";
String modeKo = "online".equalsIgnoreCase(mode) ? "온라인" : "오프라인";

// ⚠️ 반복 금지, 1회 출력, 길이 가이드, 불필요한 접두/접미 금지
return """
너는 한국어로 프로젝트 모집글을 **순수 줄글**로 작성하는 어시스턴트다.

[출력 규칙]
- 출력은 **본문 1회만** 작성한다. 같은 내용을 다시 요약하거나 반복해서 붙이지 마라.
- **동일하거나 매우 유사한 문장/문단을 반복**하지 마라. 재진술, 말 바꾸기 반복 금지.
- 각 문단은 **빈 줄 1개(Enter 1회)** 로만 구분한다. 목록/헤딩/체크박스/마크다운 사용 금지.
- 과장·차별 표현 없이 **존중하는 어조**로, 간결하고 구체적으로 쓴다.
- 제공된 사실(제목, 일정, 모집역할, 진행방식, 위치, 팀 특성, 리더 포지션, 메모)만 자연스럽게 녹여라.
- **미제공 정보는 추정하거나 채우지 말고 생략**하라.
- “요약/마무리/감사/서명/재요약” 같은 **불필요한 접두사·접미사**를 덧붙이지 마라.
- 문장은 자연스럽게 연결하되, **문단 간에는 새로운 정보**를 제시하라(같은 포인트를 반복 금지).

[입력 데이터]
- 제목: %s
- 시작일: %s
- 예상 기간: %s
- 진행 방식: %s
- 위치(주소): %s
- 모집 정원: %s명
- 팀 특성: %s
- 모집 역할: %s
- 리더 포지션: %s
- 작성자 메모(선택): %s
"""
.formatted(
orDash(title),
scheduleStart,
scheduleMonths,
modeKo,
orDash(address),
(cap == null ? "-" : String.valueOf(cap)),
joinComma(traits),
joinComma(positions),
orDash(leaderPosition),
orDash(detail)
);
}

/* =========================
* 스터디 상세 프롬프트 (줄글, 반복 방지 강화)
* ========================= */
public static String buildStudyPrompt(
String title,
String expectedStart,
Integer expectedMonth,
String mode,
LocationDto loc,
Integer cap,
List<String> traits,
String studyType,
String detail
) {
String address = (loc == null) ? null : loc.getAddress();
String scheduleStart = orDash(expectedStart);
String scheduleMonths = (expectedMonth == null) ? "-" : expectedMonth + "개월";
String modeKo = "online".equalsIgnoreCase(mode) ? "온라인" : "오프라인";

return """
너는 한국어로 스터디 모집글을 **순수 줄글**로 작성하는 어시스턴트다.

[출력 규칙]
- 출력은 **본문 1회만** 작성한다. 같은 내용을 다시 요약/재진술하지 마라.
- **동일/유사 문장·문단 반복 금지**. 문단마다 새로운 정보를 담아라.
- **3~4개의 문단**, 문단당 **2~4문장**. 문단 구분은 **빈 줄 1개**로만 한다.
- 목록/헤딩/체크박스/마크다운 금지. 간결하고 자연스러운 서술문으로만 작성.
- 제공된 사실(제목, 일정, 정원, 진행방식, 위치, 팀 특성, 스터디 유형)만 반영.
- **미제공 정보는 추정/생성 금지**. 생략해라.
- “요약/마무리/감사/서명/재요약” 등 불필요한 접두사·접미사 금지.

[입력 데이터]
- 제목: %s
- 시작일: %s
- 예상 기간: %s
- 진행 방식: %s
- 위치(주소): %s
- 모집 정원: %s명
- 팀 특성: %s
- 스터디 유형: %s
- 작성자 메모(선택): %s
"""
.formatted(
orDash(title),
scheduleStart,
scheduleMonths,
modeKo,
orDash(address),
(cap == null ? "-" : String.valueOf(cap)),
joinComma(traits),
orDash(studyType),
orDash(detail)
);
}

// 남겨두었지만 현재 사용 안 함(체크박스 템플릿 제거)
private static String pick(List<String> list, int idx) {
if (list == null || list.size() <= idx) return "-";
String v = Objects.toString(list.get(idx), "").trim();
return v.isBlank() ? "-" : v;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package goorm.ddok.ai.service.provider;

public interface AiModelClient {
String generate(String prompt, int maxTokens);
}
Loading
Loading