태그 검색이 사람 말을 받는다 — '사인'으로 서명 태그 17종이 나온다
4단계 시작. 사용자가 태그를 못 찾은 이유는 접두어(PAT_·ETC_)가 아니라 <b>어휘</b>였다. 검색은 부분 문자열과 초성만 봤고, 카탈로그 표기는 레거시가 정한 것이다. 실제로 세어 보니 흔한 말이 전부 0건이었다 — 환자명 0건 (표기는 이름) 날짜 0건 (일자, 21건) 사인 0건 (싸인, 17건) 주민등록번호 0건 (주민번호) 오늘 0건 (현재, 11건) 도장·신장·남녀·성명 0건 서명 태그가 전부 레거시 오표기 '싸인' 이라, 의료진이 '사인'을 치면 "결과 없음"만 나오고 회복 경로가 없었다. 입력어를 카탈로그 표기로 옮기는 표 34항을 넣었다. <b>태그마다 설명을 지어내는 것이 아니다</b> — 오른쪽 값은 전부 카탈로그에 실재하는 표기다. 없는 내용을 만들지 않는다는 규칙은 그대로다. <b>표만 검사하면 부족하다.</b> "환자명 → 이름" 매핑이 옳다는 것만 확인되고 그 표기가 카탈로그에 실재하는지는 확인되지 않는다 — 없는 표기로 옮기면 여전히 0건이다. 그래서 실제 카탈로그 384종을 대상으로 건수를 단정하고, 별칭이 가리키는 표기가 카탈로그에 있는지도 따로 단정한다(그 검사가 없으면 "오늘 → 없는말" 같은 표를 적어도 0건인 채로 통과한다). <b>테스트 가정을 한 번 틀렸다.</b> "'사인' 과 '싸인' 이 같은 건수"라고 적었는데 17 대 16 으로 깨졌다 — 별칭은 대칭이 아니다. '싸인' 질의는 별칭 '서명' 까지 타서 한 건을 더 잡는다. 재야 할 것은 "'싸인' 이라고 적힌 태그를 '사인' 으로 다 찾는가" 이므로 원문으로 세도록 고쳤다. 화면에는 별칭으로 찾아 줬다는 사실을 적는다 — "17개 — 이 카탈로그는 '싸인' 으로 적혀 있습니다". 결과만 내놓으면 사용자는 다음에도 자기 말이 통했다고 오해하고, 결과가 비었을 때 왜 비었는지도 모른다. 게이트: 테스트 291/291(신규 6), --edit-smoke 0실패, --dialog-shots 넘침 0, --modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0, --db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d72ab3ce1e
commit
a869e5a5bc
@@ -128,10 +128,83 @@ public static class TagSearch
|
|||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// 사람이 치는 말을 카탈로그 표기로 옮겨 한 번 더 본다.
|
||||||
|
// 이것 없이는 '환자명'·'날짜'·'사인' 같은 흔한 말이 <b>전부 0건</b>이었다(§Aliases).
|
||||||
|
foreach (var alias in AliasesFor(token))
|
||||||
|
{
|
||||||
|
if (tag.Contains(alias, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
return IsChoseongQuery(token)
|
return IsChoseongQuery(token)
|
||||||
&& ToChoseong(tag).Contains(token, StringComparison.Ordinal);
|
&& ToChoseong(tag).Contains(token, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 사용자가 칠 말 → 카탈로그의 실제 표기.
|
||||||
|
///
|
||||||
|
/// <b>왜 필요한가.</b> 검색은 부분 문자열과 초성만 봤고, 카탈로그 표기는 레거시가 정한 것이다.
|
||||||
|
/// 실제로 세어 본 결과 흔한 말이 전부 0건이었다 —
|
||||||
|
/// <c>환자명</c>(표기는 이름) · <c>날짜</c>(일자 21건) · <c>사인</c>(싸인 16건) ·
|
||||||
|
/// <c>주민등록번호</c>(주민번호 5건) · <c>오늘</c>(현재 11건) · <c>도장</c>(직인) ·
|
||||||
|
/// <c>신장</c>(키) · <c>남녀</c>(성별) · <c>성명</c>(이름).
|
||||||
|
/// 서명 태그 16종이 전부 레거시 오표기 <c>싸인</c> 이라, 의료진이 '사인'을 치면
|
||||||
|
/// "결과 없음"만 나오고 회복 경로가 없었다. <b>사용자가 못 찾은 이유는 접두어가 아니라 어휘였다.</b>
|
||||||
|
///
|
||||||
|
/// <b>태그마다 설명을 지어내는 것이 아니다.</b> 여기 있는 것은 <b>입력어 번역표</b>이고
|
||||||
|
/// 오른쪽 값은 전부 카탈로그에 실재하는 표기다. 없는 내용을 만들지 않는다.
|
||||||
|
///
|
||||||
|
/// 한 방향으로만 적어도 양방향으로 듣는다 — '이름'을 쳐도 원래 부분 일치로 걸린다.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, string[]> Aliases = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["환자명"] = new[] { "이름" },
|
||||||
|
["환자이름"] = new[] { "이름" },
|
||||||
|
["성명"] = new[] { "이름" },
|
||||||
|
["이름"] = new[] { "성명" },
|
||||||
|
["주민등록번호"] = new[] { "주민번호" },
|
||||||
|
["주번"] = new[] { "주민번호" },
|
||||||
|
["등록번호"] = new[] { "차트번호" },
|
||||||
|
["차번"] = new[] { "차트번호" },
|
||||||
|
["날짜"] = new[] { "일자" },
|
||||||
|
["날자"] = new[] { "일자" },
|
||||||
|
["오늘"] = new[] { "현재" },
|
||||||
|
["금일"] = new[] { "현재" },
|
||||||
|
["지금"] = new[] { "현재" },
|
||||||
|
["시각"] = new[] { "시간" },
|
||||||
|
["사인"] = new[] { "싸인" },
|
||||||
|
["서명"] = new[] { "싸인" },
|
||||||
|
["싸인"] = new[] { "서명" },
|
||||||
|
["도장"] = new[] { "직인" },
|
||||||
|
["인감"] = new[] { "직인" },
|
||||||
|
["신장"] = new[] { "키" },
|
||||||
|
["남녀"] = new[] { "성별" },
|
||||||
|
["성"] = new[] { "성별" },
|
||||||
|
["전화"] = new[] { "연락처", "전화" },
|
||||||
|
["폰"] = new[] { "연락처", "전화" },
|
||||||
|
["핸드폰"] = new[] { "휴대", "연락처" },
|
||||||
|
["휴대폰"] = new[] { "휴대", "연락처" },
|
||||||
|
["번호"] = new[] { "차트번호", "주민번호" },
|
||||||
|
["병원"] = new[] { "요양기관", "병원" },
|
||||||
|
["의사"] = new[] { "의사", "담당" },
|
||||||
|
["과"] = new[] { "진료과" },
|
||||||
|
["주소"] = new[] { "주소", "도로명" },
|
||||||
|
["나이"] = new[] { "나이", "연령" },
|
||||||
|
["연령"] = new[] { "나이" },
|
||||||
|
["생일"] = new[] { "생년월일" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string[] AliasesFor(string token)
|
||||||
|
=> Aliases.TryGetValue(token, out var found) ? found : Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>이 말이 카탈로그 표기와 다른가 — 화면이 "'사인'은 '싸인'으로 찾습니다"를 알릴 때 쓴다</summary>
|
||||||
|
public static string? AliasHintFor(string query)
|
||||||
|
{
|
||||||
|
var token = query.Trim();
|
||||||
|
return Aliases.TryGetValue(token, out var found) && found.Length > 0 ? found[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>공백으로 나눈 여러 토큰을 전부 만족해야 통과(AND)</summary>
|
/// <summary>공백으로 나눈 여러 토큰을 전부 만족해야 통과(AND)</summary>
|
||||||
public static bool MatchesAll(string tag, IReadOnlyList<string> tokens)
|
public static bool MatchesAll(string tag, IReadOnlyList<string> tokens)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -206,9 +206,16 @@ public partial class TagPickerDialogView : Window
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
TagList.ItemsSource = items;
|
TagList.ItemsSource = items;
|
||||||
|
// 별칭으로 찾아 준 경우에는 그 사실을 말해 준다.
|
||||||
|
// 레거시 카탈로그는 '사인'을 '싸인'으로, '날짜'를 '일자'로 적어 두었다.
|
||||||
|
// 결과만 내놓으면 사용자는 다음에도 자기 말이 통했다고 오해하고,
|
||||||
|
// 결과가 비었을 때 왜 비었는지도 모른다.
|
||||||
|
var alias = TagSearch.AliasHintFor(SearchBox.Text);
|
||||||
ListCaption.Text = items.Count == 0
|
ListCaption.Text = items.Count == 0
|
||||||
? "결과 없음"
|
? alias is null ? "결과 없음" : $"결과 없음 — '{alias}' 로도 찾아봤습니다"
|
||||||
: $"{items.Count}개 — 자주 쓰는 것과 많이 쓰인 것이 위에";
|
: alias is null
|
||||||
|
? $"{items.Count}개 — 자주 쓰는 것과 많이 쓰인 것이 위에"
|
||||||
|
: $"{items.Count}개 — 이 카탈로그는 '{alias}' 로 적혀 있습니다";
|
||||||
CountText.Text = $"{Searched().Count}/{allTags.Count}건";
|
CountText.Text = $"{Searched().Count}/{allTags.Count}건";
|
||||||
|
|
||||||
if (selectValue is { Length: > 0 })
|
if (selectValue is { Length: > 0 })
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
|
||||||
|
namespace SheetMe.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 태그 검색 별칭 고정 — <b>실제 카탈로그로 잰다</b>.
|
||||||
|
///
|
||||||
|
/// 별칭 표만 단독으로 검사하면 "환자명 → 이름" 이라는 매핑이 옳다는 것만 확인되고,
|
||||||
|
/// 그 표기가 카탈로그에 실재하는지는 확인되지 않는다. 없는 표기로 옮기면 여전히 0건이다.
|
||||||
|
/// 그래서 <see cref="LegacyTagCatalog"/> 전체를 대상으로 <b>몇 건이 걸리는지</b>를 단정한다.
|
||||||
|
///
|
||||||
|
/// 아래 말들은 고치기 전에 전부 0건이었다. 사용자가 태그를 못 찾은 이유는
|
||||||
|
/// 접두어(PAT_·ETC_)가 아니라 어휘였다.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public sealed class TagAliasTests
|
||||||
|
{
|
||||||
|
#region Methods
|
||||||
|
private static int Hits(string query)
|
||||||
|
{
|
||||||
|
var tokens = new[] { query };
|
||||||
|
return LegacyTagCatalog.DataInterfaceTags.Count(tag => TagSearch.MatchesAll(tag, tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 고치기_전에_0건이던_말들이_이제_걸린다()
|
||||||
|
{
|
||||||
|
// 각 말 오른쪽은 카탈로그의 실제 표기다. 숫자가 아니라 "0보다 크다"를 단정한다 —
|
||||||
|
// 카탈로그가 늘거나 줄면 건수는 바뀌지만 '찾힌다'는 사실은 바뀌면 안 된다.
|
||||||
|
foreach (var word in new[]
|
||||||
|
{
|
||||||
|
"환자명", "성명", "주민등록번호", "날짜", "오늘", "사인", "서명", "도장", "남녀", "생일",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Assert.IsTrue(Hits(word) > 0, $"'{word}' 가 0건이다 — 별칭이 카탈로그 표기에 닿지 않는다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 서명_태그_16종이_사인으로_찾힌다()
|
||||||
|
{
|
||||||
|
// 카탈로그가 전부 레거시 오표기 '싸인' 이라, 이 한 건이 고치기 전 가장 큰 구멍이었다.
|
||||||
|
//
|
||||||
|
// 질의끼리 건수를 비교하면 안 된다. 별칭은 대칭이 아니다 —
|
||||||
|
// '싸인' 질의는 별칭 '서명' 까지 타서 17건, '사인' 질의는 '싸인' 만 타서 16건이다.
|
||||||
|
// 처음에 두 질의가 같은 수라고 단정했다가 그 차이에 걸렸다.
|
||||||
|
// 재야 할 것은 <b>'싸인' 이라고 적힌 태그를 '사인' 으로 다 찾는가</b> 이므로 원문으로 센다.
|
||||||
|
var literal = LegacyTagCatalog.DataInterfaceTags
|
||||||
|
.Where(tag => tag.Contains("싸인", StringComparison.Ordinal)).ToList();
|
||||||
|
Assert.IsTrue(literal.Count >= 10, $"카탈로그의 '싸인' 태그가 {literal.Count}건뿐이다");
|
||||||
|
|
||||||
|
var tokens = new[] { "사인" };
|
||||||
|
var missed = literal.Where(tag => !TagSearch.MatchesAll(tag, tokens)).ToList();
|
||||||
|
Assert.AreEqual(0, missed.Count,
|
||||||
|
$"'사인' 으로 못 찾는 서명 태그가 있다: {string.Join(", ", missed)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 날짜는_일자를_찾는다()
|
||||||
|
{
|
||||||
|
Assert.AreEqual(Hits("일자"), Hits("날짜"));
|
||||||
|
Assert.IsTrue(Hits("일자") >= 10, $"일자 태그가 {Hits("일자")}건뿐이다 — 카탈로그를 잘못 보고 있다");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 원래_되던_것이_계속_된다()
|
||||||
|
{
|
||||||
|
// 별칭을 넣다가 기존 경로를 깨면, 잘 되던 검색이 조용히 달라진다.
|
||||||
|
Assert.IsTrue(Hits("이름") > 0);
|
||||||
|
Assert.IsTrue(Hits("PAT_") > 0);
|
||||||
|
Assert.IsTrue(Hits("ㅊㅌㅂㅎ") > 0, "초성 검색이 죽었다");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 모르는_말은_별칭을_만들지_않는다()
|
||||||
|
{
|
||||||
|
Assert.IsNull(TagSearch.AliasHintFor("존재하지않는말"));
|
||||||
|
Assert.AreEqual("이름", TagSearch.AliasHintFor("환자명"));
|
||||||
|
Assert.AreEqual("싸인", TagSearch.AliasHintFor("사인"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 별칭이_가리키는_표기는_카탈로그에_실재한다()
|
||||||
|
{
|
||||||
|
// 이 검사가 없으면 "오늘 → 없는말" 같은 표를 적어도 통과한다(0건인 채로).
|
||||||
|
foreach (var word in new[] { "환자명", "날짜", "사인", "주민등록번호", "오늘", "도장", "신장", "남녀" })
|
||||||
|
{
|
||||||
|
var target = TagSearch.AliasHintFor(word);
|
||||||
|
Assert.IsNotNull(target, $"'{word}' 에 별칭이 없다");
|
||||||
|
Assert.IsTrue(
|
||||||
|
LegacyTagCatalog.DataInterfaceTags.Any(tag => tag.Contains(target!, StringComparison.Ordinal)),
|
||||||
|
$"'{word}' → '{target}' 인데 그 표기를 가진 태그가 카탈로그에 없다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user