fix: 죽은 링크 취소선 처리가 마크다운 경계를 넘어 텍스트를 뒤섞던 버그
실사용 사고(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 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,16 @@ const MAX_PARALLEL = 8;
|
||||
|
||||
const IMG_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
|
||||
const LINK_RE = /(?<!!)\[([^\]]+)\]\(([^)\s]+)\)/g;
|
||||
const BARE_RE = /(?<![(\]\w])\b(https?:\/\/[^\s<>)"']+)/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 = /(?<![(\]\[\w])\b(https?:\/\/[^\s<>)"'\]\[]+)/g;
|
||||
|
||||
function shouldSkip(url: string): boolean {
|
||||
if (!url) return true;
|
||||
@@ -65,16 +74,10 @@ export async function probeUrls(urls: string[]): Promise<Map<string, boolean>> {
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function validateLinksInText(text: string): Promise<string> {
|
||||
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, boolean>): 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<string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function validateLinksInText(text: string): Promise<string> {
|
||||
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 `\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 }> {
|
||||
|
||||
@@ -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 = '';
|
||||
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)~~ ⚠️ (링크 끊김)');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user