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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 해야 할 일을 업무카
| 패키지·모듈 경계 | [프로젝트 구조](docs/project-structure.md) | 코드를 어느 패키지에 구현해야 하는지 설명 |
| 중요한 설계 결정 | [ADR 목록](docs/adr/README.md) | 저장소 경계, API·보안, Task·AiRun, RLS 결정 원본 |
| Server ↔ AI 계약 | [AI Runtime 계약](docs/ai-runtime-contract.md) | Server가 AI에 보내고 받을 수 있는 값과 검증 기준 |
| Agent DB 정보 보충 | [Slot 조회·재호출](docs/ai-slot-resolution.md) | canonical key allow-list, tenant 조회와 ANALYZE 재호출 기준 |
| 이벤트 유실·재처리 | [Outbox 운영 가이드](docs/reliability/transactional-outbox.md) | 이벤트 발행, lease, 재시도와 장애 복구 기준 |
| 구현 계획·업무 상태 | [Server Roadmap](https://github.com/orgs/fowoco/projects/3) · [Issues](https://github.com/fowoco/server/issues) | 실제 담당자, 우선순위와 진행 상태 |
| 전체 설명·운영 가이드 | [Server Wiki](https://github.com/fowoco/server/wiki) | 초보자용 아키텍처·API·배포 설명 |
Expand Down
123 changes: 123 additions & 0 deletions docs/ai-slot-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Agent 요청 Slot 조회와 재호출

이 문서는 AI Runtime이 `CONTEXT_REQUIRED`를 반환했을 때 Server가 Worker DB 값을
안전하게 보충하고 ANALYZE를 다시 호출하는 #74 구현을 설명합니다.

## 한 줄 설명

Agent가 DB를 직접 조회하는 대신 필요한 **canonical key**만 말하고, Server가 현재
사업장과 Knowledge allow-list를 확인한 뒤 고정된 코드로 값을 찾아 줍니다.

```text
PLAN response: CONTEXT_REQUIRED
→ AiSlotResolutionTransaction
1. company tenant context 설정
2. detectedIntent에 해당하는 Workflow projection 조회
3. AiRun의 requiredKnowledgeVersion과 활성 bundle version 확인
4. requiredFieldKeys allow-list 검사
5. 같은 company 안에서 targetDisplayName 조회
6. 고정 switch로 Worker 값 변환
→ AiAttemptStarter (#24가 PostgreSQL 구현)
→ ANALYZE request
→ AiRuntimeClient (#56)
```

DB transaction은 Slot 조회가 끝나면 닫습니다. 외부 Runtime 응답을 기다리는 동안 DB
transaction과 connection을 붙잡지 않습니다.

## canonical key 기준

key의 원본은 `fowoco/knowledge`의 `required_slots.yaml`입니다. Server의 활성 Workflow
projection은 다음 세 집합을 구분합니다.

- `requiredSlots`: Workflow 시작에 필요한 값
- `allowedSlotKeys`: Agent candidate와 질문에서 사용할 수 있는 전체 Slot
- `resolvableSlotKeys`: Server context 조회를 요청할 수 있는 Slot

분석이 고정한 `requiredKnowledgeVersion`과 현재 활성 projection의 `bundleVersion`이 다르면
서로 다른 지식 기준을 섞지 않고 `KNOWLEDGE_VERSION_MISMATCH`로 중단합니다.

MVP Worker DB Resolver가 실제 값으로 바꿀 수 있는 key는 다음과 같습니다.

| canonical key | Server 값 |
| --- | --- |
| `worker_id` | 현재 사업장 Worker UUID |
| `stay_expiry_date` | Worker의 체류기간 만료일 |
| `contract_end_date` | Worker의 계약 종료일 |

`due_at`처럼 Knowledge에서 context 조회 가능하지만 현재 Worker DB로 계산할 수 없는 값은
`missingFieldKeys`로 반환합니다. `legal_name`처럼 활성 projection이 허용하지 않은 key는
DB column을 추측하지 않고 `FORBIDDEN_FIELD`로 거부합니다.

## 대상 근로자 확인

MVP는 한 요청에서 Worker 한 명만 처리합니다.

- 현재 `companyId` 안에서 `targetDisplayName`이 정확히 한 명이면 계속 진행합니다.
- 없으면 `TARGET_NOT_FOUND`입니다.
- 같은 사업장에 동명이인이 두 명 이상이면 `TARGET_AMBIGUOUS`입니다.
- 다른 사업장에 같은 이름이 있어도 조회 결과에 포함하지 않습니다.

오류 메시지에는 실제 이름이나 조회값을 넣지 않습니다.

## 두 번째 호출에서 보존하는 값

#74는 먼저 Server 내부 `AiAnalysisRequest`를 만들고, #56의 HTTP Adapter가 이를 최소
Runtime JSON으로 변환합니다. Server 내부 요청은 다음 값을 잃어버리면 안 됩니다.

- 동일한 `requestId`
- 새로운 `attemptId`와 남은 deadline
- 선택한 태그까지 포함한 원래 `instruction` (`발화문, INTENT_TAG`)
- PLAN이 추출한 `extractedSlots`
- PLAN이 요청한 전체 `requestedFieldKeys`
- 응답 검증에 필요한 Worker snapshot
- DB에서 찾은 값만 포함한 `requestedFields`
- 활성 Knowledge의 `workflowConstraints`

실제 Runtime HTTP JSON에는 아래 값만 전송합니다.

```json
{
"requestId": "10000000-0000-0000-0000-000000000001",
"phase": "ANALYZE",
"analysisInput": {
"instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL",
"requestedFieldKeys": ["worker_id", "stay_expiry_date", "due_at"],
"workers": [{
"workerRef": "worker-uuid",
"requestedFields": {
"worker_id": "worker-uuid",
"stay_expiry_date": "2026-09-30"
}
}]
}
}
```

`attemptId`, version, deadline, `extractedSlots`, `workflowConstraints`는 Server 내부 관리값이라
Runtime JSON에는 넣지 않습니다. `requestedFieldKeys`에는 DB에 값이 없던 key도 남깁니다.
Runtime은 전체 요청 key와 실제로 채워진 값의 차이를 보고 `NEEDS_INFO + questions`를
반환할 수 있습니다.

## Attempt와 반복 제한

`AiAttemptStarter`는 Runtime HTTP 호출 전에 호출됩니다. 현재 #74에서는 Port만 정의하고,
#24가 V12 AiAttempt table과 transaction으로 구현합니다.

- 같은 분석: `requestId` 유지
- 매 Runtime 호출: 새 `attemptId`를 Server 내부에 기록
- 자동 DB 보충: 최대 2회
- Remote HTTP client의 투명 retry: 금지
- Agent 결과만으로 Task 생성·승인·발송: 금지

2회를 초과하면 계속 자동 호출하지 않고 HR 확인 흐름으로 넘겨야 합니다. 실제 AiRun 상태와
질문 저장은 #24와 #77에서 연결합니다.

## 검증

- `AiSlotResolutionTransactionTest`: allow-list, 누락값, 금지 key, 대상 없음·동명이인·타사 방어
- `AiSlotResolutionIntegrationTest`: 실제 JPA query가 `companyId + displayName`으로 격리되는지 확인
- `AiAnalysisContinuationServiceTest`: Attempt 기록이 HTTP 호출보다 먼저이며 PLAN 문맥이
ANALYZE에 보존되는지 확인

이 기능은 DB migration을 추가하지 않습니다. `V12__create_ai_run.sql`은 #24가 소유합니다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.fowoco.server.airun.application;

import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;

public record AiAnalysisContinuationResult(
UUID attemptId,
AiAnalysisResponse response,
Set<String> missingFieldKeys
) {

public AiAnalysisContinuationResult {
Objects.requireNonNull(attemptId, "attemptId must not be null");
Objects.requireNonNull(response, "response must not be null");
Objects.requireNonNull(missingFieldKeys, "missingFieldKeys must not be null");
missingFieldKeys = Set.copyOf(missingFieldKeys);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package com.fowoco.server.airun.application;

import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome;
import com.fowoco.server.aiintegration.application.model.AiAnalysisPhase;
import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest;
import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse;
import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext;
import com.fowoco.server.aiintegration.application.model.AnalysisInput;
import com.fowoco.server.aiintegration.application.model.WorkerContext;
import com.fowoco.server.aiintegration.application.port.AiRuntimeClient;
import com.fowoco.server.airun.application.error.AiContextResolutionException;
import com.fowoco.server.airun.application.error.AiContextResolutionFailureCode;
import com.fowoco.server.airun.application.port.AiAttemptStarter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

/**
* Continues a validated CONTEXT_REQUIRED result without holding a database transaction open.
* #24 wires this service to a durable AiAttempt implementation.
*/
public final class AiAnalysisContinuationService {

public static final int MAX_CONTEXT_ROUNDS = 2;

private final AiSlotResolutionTransaction slotResolutionTransaction;
private final AiAttemptStarter attemptStarter;
private final AiRuntimeClient runtimeClient;

public AiAnalysisContinuationService(
AiSlotResolutionTransaction slotResolutionTransaction,
AiAttemptStarter attemptStarter,
AiRuntimeClient runtimeClient
) {
this.slotResolutionTransaction = Objects.requireNonNull(
slotResolutionTransaction,
"slotResolutionTransaction must not be null"
);
this.attemptStarter = Objects.requireNonNull(attemptStarter, "attemptStarter must not be null");
this.runtimeClient = Objects.requireNonNull(runtimeClient, "runtimeClient must not be null");
}

public AiAnalysisContinuationResult continueAnalysis(
UUID companyId,
AiAnalysisRequest previousRequest,
AiAnalysisResponse previousResponse,
int completedContextRounds,
long remainingDeadlineMs,
AiRuntimeCallContext callContext
) {
Objects.requireNonNull(companyId, "companyId must not be null");
Objects.requireNonNull(previousRequest, "previousRequest must not be null");
Objects.requireNonNull(previousResponse, "previousResponse must not be null");
Objects.requireNonNull(callContext, "callContext must not be null");
validateContinuation(previousRequest, previousResponse, completedContextRounds);

AiSlotResolution resolution = slotResolutionTransaction.resolve(
companyId,
previousRequest.requiredKnowledgeVersion(),
previousResponse.contextRequirement()
);
validateSameWorker(previousRequest, resolution.worker());

int nextContextRound = completedContextRounds + 1;
UUID attemptId = attemptStarter.startAttempt(
previousRequest.requestId(),
AiAnalysisPhase.ANALYZE,
nextContextRound
);
AiAnalysisRequest analyzeRequest = new AiAnalysisRequest(
previousRequest.requestId(),
attemptId,
AiAnalysisPhase.ANALYZE,
previousRequest.contractVersion(),
previousRequest.requiredKnowledgeVersion(),
remainingDeadlineMs,
buildAnalyzeInput(previousRequest.analysisInput(), previousResponse, resolution)
);
AiAnalysisResponse response = runtimeClient.analyze(analyzeRequest, callContext);
return new AiAnalysisContinuationResult(
attemptId,
response,
resolution.missingFieldKeys()
);
}

private AnalysisInput buildAnalyzeInput(
AnalysisInput previousInput,
AiAnalysisResponse previousResponse,
AiSlotResolution resolution
) {
Map<String, String> extractedSlots = new LinkedHashMap<>(previousInput.extractedSlots());
extractedSlots.putAll(previousResponse.contextRequirement().extractedSlots());

LinkedHashSet<String> requestedFieldKeys = new LinkedHashSet<>(previousInput.requestedFieldKeys());
requestedFieldKeys.addAll(previousResponse.contextRequirement().requiredFieldKeys());

Map<String, String> requestedFields = new LinkedHashMap<>();
if (!previousInput.workers().isEmpty()) {
requestedFields.putAll(previousInput.workers().get(0).requestedFields());
}
requestedFields.putAll(resolution.resolvedFields());
WorkerContext worker = resolution.worker();
WorkerContext mergedWorker = new WorkerContext(
worker.workerRef(),
worker.displayName(),
worker.nationalityCode(),
worker.preferredLanguage(),
worker.workStatus(),
worker.stayExpiryDate(),
worker.contractStartDate(),
worker.contractEndDate(),
requestedFields
);
return new AnalysisInput(
previousInput.instruction(),
extractedSlots,
new ArrayList<>(requestedFieldKeys),
List.of(mergedWorker),
resolution.workflowConstraints()
);
}

private void validateContinuation(
AiAnalysisRequest previousRequest,
AiAnalysisResponse previousResponse,
int completedContextRounds
) {
if (!previousRequest.requestId().equals(previousResponse.requestId())
|| previousResponse.outcome() != AiAnalysisOutcome.CONTEXT_REQUIRED
|| previousResponse.contextRequirement() == null) {
reject(
AiContextResolutionFailureCode.INVALID_CONTEXT_RESPONSE,
"Only a matching CONTEXT_REQUIRED response can continue analysis."
);
}
if (completedContextRounds < 0 || completedContextRounds >= MAX_CONTEXT_ROUNDS) {
reject(
AiContextResolutionFailureCode.CONTEXT_ROUND_LIMIT,
"The automatic context resolution round limit was reached."
);
}
}

private void validateSameWorker(AiAnalysisRequest previousRequest, WorkerContext resolvedWorker) {
if (previousRequest.analysisInput().workers().isEmpty()) {
return;
}
UUID previousWorkerRef = previousRequest.analysisInput().workers().get(0).workerRef();
if (!previousWorkerRef.equals(resolvedWorker.workerRef())) {
reject(
AiContextResolutionFailureCode.TARGET_CHANGED,
"The Runtime attempted to change the Worker target during one analysis."
);
}
}

private void reject(AiContextResolutionFailureCode failureCode, String safeMessage) {
throw new AiContextResolutionException(failureCode, safeMessage);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.fowoco.server.airun.application;

import com.fowoco.server.aiintegration.application.model.WorkerContext;
import com.fowoco.server.aiintegration.application.model.WorkflowConstraint;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
* Tenant-scoped values and Knowledge constraints prepared for an ANALYZE request.
*/
public record AiSlotResolution(
WorkerContext worker,
List<WorkflowConstraint> workflowConstraints,
Map<String, String> resolvedFields,
Set<String> missingFieldKeys
) {

public AiSlotResolution {
Objects.requireNonNull(worker, "worker must not be null");
Objects.requireNonNull(workflowConstraints, "workflowConstraints must not be null");
Objects.requireNonNull(resolvedFields, "resolvedFields must not be null");
Objects.requireNonNull(missingFieldKeys, "missingFieldKeys must not be null");
workflowConstraints = List.copyOf(workflowConstraints);
resolvedFields = Map.copyOf(resolvedFields);
missingFieldKeys = Set.copyOf(missingFieldKeys);
}
}
Loading
Loading