viruagent 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -4
- package/config/agent-prompt.md +2 -1
- package/config/prompt-config.json +6 -0
- package/config/system-prompt.md +8 -0
- package/package.json +3 -2
- package/src/agent.js +71 -9
- package/src/cli-post.js +25 -1
- package/src/lib/ai.js +275 -1
- package/src/lib/pattern-store.js +138 -0
- package/src/lib/structure-policy.js +104 -0
- package/src/lib/title-policy.js +58 -0
- package/src/lib/websearch.js +409 -0
package/src/lib/ai.js
CHANGED
|
@@ -13,6 +13,10 @@ const getClient = () => {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
const { replaceImagePlaceholders } = require('./unsplash');
|
|
16
|
+
const { searchWeb } = require('./websearch');
|
|
17
|
+
const { readRecentPatterns, recordPublishedPattern } = require('./pattern-store');
|
|
18
|
+
const { pickAllowedTitleTypes, isAllowedTitle, inferTitleType } = require('./title-policy');
|
|
19
|
+
const { pickStructureTemplate, summarizeRecentPatterns } = require('./structure-policy');
|
|
16
20
|
const { createLogger } = require('./logger');
|
|
17
21
|
const aiLog = createLogger('ai');
|
|
18
22
|
|
|
@@ -34,6 +38,13 @@ const SYSTEM_PROMPT_PATH = path.join(__dirname, '..', '..', 'config', 'system-pr
|
|
|
34
38
|
|
|
35
39
|
const loadConfig = () => {
|
|
36
40
|
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
41
|
+
config.webSearch = {
|
|
42
|
+
enabled: true,
|
|
43
|
+
provider: 'duckduckgo',
|
|
44
|
+
defaultMaxResults: 5,
|
|
45
|
+
timeoutMs: 8000,
|
|
46
|
+
...(config.webSearch || {}),
|
|
47
|
+
};
|
|
37
48
|
config.systemPrompt = fs.readFileSync(SYSTEM_PROMPT_PATH, 'utf-8');
|
|
38
49
|
return config;
|
|
39
50
|
};
|
|
@@ -56,6 +67,124 @@ const MODELS = [
|
|
|
56
67
|
// reasoning 모델은 temperature를 지원하지 않음
|
|
57
68
|
const REASONING_MODELS = /^(o[1-9]|o\d+-)/;
|
|
58
69
|
const isReasoningModel = (model) => REASONING_MODELS.test(model);
|
|
70
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
71
|
+
|
|
72
|
+
const normalizeQuery = (text = '') =>
|
|
73
|
+
String(text).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
74
|
+
|
|
75
|
+
const isSimilarQuery = (a, b) => {
|
|
76
|
+
const q1 = normalizeQuery(a);
|
|
77
|
+
const q2 = normalizeQuery(b);
|
|
78
|
+
if (!q1 || !q2) return false;
|
|
79
|
+
return q1 === q2 || q1.includes(q2) || q2.includes(q1);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const extractFocusKeywords = (topic = '') => {
|
|
83
|
+
const tokens = String(topic)
|
|
84
|
+
.match(/[A-Za-z0-9+#.-]{2,}|[가-힣]{2,}/g);
|
|
85
|
+
|
|
86
|
+
if (!tokens) return [];
|
|
87
|
+
|
|
88
|
+
const stopWords = new Set(['차이', '비교', '가이드', '정리', '최신', '이슈', '대한', '관련']);
|
|
89
|
+
const uniq = [];
|
|
90
|
+
for (const token of tokens) {
|
|
91
|
+
const key = token.toLowerCase();
|
|
92
|
+
if (stopWords.has(key)) continue;
|
|
93
|
+
if (!uniq.some((x) => x.toLowerCase() === key)) uniq.push(token);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return uniq.slice(0, 8);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const buildResearchPromptBlock = (webResearch) => {
|
|
100
|
+
if (!webResearch) {
|
|
101
|
+
return '웹검색 참고자료: 검색 컨텍스트 없음. 일반적인 지식과 원칙을 기반으로 작성하세요.';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!Array.isArray(webResearch.results) || webResearch.results.length === 0) {
|
|
105
|
+
return `웹검색 참고자료:
|
|
106
|
+
- 검색어: ${webResearch.query || '없음'}
|
|
107
|
+
- 검색 시각: ${webResearch.fetchedAt || '없음'}
|
|
108
|
+
- 결과 없음: 과도한 단정 없이 일반 원칙 중심으로 작성하세요.`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const items = webResearch.results
|
|
112
|
+
.slice(0, 5)
|
|
113
|
+
.map(
|
|
114
|
+
(item, idx) =>
|
|
115
|
+
`${idx + 1}. 제목: ${item.title}\n 요약: ${item.snippet || '요약 없음'}\n URL: ${item.url}`,
|
|
116
|
+
)
|
|
117
|
+
.join('\n');
|
|
118
|
+
|
|
119
|
+
return `웹검색 참고자료:
|
|
120
|
+
- 검색어: ${webResearch.query || '없음'}
|
|
121
|
+
- 검색 시각: ${webResearch.fetchedAt || '없음'}
|
|
122
|
+
- 결과:
|
|
123
|
+
${items}`;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const getWebSearchOptions = (config, maxResultsOverride) => {
|
|
127
|
+
const fallbackMax = Number(config.webSearch?.defaultMaxResults) || 5;
|
|
128
|
+
const maxResults =
|
|
129
|
+
maxResultsOverride != null ? clamp(Number(maxResultsOverride) || fallbackMax, 1, 8) : fallbackMax;
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
maxResults,
|
|
133
|
+
timeoutMs: Number(config.webSearch?.timeoutMs) || 8000,
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const titleTypeLabel = (type) => {
|
|
138
|
+
const map = {
|
|
139
|
+
numeric: '숫자형',
|
|
140
|
+
question: '질문형',
|
|
141
|
+
contrast: '대조형',
|
|
142
|
+
statement: '문장형',
|
|
143
|
+
};
|
|
144
|
+
return map[type] || type;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const stripHtml = (html = '') =>
|
|
148
|
+
String(html)
|
|
149
|
+
.replace(/<[^>]+>/g, ' ')
|
|
150
|
+
.replace(/ /g, ' ')
|
|
151
|
+
.replace(/</g, '<')
|
|
152
|
+
.replace(/>/g, '>')
|
|
153
|
+
.replace(/&/g, '&')
|
|
154
|
+
.replace(/\s+/g, ' ')
|
|
155
|
+
.trim();
|
|
156
|
+
|
|
157
|
+
const regenerateTitleByPolicy = async ({ model, topic, tone, content, allowedTypes }) => {
|
|
158
|
+
let res;
|
|
159
|
+
try {
|
|
160
|
+
const allowedText = allowedTypes.map((t) => titleTypeLabel(t)).join(', ');
|
|
161
|
+
const plain = stripHtml(content).slice(0, 800);
|
|
162
|
+
res = await getClient().chat.completions.create({
|
|
163
|
+
model,
|
|
164
|
+
messages: [
|
|
165
|
+
{ role: 'system', content: '당신은 블로그 제목 생성기입니다. 입력된 주제와 본문 요약을 바탕으로 허용된 타입의 제목만 생성하세요.' },
|
|
166
|
+
{
|
|
167
|
+
role: 'user',
|
|
168
|
+
content: `주제: ${topic}
|
|
169
|
+
말투: ${tone}
|
|
170
|
+
허용 제목 타입: ${allowedText}
|
|
171
|
+
본문 요약:
|
|
172
|
+
${plain}
|
|
173
|
+
|
|
174
|
+
다음 JSON 형식으로 응답하세요:
|
|
175
|
+
{"title": "허용 타입을 지킨 제목"}`,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
response_format: { type: 'json_object' },
|
|
179
|
+
...(!isReasoningModel(model) && { temperature: 0.4 }),
|
|
180
|
+
});
|
|
181
|
+
} catch (e) {
|
|
182
|
+
handleApiError(e);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const parsed = JSON.parse(res.choices[0].message.content || '{}');
|
|
186
|
+
return (parsed.title || '').trim() || null;
|
|
187
|
+
};
|
|
59
188
|
|
|
60
189
|
/**
|
|
61
190
|
* 블로그 글 초안 생성
|
|
@@ -64,6 +193,9 @@ const isReasoningModel = (model) => REASONING_MODELS.test(model);
|
|
|
64
193
|
* @param {string} [options.tone] - 말투
|
|
65
194
|
* @param {number} [options.length] - 대략적인 글자 수
|
|
66
195
|
* @param {string} [options.model] - 모델명
|
|
196
|
+
* @param {{query: string, fetchedAt: string, results: Array<{title: string, url: string, snippet: string}>} | null} [options.webResearch] - 웹검색 컨텍스트
|
|
197
|
+
* @param {string} [options.categoryName] - 카테고리 이름
|
|
198
|
+
* @param {Array<Object>} [options.recentPatterns] - 최근 발행 패턴 요약
|
|
67
199
|
* @returns {Promise<{title: string, content: string, tags: string}>}
|
|
68
200
|
*/
|
|
69
201
|
const generatePost = async (topic, options = {}) => {
|
|
@@ -72,7 +204,39 @@ const generatePost = async (topic, options = {}) => {
|
|
|
72
204
|
tone = config.defaultTone,
|
|
73
205
|
length = config.defaultLength,
|
|
74
206
|
model = config.defaultModel,
|
|
207
|
+
webResearch: providedWebResearch,
|
|
208
|
+
categoryName = 'Heartbeat',
|
|
209
|
+
recentPatterns: providedRecentPatterns,
|
|
75
210
|
} = options;
|
|
211
|
+
let webResearch = providedWebResearch;
|
|
212
|
+
const recentPatterns = Array.isArray(providedRecentPatterns)
|
|
213
|
+
? providedRecentPatterns
|
|
214
|
+
: readRecentPatterns({ category: categoryName, limit: 5 });
|
|
215
|
+
const titlePolicy = pickAllowedTitleTypes(recentPatterns, 0.4);
|
|
216
|
+
const structurePlan = pickStructureTemplate({ topic, recentPatterns });
|
|
217
|
+
const recentPatternSummary = summarizeRecentPatterns(recentPatterns);
|
|
218
|
+
|
|
219
|
+
if (webResearch === undefined && config.webSearch?.enabled !== false) {
|
|
220
|
+
try {
|
|
221
|
+
webResearch = await searchWeb(topic, getWebSearchOptions(config));
|
|
222
|
+
} catch (e) {
|
|
223
|
+
aiLog.warn('웹검색 컨텍스트 확보 실패', { topic, error: e.message });
|
|
224
|
+
webResearch = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const researchPrompt = buildResearchPromptBlock(webResearch);
|
|
229
|
+
const focusKeywords = extractFocusKeywords(topic);
|
|
230
|
+
const focusKeywordLine = focusKeywords.length ? focusKeywords.join(', ') : topic;
|
|
231
|
+
const allowedTitleTypesLabel = titlePolicy.allowed.map((t) => titleTypeLabel(t)).join(', ');
|
|
232
|
+
aiLog.info('글 생성 입력 준비', {
|
|
233
|
+
topic,
|
|
234
|
+
categoryName,
|
|
235
|
+
hasWebResearch: !!webResearch,
|
|
236
|
+
webResultCount: webResearch?.results?.length || 0,
|
|
237
|
+
allowedTitleTypes: titlePolicy.allowed,
|
|
238
|
+
structureType: structurePlan.id,
|
|
239
|
+
});
|
|
76
240
|
|
|
77
241
|
let res;
|
|
78
242
|
try {
|
|
@@ -86,11 +250,27 @@ const generatePost = async (topic, options = {}) => {
|
|
|
86
250
|
말투: ${tone}
|
|
87
251
|
분량: 약 ${length}자
|
|
88
252
|
|
|
253
|
+
${researchPrompt}
|
|
254
|
+
|
|
255
|
+
핵심 주제 키워드: ${focusKeywordLine}
|
|
256
|
+
|
|
89
257
|
작성 요구사항:
|
|
90
|
-
- 제목은 검색 키워드를 포함하면서 클릭을
|
|
258
|
+
- 제목은 검색 키워드를 포함하면서 클릭을 유도하되, 허용된 제목 타입만 사용
|
|
91
259
|
- 주제에 가장 적합한 글 유형과 구조를 자율적으로 선택하여 작성
|
|
92
260
|
- 본문 중 적절한 위치에 <!-- IMAGE: 영문키워드 --> 플레이스홀더를 3개 내외 삽입
|
|
93
261
|
- 태그는 검색 유입에 효과적인 키워드 5~7개
|
|
262
|
+
- 웹검색 참고자료가 있으면 최신성과 사실성을 우선 반영
|
|
263
|
+
- 참고자료가 부족하거나 상충하면 단정적 표현을 피하고 불확실성을 반영
|
|
264
|
+
- 글의 범위가 주제에서 벗어나지 않도록 유지하고, 핵심 주제 키워드 기준으로 섹션을 구성
|
|
265
|
+
- 가격/요금제/날짜/버전 등 수치형 정보는 검색 참고자료에서 확인된 경우만 단정적으로 작성
|
|
266
|
+
- 수치형 근거가 약하면 "시점에 따라 변동될 수 있음"으로 표현하고 과도한 단정 금지
|
|
267
|
+
- 본문 HTML에는 참고 링크(URL)나 출처 섹션을 직접 노출하지 말 것
|
|
268
|
+
- 허용 제목 타입: ${allowedTitleTypesLabel}
|
|
269
|
+
- 최근 글 패턴 요약:
|
|
270
|
+
${recentPatternSummary}
|
|
271
|
+
- 이번 글 구조 템플릿: ${structurePlan.id}
|
|
272
|
+
- 템플릿 지시:
|
|
273
|
+
${structurePlan.instruction}
|
|
94
274
|
|
|
95
275
|
다음 JSON 형식으로 응답하세요:
|
|
96
276
|
{"title": "글 제목", "content": "<p>HTML 본문...</p>", "tags": "태그1,태그2,태그3,태그4,태그5"}`,
|
|
@@ -104,6 +284,35 @@ const generatePost = async (topic, options = {}) => {
|
|
|
104
284
|
}
|
|
105
285
|
|
|
106
286
|
const result = JSON.parse(res.choices[0].message.content);
|
|
287
|
+
const titleCheck = isAllowedTitle(result.title, titlePolicy.allowed);
|
|
288
|
+
if (!titleCheck.ok) {
|
|
289
|
+
aiLog.warn('제목 타입 정책 위반, 제목 재생성 시도', {
|
|
290
|
+
topic,
|
|
291
|
+
originalTitle: result.title,
|
|
292
|
+
originalType: titleCheck.type,
|
|
293
|
+
allowed: titlePolicy.allowed,
|
|
294
|
+
});
|
|
295
|
+
const regenerated = await regenerateTitleByPolicy({
|
|
296
|
+
model,
|
|
297
|
+
topic,
|
|
298
|
+
tone,
|
|
299
|
+
content: result.content,
|
|
300
|
+
allowedTypes: titlePolicy.allowed,
|
|
301
|
+
});
|
|
302
|
+
if (regenerated) {
|
|
303
|
+
const retryCheck = isAllowedTitle(regenerated, titlePolicy.allowed);
|
|
304
|
+
if (retryCheck.ok) result.title = regenerated;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
result._meta = {
|
|
309
|
+
topic,
|
|
310
|
+
categoryName,
|
|
311
|
+
structureType: structurePlan.id,
|
|
312
|
+
sectionKeys: structurePlan.sectionKeys,
|
|
313
|
+
titleType: inferTitleType(result.title),
|
|
314
|
+
allowedTitleTypes: titlePolicy.allowed,
|
|
315
|
+
};
|
|
107
316
|
aiLog.info('GPT 응답 수신', { title: result.title, contentLength: result.content?.length });
|
|
108
317
|
|
|
109
318
|
// 티스토리 업로드 함수가 있으면 전달 (initBlog 완료 상태에서만 동작)
|
|
@@ -201,6 +410,21 @@ const loadAgentPrompt = () => {
|
|
|
201
410
|
};
|
|
202
411
|
|
|
203
412
|
const agentTools = [
|
|
413
|
+
{
|
|
414
|
+
type: 'function',
|
|
415
|
+
function: {
|
|
416
|
+
name: 'search_web',
|
|
417
|
+
description: '웹에서 최신 정보를 검색합니다. 글 작성 전 사실 확인이나 트렌드 파악이 필요할 때 사용합니다.',
|
|
418
|
+
parameters: {
|
|
419
|
+
type: 'object',
|
|
420
|
+
properties: {
|
|
421
|
+
query: { type: 'string', description: '검색어' },
|
|
422
|
+
max_results: { type: 'integer', description: '가져올 최대 결과 수 (1~8)', minimum: 1, maximum: 8 },
|
|
423
|
+
},
|
|
424
|
+
required: ['query'],
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
},
|
|
204
428
|
{
|
|
205
429
|
type: 'function',
|
|
206
430
|
function: {
|
|
@@ -381,13 +605,53 @@ const runAgent = async (userMessage, context) => {
|
|
|
381
605
|
/**
|
|
382
606
|
* 에이전트 도구 실행
|
|
383
607
|
*/
|
|
608
|
+
const NEEDS_LOGIN = ['publish_post', 'set_category'];
|
|
609
|
+
|
|
384
610
|
const executeAgentTool = async (name, args, { state, publishFn, config }) => {
|
|
611
|
+
if (NEEDS_LOGIN.includes(name) && !state.blogConnected) {
|
|
612
|
+
return { error: '티스토리 로그인이 필요합니다. /login 명령어로 먼저 로그인하세요.' };
|
|
613
|
+
}
|
|
614
|
+
|
|
385
615
|
switch (name) {
|
|
616
|
+
case 'search_web': {
|
|
617
|
+
if (config.webSearch?.enabled === false) {
|
|
618
|
+
return { error: '웹검색 기능이 비활성화되어 있습니다.' };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const maxResults = args.max_results != null ? args.max_results : undefined;
|
|
622
|
+
const result = await searchWeb(args.query, getWebSearchOptions(config, maxResults));
|
|
623
|
+
state.lastWebResearch = result;
|
|
624
|
+
return {
|
|
625
|
+
success: true,
|
|
626
|
+
query: result.query,
|
|
627
|
+
fetchedAt: result.fetchedAt,
|
|
628
|
+
count: result.results.length,
|
|
629
|
+
results: result.results,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
386
633
|
case 'generate_post': {
|
|
387
634
|
const tone = args.tone || state.tone || config.defaultTone;
|
|
635
|
+
let webResearch = state.lastWebResearch;
|
|
636
|
+
const categoryName = Object.entries(state.categories || {}).find(([, id]) => id === state.category)?.[0] || 'Heartbeat';
|
|
637
|
+
const recentPatterns = readRecentPatterns({ category: categoryName, limit: 5 });
|
|
638
|
+
|
|
639
|
+
if (!webResearch || !isSimilarQuery(webResearch.query, args.topic)) {
|
|
640
|
+
try {
|
|
641
|
+
webResearch = await searchWeb(args.topic, getWebSearchOptions(config));
|
|
642
|
+
state.lastWebResearch = webResearch;
|
|
643
|
+
} catch (e) {
|
|
644
|
+
aiLog.warn('에이전트 웹검색 실패, 생성 계속 진행', { topic: args.topic, error: e.message });
|
|
645
|
+
webResearch = null;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
388
649
|
const result = await generatePost(args.topic, {
|
|
389
650
|
model: state.model,
|
|
390
651
|
tone,
|
|
652
|
+
webResearch,
|
|
653
|
+
categoryName,
|
|
654
|
+
recentPatterns,
|
|
391
655
|
});
|
|
392
656
|
state.draft = result;
|
|
393
657
|
return { success: true, title: result.title, tags: result.tags };
|
|
@@ -429,6 +693,16 @@ const executeAgentTool = async (name, args, { state, publishFn, config }) => {
|
|
|
429
693
|
thumbnail: state.draft.thumbnailKage || null,
|
|
430
694
|
});
|
|
431
695
|
const url = result.entryUrl || '';
|
|
696
|
+
const categoryName = Object.entries(state.categories || {}).find(([, id]) => id === state.category)?.[0] || 'Heartbeat';
|
|
697
|
+
recordPublishedPattern({
|
|
698
|
+
title: state.draft.title,
|
|
699
|
+
topic: state.draft?._meta?.topic || '',
|
|
700
|
+
content: state.draft.content,
|
|
701
|
+
url,
|
|
702
|
+
postId: result?.post?.id || result?.id || null,
|
|
703
|
+
category: categoryName,
|
|
704
|
+
generationMeta: state.draft?._meta || null,
|
|
705
|
+
});
|
|
432
706
|
state.draft = null;
|
|
433
707
|
return { success: true, url };
|
|
434
708
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { createLogger } = require('./logger');
|
|
4
|
+
const { inferTitleType } = require('./title-policy');
|
|
5
|
+
|
|
6
|
+
const patternLog = createLogger('pattern');
|
|
7
|
+
|
|
8
|
+
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
|
9
|
+
const HISTORY_PATH = path.join(DATA_DIR, 'pattern-history.jsonl');
|
|
10
|
+
|
|
11
|
+
const ensureDataDir = () => {
|
|
12
|
+
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const normalizeText = (text = '') =>
|
|
16
|
+
String(text)
|
|
17
|
+
.replace(/<[^>]+>/g, ' ')
|
|
18
|
+
.replace(/ /g, ' ')
|
|
19
|
+
.replace(/\s+/g, ' ')
|
|
20
|
+
.trim();
|
|
21
|
+
|
|
22
|
+
const toSectionKey = (h2 = '') => {
|
|
23
|
+
const t = normalizeText(h2).toLowerCase();
|
|
24
|
+
if (!t) return 'section';
|
|
25
|
+
|
|
26
|
+
if (/비교|테이블|표/.test(t)) return 'compare_table';
|
|
27
|
+
if (/plus/.test(t) && /특징|핵심/.test(t)) return 'plus_features';
|
|
28
|
+
if (/pro/.test(t) && /특징|핵심/.test(t)) return 'pro_features';
|
|
29
|
+
if (/가이드|선택|추천/.test(t)) return 'decision_guide';
|
|
30
|
+
if (/faq|자주 묻는|질문/.test(t)) return 'faq';
|
|
31
|
+
if (/주의|리스크/.test(t)) return 'risk_notice';
|
|
32
|
+
if (/요약|핵심/.test(t)) return 'summary_insight';
|
|
33
|
+
if (/문제|배경/.test(t)) return 'problem_context';
|
|
34
|
+
|
|
35
|
+
return t
|
|
36
|
+
.replace(/[^a-z0-9가-힣\s]/g, ' ')
|
|
37
|
+
.replace(/\s+/g, '_')
|
|
38
|
+
.slice(0, 30) || 'section';
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const summarizeContentStructure = (content = '', fallbackTopic = '') => {
|
|
42
|
+
const h2Matches = [...String(content).matchAll(/<h2[^>]*>([\s\S]*?)<\/h2>/gi)]
|
|
43
|
+
.map((m) => normalizeText(m[1]))
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
|
|
46
|
+
const sectionKeys = h2Matches.map((h) => toSectionKey(h)).slice(0, 6);
|
|
47
|
+
const hasTable = /<table\b/i.test(content);
|
|
48
|
+
const hasFaq = sectionKeys.includes('faq') || /faq|자주 묻는 질문/i.test(content);
|
|
49
|
+
|
|
50
|
+
let structureType = 'general_a';
|
|
51
|
+
const topic = String(fallbackTopic).toLowerCase();
|
|
52
|
+
if (/\bvs\b|비교|차이|plus|pro/.test(topic) || sectionKeys.includes('compare_table')) {
|
|
53
|
+
structureType = 'compare_a';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
sectionKeys: sectionKeys.length ? sectionKeys : ['section'],
|
|
58
|
+
h2Count: h2Matches.length,
|
|
59
|
+
hasTable,
|
|
60
|
+
hasFaq,
|
|
61
|
+
structureType,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const appendPatternRecord = (record) => {
|
|
66
|
+
ensureDataDir();
|
|
67
|
+
fs.appendFileSync(HISTORY_PATH, `${JSON.stringify(record)}\n`);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const readRecentPatterns = ({ category = null, limit = 5 } = {}) => {
|
|
71
|
+
if (!fs.existsSync(HISTORY_PATH)) return [];
|
|
72
|
+
|
|
73
|
+
const raw = fs.readFileSync(HISTORY_PATH, 'utf-8');
|
|
74
|
+
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
75
|
+
const records = [];
|
|
76
|
+
|
|
77
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(lines[i]);
|
|
80
|
+
if (category && parsed.category !== category) continue;
|
|
81
|
+
records.push(parsed);
|
|
82
|
+
if (records.length >= limit) break;
|
|
83
|
+
} catch {
|
|
84
|
+
// ignore broken line
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return records;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const recordPublishedPattern = ({
|
|
92
|
+
title,
|
|
93
|
+
topic,
|
|
94
|
+
content,
|
|
95
|
+
url,
|
|
96
|
+
postId,
|
|
97
|
+
category,
|
|
98
|
+
generationMeta,
|
|
99
|
+
}) => {
|
|
100
|
+
try {
|
|
101
|
+
const fallback = summarizeContentStructure(content || '', topic || '');
|
|
102
|
+
const structureType = generationMeta?.structureType || fallback.structureType;
|
|
103
|
+
const sectionKeys = generationMeta?.sectionKeys || fallback.sectionKeys;
|
|
104
|
+
|
|
105
|
+
const record = {
|
|
106
|
+
ts: new Date().toISOString(),
|
|
107
|
+
postId: postId || null,
|
|
108
|
+
url: url || null,
|
|
109
|
+
title: title || '',
|
|
110
|
+
titleType: inferTitleType(title || ''),
|
|
111
|
+
topic: topic || '',
|
|
112
|
+
structureType,
|
|
113
|
+
sectionKeys,
|
|
114
|
+
h2Count: fallback.h2Count,
|
|
115
|
+
hasTable: fallback.hasTable,
|
|
116
|
+
hasFaq: fallback.hasFaq,
|
|
117
|
+
category: category || 'unknown',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
appendPatternRecord(record);
|
|
121
|
+
patternLog.info('발행 패턴 기록 완료', {
|
|
122
|
+
title: record.title,
|
|
123
|
+
titleType: record.titleType,
|
|
124
|
+
structureType: record.structureType,
|
|
125
|
+
category: record.category,
|
|
126
|
+
});
|
|
127
|
+
} catch (e) {
|
|
128
|
+
patternLog.warn('발행 패턴 기록 실패', { error: e.message });
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
HISTORY_PATH,
|
|
134
|
+
summarizeContentStructure,
|
|
135
|
+
appendPatternRecord,
|
|
136
|
+
readRecentPatterns,
|
|
137
|
+
recordPublishedPattern,
|
|
138
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const normalize = (text = '') => String(text).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
2
|
+
|
|
3
|
+
const isCompareTopic = (topic = '') => {
|
|
4
|
+
const t = normalize(topic);
|
|
5
|
+
return /(\bvs\b|비교|차이|플랜|요금제|plus|pro|review|리뷰)/.test(t);
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const COMPARE_TEMPLATES = [
|
|
9
|
+
{
|
|
10
|
+
id: 'compare_a',
|
|
11
|
+
sectionKeys: ['compare_table', 'core_diff', 'use_case', 'decision_guide'],
|
|
12
|
+
instruction:
|
|
13
|
+
'- 섹션 순서: 한눈에 비교표 → 핵심 차이 3가지 → 사용자 시나리오별 추천 → 최종 선택 가이드\n' +
|
|
14
|
+
'- 비교표는 초반에 1회만 배치하고, 이후는 사례 중심으로 전개',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'compare_b',
|
|
18
|
+
sectionKeys: ['summary_insight', 'compare_table', 'cost_tradeoff', 'checklist'],
|
|
19
|
+
instruction:
|
|
20
|
+
'- 섹션 순서: 요약 인사이트 → 비교표 → 비용/성능 트레이드오프 → 선택 체크리스트\n' +
|
|
21
|
+
'- 초반 요약 인사이트로 결론 방향을 먼저 제시',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'compare_c',
|
|
25
|
+
sectionKeys: ['compare_table', 'workflow_example', 'risk_notice', 'decision_guide'],
|
|
26
|
+
instruction:
|
|
27
|
+
'- 섹션 순서: 비교표 → 실제 활용 워크플로 예시 2개 → 리스크/주의점 → 선택 가이드\n' +
|
|
28
|
+
'- 기능 나열보다 실제 사용 흐름 설명 비중을 높임',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'compare_d',
|
|
32
|
+
sectionKeys: ['problem_context', 'compare_table', 'faq', 'next_action'],
|
|
33
|
+
instruction:
|
|
34
|
+
'- 섹션 순서: 문제 맥락 정의 → 비교표 → FAQ 3문항 → 다음 액션\n' +
|
|
35
|
+
'- FAQ 섹션을 반드시 포함해서 반복 질문을 정리',
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const DEFAULT_TEMPLATE = {
|
|
40
|
+
id: 'general_a',
|
|
41
|
+
sectionKeys: ['intro', 'main_point', 'detail', 'action'],
|
|
42
|
+
instruction:
|
|
43
|
+
'- 섹션 순서: 문제 정의 → 핵심 포인트 → 상세 사례 → 실행 액션\n' +
|
|
44
|
+
'- 동일 레이블 반복을 피하고 섹션 역할을 분명히 분리',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const collisionScore = (template, recentPatterns) => {
|
|
48
|
+
let score = 0;
|
|
49
|
+
|
|
50
|
+
for (const item of recentPatterns) {
|
|
51
|
+
if (!item) continue;
|
|
52
|
+
|
|
53
|
+
if (item.structureType === template.id) score += 3;
|
|
54
|
+
|
|
55
|
+
const recentKeys = Array.isArray(item.sectionKeys) ? item.sectionKeys : [];
|
|
56
|
+
const recentHead = recentKeys.slice(0, 3).join('|');
|
|
57
|
+
const currentHead = template.sectionKeys.slice(0, 3).join('|');
|
|
58
|
+
if (recentHead && recentHead === currentHead) score += 2;
|
|
59
|
+
|
|
60
|
+
if (recentKeys[0] && recentKeys[0] === template.sectionKeys[0]) score += 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return score;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const pickStructureTemplate = ({ topic, recentPatterns = [] }) => {
|
|
67
|
+
const templates = isCompareTopic(topic) ? COMPARE_TEMPLATES : [DEFAULT_TEMPLATE];
|
|
68
|
+
|
|
69
|
+
let best = templates[0];
|
|
70
|
+
let bestScore = collisionScore(best, recentPatterns);
|
|
71
|
+
|
|
72
|
+
for (let i = 1; i < templates.length; i++) {
|
|
73
|
+
const candidate = templates[i];
|
|
74
|
+
const score = collisionScore(candidate, recentPatterns);
|
|
75
|
+
if (score < bestScore) {
|
|
76
|
+
best = candidate;
|
|
77
|
+
bestScore = score;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...best,
|
|
83
|
+
collisionScore: bestScore,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const summarizeRecentPatterns = (records = []) => {
|
|
88
|
+
if (!records.length) return '최근 발행 패턴 데이터 없음';
|
|
89
|
+
|
|
90
|
+
return records
|
|
91
|
+
.slice(0, 5)
|
|
92
|
+
.map((r, idx) => {
|
|
93
|
+
const title = (r.title || '').slice(0, 60);
|
|
94
|
+
const sectionHead = Array.isArray(r.sectionKeys) ? r.sectionKeys.slice(0, 3).join(' > ') : 'none';
|
|
95
|
+
return `${idx + 1}) [${r.titleType || 'unknown'}] ${title} | ${r.structureType || 'unknown'} | ${sectionHead}`;
|
|
96
|
+
})
|
|
97
|
+
.join('\n');
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
isCompareTopic,
|
|
102
|
+
pickStructureTemplate,
|
|
103
|
+
summarizeRecentPatterns,
|
|
104
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const TITLE_TYPES = ['numeric', 'question', 'contrast', 'statement'];
|
|
2
|
+
|
|
3
|
+
const inferTitleType = (title = '') => {
|
|
4
|
+
const text = String(title).trim();
|
|
5
|
+
const lower = text.toLowerCase();
|
|
6
|
+
|
|
7
|
+
if (/\d+\s*(가지|개|단계|포인트|핵심)|^\d+/.test(text)) return 'numeric';
|
|
8
|
+
if (/\?|무엇|어떻게|왜|할까|인가/.test(text)) return 'question';
|
|
9
|
+
if (/\bvs\b|대\s|비교|차이/.test(lower)) return 'contrast';
|
|
10
|
+
return 'statement';
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const computeTitleStats = (records = []) => {
|
|
14
|
+
const counts = {
|
|
15
|
+
numeric: 0,
|
|
16
|
+
question: 0,
|
|
17
|
+
contrast: 0,
|
|
18
|
+
statement: 0,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
for (const record of records) {
|
|
22
|
+
const type = record?.titleType || inferTitleType(record?.title || '');
|
|
23
|
+
if (counts[type] == null) continue;
|
|
24
|
+
counts[type] += 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
|
28
|
+
return { counts, total };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const pickAllowedTitleTypes = (records = [], numericCap = 0.4) => {
|
|
32
|
+
const stats = computeTitleStats(records);
|
|
33
|
+
const numericRatio = stats.total ? stats.counts.numeric / stats.total : 0;
|
|
34
|
+
|
|
35
|
+
const allowed = numericRatio >= numericCap
|
|
36
|
+
? TITLE_TYPES.filter((t) => t !== 'numeric')
|
|
37
|
+
: [...TITLE_TYPES];
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
allowed,
|
|
41
|
+
numericRatio,
|
|
42
|
+
counts: stats.counts,
|
|
43
|
+
total: stats.total,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isAllowedTitle = (title, allowedTypes = TITLE_TYPES) => {
|
|
48
|
+
const type = inferTitleType(title);
|
|
49
|
+
return { ok: allowedTypes.includes(type), type };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
TITLE_TYPES,
|
|
54
|
+
inferTitleType,
|
|
55
|
+
computeTitleStats,
|
|
56
|
+
pickAllowedTitleTypes,
|
|
57
|
+
isAllowedTitle,
|
|
58
|
+
};
|