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
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 체류·계약·서류·신
| 개발·배포 DB | PostgreSQL + Flyway |
| 보안 | JWT Access Token, `ADMIN`·`HR`·`VIEWER` 역할, `company_id` 기반 ActorContext |
| 개발 기반 | Swagger UI, 공통 오류, `request_id`, CI 구성 완료 |
| AI·Workflow | Knowledge Catalog projection, Task·Checklist·승인·감사 구현. AiRun 연동은 후속 Issue |
| AI·Workflow | Knowledge Catalog projection, Task·Checklist·승인·감사와 AI Runtime 계약·방어 검증 구현. Remote 연동·AiRun은 후속 Issue |

계획 문서는 현재 동작하는 API가 아닙니다. 구현의 원본은 코드·테스트와 실행 시 생성되는 OpenAPI이고, 장기 아키텍처 결정은 [ADR](docs/adr/README.md), 계획 범위와 예시는 [API 카탈로그](https://github.com/fowoco/server/wiki/09-API-Specification)와 Issue에서 확인합니다.

Expand Down Expand Up @@ -140,6 +140,22 @@ POST /api/v1/tasks/{taskId}/approval-requests
- 상태 변경, 승인 기록, 감사 이벤트는 같은 DB transaction에 기록되므로 중간 하나가 실패하면 함께 되돌아갑니다.
- `GET /api/v1/tasks/{taskId}/activities`는 화면용 안전 타임라인이고, `GET /api/v1/audit-events`는 ADMIN용 필터·cursor 조회입니다. 내부 snapshot 원문은 두 API에 노출하지 않습니다.

### AI Runtime 계약 기반

Server는 AI Runtime에 보낼 수 있는 field를 typed DTO로 제한하고, 전송 전과 응답 후에
`ValidatingAiRuntimeClient`로 개인정보·request ID·version·worker·workflow·slot을
검증합니다.

- `AiRuntimeClient`는 Provider-neutral Port이며 OpenAI·Gemini SDK를 포함하지 않습니다.
- 테스트에서는 네트워크를 호출하지 않는 `FakeAiRuntimeClient`를 사용합니다.
- 실제 `RemoteAiRuntimeClient`와 `/internal/v1/analyses` HTTP 연결은 AI 저장소의 원본
OpenAPI·JSON Schema가 release된 뒤 추가합니다.
- Remote Client는 자동 retry하지 않습니다. 후속 #24가 새 AiAttempt를 영속한 경우에만
다시 호출할 수 있습니다.

요청·응답 예시와 차단 규칙은
[Server ↔ AI Runtime 계약 기반](docs/ai-runtime-contract.md)에서 확인합니다.

### PostgreSQL 개발 Profile

```bash
Expand Down Expand Up @@ -175,6 +191,7 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 또는 배포 Secret의 12자 이상 값
| Profile | `local`은 H2, `dev`·`prod`는 PostgreSQL을 사용합니다. | `application.yaml` |
| Flyway | 서버 시작 시 적용하지 않은 DB 변경 파일을 순서대로 실행합니다. | `db/migration` |
| Workflow Catalog | Knowledge release의 Server용 read-only projection을 시작 시 검증합니다. | `workflow/` |
| AI Runtime 계약 | 외부 AI 요청·응답을 allow-list와 version으로 다시 검증합니다. | `aiintegration/` |
| Security | JWT에서 ActorContext와 역할을 만들고 VIEWER의 쓰기 요청을 기본 차단합니다. | `SecurityConfig` |
| Swagger | Controller의 API 설명을 브라우저 문서로 보여줍니다. | `OpenApiConfig` |
| 공통 오류 | 모든 실패를 같은 JSON 구조로 반환합니다. | `common/error` |
Expand Down Expand Up @@ -308,6 +325,7 @@ server/
├── auth/
├── worker/
├── task/
├── aiintegration/
└── airun/
```

Expand Down Expand Up @@ -338,6 +356,7 @@ server/
| --- | --- |
| 전체 백엔드 목표·작업 순서 | [MVP Epic #2](https://github.com/fowoco/server/issues/2) |
| 저장소·모듈·API·상태 결정 원본 | [Architecture Decision Records](docs/adr/README.md) |
| Server와 AI Runtime의 구현 계약 | [AI Runtime 계약 기반](docs/ai-runtime-contract.md) |
| 계획 API와 사용자 흐름 | [Wiki API 카탈로그](https://github.com/fowoco/server/wiki/09-API-Specification) |
| 사람이 읽는 상세 DTO·화면 기획 | [Notion API 명세](https://app.notion.com/p/f250e15aa74e82b8872581be4d7c6c3c?v=f280e15aa74e82ce8d6e8848514d41c3&pvs=23) |
| 화면·사용 흐름 | [Figma](https://www.figma.com/design/eaOD8OXZOGq6vK4H9pGXNi/FOWOCO?node-id=143-2&t=YbytLHiwZ5m1IChO-1) |
Expand Down
143 changes: 143 additions & 0 deletions docs/ai-runtime-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Server ↔ AI Runtime 계약 기반

이 문서는 `fowoco/server`가 별도 배포되는 `fowoco/ai` Runtime을 호출할 때 지켜야 하는
최소 계약과 방어 규칙을 설명합니다.

현재 단계는 **HTTP 연결 전 계약 기반**입니다. 실제 `/internal/v1/analyses` OpenAPI와
Structured Output JSON Schema의 원본은 `fowoco/ai`가 소유하며, 원본 계약이 release되면
Server의 `RemoteAiRuntimeClient`와 fixture를 그 version에 맞춰 연결합니다.

## 초보자용 한 줄 설명

AI 서버에 무엇을 보낼 수 있는지 먼저 좁혀 놓고, AI가 돌려준 값도 그대로 믿지 않고 다시
검사하는 안전문입니다.

```text
AiRunWorker (#24, 후속)
→ ValidatingAiRuntimeClient
1. 요청 개인정보·허용 범위 검사
2. AiRuntimeClient transport를 정확히 한 번 호출
3. 응답 ID·version·worker·workflow·slot 재검사
→ FakeAiRuntimeClient (test)
→ RemoteAiRuntimeClient (#8 후속)
→ POST /internal/v1/analyses (fowoco/ai)
```

`AiRuntimeClient`는 OpenAI, Gemini, Anthropic 같은 Provider를 직접 호출하지 않습니다.
Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입니다.

## 요청 계약

```json
{
"requestId": "10000000-0000-0000-0000-000000000001",
"attemptId": "20000000-0000-0000-0000-000000000001",
"contractVersion": "1.0.0",
"requiredKnowledgeVersion": "0.2.0",
"deadlineMs": 10000,
"maskedInput": {
"maskedInstruction": "workerRef 30000000-0000-0000-0000-000000000001의 체류연장 준비",
"workers": [
{
"workerRef": "30000000-0000-0000-0000-000000000001",
"preferredLanguage": "vi",
"workStatus": "ACTIVE",
"stayExpiryDate": "2026-12-31"
}
],
"workflowConstraints": [
{
"workflowId": "EXPIRY_RENEWAL",
"allowedSlotKeys": [
"stay_expiry_date",
"contract_end_date",
"monthly_wage"
]
}
]
}
}
```

- `requestId`: Server 요청과 Runtime 응답을 같은 실행으로 연결합니다.
- `attemptId`: 한 번의 `AiRuntimeClient.analyze` 호출과 정확히 하나로 대응합니다.
- `contractVersion`: 양쪽이 같은 JSON 계약을 사용하는지 확인합니다.
- `requiredKnowledgeVersion`: Server와 Runtime이 같은 Workflow release를 사용하게 합니다.
- `deadlineMs`: 이번 시도 전체에서 남은 실행 시간입니다.
- `maskedInstruction`: 이름과 식별번호를 `workerRef`로 바꾼 자연어입니다.
- `workflowConstraints`: Knowledge projection에서 가져온 Workflow와 slot allow-list입니다.

근로자 Context에는 여권번호, 외국인등록번호, 전화번호, 계좌번호, 법적 실명, 원본 문서와
Worker Link token을 추가하지 않습니다.

## 응답 계약

```json
{
"requestId": "10000000-0000-0000-0000-000000000001",
"outcome": "REVIEW_REQUIRED",
"candidates": [
{
"candidateRef": "candidate-1",
"workerRef": "30000000-0000-0000-0000-000000000001",
"workflowId": "EXPIRY_RENEWAL",
"extractedSlots": {
"stay_expiry_date": "2026-12-31"
},
"missingSlots": [
"contract_end_date",
"monthly_wage"
],
"confidence": 0.92
}
],
"validationErrors": [],
"versions": {
"agentVersion": "agent-1.0.0",
"modelProvider": "openai",
"modelName": "gpt-5-mini",
"modelVersion": "2026-07-01",
"promptVersion": "prompt-3",
"contextPackVersion": "context-0.2.0",
"workflowCatalogVersion": "0.2.0",
"contractVersion": "1.0.0"
},
"providerAttemptCount": 1,
"latencyMs": 245
}
```

`NEEDS_INFO`와 `REVIEW_REQUIRED`는 정상 분석 결과입니다. 이 값은 AiRun의 기술적
`FAILED` 상태와 섞지 않습니다. Candidate는 Task도 승인도 아니며, #24에서 HR이 채택한
후에만 Server Task command로 전달됩니다.

## Server가 거부하는 응답

- 요청과 다른 `requestId`
- 요청과 다른 contract 또는 Workflow Catalog version
- 요청에 없던 `workerRef`나 `workflowId`
- Workflow가 허용하지 않은 slot
- 0 미만 또는 1 초과 confidence
- 중복 candidate reference와 잘못된 outcome 구조
- 여권번호·외국인등록번호·전화번호·계좌번호·Bearer Token·Secret이 섞인 값

거부 예외에는 발견한 원문을 넣지 않습니다. 앞으로 #24 AiAttempt에는
`AiRuntimeFailureCode`와 `requestId` 같은 안전한 진단값만 저장합니다.

## 테스트와 실제 구현의 차이

- `FakeAiRuntimeClient`: `src/test`에만 있으며 응답이나 예외를 순서대로 예약합니다.
- `ValidatingAiRuntimeClient`: transport 앞뒤에서 같은 방어 검증을 수행합니다.
- `RemoteAiRuntimeClient`: 아직 없습니다. AI 원본 계약 release 후 추가합니다.

실제 HTTP 연결 PR에서는 다음을 추가로 검증합니다.

1. `Service-Authorization`, `X-Request-Id`, `traceparent` 전달
2. 알 수 없는 JSON field와 body size 제한
3. connect/read/overall deadline
4. circuit breaker와 concurrency bulkhead
5. HTTP·parsing·contract 오류의 안정적인 분류
6. contract fixture와 WireMock 통합 테스트

Remote Client는 자동 HTTP retry를 하지 않습니다. 다시 호출하려면 #24가 먼저 새로운
AiAttempt를 DB에 기록해야 합니다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.fowoco.server.aiintegration.application.error;

import java.util.Objects;

/**
* Contract failure that never includes the rejected raw value in its message.
*/
public final class AiRuntimeContractException extends RuntimeException {

private final AiRuntimeFailureCode failureCode;

public AiRuntimeContractException(AiRuntimeFailureCode failureCode, String safeMessage) {
super(safeMessage);
this.failureCode = Objects.requireNonNull(failureCode, "failureCode must not be null");
}

public AiRuntimeFailureCode failureCode() {
return failureCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.fowoco.server.aiintegration.application.error;

/**
* Stable, non-sensitive reason stored by a future AiAttempt.
*/
public enum AiRuntimeFailureCode {
INVALID_REQUEST_CONTRACT,
SENSITIVE_DATA_REJECTED,
INVALID_RESPONSE_CONTRACT,
REQUEST_ID_MISMATCH,
CONTRACT_VERSION_MISMATCH,
KNOWLEDGE_VERSION_MISMATCH,
UNEXPECTED_WORKER_REFERENCE,
UNEXPECTED_WORKFLOW,
UNEXPECTED_SLOT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.fowoco.server.aiintegration.application.model;

/**
* A successful business outcome returned by the AI Runtime.
*
* <p>Low confidence and missing information are not transport failures.</p>
*/
public enum AiAnalysisOutcome {
NEEDS_INFO,
REVIEW_REQUIRED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.fowoco.server.aiintegration.application.model;

import java.util.Objects;
import java.util.UUID;

/**
* Provider-neutral body for one Server-managed AI attempt.
*/
public record AiAnalysisRequest(
UUID requestId,
UUID attemptId,
String contractVersion,
String requiredKnowledgeVersion,
long deadlineMs,
MaskedAnalysisInput maskedInput
) {

public AiAnalysisRequest {
Objects.requireNonNull(requestId, "requestId must not be null");
Objects.requireNonNull(attemptId, "attemptId must not be null");
Objects.requireNonNull(contractVersion, "contractVersion must not be null");
Objects.requireNonNull(requiredKnowledgeVersion, "requiredKnowledgeVersion must not be null");
Objects.requireNonNull(maskedInput, "maskedInput must not be null");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.fowoco.server.aiintegration.application.model;

import java.util.List;
import java.util.Objects;
import java.util.UUID;

/**
* Untrusted response returned by the AI Runtime and validated again by the Server.
*/
public record AiAnalysisResponse(
UUID requestId,
AiAnalysisOutcome outcome,
List<AiCandidate> candidates,
List<AiValidationError> validationErrors,
AiRuntimeVersions versions,
int providerAttemptCount,
long latencyMs
) {

public AiAnalysisResponse {
Objects.requireNonNull(requestId, "requestId must not be null");
Objects.requireNonNull(outcome, "outcome must not be null");
Objects.requireNonNull(candidates, "candidates must not be null");
Objects.requireNonNull(validationErrors, "validationErrors must not be null");
Objects.requireNonNull(versions, "versions must not be null");
candidates = List.copyOf(candidates);
validationErrors = List.copyOf(validationErrors);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.fowoco.server.aiintegration.application.model;

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

/**
* Untrusted Workflow candidate returned by the AI Runtime.
*/
public record AiCandidate(
String candidateRef,
UUID workerRef,
String workflowId,
Map<String, String> extractedSlots,
List<String> missingSlots,
BigDecimal confidence
) {

public AiCandidate {
Objects.requireNonNull(candidateRef, "candidateRef must not be null");
Objects.requireNonNull(workerRef, "workerRef must not be null");
Objects.requireNonNull(workflowId, "workflowId must not be null");
Objects.requireNonNull(extractedSlots, "extractedSlots must not be null");
Objects.requireNonNull(missingSlots, "missingSlots must not be null");
Objects.requireNonNull(confidence, "confidence must not be null");
extractedSlots = Map.copyOf(extractedSlots);
missingSlots = List.copyOf(missingSlots);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.fowoco.server.aiintegration.application.model;

import java.util.Objects;

/**
* Version evidence persisted with a future AiRun.
*/
public record AiRuntimeVersions(
String agentVersion,
String modelProvider,
String modelName,
String modelVersion,
String promptVersion,
String contextPackVersion,
String workflowCatalogVersion,
String contractVersion
) {

public AiRuntimeVersions {
Objects.requireNonNull(agentVersion, "agentVersion must not be null");
Objects.requireNonNull(modelProvider, "modelProvider must not be null");
Objects.requireNonNull(modelName, "modelName must not be null");
Objects.requireNonNull(modelVersion, "modelVersion must not be null");
Objects.requireNonNull(promptVersion, "promptVersion must not be null");
Objects.requireNonNull(contextPackVersion, "contextPackVersion must not be null");
Objects.requireNonNull(workflowCatalogVersion, "workflowCatalogVersion must not be null");
Objects.requireNonNull(contractVersion, "contractVersion must not be null");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.fowoco.server.aiintegration.application.model;

import java.util.Objects;

/**
* Machine-readable validation result. Free-form Provider error messages are intentionally omitted.
*/
public record AiValidationError(
String code,
String field
) {

public AiValidationError {
Objects.requireNonNull(code, "code must not be null");
Objects.requireNonNull(field, "field must not be null");
}
}
Loading