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
6 changes: 6 additions & 0 deletions docs/architecture/gemini-narrative-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ progress의 transient retry가 모두 소진되면 API는 영구 차단 대신 `

이 계약은 prompt 지침이며 별도 외부 언어 감지/번역 서비스를 도입하지 않습니다. 또한 언어 선택은 canonical game state나 Skill Check/GameResult 판정 권한을 LLM에 부여하지 않습니다.

## 선택적 gameplay 제안 복구 경계

Gemini 응답의 `title`, `story_text`, `choices[].id/text`는 계속 엄격하게 검증하며 누락·중복·범위 초과를 거부합니다. `choices[].intent`/`subject_key`는 서버 소유 행동 규칙에 대한 **선택적 제안**일 뿐입니다. 두 필드가 불완전하거나 타입, enum, slug 규칙에 맞지 않으면 둘 다 버리고 해당 선택지를 기본 SKILL_CHECK로 발급합니다. 유효한 제안만 `GameplayActionRules.validateProposal`을 거쳐 GAMEPLAY 행동으로 발급하며 수량/HP/결과는 LLM이 결정하지 않습니다.

이 강등은 provider 결과를 관용적으로 수용하는 경계에만 적용하며, 이미 서버가 발급한 행동 토큰·turn·arguments 무결성이나 저장된 canonical 상태 검증을 완화하지 않습니다. 로그에는 제거된 부가 제안의 **개수만** 기록하고 raw provider 내용은 출력하지 않습니다. `INVALID_GAMEPLAY_PROPOSAL` 복구 재시도 비용이 반복되던 운영 장애를 줄이기 위한 변경입니다.

## 관측성

기존 operation-level `provider_call` event는 configured primary Narrative model ID와 전체 bounded recovery의 retry/attempt count를 기록합니다. Gemini adapter는 실제 provider attempt별 `gemini_provider_result` 구조화 로그에 다음을 기록합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public class GeminiNarrativeAdapter implements NarrativeGenerator {
4. **메모리 우선순위:** 확정 게임 결과/상태 > 캐논 사실 > 누적 요약 > 최근 턴 순으로 충돌을 해결하십시오.
5. **출력 책임:** story_text와 다음 choices를 제안하고, 선택적으로 visual_assets를 묘사하십시오. canonical state 변경값을 출력 계약으로 만들지 마십시오.
6. **행동 의도:** 필요한 경우 choices[]에 intent(COLLECT, REST, TALK, START_QUEST, START_COMBAT, FIND_WEAPON, EQUIP_WEAPON, UNEQUIP_WEAPON, APPLY_STATUS)와 subject_key(소문자 영문 slug)를 함께 제안할 수 있습니다. 일반 선택에는 두 필드를 생략하세요. HP·수량·친밀도 수치는 제안하지 마십시오.
7. **행동 제안 형식:** gameplay 의도가 명확할 때만 intent와 subject_key를 반드시 함께 출력하세요. subject_key는 소문자 영문자로 시작하고 이후 소문자 영문·숫자·하이픈만 포함하는 1~32자 slug여야 합니다. 공백, 한글, 빈 문자열, null을 사용하지 마세요. 둘 중 하나라도 확신할 수 없으면 두 필드를 모두 생략하세요.

[내러티브 출력 언어 계약 - 매우 중요]
1. 사용자에게 노출되는 `title`, `story_text`, `choices[].text`는 세계관/캐릭터 설정과 현재 narrative context에서 확립된 **주 언어**를 따르세요.
Expand Down Expand Up @@ -444,6 +445,7 @@ private NarrativeTurn mapNarrative(JsonNode rootNode) {

List<NarrativeTurn.Choice> choices = new ArrayList<>();
Set<Integer> ids = new HashSet<>();
int discardedGameplayProposals = 0;
for (JsonNode choiceNode : choicesNode) {
if (choiceNode == null || !choiceNode.isObject()) {
throw recoverable("INVALID_CHOICE", "Narrative choice는 object여야 합니다.", null);
Expand All @@ -459,15 +461,24 @@ private NarrativeTurn mapNarrative(JsonNode rootNode) {
String text = requiredText(choiceNode, "text", MAX_CHOICE_TEXT_LENGTH, false, "INVALID_CHOICE_TEXT");
JsonNode intentNode = choiceNode.get("intent");
JsonNode subjectNode = choiceNode.get("subject_key");
String intent = intentNode == null || intentNode.isNull() ? null : gameplayField(intentNode);
String subjectKey = subjectNode == null || subjectNode.isNull() ? null : gameplayField(subjectNode);
String intent;
String subjectKey;
try {
intent = intentNode == null || intentNode.isNull() ? null : gameplayField(intentNode);
subjectKey = subjectNode == null || subjectNode.isNull() ? null : gameplayField(subjectNode);
com.uctale.uctale.domain.game.GameplayActionRules.validateProposal(intent, subjectKey);
} catch (IllegalArgumentException exception) {
throw recoverable("INVALID_GAMEPLAY_PROPOSAL", "허용되지 않는 gameplay action 제안입니다.", exception);
} catch (RecoverableNarrativeResponseException | IllegalArgumentException exception) {
// Optional provider proposals must not block a valid story. Do not issue gameplay
// actions from incomplete/invalid hints; the server issues a normal skill check.
intent = null;
subjectKey = null;
discardedGameplayProposals++;
}
choices.add(new NarrativeTurn.Choice(id, text, intent, subjectKey));
}
if (discardedGameplayProposals > 0) {
log.warn("gemini_gameplay_proposal_discarded count={}", discardedGameplayProposals);
}

JsonNode visualNode = rootNode.get("visual_assets");
NarrativeTurn.VisualAssets visualAssets = visualNode == null || visualNode.isNull()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,45 @@ void openingUnrelatedRestClientExceptionIsNotRetryable() {
mockServer.verify();
}

@Test
@DisplayName("잘못된 선택적 gameplay metadata는 일반 선택지로 안전하게 강등한다")
void parseResponse_DiscardsInvalidOptionalGameplayMetadata() {
String narrative = """
{"title":"첫 장면","story_text":"이야기가 시작된다","choices":[
{"id":1,"text":"구출한다","intent":"COLLECT","subject_key":"한글 대상"},
{"id":2,"text":"경계한다","intent":"TALK"},
{"id":3,"text":"대화한다","intent":"TALK","subject_key":123},
{"id":4,"text":"살펴본다","intent":"REST","subject_key":"camp-1"}
]}
""";
NarrativeTurn turn = adapter.parseResponse(apiResponse(narrative));

assertThat(turn.choices()).hasSize(4);
assertThat(turn.choices().get(0).intent()).isNull();
assertThat(turn.choices().get(0).subjectKey()).isNull();
assertThat(turn.choices().get(1).intent()).isNull();
assertThat(turn.choices().get(1).subjectKey()).isNull();
assertThat(turn.choices().get(2).intent()).isNull();
assertThat(turn.choices().get(2).subjectKey()).isNull();
assertThat(turn.choices().get(3).intent()).isEqualTo("REST");
assertThat(turn.choices().get(3).subjectKey()).isEqualTo("camp-1");
}

@Test
@DisplayName("잘못된 부가 제안만 포함된 응답도 title/story/choice text는 그대로 유지한다")
void parseResponse_DoesNotAlterValidNarrative() {
String narrative = """
{"title":"그날 밤","story_text":"정문이 열린다","choices":[
{"id":1,"text":"문을 통과한다","intent":"INVALID","subject_key":"main-door"}
]}
""";
NarrativeTurn turn = adapter.parseResponse(apiResponse(narrative));
assertThat(turn.title()).isEqualTo("그날 밤");
assertThat(turn.storyText()).isEqualTo("정문이 열린다");
assertThat(turn.choices().getFirst().text()).isEqualTo("문을 통과한다");
assertThat(turn.choices().getFirst().intent()).isNull();
}

private String validNarrativeJson() {
return "{\"title\":\"첫날 밤\",\"story_text\":\"좀비가 나타났다!\",\"choices\":[{\"id\":1,\"text\":\"도망간다\"}],\"visual_assets\":{\"background\":\"dark subway\",\"characters\":[],\"assets\":[]}}";
}
Expand Down
Loading