diff --git a/src/SheetMe.Core/Catalog/TagSearch.cs b/src/SheetMe.Core/Catalog/TagSearch.cs index 581a6bc..08e75c8 100644 --- a/src/SheetMe.Core/Catalog/TagSearch.cs +++ b/src/SheetMe.Core/Catalog/TagSearch.cs @@ -128,10 +128,83 @@ public static class TagSearch { return true; } + // 사람이 치는 말을 카탈로그 표기로 옮겨 한 번 더 본다. + // 이것 없이는 '환자명'·'날짜'·'사인' 같은 흔한 말이 전부 0건이었다(§Aliases). + foreach (var alias in AliasesFor(token)) + { + if (tag.Contains(alias, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } return IsChoseongQuery(token) && ToChoseong(tag).Contains(token, StringComparison.Ordinal); } + /// + /// 사용자가 칠 말 → 카탈로그의 실제 표기. + /// + /// 왜 필요한가. 검색은 부분 문자열과 초성만 봤고, 카탈로그 표기는 레거시가 정한 것이다. + /// 실제로 세어 본 결과 흔한 말이 전부 0건이었다 — + /// 환자명(표기는 이름) · 날짜(일자 21건) · 사인(싸인 16건) · + /// 주민등록번호(주민번호 5건) · 오늘(현재 11건) · 도장(직인) · + /// 신장(키) · 남녀(성별) · 성명(이름). + /// 서명 태그 16종이 전부 레거시 오표기 싸인 이라, 의료진이 '사인'을 치면 + /// "결과 없음"만 나오고 회복 경로가 없었다. 사용자가 못 찾은 이유는 접두어가 아니라 어휘였다. + /// + /// 태그마다 설명을 지어내는 것이 아니다. 여기 있는 것은 입력어 번역표이고 + /// 오른쪽 값은 전부 카탈로그에 실재하는 표기다. 없는 내용을 만들지 않는다. + /// + /// 한 방향으로만 적어도 양방향으로 듣는다 — '이름'을 쳐도 원래 부분 일치로 걸린다. + /// + private static readonly Dictionary 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(); + + /// 이 말이 카탈로그 표기와 다른가 — 화면이 "'사인'은 '싸인'으로 찾습니다"를 알릴 때 쓴다 + public static string? AliasHintFor(string query) + { + var token = query.Trim(); + return Aliases.TryGetValue(token, out var found) && found.Length > 0 ? found[0] : null; + } + /// 공백으로 나눈 여러 토큰을 전부 만족해야 통과(AND) public static bool MatchesAll(string tag, IReadOnlyList tokens) { diff --git a/src/SheetMe.Designer/Views/TagPickerDialogView.xaml.cs b/src/SheetMe.Designer/Views/TagPickerDialogView.xaml.cs index 1c87431..2d24402 100644 --- a/src/SheetMe.Designer/Views/TagPickerDialogView.xaml.cs +++ b/src/SheetMe.Designer/Views/TagPickerDialogView.xaml.cs @@ -206,9 +206,16 @@ public partial class TagPickerDialogView : Window .ToList(); TagList.ItemsSource = items; + // 별칭으로 찾아 준 경우에는 그 사실을 말해 준다. + // 레거시 카탈로그는 '사인'을 '싸인'으로, '날짜'를 '일자'로 적어 두었다. + // 결과만 내놓으면 사용자는 다음에도 자기 말이 통했다고 오해하고, + // 결과가 비었을 때 왜 비었는지도 모른다. + var alias = TagSearch.AliasHintFor(SearchBox.Text); ListCaption.Text = items.Count == 0 - ? "결과 없음" - : $"{items.Count}개 — 자주 쓰는 것과 많이 쓰인 것이 위에"; + ? alias is null ? "결과 없음" : $"결과 없음 — '{alias}' 로도 찾아봤습니다" + : alias is null + ? $"{items.Count}개 — 자주 쓰는 것과 많이 쓰인 것이 위에" + : $"{items.Count}개 — 이 카탈로그는 '{alias}' 로 적혀 있습니다"; CountText.Text = $"{Searched().Count}/{allTags.Count}건"; if (selectValue is { Length: > 0 }) diff --git a/tests/SheetMe.Core.Tests/TagAliasTests.cs b/tests/SheetMe.Core.Tests/TagAliasTests.cs new file mode 100644 index 0000000..b8b3e67 --- /dev/null +++ b/tests/SheetMe.Core.Tests/TagAliasTests.cs @@ -0,0 +1,96 @@ +using SheetMe.Core.Catalog; + +namespace SheetMe.Core.Tests; + +/// +/// 태그 검색 별칭 고정 — 실제 카탈로그로 잰다. +/// +/// 별칭 표만 단독으로 검사하면 "환자명 → 이름" 이라는 매핑이 옳다는 것만 확인되고, +/// 그 표기가 카탈로그에 실재하는지는 확인되지 않는다. 없는 표기로 옮기면 여전히 0건이다. +/// 그래서 전체를 대상으로 몇 건이 걸리는지를 단정한다. +/// +/// 아래 말들은 고치기 전에 전부 0건이었다. 사용자가 태그를 못 찾은 이유는 +/// 접두어(PAT_·ETC_)가 아니라 어휘였다. +/// +[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건이다. + // 처음에 두 질의가 같은 수라고 단정했다가 그 차이에 걸렸다. + // 재야 할 것은 '싸인' 이라고 적힌 태그를 '사인' 으로 다 찾는가 이므로 원문으로 센다. + 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 +}