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 @@ -42,25 +42,11 @@ public interface DocumentRepository extends JpaRepository<Document, Long> {

// currentVersion은 LAZY라, OSIV가 꺼진 경로(/mcp)에서 findById만 쓰면
// 트랜잭션 종료 후 지연 로딩 시 LazyInitializationException이 난다. JOIN FETCH로 즉시 로딩한다.
// 상세 조회는 이 쿼리 뒤의 읽기 전용 Transaction 안에서 owner와 fileObject를 개별 로딩해
// 배포 데이터의 선택적 연관관계까지 한 번에 묶는 다중 JOIN FETCH를 피한다.
@Query("SELECT d FROM Document d LEFT JOIN FETCH d.currentVersion WHERE d.id = :documentId")
Optional<Document> findByIdWithCurrentVersion(@Param("documentId") Long documentId);

/**
* 문서 상세·본문·원본 파일 조회에 필요한 소유자, 현재 버전과 파일 정보를 한 번에 조회한다.
*
* @param documentId 조회할 문서 식별자
* @return 상세 조회에 필요한 연관관계가 초기화된 문서
*/
@Query("""
SELECT d
FROM Document d
JOIN FETCH d.owner
LEFT JOIN FETCH d.currentVersion currentVersion
LEFT JOIN FETCH currentVersion.fileObject
WHERE d.id = :documentId
""")
Optional<Document> findByIdWithDetail(@Param("documentId") Long documentId);

@Query("""
SELECT d.id AS documentId,
d.status AS documentStatus,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ private Document getReadableDocument(Long userId, Long documentId) {
throw new DocGridException(ErrorCode.PERMISSION_DENIED);
}

Document document = documentRepository.findByIdWithDetail(documentId)
// 현재 버전만 JOIN FETCH하고 소유자·파일은 이 읽기 전용 Transaction 안에서 필요할 때 로딩한다.
Document document = documentRepository.findByIdWithCurrentVersion(documentId)
.orElseThrow(() -> new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND));
if (document.getStatus() == DocumentStatus.DELETED) {
throw new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* 2. PromptBuilder로 프롬프트 조립
* 3. OllamaClient 호출
* 4. rag_responses 저장 (성공/실패)
* 5. 성공 시 response_citations 저장
* 5. 성공 시 response_citations 저장, 실패 시 검색 후보와 안내 답변 반환
* </pre>
*
* <p>SearchFacade와 별도 트랜잭션으로 분리되어 있다(SearchController가 순차 호출) — 검색 DB 작업과
Expand All @@ -39,6 +39,9 @@
@Slf4j
public class RagFacade {

private static final String LLM_FALLBACK_ANSWER =
"관련 문서는 찾았지만 AI 답변 생성이 지연되고 있습니다. 아래 검색 결과와 근거 문서를 확인해 주세요.";

private final PromptBuilder promptBuilder;
private final OllamaClient ollamaClient;
private final RagResponseCommandService ragResponseCommandService;
Expand All @@ -59,15 +62,20 @@ public RagAnswer generate(

// 검색 후보가 있으면 프롬프트 조립 후 LLM 호출
String prompt = promptBuilder.build(queryText, candidates);
OllamaGenerateResult result;
try {
OllamaGenerateResult result = ollamaClient.generate(prompt);
RagResponse ragResponse = ragResponseCommandService.createSuccess(queryRef, prompt, result);
responseCitationCommandService.saveAll(ragResponse, candidates, searchResults);
log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, ragResponse.getId(), result.latencyMs());
return RagAnswer.of(result.answerText(), candidates);
result = ollamaClient.generate(prompt);
} catch (DocGridException e) {
ragResponseCommandService.createFailed(queryRef, prompt, e.getMessage());
throw e;
// LLM 장애가 권한 검증을 통과한 벡터 검색 결과까지 숨기지 않도록 저하 응답으로 마무리한다.
log.warn("[RAG] fallback queryId={} errorCode={}", queryId, e.getErrorCode().getCode());
return RagAnswer.of(LLM_FALLBACK_ANSWER, candidates);
}

// LLM 이후의 영속화 실패는 검색 저하 응답으로 숨기지 않고 Transaction 오류로 전달한다.
RagResponse ragResponse = ragResponseCommandService.createSuccess(queryRef, prompt, result);
responseCitationCommandService.saveAll(ragResponse, candidates, searchResults);
log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, ragResponse.getId(), result.latencyMs());
return RagAnswer.of(result.answerText(), candidates);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,37 @@
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

/**
* Ollama HTTP 연결과 추론 응답 제한 시간을 실행 환경별로 구성한다.
*
* <p>RAG 도메인은 Timeout 이후의 검색 결과 Fallback을 책임지고, 이 설정은 Sites 요청 제한보다
* 먼저 호출을 종료할 수 있는 Transport 경계만 책임진다.</p>
*/
@Configuration
public class OllamaServerConfig {

@Value("${ollama.server.base-url}")
private String baseUrl;

@Value("${ollama.server.connect-timeout:5s}")
private Duration connectTimeout;

@Value("${ollama.server.read-timeout:20s}")
private Duration readTimeout;

/**
* 실행 환경별 제한 시간을 적용한 Ollama 전용 Client를 만든다.
*/
@Bean("ollamaRestClient")
public RestClient ollamaRestClient() {
// 1. 연결 실패와 느린 추론을 서로 다른 제한 시간으로 중단한다.
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.connectTimeout(connectTimeout)
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(Duration.ofSeconds(30));
requestFactory.setReadTimeout(readTimeout);

// 2. RAG Client가 동일한 Transport 정책을 사용하도록 이름이 지정된 Client를 제공한다.
return RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(requestFactory)
Expand Down
3 changes: 3 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,7 @@ embedding:
ollama:
server:
base-url: ${OLLAMA_SERVER_URL:http://localhost:11434}
# Sites Worker의 30초 요청 제한보다 먼저 종료해 검색 결과 Fallback을 반환한다.
connect-timeout: ${OLLAMA_SERVER_CONNECT_TIMEOUT:5s}
read-timeout: ${OLLAMA_SERVER_READ_TIMEOUT:20s}
model: ${OLLAMA_MODEL:qwen2.5:3b}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class DocumentRepositoryFetchTest {
@Autowired private EntityManager entityManager;

@Test
@DisplayName("정상 케이스: currentVersion이 JOIN FETCH로 즉시 로딩되어 영속성 컨텍스트 초기화 후에도 지연 로딩 없이 접근 가능하다")
@DisplayName("정상 케이스: 현재 버전을 즉시 로딩하고 선택적 파일이 없어도 상세 연관관계에 접근할 수 있다")
void findByIdWithCurrentVersion_eagerlyFetchesCurrentVersion() {
// Given
User owner = saveOwner();
Expand All @@ -55,6 +55,8 @@ void findByIdWithCurrentVersion_eagerlyFetchesCurrentVersion() {
// Then
assertThat(Hibernate.isInitialized(found.getCurrentVersion())).isTrue();
assertThat(found.getCurrentVersion().getVersionNo()).isEqualTo(1);
assertThat(found.getCurrentVersion().getFileObject()).isNull();
assertThat(found.getOwner().getName()).isEqualTo("JOIN FETCH 테스트 사용자");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ void getDocumentDetail_throws_whenReadPermissionIsDenied() {
assertThatThrownBy(() -> service.getDocumentDetail(USER_ID, DOCUMENT_ID))
.isInstanceOf(DocGridException.class)
.hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED);
then(documentRepository).should(never()).findByIdWithDetail(DOCUMENT_ID);
then(documentRepository).should(never()).findByIdWithCurrentVersion(DOCUMENT_ID);
}

@Test
Expand Down Expand Up @@ -378,7 +378,7 @@ private DocumentSummaryResponse summaryResponse() {

private void givenReadableDocument(Document document) {
given(permissionQueryService.canReadDocument(USER_ID, DOCUMENT_ID)).willReturn(true);
given(documentRepository.findByIdWithDetail(DOCUMENT_ID)).willReturn(Optional.of(document));
given(documentRepository.findByIdWithCurrentVersion(DOCUMENT_ID)).willReturn(Optional.of(document));
given(document.getStatus()).willReturn(DocumentStatus.INDEXED);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.opensource.docgrid.domain.rag.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
Expand Down Expand Up @@ -115,8 +114,8 @@ void generate_success_savesResponseAndCitations() {
}

@Test
@DisplayName("Ollama 호출 실패: FAILED로 기록하고 예외를 다시 던진다")
void generate_ollamaFails_savesFailedAndRethrows() {
@DisplayName("Ollama 호출 실패: FAILED로 기록하고 검색 후보가 포함된 저하 응답을 반환한다")
void generate_ollamaFails_savesFailedAndReturnsFallback() {
SearchQuery queryRef = mock(SearchQuery.class);
given(entityManager.getReference(SearchQuery.class, QUERY_ID)).willReturn(queryRef);

Expand All @@ -129,9 +128,11 @@ void generate_ollamaFails_savesFailedAndRethrows() {
given(ollamaClient.generate("조립된 프롬프트"))
.willThrow(new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE));

assertThatThrownBy(() -> ragFacade.generate(QUERY_ID, "질문", candidates, List.of()))
.isInstanceOf(DocGridException.class)
.hasFieldOrPropertyWithValue("errorCode", ErrorCode.RAG_SERVICE_UNAVAILABLE);
RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", candidates, List.of());

assertThat(answer.answerText()).contains("AI 답변 생성이 지연");
assertThat(answer.citations()).hasSize(1);
assertThat(answer.citations().get(0).documentId()).isEqualTo(100L);

then(ragResponseCommandService).should(times(1))
.createFailed(eq(queryRef), eq("조립된 프롬프트"), anyString());
Expand Down
2 changes: 1 addition & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
BACKEND_API_URL=http://localhost:8080
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
# misc
.DS_Store
*.pem
*.tsbuildinfo

# debug
npm-debug.log*
Expand Down
104 changes: 22 additions & 82 deletions frontend/README.md
Original file line number Diff line number Diff line change
@@ -1,100 +1,40 @@
# vinext-starter
# DocGrid Frontend

A clean full-stack starter running on
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
Drizzle support.
DocGrid 백엔드 API를 사용하는 실제 웹 클라이언트입니다. 기존 화면 프로토타입의 보라색 워크스페이스 디자인을 유지하면서 검색, 문서, 컬렉션, 권한, MCP 토큰, 계정, RAGOps 관리 화면을 API에 연결합니다.

## Prerequisites
## 실행

- Node.js `>=22.13.0`

## Quick Start
Node.js 22.13 이상이 필요합니다.

```bash
cp .env.example .env.local
npm install
npm run dev
npm run build
```

This starter does not use `wrangler.jsonc`.

## Included Shape

- edit site code under `app/`
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
- `vite.config.ts` simulates declared bindings for local development
- `db/schema.ts` starts intentionally empty
- `examples/d1/` contains an optional D1 example surface
- `drizzle.config.ts` supports local migration generation when needed

## Workspace Auth Headers

Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present.

The user ID is stable for the same user on the same Site and different across Sites. Email and name are intended for display or contact purposes.

SIWC-authenticated workspace sites may also receive
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.

Treat the full name as optional and fall back to email when it is absent:
`.env.local`에서 백엔드 주소를 지정합니다.

```tsx
import { headers } from "next/headers";

export default async function Home() {
const requestHeaders = await headers();
const userId = requestHeaders.get("oai-authenticated-user-id");
const email = requestHeaders.get("oai-authenticated-user-email");
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
const fullName =
encodedFullName &&
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
"percent-encoded-utf-8"
? decodeURIComponent(encodedFullName)
: null;

const displayName = fullName ?? email;
// ...
}
```dotenv
BACKEND_API_URL=http://localhost:8080
```

## Optional Dispatch-Owned ChatGPT Sign-In

Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
optional or required ChatGPT sign-in:

- Use `getChatGPTUser()` for optional signed-in UI.
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
anonymous visitors through Sign in with ChatGPT.
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
browser links or actions.
- Pass a same-origin relative `returnTo` path for the destination after sign-in
or sign-out. The helper validates and safely encodes it.
- Mark protected pages with `export const dynamic = "force-dynamic"` because
they depend on per-request identity headers.
브라우저는 `/api/backend/*` 같은 출처 프록시만 호출합니다. 프록시가 `BACKEND_API_URL`로 요청을 전달하므로 별도 프론트엔드 CORS 허용 목록 없이 로컬·배포 환경을 동일하게 사용할 수 있습니다.

Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
OAuth cookies, and identity header injection. Do not implement app routes for
those reserved paths. Routes that do not import and call the helper remain
anonymous-compatible.
## 인증

SIWC establishes identity only; it does not prove workspace membership. Use the
Sites hosting platform's access policy controls for workspace-wide restrictions,
or enforce explicit server-side membership or allowlist checks.
- ChatGPT 로그인은 사용하지 않습니다.
- `/auth/login`, `/auth/signup`, `/auth/me`를 사용합니다.
- JWT는 탭이 닫히면 사라지는 `sessionStorage`에만 보관합니다.
- 백엔드가 `401`을 반환하면 세션을 지우고 로그인 화면으로 이동합니다.

Use SIWC for account pages, user-specific dashboards, saved records, and write
actions tied to the current ChatGPT user. Leave public content anonymous.
## 검증

## Useful Commands

- `npm run dev`: start local development
- `npm run build`: verify the vinext build output
- `npm test`: build the starter and verify its rendered loading skeleton
- `npm run db:generate`: generate Drizzle migrations after schema changes
```bash
npm run lint
npm run build
npm test
```

## Learn More
## API 제공 범위

- [vinext Documentation](https://github.com/cloudflare/vinext)
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
문서 상세·추출 본문·원본 미리보기·다운로드, 컬렉션 소속 문서, 직접 권한 목록, 관리자 사용자 목록은 현재 모노레포의 백엔드 계약에 연결되어 있습니다. 배포된 백엔드가 해당 조회 API보다 오래된 버전이면 화면에서 버전 불일치를 안내합니다.
6 changes: 3 additions & 3 deletions frontend/app/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import PrototypeApp from "../components/PrototypeApp";
import DocGridApp from "../components/DocGridApp";

export default async function RoutedPrototype({
export default async function RoutedDocGrid({
params,
}: {
params: Promise<{ slug: string[] }>;
}) {
const { slug } = await params;
return <PrototypeApp initialRoute={`/${slug.join("/")}`} />;
return <DocGridApp initialRoute={`/${slug.join("/")}`} />;
}
Loading