From 74e8e89c07db4d90e4eaf732a62c8c474ee3e3e3 Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:16:21 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(ai):=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A0=81=20gameplay=20=EC=A0=9C=EC=95=88?= =?UTF-8?q?=EB=A7=8C=20=EC=95=88=EC=A0=84=ED=95=98=EA=B2=8C=20=EB=AC=B4?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gemini/GeminiNarrativeAdapter.java | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapter.java b/src/main/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapter.java index 1d46fe9..87b036a 100644 --- a/src/main/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapter.java +++ b/src/main/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapter.java @@ -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에서 확립된 **주 언어**를 따르세요. @@ -444,6 +445,7 @@ private NarrativeTurn mapNarrative(JsonNode rootNode) { List choices = new ArrayList<>(); Set ids = new HashSet<>(); + int discardedGameplayProposals = 0; for (JsonNode choiceNode : choicesNode) { if (choiceNode == null || !choiceNode.isObject()) { throw recoverable("INVALID_CHOICE", "Narrative choice는 object여야 합니다.", null); @@ -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() From c0b014a1e609ff41a51eed7ba553ebb2df28f4a5 Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:16:42 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(ai):=20=EB=AC=B4=ED=9A=A8=ED=95=9C=20g?= =?UTF-8?q?ameplay=20=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=EC=99=80=20=EC=A0=95=EC=83=81=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=A7=80=20=EB=B3=B4=EC=A1=B4=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gemini/GeminiNarrativeAdapterTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/test/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapterTest.java b/src/test/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapterTest.java index 4fbda01..f8c87cc 100644 --- a/src/test/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapterTest.java +++ b/src/test/java/com/uctale/uctale/provider/gemini/GeminiNarrativeAdapterTest.java @@ -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\":[]}}"; } From 1b88d87bbf90e4995e06e841b2e135340efcca35 Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:17:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(ai):=20=EC=84=A0=ED=83=9D=EC=A0=81=20g?= =?UTF-8?q?ameplay=20=EC=A0=9C=EC=95=88=20=EC=8B=A4=ED=8C=A8=20=EA=B0=95?= =?UTF-8?q?=EB=93=B1=20=EA=B2=BD=EA=B3=84=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/gemini-narrative-provider.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/architecture/gemini-narrative-provider.md b/docs/architecture/gemini-narrative-provider.md index f3a701b..b1d210f 100644 --- a/docs/architecture/gemini-narrative-provider.md +++ b/docs/architecture/gemini-narrative-provider.md @@ -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` 구조화 로그에 다음을 기록합니다.