Compare commits

...
2 Commits
Author SHA1 Message Date
kimandClaude Code 03a462d72e fix: 작업실 채팅의 링크가 클릭 불가 평문이던 문제
- 작업실 채팅 renderMd에 링크 지원이 아예 없었다(굵게·표·리스트만 구현) — 모델이
  표에 넣어준 출처 링크([바로가기](https://www.printables.com/model/145094))를
  클릭해도 아무 일 없었음(2026-09-25 사용자 실측).
- [라벨](URL) → <a target=_blank> 변환 추가 + 벗겨진 벌주 URL도 자동 링크.
  이스케이프 이후 처리라 URL의 &가 &amp;로 href에 들어가는 것까지 확인.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:36:47 +09:00
kimandClaude Code b4f9a3df9f feat: ACTION-PROMISE 가드 — 실행 예고만 하고 도구 0개로 끝난 턴 1회 넛지
- 2026-09-25 작업실 실측(로봇 프로젝트 채팅): 모델이 '먼저 GitHub에서 관련
  리포지토리를 찾겠습니다'만 내놓고 도구 0개로 턴 종료, 사용자가 '?'로 되물어도
  사과+예고만 반복. deferral 넛지는 '잠시만 기다려'류만 잡고 실행형 요청은 아예
  제외라 이 케이스가 두 망 다 통과했다.
- isActionPromiseNoTool: 답 끝부분(마지막 220자)이 행동 동사+미래 서약
  (~하겠습니다/~해 보겠습니다/~시작합니다) 또는 let me / I'll … 로 끝나는지.
  과거형·의문형·의례적 맺음말은 제외, 800자 이상 완성 답변도 제외.
- handleChat: 이번 턴 도구 호출 0개일 때만 1회 넛지(라운드 소진 방지), 로그줄
  '[v2] ACTION-PROMISE POST-CHECK'가 발동 증거. 테스트 3건(실측 문구 포함) 추가.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:30:16 +09:00
4 changed files with 85 additions and 0 deletions
+24
View File
@@ -22,6 +22,7 @@ import {
hasConcreteCompletion,
isIntentOnlyReply,
isDeferralPromiseReply,
isActionPromiseNoTool,
hasUnversionedNewestIntent,
assumedGenerationTokenInQuery,
isMessagingRequest,
@@ -444,6 +445,7 @@ async function handleChat(
// 직전 라운드가 가드 재프롬프트였는지 — 예고만 한 응답을 걸러낼 때 이 조건으로 좁힌다.
let lastRoundWasGuardReprompt = false;
let intentOnlyNudges = 0;
let actionPromiseNudges = 0;
const MAX_NUMERIC_GROUNDING_FORCED_RETRIES = 1;
let searchAbandonmentForcedRetries = 0;
const MAX_SEARCH_ABANDONMENT_FORCED_RETRIES = 1;
@@ -1906,6 +1908,28 @@ async function handleChat(
}
// ACTION-PROMISE POST-CHECK (2026-09-25). 작업실 실측(로봇 프로젝트 채팅): "먼저 GitHub에서
// 관련 리포지토리를 찾겠습니다"만 내놓고 도구 0개로 턴 종료 — 사용자가 "?"로 되물어야 사과
// 또 예고만 반복. deferral 넛지는 "잠시만 기다려"류만 잡고 실행형 요청은 아예 제외라 이
// 케이스가 두 망 다 통과했다. 행동 예고로 끝났는데 이번 턴 도구 호출이 0개면 1회 넛지 —
// 지금 실행하게 하거나, 못 하는 사정이 있으면 예고 대신 지금 말하게.
const shouldNudgeActionPromise =
actionPromiseNudges < 1
&& allToolResults.length === 0
&& isActionPromiseNoTool(candidateText);
if (shouldNudgeActionPromise) {
actionPromiseNudges++;
console.log(`[v2] ACTION-PROMISE POST-CHECK: promised an action ("...${candidateText.slice(-70).trim()}") but made 0 tool calls this turn. Nudging once...`);
sendSSE('info', { message: '모델이 실행 예고만 하고 멈췄습니다 — 실제 실행을 요청합니다...' });
messages.push({ role: 'assistant', content: candidateText });
messages.push({
role: 'user',
content: 'You announced an action (search/download/create/…) but the turn ended with zero tool calls — the user only sees the promise, which reads as if you stopped mid-answer. Do it now: make the real tool call, then report the result. If you cannot or should not perform it, say so plainly right now instead of promising.',
});
lastRoundWasGuardReprompt = false;
continue;
}
const shouldForceBrowserRetry =
orchestrationSkillEnabled
&& browserContinuationPending
+22
View File
@@ -513,6 +513,28 @@ export function isDeferralPromiseReply(text: string): boolean {
return /잠(시|깐)\s*(만)?\s*(기다려|대기|후에|뒤에|만요)|기다려\s*주(세요|십시오)|곧\s*(알려|말씀|정리해|공유)|잠시\s*후\s*(다시|알려)|조금만\s*기다|hold on\b|one moment\b|give me a (moment|sec|minute)|bear with me|be right back|will (get back|come back) to you|let me get back to you/i.test(s);
}
/**
* 행동 예고("먼저 검색을 시작합니다", "찾아보겠습니다", "Let me search…")로 끝나는 답변.
*
* 2026-09-25 작업실 실측(로봇 프로젝트 채팅): 사용자가 "필요한 파일들 다운로드해서 프로젝트를
* 채우자"라고 하자 모델이 "먼저 GitHub 등에서 관련 리포지토리를 찾겠습니다"만 내놓고 도구
* 0개로 턴 종료 — 사용자가 "?"로 되물어서야 사과하고 또 예고만 반복. isDeferralPromiseReply는
* "잠시만 기다려" 류만 잡고, 호출부의 deferral 넛지는 isExecutionLikeRequest를 아예 제외하므로
* 이 케이스(실행형 요청 + 명시적 실행 예고)가 두 망 다 통과했다.
*
* 잡는 범위: 답변 **끝부분**이 행동 동사+미래 서약(~하겠습니다/~해 보겠습니다/~시작합니다) 또는
* 영어 let me / I'll … 로 끝난다. 호출부(handleChat)가 "이번 턴 도구 호출 0개"를 보장한다 —
* 도구를 이미 썼다면 예고 문구가 남아도 실행한 것이므로 건드리지 않는다.
*/
export function isActionPromiseNoTool(text: string): boolean {
const s = String(text || '').trim();
if (!s || s.length > 800) return false;
const tail = s.slice(-220);
return (
/(검색|찾|조사|다운로드|받아|불러|만들|생성|저장|정리|수집|모으|확인|시작|진행|시도|실행)[가-힣\s]{0,5}겠(습니다|다)|시작합니다|(?:let\s+me|i'?ll|i\s+will)\s+(?:search|find|check|look|download|fetch|grab|read|create|make|try)\b/i.test(tail)
);
}
/**
* 사용자가 던진 추측을 근거 없이 확인해 주는 답변인가.
*
+30
View File
@@ -31,6 +31,7 @@ import {
isDisasterRequest,
isFactualThreadFollowUp,
confirmsUserGuessWithoutGrounding,
isActionPromiseNoTool,
} from '../src/gateway/guards/prompt-gates';
describe('looksLikeUnverifiedSpecClaim — 출력에서 검증 안 된 수치 감지', () => {
@@ -503,3 +504,32 @@ describe('도달률 감사 회귀 (2026-09-04)', () => {
});
});
// ── isActionPromiseNoTool (2026-09-25) ─────────────────────────────────────
// 작업실 실측(로봇 프로젝트 채팅): "먼저 GitHub에서 관련 리포지토리를 찾겠습니다"만 내놓고
// 도구 0개로 턴 종료. deferral 넛지가 "잠시만 기다려"류만 잡고 실행형 요청은 제외라 두 망 다
// 통과한 케이스. 아래 실측 문구는 로그에서 그대로 가져왔다.
describe('isActionPromiseNoTool — 행동 예고 후 도구 0개 턴', () => {
test('오늘 실측 문구들', () => {
assert.equal(isActionPromiseNoTool(
'죄송합니다. 말만 하고 실제 행동(도구 호출)을 하지 않았습니다. 바로 LanderPi의 오픈소스 자료(URDF, ROS2 패키지, 설계 파일 등)를 검색해서 프로젝트 폴더에 채워 넣겠습니다.\n\n먼저 GitHub 등에서 관련 리포지토리를 찾겠습니다.',
), true);
assert.equal(isActionPromiseNoTool('먼저 검색을 시작합니다.'), true);
assert.equal(isActionPromiseNoTool('Let me search GitHub for the repository.'), true);
assert.equal(isActionPromiseNoTool("I'll download the URDF files into the project folder."), true);
assert.equal(isActionPromiseNoTool(' STL을 컴파일해서 CAD/에 저장하겠습니다'), true);
});
test('오탐 방지: 과거형·의문형·의례적 맺음말', () => {
assert.equal(isActionPromiseNoTool('네, 방금 전 턴에 이미 저장했습니다.'), false);
assert.equal(isActionPromiseNoTool('어떤 파일을 찾아드릴까요?'), false);
assert.equal(isActionPromiseNoTool('더 필요하시면 말씀드리겠습니다.'), false);
assert.equal(isActionPromiseNoTool('검색 결과가 없습니다. 다른 키워드를 시도해 보세요.'), false);
assert.equal(isActionPromiseNoTool('다운로드는 완료됐습니다. 파일은 CAD/에 있습니다.'), false);
});
test('긴 답변(>800자)은 대상 아님 — 완성된 답을 넛지하지 않는다', () => {
const long = '정상 답변 본문. '.repeat(95) + '먼저 검색을 시작합니다.';
assert.equal(isActionPromiseNoTool(long), false);
});
});
+9
View File
@@ -52,6 +52,15 @@ function renderMd(text){
s=s.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>');
// 링크 — [라벨](URL) → <a>. 작업실 채팅엔 링크 지원이 아예 없어 모델이 표에 넣어준
// 출처 링크(Printables 바로가기 등)가 클릭 불가 평문이었다(2026-09-25 사용자 실측).
// 이스케이프(47행) 이후에 돌리므로 URL 속 & 는 &amp; — href 속성값으로 그대로 맞다.
s=s.replace(/\[([^\]\n]+)\]\((https?:[^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener" style="color:#f59e0b;text-decoration:underline">$1</a>');
// 남는 벌주 URL도 자동 링크. 이미 만든 <a href="..."> 안은 앞 글자가 "라 안 걸리고,
// 표/리스트 처리 전이라 셀 안에서도 살아난다.
s=s.replace(/(^|[\s>])(https?:\/\/[^\s<)"'\u0000]+)/g,(m,pre,url)=>
pre+'<a href="'+url+'" target="_blank" rel="noopener" style="color:#f59e0b;text-decoration:underline">'+url+'</a>');
// 인라인 수식 $...$ — KaTeX가 실려 있으면 삽입 직후 renderKatexIn이 진짜 수식으로
// 그려주므로 그냥 둔다(09-24: 다른 앱들은 전부 KaTeX 실려 있었는데 작업실만 빠져 있었다).
// KaTeX가 없는 환경의 폴백으로만 이 유니코드 치환을 돌린다. 자주 나오는 LaTeX 기호를