From 6a4e333822b518cb80a268cb04222bf6bc9e3534 Mon Sep 17 00:00:00 2001 From: kim Date: Mon, 10 Aug 2026 14:34:42 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A3=BD=EC=9D=80=20=EB=A7=81=ED=81=AC?= =?UTF-8?q?=20=EC=B7=A8=EC=86=8C=EC=84=A0=20=EC=B2=98=EB=A6=AC=EA=B0=80=20?= =?UTF-8?q?=EB=A7=88=ED=81=AC=EB=8B=A4=EC=9A=B4=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EB=84=98=EC=96=B4=20=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=92=A4=EC=84=9E=EB=8D=98=20=EB=B2=84=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실사용 사고(2026-08-10, "16호 태풍" 답변): 모델이 URL을 그 자체로 링크 텍스트로 쓰는 흔한 패턴 `[https://x.com](https://x.com)`에서, 죽은 링크 탐지 정규식(BARE_RE)이 `]`/`[`에서 멈추지 않아 라벨의 `]`, href의 `(`를 넘어 마크다운 링크 전체를 하나의 "URL"로 삼켜버렸다. 그 결과 사용자가 실제로 본 출력은 `~~[~~url](url~~ ⚠️ (링크 끊김)))~~ ⚠️ (링크 끊김)` 같은 뒤섞인 텍스트였다. BARE_RE의 부정 lookbehind와 문자클래스 양쪽에 `]`/`[` 제외를 추가해 마크다운 링크 경계를 넘지 못하게 했다. LINK_RE(먼저 실행됨)가 이미 "라벨이 URL 자체인" 경우를 포함해 [label](url) 쌍 전체를 처리하므로 BARE_RE는 그 경계 안으로 들어갈 필요가 없다. rewriteDeadLinks()를 validateLinksInText()에서 분리해 순수 문자열 변환만 네트워크 모킹 없이 테스트 가능하게 했다 — 회귀 테스트 8개 추가, 실제 사고 사례를 그대로 재현해 고정. Co-Authored-By: Claude Opus 5 --- src/gateway/guards/link-validator.ts | 38 ++++++++++----- tests/link-validator.test.ts | 71 ++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 tests/link-validator.test.ts diff --git a/src/gateway/guards/link-validator.ts b/src/gateway/guards/link-validator.ts index d488fbf..989f1cf 100644 --- a/src/gateway/guards/link-validator.ts +++ b/src/gateway/guards/link-validator.ts @@ -8,7 +8,16 @@ const MAX_PARALLEL = 8; const IMG_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g; const LINK_RE = /(?)"']+)/g; +// 2026-08-10 fix: models very commonly write citations as [https://x.com](https://x.com) — +// the URL used as its own link label. BARE_RE's body class didn't exclude `]`/`[`, so a match +// starting right after the `[` swallowed straight through the label's closing `]`, the href's +// opening `(`, and the entire href — merging two separate markdown tokens into one "URL" that +// spanned the whole link. Dead-link marking (validateLinksInText below) then wrapped that +// mangled span in ~~...~~, producing garbage like "~~[~~url](url~~ ⚠️...))~~ ⚠️...". Excluding +// `]`/`[` from both the lookbehind and the body class stops BARE_RE from ever starting or +// continuing through a markdown link boundary; LINK_RE (which runs first, line ~88 below) +// already owns full [label](url) pairs including this label-is-a-URL case. +const BARE_RE = /(?)"'\]\[]+)/g; function shouldSkip(url: string): boolean { if (!url) return true; @@ -65,16 +74,10 @@ export async function probeUrls(urls: string[]): Promise> { return status; } -export async function validateLinksInText(text: string): Promise { - if (!text || text.length < 8) return text; - - const urls: string[] = []; - for (const m of text.matchAll(IMG_RE)) urls.push(m[2]); - for (const m of text.matchAll(LINK_RE)) urls.push(m[2]); - for (const m of text.matchAll(BARE_RE)) urls.push(m[1]); - - if (urls.length === 0) return text; - const status = await probeUrls(urls); +// Split out from validateLinksInText (2026-08-10) so the rewrite logic — the part that was +// actually buggy — can be unit-tested without mocking network probing. Pure string transform: +// given a text and a pre-computed liveness map, mark every dead link/image inline. +export function rewriteDeadLinks(text: string, status: Map): string { const dead = [...status.entries()].filter(([, ok]) => !ok).map(([u]) => u); if (dead.length === 0) return text; @@ -97,6 +100,19 @@ export async function validateLinksInText(text: string): Promise { return out; } +export async function validateLinksInText(text: string): Promise { + if (!text || text.length < 8) return text; + + const urls: string[] = []; + for (const m of text.matchAll(IMG_RE)) urls.push(m[2]); + for (const m of text.matchAll(LINK_RE)) urls.push(m[2]); + for (const m of text.matchAll(BARE_RE)) urls.push(m[1]); + + if (urls.length === 0) return text; + const status = await probeUrls(urls); + return rewriteDeadLinks(text, status); +} + // Drops entire `![alt](url)\n*📷 credit*` blocks whose image URL is dead. // Used inside search_images so the model and SSE only see live photos. export async function filterImageMarkdown(markdown: string): Promise<{ text: string; liveCount: number; deadCount: number }> { diff --git a/tests/link-validator.test.ts b/tests/link-validator.test.ts new file mode 100644 index 0000000..600d631 --- /dev/null +++ b/tests/link-validator.test.ts @@ -0,0 +1,71 @@ +/** + * link-validator.test.ts + * + * rewriteDeadLinks was extracted from validateLinksInText (2026-08-10) specifically so this + * bug could be pinned: models very commonly cite a URL using the URL itself as the link label, + * [https://x.com](https://x.com). The old BARE_RE's character class didn't stop at `]`/`[`, so + * a bare-URL match starting right after the label's `[` ran straight through the label's `]`, + * the href's `(`, and the whole href — merging two markdown tokens into one match. Wrapping + * that in ~~...~~ produced garbage the user actually saw in production: + * "~~[~~https://stormatlasx.com/ko](https://stormatlasx.com/ko~~ ⚠️ (링크 끊김)))~~ ⚠️ ...". + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { rewriteDeadLinks } from '../src/gateway/guards/link-validator'; + +describe('rewriteDeadLinks', () => { + test('링크 텍스트가 없으면 원문 그대로', () => { + const text = '아무 링크도 없는 평범한 답변입니다.'; + assert.equal(rewriteDeadLinks(text, new Map()), text); + }); + + test('전부 살아있으면 원문 그대로', () => { + const text = '자세한 내용은 [기상청](https://weather.go.kr)에서 확인하세요.'; + assert.equal(rewriteDeadLinks(text, new Map([['https://weather.go.kr', true]])), text); + }); + + test('일반 마크다운 링크 — 라벨이 URL과 다르면 정상적으로 취소선 처리', () => { + const text = '자세한 내용은 [여기](https://dead.example.com)에서 확인하세요.'; + const out = rewriteDeadLinks(text, new Map([['https://dead.example.com', false]])); + assert.equal(out, '자세한 내용은 ~~[여기](https://dead.example.com)~~ ⚠️ (링크 끊김)에서 확인하세요.'); + }); + + test('죽은 이미지는 경고 문구로 치환', () => { + const text = '![낙원 사진](https://dead.example.com/x.jpg)'; + const out = rewriteDeadLinks(text, new Map([['https://dead.example.com/x.jpg', false]])); + assert.equal(out, '⚠️ 이미지 불러올 수 없음: 낙원 사진'); + }); + + test('평문 URL은 취소선으로 치환', () => { + const text = '참고: https://dead.example.com/page 여기 있습니다.'; + const out = rewriteDeadLinks(text, new Map([['https://dead.example.com/page', false]])); + assert.equal(out, '참고: ~~https://dead.example.com/page~~ ⚠️ (링크 끊김) 여기 있습니다.'); + }); + + // 실제 프로덕션 사고 재현 (2026-08-10, 세션 papa, "16호 태풍" 질문 답변). + test('라벨이 URL 자체인 죽은 링크도 깨지지 않고 정상 처리된다', () => { + const url = 'https://stormatlasx.com/ko'; + const text = `Storm Atlas (실시간 경로 추적): [${url}](${url})`; + const out = rewriteDeadLinks(text, new Map([[url, false]])); + assert.equal(out, `Storm Atlas (실시간 경로 추적): ~~[${url}](${url})~~ ⚠️ (링크 끊김)`); + // 회귀 방지: 예전 버그의 특징적 증상 — 마크다운 링크 경계를 넘어 뒤섞인 취소선/괄호. + assert.ok(!out.includes(')~~ ⚠️ (링크 끊김))'), '마크다운 경계를 넘어 뒤섞이면 안 됨'); + assert.equal((out.match(/⚠️/g) || []).length, 1, '경고 문구가 중복되면 안 됨'); + }); + + test('URL을 포함한 괄호가 있는 일반 URL은 여전히 매칭된다(회귀 확인)', () => { + const text = '위키백과: https://en.wikipedia.org/wiki/Foo_bar 참고'; + const out = rewriteDeadLinks(text, new Map([['https://en.wikipedia.org/wiki/Foo_bar', false]])); + assert.equal(out, '위키백과: ~~https://en.wikipedia.org/wiki/Foo_bar~~ ⚠️ (링크 끊김) 참고'); + }); + + test('여러 링크가 연달아 있어도 서로 침범하지 않는다', () => { + const text = '[A](https://a.example.com) [https://b.example.com](https://b.example.com)'; + const out = rewriteDeadLinks(text, new Map([ + ['https://a.example.com', true], + ['https://b.example.com', false], + ])); + assert.equal(out, '[A](https://a.example.com) ~~[https://b.example.com](https://b.example.com)~~ ⚠️ (링크 끊김)'); + }); +});