harulog 0.1.0

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/src/doctor.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ import { listLocalGitRepositories } from "./collectors/localGit";
5
+ import { loadConfig } from "./config";
6
+ import { getSchedulerStatus } from "./scheduler";
7
+ import { pathExists } from "./utils/file";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ async function hasGit(): Promise<boolean> {
12
+ try {
13
+ await execFileAsync("git", ["--version"]);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export async function runDoctor(): Promise<number> {
21
+ const config = await loadConfig();
22
+ const schedulerStatus = await getSchedulerStatus(config);
23
+ const [codexIndexExists, codexLogsExist, gitExists, repoPaths] = await Promise.all([
24
+ pathExists(config.codexSessionIndexPath),
25
+ pathExists(config.codexLogsPath),
26
+ hasGit(),
27
+ listLocalGitRepositories(config)
28
+ ]);
29
+
30
+ const checks = [
31
+ { label: "config file", ok: config.configExists, detail: config.configPath },
32
+ {
33
+ label: "gemini api key",
34
+ ok: Boolean(config.geminiApiKey),
35
+ detail: config.geminiApiKey ? `configured via ${config.geminiApiKeySource}` : "missing"
36
+ },
37
+ { label: "codex session index", ok: codexIndexExists, detail: config.codexSessionIndexPath },
38
+ { label: "codex logs sqlite", ok: codexLogsExist, detail: config.codexLogsPath },
39
+ { label: "git executable", ok: gitExists, detail: gitExists ? "available" : "missing" },
40
+ {
41
+ label: "local git repositories",
42
+ ok: repoPaths.length > 0,
43
+ detail: `${repoPaths.length} repositories from ${config.localGitScanRoots.join(", ")}`
44
+ },
45
+ {
46
+ label: "launchd scheduler",
47
+ ok: schedulerStatus.installed,
48
+ detail: schedulerStatus.installed
49
+ ? `${schedulerStatus.loaded ? "loaded" : "installed"} at ${schedulerStatus.plistPath}`
50
+ : "not installed"
51
+ }
52
+ ];
53
+
54
+ console.log(`harulog doctor (${config.packageVersion})`);
55
+ console.log(`app home: ${config.appHome}`);
56
+ console.log("");
57
+
58
+ for (const check of checks) {
59
+ console.log(`${check.ok ? "[ok]" : "[warn]"} ${check.label}: ${check.detail}`);
60
+ }
61
+
62
+ return checks.every((check) => check.ok) ? 0 : 1;
63
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { loadConfig } from "./config";
2
+ import { formatWeekdayLabel } from "./utils/date";
3
+
4
+ const config = await loadConfig();
5
+
6
+ console.log("haruLog CLI package is ready.");
7
+ console.log(`package: ${config.packageName}@${config.packageVersion}`);
8
+ console.log(`appHome: ${config.appHome}`);
9
+ console.log(`configPath: ${config.configPath}`);
10
+ console.log(`codexSessionIndexPath: ${config.codexSessionIndexPath}`);
11
+ console.log(`llmProvider: ${config.llmProvider}`);
12
+ console.log(`geminiModel: ${config.geminiModel}`);
13
+ console.log(
14
+ `schedule: every ${formatWeekdayLabel(config.scheduleWeekday)} at ${String(config.scheduleHour).padStart(2, "0")}:00`
15
+ );
16
+ console.log(
17
+ `localGitScanRoots: ${config.localGitScanRoots.length > 0 ? config.localGitScanRoots.join(", ") : "(none)"}`
18
+ );
19
+ console.log(
20
+ `localGitRepos: ${config.localGitRepos.length > 0 ? config.localGitRepos.join(", ") : "(auto-discover)"}`
21
+ );
22
+ console.log("Use `bun run src/cli.ts help` to inspect CLI commands.");
@@ -0,0 +1,10 @@
1
+ import { runTopicsJob } from "../services/topicsJob";
2
+
3
+ const force = process.argv.includes("--force");
4
+ const ignoreHistory = process.argv.includes("--ignore-history");
5
+
6
+ runTopicsJob({ force, ignoreHistory, scheduled: true }).catch((error) => {
7
+ console.error("[error] dailyTopics job failed");
8
+ console.error(error);
9
+ process.exitCode = 1;
10
+ });
@@ -0,0 +1,43 @@
1
+ import type { ActivityItem } from "../types";
2
+ import { subtractDays } from "../utils/date";
3
+
4
+ function isValidTimestamp(value: string): boolean {
5
+ return Number.isFinite(new Date(value).getTime());
6
+ }
7
+
8
+ function dedupeActivities(activities: ActivityItem[]): ActivityItem[] {
9
+ const seen = new Set<string>();
10
+ const result: ActivityItem[] = [];
11
+
12
+ for (const activity of activities) {
13
+ const key = `${activity.source}:${activity.id}`;
14
+ if (seen.has(key)) {
15
+ continue;
16
+ }
17
+
18
+ seen.add(key);
19
+ result.push(activity);
20
+ }
21
+
22
+ return result;
23
+ }
24
+
25
+ export function normalizeActivities(
26
+ activities: ActivityItem[],
27
+ now: Date,
28
+ windowDays: number
29
+ ): ActivityItem[] {
30
+ const windowStart = subtractDays(now, windowDays);
31
+
32
+ return dedupeActivities(
33
+ activities.filter((activity) => {
34
+ if (!isValidTimestamp(activity.timestamp)) {
35
+ return false;
36
+ }
37
+
38
+ return new Date(activity.timestamp).getTime() >= windowStart.getTime();
39
+ })
40
+ ).sort((left, right) => {
41
+ return new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime();
42
+ });
43
+ }
@@ -0,0 +1,142 @@
1
+ import type { ActivityItem, TopicHistoryItem } from "../types";
2
+
3
+ function toActivityPromptItem(activity: ActivityItem): Record<string, unknown> {
4
+ return {
5
+ id: activity.id,
6
+ source: activity.source,
7
+ type: activity.type,
8
+ title: activity.title,
9
+ summary: activity.summary,
10
+ timestamp: activity.timestamp,
11
+ status: activity.status,
12
+ tags: activity.tags,
13
+ weight: activity.weight,
14
+ evidence: activity.evidence,
15
+ metadata: activity.metadata ?? {}
16
+ };
17
+ }
18
+
19
+ function toHistoryPromptItem(item: TopicHistoryItem): Record<string, unknown> {
20
+ return {
21
+ title: item.title,
22
+ createdAt: item.createdAt,
23
+ relatedKeywords: item.relatedKeywords
24
+ };
25
+ }
26
+
27
+ const WRITING_QUALITY_GUIDE = [
28
+ "당신은 사내 기술 블로그와 외부 기술 미디어에 글을 써본 시니어 테크니컬 라이터다.",
29
+ "당신의 역할은 마케팅 카피라이터가 아니라, 실제 코드 변경과 운영 맥락을 정확하게 설명하는 기술 문서 작성자에 가깝다.",
30
+ "글의 목표는 활동 기록을 단순 요약하는 것이 아니라, 실제 문제 해결 경험을 읽을 만한 기술 블로그 초안으로 바꾸는 것이다.",
31
+ "문장은 자연스럽고 밀도 있게 쓰되, 마케팅 문구처럼 과장하지 않는다.",
32
+ "독자는 실무 개발자다. 추상적인 조언보다 상황, 판단, 트레이드오프, 배운 점이 드러나야 한다.",
33
+ "사실이 부족하면 보수적으로 표현하고, 입력에 없는 파일명, 수치, 원인, 결과를 만들어내지 않는다.",
34
+ "변경 요약은 가능한 한 실제 작업 단위, 관련 파일, 영향 범위, 동작 차이에 기대어 작성한다.",
35
+ "audience는 개발자, 운영자, 통합 담당자 중 누구에게 특히 유용한지 드러나게 구체화한다.",
36
+ "입력 근거에 환경 설정, 브랜치, 파일, API, 배포 흐름이 보이면 초안에 prerequisites, caveats, troubleshooting 포인트를 자연스럽게 녹여라.",
37
+ "마이그레이션, 롤백, 설정 변경, 운영상 주의가 중요한 주제라면 그 포인트를 빼먹지 않는다.",
38
+ "제목은 블로그 게시물 제목처럼 구체적이고 읽고 싶게 써야 한다. '정리할 만한', '포인트', '이 글에서는' 같은 뻔한 표현은 피한다.",
39
+ "reason는 한두 문장이 아니라, 왜 이 주제가 지금 가치 있는지 편집자의 시선으로 설득력 있게 설명한다.",
40
+ "audience는 막연한 '개발자' 대신 어떤 상황의 개발자에게 유용한지 구체화한다.",
41
+ "outline은 목차처럼 보이되, 실제 글 전개가 느껴지는 짧은 문장 4개로 작성한다.",
42
+ "draft는 기술 블로그 초안답게 서론-문제 상황-탐색/판단-해결/교훈 흐름이 살아 있어야 한다.",
43
+ "draft는 최소 4문단, 권장 5~7문단으로 작성하고, 문단마다 역할이 다르게 느껴져야 한다.",
44
+ "draft 첫 문단은 독자의 관심을 끌 수 있도록 실제 상황이나 문제의식으로 시작한다. 상투적인 인삿말로 시작하지 않는다.",
45
+ "draft에는 왜 이 문제가 헷갈렸는지, 어떤 기준으로 접근을 바꿨는지, 해결 과정에서 무엇이 핵심이었는지가 드러나야 한다.",
46
+ "draft 마지막 문단은 단순 요약 대신, 실무적인 교훈이나 재사용 가능한 판단 기준으로 마무리한다.",
47
+ "draft는 글쓰기 과정 자체를 설명하지 않는다. '이 초안은', '이 글감은', '활동 기록을 글감으로 전환', '무엇을 문제로 볼지부터 판단해야 한다' 같은 메타 서술은 금지한다.",
48
+ "실제 변경이 무엇인지 설명하지 못한 채 추상적인 생산성 이야기로 흐르지 않는다.",
49
+ "반복적으로 같은 표현을 복사하듯 쓰지 않는다. 특히 '단순한 기능 추가보다', '이 글에서는', '어디에서 막혔는지와 무엇을 확인했는지' 같은 템플릿 문장을 그대로 쓰지 않는다."
50
+ ];
51
+
52
+ const TECHNICAL_WRITING_MODE = [
53
+ "1. 활동 데이터에서 실제 변경 내용, 영향을 받는 대상, 운영 맥락을 먼저 파악한다.",
54
+ "2. 글의 전개는 가능한 한 adopt, configure, migrate, troubleshoot 같은 실무 과업 관점으로 구조화한다.",
55
+ "3. 초안에는 caveat, prerequisite, limit, tradeoff를 독자가 바로 써먹을 수 있게 녹여 쓴다.",
56
+ "4. 파일명, 브랜치, 커밋, 작업 흐름에 대한 서술은 입력 evidence와 metadata에 맞아야 한다."
57
+ ];
58
+
59
+ const SUGGESTION_RULES = [
60
+ "가능하면 하나의 suggestion에 서로 관련 있는 evidenceIds를 2~3개까지 묶어 더 풍부한 맥락을 만들어라.",
61
+ "다만 evidenceIds는 반드시 입력 activities에 실제로 존재하는 id만 사용해야 한다.",
62
+ "activities의 제목만 보고도 충분히 추론 가능한 범위에서만 맥락을 확장하라.",
63
+ "단순 push 이벤트만으로 맥락이 빈약하면, 관련 commit이나 같은 주제의 activity를 함께 evidenceIds에 묶어 해상도를 높여라.",
64
+ "git 활동에 metadata.changedFiles, repoName, branchName, relatedCommitTitle이 있다면 글의 맥락을 더 구체화하는 단서로 활용하라.",
65
+ "파일명은 강한 힌트지만 구현 세부사항의 확정 근거는 아니다. 파일명만으로 내부 로직을 단정하지 않는다.",
66
+ "근거가 부족한 주제는 억지로 장식하지 말고, 더 해상도가 높은 활동을 우선 추천하라.",
67
+ "최근 추천 이력과 제목이나 핵심 키워드가 크게 겹치는 주제는 피하라."
68
+ ];
69
+
70
+ const DRAFT_STRUCTURE_GUIDE = [
71
+ "1. 도입: 실제 문제 상황, 변경 배경, 독자가 공감할 만한 혼란 지점을 연다.",
72
+ "2. 맥락: 어떤 기능, 설정, 흐름, 파일, 브랜치, 작업 단위가 관련되는지 설명한다.",
73
+ "3. 판단: 여러 접근 중 무엇을 기준으로 선택했는지와 트레이드오프를 드러낸다.",
74
+ "4. 구현: 바뀐 동작이나 구조를 구체적으로 설명하되, evidence에 없는 세부사항은 단정하지 않는다.",
75
+ "5. 운영 포인트: 필요하면 prerequisites, 주의사항, migration/rollback, troubleshooting 힌트를 녹여 쓴다.",
76
+ "6. 마무리: 비슷한 상황에서 재사용 가능한 판단 기준이나 체크포인트로 끝낸다."
77
+ ];
78
+
79
+ const BANNED_PATTERNS = [
80
+ "1. 글쓰기 과정 자체를 설명하는 메타 문장",
81
+ "2. 입력 evidence에 없는 구현 세부사항을 사실처럼 단정하는 문장",
82
+ "3. 제목만 바꿔 반복한 템플릿 초안",
83
+ "4. 실제 변경 내용 없이 추상적인 성장담이나 생산성 담론으로만 채운 서술"
84
+ ];
85
+
86
+ export function buildTopicRecommendationPrompt(input: {
87
+ activities: ActivityItem[];
88
+ history: TopicHistoryItem[];
89
+ count: number;
90
+ now: Date;
91
+ }): string {
92
+ const activities = input.activities.slice(0, 40).map(toActivityPromptItem);
93
+ const history = input.history.slice(-20).map(toHistoryPromptItem);
94
+
95
+ return [
96
+ "당신은 한국어로 글감을 추천하고 초안을 작성하는 숙련된 개발자 편집자다.",
97
+ "입력으로 들어온 활동 데이터만 근거로 사용해야 한다.",
98
+ "사실이 부족한 내용은 추측하지 말고 보수적으로 작성한다.",
99
+ "최근 추천 이력과 과하게 겹치는 주제는 피한다.",
100
+ "각 suggestion의 evidenceIds는 반드시 activities에 있는 id를 그대로 사용한다.",
101
+ "출력은 JSON 스키마를 정확히 따라야 하며, 모든 자연어 필드는 한국어로 작성한다.",
102
+ "draft는 기술 블로그 초안으로, 자연스럽고 완성도 높은 한국어 문단으로 작성한다.",
103
+ "outline은 글의 흐름이 드러나는 짧은 문장 4개로 작성한다.",
104
+ `현재 시각: ${input.now.toISOString()}`,
105
+ `반환할 추천 개수: ${input.count}`,
106
+ "",
107
+ "작업 방식:",
108
+ ...TECHNICAL_WRITING_MODE,
109
+ "",
110
+ "글쓰기 품질 가이드:",
111
+ ...WRITING_QUALITY_GUIDE.map((item, index) => `${index + 1}. ${item}`),
112
+ "",
113
+ "추천 생성 규칙:",
114
+ ...SUGGESTION_RULES.map((item, index) => `${index + 1}. ${item}`),
115
+ "",
116
+ "draft 구조 가이드:",
117
+ ...DRAFT_STRUCTURE_GUIDE,
118
+ "",
119
+ "금지 패턴:",
120
+ ...BANNED_PATTERNS,
121
+ "",
122
+ "최근 추천 이력(JSON):",
123
+ JSON.stringify(history, null, 2),
124
+ "",
125
+ "최근 활동 데이터(JSON):",
126
+ JSON.stringify(activities, null, 2),
127
+ "",
128
+ "좋은 추천의 기준:",
129
+ "1. 실제로 겪은 문제, 변경 흐름, 영향 범위가 보일 것",
130
+ "2. 다른 개발자나 운영자가 바로 써먹을 만한 판단 기준이 있을 것",
131
+ "3. 단순 작업 보고가 아니라 구현/운영 인사이트로 확장될 수 있을 것",
132
+ "4. 최근 추천 이력과 제목/키워드가 과하게 겹치지 않을 것",
133
+ "",
134
+ "draft 작성 체크리스트:",
135
+ "1. 첫 문단에서 문제의식이나 맥락을 실제 작업 기준으로 열 것",
136
+ "2. 중간 문단에서 무엇이 바뀌었고 왜 어려웠는지 설명할 것",
137
+ "3. 해결 또는 개선 방향을 실무적으로 풀어낼 것",
138
+ "4. 필요하면 caveat, prerequisite, troubleshooting을 포함할 것",
139
+ "5. 마지막 문단에서 재사용 가능한 교훈을 남길 것",
140
+ "6. 문단마다 역할이 다르고, 실제 기술 블로그처럼 읽히게 쓸 것"
141
+ ].join("\n");
142
+ }
@@ -0,0 +1,404 @@
1
+ import type { AppConfig } from "../config";
2
+ import { buildTopicRecommendationPrompt } from "../prompts/topicPrompt";
3
+ import type {
4
+ ActivityItem,
5
+ RecommendInput,
6
+ TopicRecommender,
7
+ TopicSuggestion
8
+ } from "../types";
9
+ import { logWarn } from "../utils/logger";
10
+
11
+ const STOPWORDS = new Set([
12
+ "the",
13
+ "and",
14
+ "for",
15
+ "with",
16
+ "from",
17
+ "that",
18
+ "this",
19
+ "into",
20
+ "then",
21
+ "when",
22
+ "where",
23
+ "while",
24
+ "using",
25
+ "하면서",
26
+ "수정",
27
+ "추가",
28
+ "작업",
29
+ "문제",
30
+ "해결",
31
+ "session",
32
+ "codex"
33
+ ]);
34
+
35
+ const GEMINI_RESPONSE_SCHEMA = {
36
+ type: "object",
37
+ additionalProperties: false,
38
+ properties: {
39
+ suggestions: {
40
+ type: "array",
41
+ items: {
42
+ type: "object",
43
+ additionalProperties: false,
44
+ properties: {
45
+ title: { type: "string" },
46
+ reason: { type: "string" },
47
+ audience: { type: "string" },
48
+ outline: {
49
+ type: "array",
50
+ items: { type: "string" }
51
+ },
52
+ draft: { type: "string" },
53
+ keywords: {
54
+ type: "array",
55
+ items: { type: "string" }
56
+ },
57
+ evidenceIds: {
58
+ type: "array",
59
+ items: { type: "string" }
60
+ },
61
+ score: { type: "integer" }
62
+ },
63
+ required: [
64
+ "title",
65
+ "reason",
66
+ "audience",
67
+ "outline",
68
+ "draft",
69
+ "keywords",
70
+ "evidenceIds",
71
+ "score"
72
+ ]
73
+ }
74
+ }
75
+ },
76
+ required: ["suggestions"]
77
+ } as const;
78
+
79
+ interface RecommendationPayload {
80
+ suggestions?: unknown;
81
+ }
82
+
83
+ interface SuggestionPayload {
84
+ title?: unknown;
85
+ reason?: unknown;
86
+ audience?: unknown;
87
+ outline?: unknown;
88
+ draft?: unknown;
89
+ keywords?: unknown;
90
+ evidenceIds?: unknown;
91
+ score?: unknown;
92
+ }
93
+
94
+ interface GeminiErrorPayload {
95
+ error?: {
96
+ code?: number;
97
+ message?: string;
98
+ status?: string;
99
+ };
100
+ }
101
+
102
+ interface GeminiGenerateContentResponse {
103
+ candidates?: Array<{
104
+ content?: {
105
+ parts?: Array<{
106
+ text?: string;
107
+ }>;
108
+ };
109
+ }>;
110
+ }
111
+
112
+ function tokenize(text: string): string[] {
113
+ return text
114
+ .toLowerCase()
115
+ .split(/[^a-z0-9가-힣]+/u)
116
+ .map((token) => token.trim())
117
+ .filter((token) => token.length >= 2 && !STOPWORDS.has(token));
118
+ }
119
+
120
+ function overlapCount(left: string[], right: string[]): number {
121
+ const rightSet = new Set(right);
122
+ return left.filter((item) => rightSet.has(item)).length;
123
+ }
124
+
125
+ function uniqueStrings(values: string[], limit: number): string[] {
126
+ const result: string[] = [];
127
+ const seen = new Set<string>();
128
+
129
+ for (const value of values) {
130
+ const normalized = value.trim().toLowerCase();
131
+ if (!normalized || seen.has(normalized)) {
132
+ continue;
133
+ }
134
+
135
+ seen.add(normalized);
136
+ result.push(value.trim());
137
+
138
+ if (result.length >= limit) {
139
+ break;
140
+ }
141
+ }
142
+
143
+ return result;
144
+ }
145
+
146
+ function shouldSkipByHistory(
147
+ title: string,
148
+ historyTitles: Set<string>,
149
+ historyKeywords: string[][]
150
+ ): boolean {
151
+ const normalizedTitle = title.trim().toLowerCase();
152
+ if (historyTitles.has(normalizedTitle)) {
153
+ return true;
154
+ }
155
+
156
+ const keywords = tokenize(title);
157
+ return historyKeywords.some((saved) => overlapCount(keywords, saved) >= 2);
158
+ }
159
+
160
+ function clampScore(value: unknown, fallback: number): number {
161
+ if (typeof value !== "number" || !Number.isFinite(value)) {
162
+ return fallback;
163
+ }
164
+
165
+ return Math.max(1, Math.min(100, Math.round(value)));
166
+ }
167
+
168
+ function toStringArray(value: unknown): string[] {
169
+ if (!Array.isArray(value)) {
170
+ return [];
171
+ }
172
+
173
+ return value
174
+ .filter((item): item is string => typeof item === "string")
175
+ .map((item) => item.trim())
176
+ .filter(Boolean);
177
+ }
178
+
179
+ function buildPlaceholderDraft(activity: ActivityItem): string {
180
+ return [
181
+ `${activity.title} 같은 작업은 결과만 보면 한 줄로 설명되지만, 실제 구현 과정에서는 맥락을 정리하는 방식에 따라 난이도가 크게 달라진다. 특히 최근 활동 기록을 글감으로 전환하려면 무엇을 문제로 볼지, 어디까지를 경험으로 묶을지부터 판단해야 한다.`,
182
+ `이 초안은 그런 판단의 흐름을 중심에 둔다. 단순히 무엇을 만들었는지 나열하기보다, 왜 이 주제가 기술 블로그 글감이 되는지, 구현 과정에서 어떤 관점이 중요했는지, 그리고 어떤 지점이 다른 개발자에게도 전달 가치가 있는지를 풀어내는 방향이다.`,
183
+ `본문에서는 먼저 작업의 배경과 문제의식을 설명하고, 이어서 탐색 과정에서 드러난 고민과 선택 기준을 정리할 수 있다. 마지막에는 이번 경험이 이후 비슷한 구현이나 리팩터링 상황에서 어떤 기준으로 재사용될 수 있는지까지 연결하면 글의 완성도가 높아진다.`
184
+ ].join("\n\n");
185
+ }
186
+
187
+ function buildFallbackSuggestion(activity: ActivityItem): TopicSuggestion {
188
+ const keywords = uniqueStrings(tokenize(`${activity.title} ${activity.summary}`), 5);
189
+ const title = `${activity.title}를 통해 본 구현 판단과 설계 포인트`;
190
+
191
+ return {
192
+ title,
193
+ reason: `${activity.source} 기록에 실제 작업 흔적이 남아 있고, 제목만으로도 문제의식과 구현 맥락이 비교적 선명하게 드러난다. 기술 블로그 주제로 확장했을 때 실무적인 판단 과정을 담기 좋다.`,
194
+ audience: "비슷한 구현 흐름이나 설계 선택을 고민하는 실무 개발자",
195
+ outline: [
196
+ `${activity.title}를 다루게 된 배경과 문제의식`,
197
+ "구현 과정에서 헷갈렸던 지점과 판단 기준",
198
+ "선택한 접근 방식과 그 이유",
199
+ "비슷한 상황에서 재사용할 수 있는 체크포인트"
200
+ ],
201
+ draft: buildPlaceholderDraft(activity),
202
+ keywords,
203
+ evidenceIds: [activity.id],
204
+ score: 70 + Math.min(keywords.length, 3) * 5
205
+ };
206
+ }
207
+
208
+ function sanitizeSuggestion(
209
+ payload: SuggestionPayload,
210
+ availableActivityIds: Set<string>
211
+ ): TopicSuggestion | null {
212
+ if (typeof payload.title !== "string" || typeof payload.reason !== "string") {
213
+ return null;
214
+ }
215
+
216
+ const title = payload.title.trim();
217
+ const reason = payload.reason.trim();
218
+ const audience =
219
+ typeof payload.audience === "string" && payload.audience.trim()
220
+ ? payload.audience.trim()
221
+ : "비슷한 문제를 겪는 개발자";
222
+ const outline = uniqueStrings(toStringArray(payload.outline), 4);
223
+ const draft =
224
+ typeof payload.draft === "string" && payload.draft.trim()
225
+ ? payload.draft.trim()
226
+ : "";
227
+ const keywords = uniqueStrings(toStringArray(payload.keywords), 5);
228
+ const evidenceIds = uniqueStrings(
229
+ toStringArray(payload.evidenceIds).filter((item) => availableActivityIds.has(item)),
230
+ 3
231
+ );
232
+
233
+ if (!title || !reason || !draft || evidenceIds.length === 0) {
234
+ return null;
235
+ }
236
+
237
+ return {
238
+ title,
239
+ reason,
240
+ audience,
241
+ outline:
242
+ outline.length > 0
243
+ ? outline
244
+ : [
245
+ "이 주제를 다루게 된 배경",
246
+ "구현 중 실제로 겪은 문제",
247
+ "해결 과정에서의 핵심 판단",
248
+ "같은 문제를 다시 만났을 때의 체크포인트"
249
+ ],
250
+ draft,
251
+ keywords: keywords.length > 0 ? keywords : uniqueStrings(tokenize(title), 5),
252
+ evidenceIds,
253
+ score: clampScore(payload.score, 75)
254
+ };
255
+ }
256
+
257
+ export class DeterministicTopicRecommender implements TopicRecommender {
258
+ async recommend(input: RecommendInput): Promise<TopicSuggestion[]> {
259
+ const historyTitles = new Set(input.history.map((item) => item.title.trim().toLowerCase()));
260
+ const historyKeywords = input.history.map((item) =>
261
+ item.relatedKeywords.map((keyword) => keyword.toLowerCase())
262
+ );
263
+
264
+ const suggestions = input.activities
265
+ .filter((activity) => !shouldSkipByHistory(activity.title, historyTitles, historyKeywords))
266
+ .map((activity) => buildFallbackSuggestion(activity))
267
+ .sort((left, right) => right.score - left.score)
268
+ .slice(0, input.count);
269
+
270
+ if (suggestions.length === 0 && input.activities.length > 0) {
271
+ logWarn("Placeholder recommender returned no suggestions because recent topic history filtered them out.");
272
+ }
273
+
274
+ return suggestions;
275
+ }
276
+ }
277
+
278
+ export class GeminiTopicRecommender implements TopicRecommender {
279
+ constructor(private readonly config: AppConfig) {
280
+ if (!config.geminiApiKey) {
281
+ throw new Error(
282
+ "GEMINI_API_KEY or GOOGLE_API_KEY is required when LLM_PROVIDER=gemini."
283
+ );
284
+ }
285
+ }
286
+
287
+ async recommend(input: RecommendInput): Promise<TopicSuggestion[]> {
288
+ if (input.activities.length === 0) {
289
+ return [];
290
+ }
291
+
292
+ const apiKey = this.config.geminiApiKey;
293
+ if (!apiKey) {
294
+ throw new Error("GEMINI_API_KEY or GOOGLE_API_KEY is required when LLM_PROVIDER=gemini.");
295
+ }
296
+
297
+ const historyTitles = new Set(input.history.map((item) => item.title.trim().toLowerCase()));
298
+ const historyKeywords = input.history.map((item) =>
299
+ item.relatedKeywords.map((keyword) => keyword.toLowerCase())
300
+ );
301
+ const prompt = buildTopicRecommendationPrompt(input);
302
+ const availableActivityIds = new Set(input.activities.map((activity) => activity.id));
303
+
304
+ const response = await fetch(
305
+ `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
306
+ this.config.geminiModel
307
+ )}:generateContent`,
308
+ {
309
+ method: "POST",
310
+ headers: {
311
+ "Content-Type": "application/json",
312
+ "x-goog-api-key": apiKey
313
+ },
314
+ body: JSON.stringify({
315
+ contents: [
316
+ {
317
+ role: "user",
318
+ parts: [{ text: prompt }]
319
+ }
320
+ ],
321
+ generationConfig: {
322
+ responseMimeType: "application/json",
323
+ responseJsonSchema: GEMINI_RESPONSE_SCHEMA
324
+ }
325
+ })
326
+ }
327
+ );
328
+
329
+ if (!response.ok) {
330
+ let errorMessage = `Gemini API request failed with status ${response.status}.`;
331
+ try {
332
+ const payload = (await response.json()) as GeminiErrorPayload;
333
+ const details = payload.error?.message?.trim();
334
+ if (details) {
335
+ errorMessage = `Gemini API request failed with status ${response.status}: ${details}`;
336
+ }
337
+ } catch {
338
+ const details = await response.text();
339
+ if (details.trim()) {
340
+ errorMessage = `Gemini API request failed with status ${response.status}: ${details.trim()}`;
341
+ }
342
+ }
343
+
344
+ throw new Error(errorMessage);
345
+ }
346
+
347
+ const payload = (await response.json()) as GeminiGenerateContentResponse;
348
+ const outputText = payload.candidates
349
+ ?.flatMap((candidate) => candidate.content?.parts ?? [])
350
+ .map((part) => part.text ?? "")
351
+ .join("")
352
+ .trim();
353
+
354
+ if (!outputText) {
355
+ throw new Error("Gemini returned an empty response.");
356
+ }
357
+
358
+ let parsed: RecommendationPayload;
359
+ try {
360
+ parsed = JSON.parse(outputText) as RecommendationPayload;
361
+ } catch (error) {
362
+ throw new Error(`Gemini returned invalid JSON: ${String(error)}`);
363
+ }
364
+
365
+ if (!Array.isArray(parsed.suggestions)) {
366
+ throw new Error("Gemini response did not contain a suggestions array.");
367
+ }
368
+
369
+ const sanitizedSuggestions = parsed.suggestions
370
+ .map((item) => sanitizeSuggestion(item as SuggestionPayload, availableActivityIds))
371
+ .filter((item): item is TopicSuggestion => item !== null);
372
+
373
+ const suggestions = sanitizedSuggestions
374
+ .filter((item) => !shouldSkipByHistory(item.title, historyTitles, historyKeywords))
375
+ .filter((item, index, array) => {
376
+ return (
377
+ array.findIndex(
378
+ (candidate) => candidate.title.trim().toLowerCase() === item.title.trim().toLowerCase()
379
+ ) === index
380
+ );
381
+ })
382
+ .slice(0, input.count);
383
+
384
+ if (suggestions.length === 0) {
385
+ if (sanitizedSuggestions.length === 0) {
386
+ logWarn("Gemini returned no usable suggestions after validation.");
387
+ } else {
388
+ logWarn(
389
+ `Gemini returned ${sanitizedSuggestions.length} validated suggestions, but all were filtered out by recent topic history.`
390
+ );
391
+ }
392
+ }
393
+
394
+ return suggestions;
395
+ }
396
+ }
397
+
398
+ export function createTopicRecommender(config: AppConfig): TopicRecommender {
399
+ if (config.llmProvider === "placeholder") {
400
+ return new DeterministicTopicRecommender();
401
+ }
402
+
403
+ return new GeminiTopicRecommender(config);
404
+ }