Skip to content
Draft
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.sh text eol=lf
Binary file removed Memory.png
Binary file not shown.
Binary file removed MemoryData_overview.png
Binary file not shown.
339 changes: 0 additions & 339 deletions README_ZH.md

This file was deleted.

17 changes: 17 additions & 0 deletions aisearch-retrieve/aisearch-retrieve/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Linkpoket AI 검색 — retrieve(자료 조회·선별) 코드 발췌

원본: Linkrew-backEnd `src/main/java/com/linkpoket/source/domain/aisearch/` (develop 48b35093 기준, 2026-08-19 추출)

## 포함 범위 — 질문이 들어와서 "참고 자료"가 확정되기까지
- service/ : 파이프라인 오케스트레이션(AiSearchService·Router), 프롬프트 조립(MdComposer),
LLM 호출(AiSearchLlmCaller), 폴더/페이지 설명 생성(Description*, *Builder)
- event/ : 링크 본문 추출(LinkBodyExtraction*), 폴더/페이지 메타 갱신 리스너
- entity/ : ai_item_body(추출 본문), ai_folder_meta / ai_page_meta(검색용 요약)
- repository/, dto/, error/, util/(프롬프트 인젝션 방어)

## 제외
- 대화 저장(AiConversation*), 사용량 로그(AiSearchUsageLog*), 쿼터(QuotaChecker), 컨트롤러

## 참고 (외부 의존)
- 폴더/아이템 실조회는 folder·link·note·file 도메인의 DataAccess를 호출함 (이 zip엔 미포함)
- 알려진 이슈·개선안은 AI_SEARCH_UPGRADE_SPEC.md 참조
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.linkpoket.source.domain.aisearch.dto.request;

import com.linkpoket.source.global.json.SnowflakeId;

import jakarta.validation.constraints.NotBlank;

/**
* AI 검색 요청 DTO입니다.
*/
public record AiSearchRequest(
@NotBlank(message = "검색어는 필수입니다.") String query,
@SnowflakeId Long pageId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.linkpoket.source.domain.aisearch.dto.response;

import java.util.List;

/**
* AI 검색 응답 DTO입니다.
*/
public record AiSearchResponse(
String answer,
List<AiSearchSourceDto> sources,
AiSearchUsageDto usage
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.linkpoket.source.domain.aisearch.dto.response;

import com.linkpoket.source.global.json.SnowflakeId;

import lombok.Builder;

/**
* AI 검색 답변의 근거로 사용된 아이템 정보입니다.
*/
@Builder
public record AiSearchSourceDto(
@SnowflakeId Long itemId,
String itemType,
String title,
String url
) {
}
65 changes: 65 additions & 0 deletions aisearch-retrieve/aisearch-retrieve/entity/AiFolderMeta.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.linkpoket.source.domain.aisearch.entity;

import com.linkpoket.source.global.entity.BaseEntity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* 폴더별 AI 메타데이터 엔티티입니다.
*/
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "ai_folder_meta")
public class AiFolderMeta extends BaseEntity {

@Id
@Column(name = "folder_id", nullable = false)
private Long folderId;

@Column(name = "description", columnDefinition = "TEXT", nullable = false)
private String description;

@Column(name = "raw_title_index", columnDefinition = "TEXT", nullable = false)
private String rawTitleIndex;

@Column(name = "embedding")
private byte[] embedding;

@Version
@Column(name = "version", nullable = false)
private Long version;

@Builder
public AiFolderMeta(
final Long folderId,
final String description,
final String rawTitleIndex,
final byte[] embedding
) {
this.folderId = folderId;
this.description = description;
this.rawTitleIndex = rawTitleIndex;
this.embedding = embedding;
}

public void updateDescriptionAndEmbedding(
final String description,
final byte[] embedding
) {
this.description = description;
this.embedding = embedding;
}

public void updateRawTitleIndex(final String rawTitleIndex) {
this.rawTitleIndex = rawTitleIndex;
}
}
108 changes: 108 additions & 0 deletions aisearch-retrieve/aisearch-retrieve/entity/AiItemBody.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.linkpoket.source.domain.aisearch.entity;

import org.springframework.data.domain.Persistable;

import com.linkpoket.source.global.entity.BaseEntity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.PostLoad;
import jakarta.persistence.PostPersist;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* 아이템 본문 추출 결과 엔티티입니다.
* 식별자를 애플리케이션이 직접 채번하므로 Persistable을 구현하여, Hibernate가 신규 저장을
* merge(UPDATE)로 오판해 INSERT가 누락되는 것을 방지합니다.
*/
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(
name = "ai_item_body",
uniqueConstraints = {
@UniqueConstraint(name = "uk_item", columnNames = {"item_type", "item_id"})
}
)
public class AiItemBody extends BaseEntity implements Persistable<Long> {

@Id
@Column(name = "id", nullable = false)
private Long id;

@Transient
@Getter(AccessLevel.NONE)
private boolean newEntity = true;

@Column(name = "item_type", length = 20, nullable = false)
private String itemType;

@Column(name = "item_id", nullable = false)
private Long itemId;

@Column(name = "body_text", columnDefinition = "MEDIUMTEXT")
private String bodyText;

@Enumerated(EnumType.STRING)
@Column(name = "extraction_status", length = 20, nullable = false)
private ExtractionStatus extractionStatus;

@Column(name = "fail_reason", length = 255)
private String failReason;

@Column(name = "retry_count", nullable = false)
private Integer retryCount;

@Column(name = "source_hash", length = 64)
private String sourceHash;

@Builder
public AiItemBody(
final Long id,
final String itemType,
final Long itemId,
final ExtractionStatus extractionStatus
) {
this.id = id;
this.itemType = itemType;
this.itemId = itemId;
this.extractionStatus = extractionStatus;
this.retryCount = 0;
}

public void markAsDone(
final String bodyText,
final String sourceHash
) {
this.bodyText = bodyText;
this.extractionStatus = ExtractionStatus.DONE;
this.sourceHash = sourceHash;
this.failReason = null;
}

public void markAsFailed(final String failReason) {
this.extractionStatus = ExtractionStatus.FAILED;
this.failReason = failReason;
this.retryCount = this.retryCount == null ? 1 : this.retryCount + 1;
}

@Override
public boolean isNew() {
return newEntity;
}

@PostPersist
@PostLoad
void markNotNew() {
this.newEntity = false;
}
}
74 changes: 74 additions & 0 deletions aisearch-retrieve/aisearch-retrieve/entity/AiPageMeta.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.linkpoket.source.domain.aisearch.entity;

import com.linkpoket.source.global.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* 페이지별 AI 요약 메타데이터를 저장하는 엔티티 클래스입니다.
* 가변 도메인 객체이므로 BaseEntity를 상속하여 생성/수정/삭제 시점을 관리합니다.
*/
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "ai_page_meta")
public class AiPageMeta extends BaseEntity {

@Id
@Column(name = "page_id", nullable = false)
private Long pageId;

@Column(name = "description", columnDefinition = "TEXT")
private String description;

@Version
@Column(name = "version", nullable = false)
private Long version;

/**
* AiPageMeta 엔티티를 생성하기 위한 빌더 생성자입니다.
*
* @param pageId 대상 페이지 식별자
* @param description 생성할 페이지 AI 요약 설명
*/
@Builder
public AiPageMeta(final Long pageId, final String description) {
this.pageId = pageId;
this.description = description;
}

/**
* 페이지의 요약(description) 텍스트를 최신화하여 갱신합니다.
*
* @param description 갱신할 새로운 요약 텍스트
*/
public void rebuildDescription(final String description) {
this.description = description;
}

/**
* 임베딩 값(바이트 배열)을 업데이트하는 갱신 메서드입니다. (추후 활용 대비 스텁 메서드)
*
* @param description 갱신할 새로운 요약 텍스트
* @param embedding 갱신할 새로운 임베딩 바이트 배열
*/
public void updateDescriptionAndEmbedding(final String description, final byte[] embedding) {
this.description = description;
}

/**
* 현재 페이지 메타데이터의 요약 임베딩 벡터를 바이트 배열 형태로 반환합니다.
*
* @return 임베딩 바이트 배열
*/
public byte[] getEmbedding() {
return null;
}
}
11 changes: 11 additions & 0 deletions aisearch-retrieve/aisearch-retrieve/entity/ExtractionStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.linkpoket.source.domain.aisearch.entity;

/**
* 아이템 본문 추출 처리 상태입니다.
*/
public enum ExtractionStatus {
PENDING,
DONE,
FAILED,
UNSUPPORTED
}
36 changes: 36 additions & 0 deletions aisearch-retrieve/aisearch-retrieve/error/AiSearchErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.linkpoket.source.domain.aisearch.error;

import org.springframework.http.HttpStatus;

import com.linkpoket.source.global.error.code.spec.ErrorCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

/**
* AI 검색 도메인 에러코드입니다.
*/
@Getter
@RequiredArgsConstructor
public enum AiSearchErrorCode implements ErrorCode {

SEARCH_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "ai.search.error.timeout"),
UNAUTHORIZED_PAGE_ACCESS(HttpStatus.FORBIDDEN, "ai.search.error.unauthorized_page"),
SEARCH_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "ai.search.error.unavailable"),
QUOTA_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "ai.search.error.quota_exceeded"),
CONVERSATION_NOT_FOUND(HttpStatus.NOT_FOUND, "ai.search.error.conversation_not_found"),
CANARY_NOT_ALLOWED(HttpStatus.FORBIDDEN, "ai.search.error.canary_not_allowed");

private final HttpStatus httpStatus;
private final String errorMessage;

@Override
public HttpStatus getHttpStatus() {
return httpStatus;
}

@Override
public String getErrorMessage() {
return errorMessage;
}
}
13 changes: 13 additions & 0 deletions aisearch-retrieve/aisearch-retrieve/event/FolderChangeType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.linkpoket.source.domain.aisearch.event;

/**
* 폴더 요약(description) 비동기 조건부 갱신 대상 행위의 종류를 표시하는 Enum 클래스입니다.
*/
public enum FolderChangeType {
/** 아이템이 새로 등록되었음을 지칭합니다. */
CREATED,
/** 아이템의 내부 내용이나 상세가 수정 편집되었음을 지칭합니다. */
UPDATED,
/** 아이템이 삭제되었음을 지칭합니다. */
DELETED
}
Loading