generated from ttipo/project-base
fix(backend): 메일함 안 검색을 찾기 GIN 하나로 거르고 미리 보기 · 본문 강조를 고친다
14단계 실사용 시험(2026-11-06)에서 나온 것을 고친다. - 1만 통 메일함 안 검색이 1.1초였다. 계획기가 메일함만 담은 GIN(메일함 id 가 계정마다 같은 꼴이라 posting 이 회사 전체)으로 그 메일함 줄을 다 읽고 묶음 배열을 줄마다 풀었다. 직원 · 묶음 · 메일함을 한 색인에 담은 GIN 하나로 바꾸고 옛 GIN 둘을 지운다(마이그레이션 SearchIndexUserGramsMailboxes — 큰 메일함 15ms · 작은 메일함 1ms · 전체 15ms) - 한 글자 낱말뿐이면 본문을 찾지 않는데 본문 글자를 강조했다 — 본문 강조는 본문을 찾은 두 글자 이상 낱말만 - 답장의 결과 미리 보기가 원문 인용 조각이었다 — 인용 줄 밖의 맞은 곳을 먼저, 인용 안에서만 맞고 다른 칸에서도 맞았으면 본문 첫머리, 인용 조각은 줄 앞 `>` 를 뗀다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -130,8 +130,14 @@ import {
|
||||
import { MailSearchBuildService } from '@/modules/mail-search/mail-search-build.service';
|
||||
import type { SearchScope } from '@/modules/mail-search/mail-search.types';
|
||||
import { MailSearchIndexRepository } from '@/modules/mail-search/mail-search-index.repository';
|
||||
import { INDEX_FORMAT } from '@/modules/mail-search/mail-search.constants';
|
||||
import { PLANNER_SETTINGS } from '@/modules/mail-search/mail-search-query.service';
|
||||
import {
|
||||
INDEX_FORMAT,
|
||||
SEARCH_CHECK_ROWS_LIMIT,
|
||||
} from '@/modules/mail-search/mail-search.constants';
|
||||
import { toIndexRow } from '@/modules/mail-search/index-document';
|
||||
import { planSearch } from '@/modules/mail-search/search-plan';
|
||||
import { candidatesSql } from '@/modules/mail-search/search-sql';
|
||||
import {
|
||||
SystemSettingsService,
|
||||
type SystemSettingsPatch,
|
||||
@@ -8082,6 +8088,16 @@ describe('응답 계약 (e2e)', () => {
|
||||
mailboxIds: [SEARCH_EXCLUDED_MAILBOX],
|
||||
receivedAt: '2026-11-01T01:00:00Z',
|
||||
}),
|
||||
// 답장 — 내 글 아래에 원문 인용(> 줄)이 있다 (미리 보기 고르기 — 2026-11-06 사용자 관점 점검)
|
||||
indexDocument({
|
||||
id: 'm-reply',
|
||||
subject: 'RE: 해오름 프로젝트 안내',
|
||||
bodyText:
|
||||
'네, 받았습니다. 금요일까지 확인하겠습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||||
preview:
|
||||
'네, 받았습니다. 금요일까지 확인하겠습니다. > 원본 메일 > 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||||
receivedAt: '2026-10-30T01:00:00Z',
|
||||
}),
|
||||
];
|
||||
|
||||
interface ListItem {
|
||||
@@ -8202,6 +8218,102 @@ describe('응답 계약 (e2e)', () => {
|
||||
expect(await idsOf({ q: '층' })).toEqual(['m-floor']);
|
||||
// 두 글자 넘는 낱말과 함께면 본문에서도 확인한다
|
||||
expect(await idsOf({ q: '층 회의실' })).toEqual(['m-meeting']);
|
||||
// 본문을 찾지 않았으니 미리 보기에도 강조하지 않는다 — '공' 은 m-floor 의 제목과 본문에 있다 (2026-11-06 실사용 시험)
|
||||
const found = dataOf<Page<ListItem>>(
|
||||
await list({ q: '공' }).expect(200),
|
||||
).items;
|
||||
expect(found.map((item) => item.id)).toEqual(['m-contract', 'm-floor']);
|
||||
expect(found[1].searchHighlight).toEqual({
|
||||
subject: [[3, 4]],
|
||||
preview: { text: '', ranges: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('미리 보기는 답장의 원문 인용(> 줄)을 피한다 — 인용 안에서만 맞고 제목에서도 맞으면 인용 앞의 글, 인용에만 있는 말은 > 를 뗀 인용 조각 (2026-11-06 사용자 관점 점검)', async () => {
|
||||
const [byTitle] = dataOf<Page<ListItem>>(
|
||||
await list({ q: '해오름' }).expect(200),
|
||||
).items;
|
||||
expect(byTitle.id).toBe('m-reply');
|
||||
expect(byTitle.searchHighlight).toEqual({
|
||||
subject: [[4, 7]],
|
||||
preview: {
|
||||
text: '네, 받았습니다. 금요일까지 확인하겠습니다.…',
|
||||
ranges: [],
|
||||
},
|
||||
});
|
||||
|
||||
const [byQuote] = dataOf<Page<ListItem>>(
|
||||
await list({ q: 'R9A51' }).expect(200),
|
||||
).items;
|
||||
expect(byQuote.id).toBe('m-reply');
|
||||
expect(byQuote.searchHighlight?.preview).toEqual({
|
||||
text: '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||||
ranges: [[16, 21]],
|
||||
});
|
||||
});
|
||||
|
||||
it('메일함 범위의 후보는 찾기 GIN 하나로 — 주소 · 묶음 · 메일함을 색인 조건으로 맞추고, 메일함만으로 줄을 모아 묶음을 줄마다 풀지 않는다 (실제 PostgreSQL — 2026-11-06 실사용 시험)', async () => {
|
||||
// 메일함만 담은 GIN 은 없다 — 있으면 계획기가 그것만으로 메일함의 줄(회사 전체 posting)을 읽고 묶음 배열을 줄마다 풀 수 있다
|
||||
const gins: { indexname: string }[] = await dataSource.query(
|
||||
`SELECT indexname FROM pg_indexes WHERE tablename = 'mail_search_messages' AND indexdef ILIKE '%USING gin%' ORDER BY indexname`,
|
||||
);
|
||||
expect(gins.map((row) => row.indexname)).toEqual([
|
||||
'IDX_mail_search_messages_refs',
|
||||
'IDX_mail_search_messages_user_grams_mailboxes',
|
||||
]);
|
||||
|
||||
interface PlanNode {
|
||||
'Index Name'?: string;
|
||||
'Index Cond'?: string;
|
||||
Filter?: string;
|
||||
'Recheck Cond'?: string;
|
||||
Plans?: PlanNode[];
|
||||
}
|
||||
const nodesOf = (node: PlanNode): PlanNode[] => [
|
||||
node,
|
||||
...(node.Plans ?? []).flatMap(nodesOf),
|
||||
];
|
||||
const scope: SearchScope = {
|
||||
mailboxId: 'inbox',
|
||||
excludedMailboxIds: [],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
};
|
||||
for (const text of ['계약', '계약서 갱신']) {
|
||||
const query = candidatesSql(
|
||||
ME,
|
||||
scope,
|
||||
planSearch({ text, hasAttachment: false }),
|
||||
null,
|
||||
SEARCH_CHECK_ROWS_LIMIT + 1,
|
||||
);
|
||||
// 서비스와 같은 계획기 설정(SET LOCAL — 트랜잭션 안)으로
|
||||
const rows: { 'QUERY PLAN': unknown }[] = await dataSource.transaction(
|
||||
async (manager) => {
|
||||
for (const setting of PLANNER_SETTINGS.bitmap) {
|
||||
await manager.query(setting);
|
||||
}
|
||||
return manager.query(
|
||||
`EXPLAIN (FORMAT JSON) ${query.sql}`,
|
||||
query.params,
|
||||
);
|
||||
},
|
||||
);
|
||||
const raw = rows[0]['QUERY PLAN'];
|
||||
const [explained] = (
|
||||
typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
) as { Plan: PlanNode }[];
|
||||
const nodes = nodesOf(explained.Plan);
|
||||
const scans = nodes.filter((node) => node['Index Name']);
|
||||
expect(scans.map((node) => node['Index Name'])).toEqual([
|
||||
'IDX_mail_search_messages_user_grams_mailboxes',
|
||||
]);
|
||||
expect(scans[0]['Index Cond']).toContain('mailbox_ids');
|
||||
expect(scans[0]['Index Cond']).toContain('grams');
|
||||
// 묶음은 줄마다 보지 않는다 — 걸러 내기(Filter)에 묶음이 없다
|
||||
expect(nodes.map((node) => node.Filter ?? '').join(' ')).not.toContain(
|
||||
'grams',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('쪽 넘기기 — 다음 쪽은 커서의 자리부터, 다음 쪽의 total 은 null · 다른 검색의 커서는 400 VAL_001', async () => {
|
||||
@@ -8500,6 +8612,19 @@ describe('응답 계약 (e2e)', () => {
|
||||
expect(detail.htmlBody).not.toContain('<mark>로고');
|
||||
});
|
||||
|
||||
it('한 글자 낱말뿐이면 본문에는 강조하지 않는다 — 제목은 그대로 (2026-11-06 실사용 시험)', async () => {
|
||||
// '든' 은 제목(그림이 든 메일)에, '안' 은 본문(안녕하세요)에 있다
|
||||
const detail = dataOf<ReadDetail>(
|
||||
await read({ q: '든 안' }).expect(200),
|
||||
);
|
||||
expect(detail.searchHighlight).toEqual({
|
||||
subject: [[4, 5]],
|
||||
attachments: {},
|
||||
textBody: [],
|
||||
});
|
||||
expect(detail.htmlBody).not.toContain('<mark>');
|
||||
});
|
||||
|
||||
it('images=show 와 함께 — 중계 주소로 바꾼 본문에 강조, 제목 조건은 제목에', async () => {
|
||||
const detail = dataOf<ReadDetail>(
|
||||
await read({ images: 'show', subject: '그림', q: '안녕' }).expect(
|
||||
|
||||
Reference in New Issue
Block a user