@wooojin/forgen 0.4.7 → 0.4.9
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +40 -0
- package/assets/dev-guide/be/README.md +226 -0
- package/assets/dev-guide/be/adapters/build-agents-md.sh +63 -0
- package/assets/dev-guide/be/principles/common.md +433 -0
- package/assets/dev-guide/be/principles/go.md +469 -0
- package/assets/dev-guide/be/principles/node.md +388 -0
- package/assets/dev-guide/be/skills/go/be-build/SKILL.md +262 -0
- package/assets/dev-guide/be/skills/go/be-perf/SKILL.md +308 -0
- package/assets/dev-guide/be/skills/go/be-review/SKILL.md +119 -0
- package/assets/dev-guide/be/skills/go/be-security/SKILL.md +362 -0
- package/assets/dev-guide/be/skills/node/be-build/SKILL.md +239 -0
- package/assets/dev-guide/be/skills/node/be-perf/SKILL.md +272 -0
- package/assets/dev-guide/be/skills/node/be-review/SKILL.md +118 -0
- package/assets/dev-guide/be/skills/node/be-security/SKILL.md +355 -0
- package/assets/dev-guide/be/sources/12factor/INDEX.md +53 -0
- package/assets/dev-guide/be/sources/api-design/INDEX.md +56 -0
- package/assets/dev-guide/be/sources/ddia/INDEX.md +55 -0
- package/assets/dev-guide/be/sources/go-runtime/INDEX.md +62 -0
- package/assets/dev-guide/be/sources/node-runtime/INDEX.md +60 -0
- package/assets/dev-guide/be/sources/otel/INDEX.md +53 -0
- package/assets/dev-guide/be/sources/owasp-api/INDEX.md +52 -0
- package/assets/dev-guide/be/sources/postgres/INDEX.md +55 -0
- package/assets/dev-guide/be/sources/sre-book/INDEX.md +48 -0
- package/assets/dev-guide/fe/README.md +197 -0
- package/assets/dev-guide/fe/adapters/build-agents-md.sh +63 -0
- package/assets/dev-guide/fe/adapters/refresh.sh +68 -0
- package/assets/dev-guide/fe/principles/common.md +160 -0
- package/assets/dev-guide/fe/principles/react.md +183 -0
- package/assets/dev-guide/fe/principles/vue.md +196 -0
- package/assets/dev-guide/fe/skills/react/fe-build/SKILL.md +139 -0
- package/assets/dev-guide/fe/skills/react/fe-perf/SKILL.md +179 -0
- package/assets/dev-guide/fe/skills/react/fe-review/SKILL.md +141 -0
- package/assets/dev-guide/fe/skills/vue/fe-build/SKILL.md +148 -0
- package/assets/dev-guide/fe/skills/vue/fe-perf/SKILL.md +163 -0
- package/assets/dev-guide/fe/skills/vue/fe-review/SKILL.md +136 -0
- package/assets/dev-guide/fe/sources/a11y-dx/INDEX.md +41 -0
- package/assets/dev-guide/fe/sources/a11y-dx/chrome-devtools-memory.md +150 -0
- package/assets/dev-guide/fe/sources/a11y-dx/chrome-devtools-performance.md +99 -0
- package/assets/dev-guide/fe/sources/a11y-dx/lighthouse-audits.md +146 -0
- package/assets/dev-guide/fe/sources/a11y-dx/react-devtools-profiler.md +128 -0
- package/assets/dev-guide/fe/sources/a11y-dx/wcag22-new-criteria.md +174 -0
- package/assets/dev-guide/fe/sources/perf/01-core-web-vitals.md +58 -0
- package/assets/dev-guide/fe/sources/perf/02-inp.md +83 -0
- package/assets/dev-guide/fe/sources/perf/03-lcp-cls.md +130 -0
- package/assets/dev-guide/fe/sources/perf/04-speculation-rules.md +148 -0
- package/assets/dev-guide/fe/sources/perf/05-view-transitions.md +153 -0
- package/assets/dev-guide/fe/sources/perf/06-nextjs-caching.md +188 -0
- package/assets/dev-guide/fe/sources/perf/07-server-components.md +181 -0
- package/assets/dev-guide/fe/sources/perf/08-ppr.md +133 -0
- package/assets/dev-guide/fe/sources/perf/09-nextjs-image.md +200 -0
- package/assets/dev-guide/fe/sources/perf/10-optimize-lcp.md +201 -0
- package/assets/dev-guide/fe/sources/perf/INDEX.md +88 -0
- package/assets/dev-guide/fe/sources/react/INDEX.md +41 -0
- package/assets/dev-guide/fe/sources/react/keeping-components-pure.md +135 -0
- package/assets/dev-guide/fe/sources/react/no-effect-patterns.md +183 -0
- package/assets/dev-guide/fe/sources/react/react-compiler.md +182 -0
- package/assets/dev-guide/fe/sources/react/server-components.md +194 -0
- package/assets/dev-guide/fe/sources/react/server-functions.md +192 -0
- package/assets/dev-guide/fe/sources/react/suspense.md +218 -0
- package/assets/dev-guide/fe/sources/react/use-action-state.md +123 -0
- package/assets/dev-guide/fe/sources/react/use-form-status.md +158 -0
- package/assets/dev-guide/fe/sources/react/use-hook.md +153 -0
- package/assets/dev-guide/fe/sources/react/use-optimistic.md +194 -0
- package/assets/dev-guide/fe/sources/toss-ff/INDEX.md +58 -0
- package/assets/dev-guide/fe/sources/toss-ff/cohesion-code-directory.md +79 -0
- package/assets/dev-guide/fe/sources/toss-ff/cohesion-form-fields.md +110 -0
- package/assets/dev-guide/fe/sources/toss-ff/cohesion-magic-number.md +47 -0
- package/assets/dev-guide/fe/sources/toss-ff/coupling-item-edit-modal.md +124 -0
- package/assets/dev-guide/fe/sources/toss-ff/coupling-use-bottom-sheet.md +57 -0
- package/assets/dev-guide/fe/sources/toss-ff/coupling-use-page-state.md +71 -0
- package/assets/dev-guide/fe/sources/toss-ff/overview-4-principles.md +77 -0
- package/assets/dev-guide/fe/sources/toss-ff/predictability-hidden-logic.md +59 -0
- package/assets/dev-guide/fe/sources/toss-ff/predictability-http.md +77 -0
- package/assets/dev-guide/fe/sources/toss-ff/predictability-use-user.md +110 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-comparison-order.md +52 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-condition-name.md +64 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-login-start-page.md +183 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-magic-number.md +53 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-submit-button.md +73 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-ternary-operator.md +38 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-use-page-state.md +77 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-user-policy.md +98 -0
- package/assets/dev-guide/fe/sources/vue/INDEX.md +17 -0
- package/assets/dev-guide/fe/sources/vue/composition-api.md +251 -0
- package/assets/dev-guide/fe/sources/vue/nuxt-data-fetching.md +232 -0
- package/assets/dev-guide/fe/sources/vue/pinia-state-management.md +134 -0
- package/assets/dev-guide/fe/sources/vue/reactivity-pitfalls.md +261 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-a.md +117 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-b.md +231 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-c.md +86 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-d.md +72 -0
- package/dist/checks/self-score-deflation.js +6 -4
- package/dist/cli.js +47 -2
- package/dist/core/auto-compound-runner.js +6 -2
- package/dist/core/dashboard-cli.d.ts +12 -0
- package/dist/core/dashboard-cli.js +226 -0
- package/dist/core/dashboard.js +2 -2
- package/dist/core/dev-guide-injector.d.ts +26 -0
- package/dist/core/dev-guide-injector.js +137 -0
- package/dist/core/doctor.d.ts +10 -0
- package/dist/core/doctor.js +49 -8
- package/dist/core/harness.js +8 -2
- package/dist/core/init.js +53 -0
- package/dist/core/inspect-cli.js +4 -4
- package/dist/core/lifecycle-classifier.d.ts +23 -0
- package/dist/core/lifecycle-classifier.js +104 -0
- package/dist/core/migrate-evidence-host.js +1 -1
- package/dist/core/notify.js +7 -0
- package/dist/core/observability-backfill.d.ts +31 -0
- package/dist/core/observability-backfill.js +178 -0
- package/dist/core/observability-store.d.ts +58 -0
- package/dist/core/observability-store.js +195 -0
- package/dist/core/paths.d.ts +16 -2
- package/dist/core/paths.js +16 -2
- package/dist/core/session-store.d.ts +12 -1
- package/dist/core/session-store.js +77 -1
- package/dist/core/spawn.d.ts +17 -0
- package/dist/core/spawn.js +191 -8
- package/dist/core/statusline-cli.js +34 -1
- package/dist/core/v1-bootstrap.d.ts +7 -0
- package/dist/core/v1-bootstrap.js +28 -6
- package/dist/engine/compound-extractor.js +40 -1
- package/dist/engine/compound-loop.js +6 -0
- package/dist/engine/compound-retire.d.ts +20 -0
- package/dist/engine/compound-retire.js +85 -0
- package/dist/engine/learn-cli.js +2 -2
- package/dist/engine/lifecycle/bypass-detector.js +3 -2
- package/dist/engine/lifecycle/meta-reclassifier.js +1 -1
- package/dist/engine/lifecycle/signals.js +2 -2
- package/dist/engine/lifecycle/trigger-t1-correction.js +1 -1
- package/dist/engine/solution-candidate.js +1 -1
- package/dist/engine/solution-outcomes.js +1 -1
- package/dist/engine/solution-quarantine.js +1 -1
- package/dist/engine/solution-weakness.js +8 -2
- package/dist/forge/cli.js +1 -1
- package/dist/hooks/context-guard.js +25 -1
- package/dist/hooks/keyword-detector.js +1 -1
- package/dist/hooks/post-tool-use.js +48 -0
- package/dist/hooks/secret-filter.js +2 -2
- package/dist/hooks/shared/hook-response.js +1 -1
- package/dist/hooks/shared/hook-timing.js +3 -3
- package/dist/hooks/solution-injector.js +94 -1
- package/dist/hooks/stop-guard.js +3 -3
- package/dist/host/install-claude.d.ts +6 -2
- package/dist/host/install-claude.js +74 -2
- package/dist/host/install-codex.d.ts +4 -0
- package/dist/host/install-codex.js +72 -1
- package/dist/host/install-orchestrator.js +1 -0
- package/dist/mcp/tools.js +1 -1
- package/dist/preset/facet-catalog.js +2 -2
- package/dist/renderer/rule-renderer.js +7 -7
- package/dist/store/compound-usage-store.js +1 -1
- package/dist/store/implicit-feedback-store.js +2 -2
- package/dist/store/profile-store.d.ts +11 -0
- package/dist/store/profile-store.js +23 -0
- package/package.json +6 -6
- package/plugin.json +1 -1
- package/scripts/postinstall.js +134 -0
package/dist/core/inspect-cli.js
CHANGED
|
@@ -21,7 +21,7 @@ export async function handleInspect(args) {
|
|
|
21
21
|
console.log('\n No v1 profile found. Run onboarding first.\n');
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
|
-
console.log(
|
|
24
|
+
console.log(`\n${inspect.renderProfile(profile)}\n`);
|
|
25
25
|
// ── Learning Loop Status ──
|
|
26
26
|
const activeRules = loadActiveRules();
|
|
27
27
|
const rulesByScope = {
|
|
@@ -75,13 +75,13 @@ export async function handleInspect(args) {
|
|
|
75
75
|
}
|
|
76
76
|
if (sub === 'rules') {
|
|
77
77
|
const rules = loadAllRules();
|
|
78
|
-
console.log(
|
|
78
|
+
console.log(`\n${inspect.renderRules(rules)}\n`);
|
|
79
79
|
return;
|
|
80
80
|
}
|
|
81
81
|
// R9-IA2: user-facing name is "corrections"; "evidence" kept as back-compat alias.
|
|
82
82
|
if (sub === 'corrections' || sub === 'evidence') {
|
|
83
83
|
const evidence = loadRecentEvidence(20);
|
|
84
|
-
console.log(
|
|
84
|
+
console.log(`\n${inspect.renderEvidence(evidence)}\n`);
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
87
|
if (sub === 'session') {
|
|
@@ -90,7 +90,7 @@ export async function handleInspect(args) {
|
|
|
90
90
|
console.log('\n No session state found.\n');
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
|
-
console.log(
|
|
93
|
+
console.log(`\n${inspect.renderSession(sessions[0])}\n`);
|
|
94
94
|
return;
|
|
95
95
|
}
|
|
96
96
|
// R5-G1: 2AM 디버깅용 jsonl tail — violations/bypass/drift
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Lifecycle Classifier (P3)
|
|
3
|
+
*
|
|
4
|
+
* 솔루션 catalog (~/.forgen/me/solutions/*.md) 를 읽어 각 솔루션의
|
|
5
|
+
* lifecycle 을 hot/warm/cold/dead/new 로 분류한다.
|
|
6
|
+
*/
|
|
7
|
+
import type { HitRateRow } from './observability-store.js';
|
|
8
|
+
export type Lifecycle = 'hot' | 'warm' | 'cold' | 'dead' | 'new';
|
|
9
|
+
export interface LifecycleClass {
|
|
10
|
+
solutionId: string;
|
|
11
|
+
lifecycle: Lifecycle;
|
|
12
|
+
/** acted_90d / max(surfaced_90d, 1). surfaced_90d == 0 이면 null */
|
|
13
|
+
hitRate: number | null;
|
|
14
|
+
matched_90d: number;
|
|
15
|
+
surfaced_90d: number;
|
|
16
|
+
acted_90d: number;
|
|
17
|
+
matched_180d: number;
|
|
18
|
+
ageDays: number;
|
|
19
|
+
}
|
|
20
|
+
/** 분류 로직 — §5.2 */
|
|
21
|
+
export declare function classifyOne(_solutionId: string, ageDays: number, rates: HitRateRow): Lifecycle;
|
|
22
|
+
/** ~/.forgen/me/solutions/*.md 전체를 분류하여 반환 */
|
|
23
|
+
export declare function classifySolutions(): LifecycleClass[];
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Lifecycle Classifier (P3)
|
|
3
|
+
*
|
|
4
|
+
* 솔루션 catalog (~/.forgen/me/solutions/*.md) 를 읽어 각 솔루션의
|
|
5
|
+
* lifecycle 을 hot/warm/cold/dead/new 로 분류한다.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { ME_SOLUTIONS } from './paths.js';
|
|
10
|
+
import { parseFrontmatterOnly } from '../engine/solution-format.js';
|
|
11
|
+
import { queryHitRate } from './observability-store.js';
|
|
12
|
+
/** 분류 로직 — §5.2 */
|
|
13
|
+
export function classifyOne(_solutionId, ageDays, rates) {
|
|
14
|
+
// new: age ≤ 30d
|
|
15
|
+
if (ageDays <= 30)
|
|
16
|
+
return 'new';
|
|
17
|
+
// dead: matched_180d == 0 AND age > 30d
|
|
18
|
+
if (rates.matched_180d === 0)
|
|
19
|
+
return 'dead';
|
|
20
|
+
// hot: acted_90d >= 3 AND (acted_90d / max(surfaced_90d, 1)) >= 0.4
|
|
21
|
+
const hitRate = rates.acted_90d / Math.max(rates.surfaced_90d, 1);
|
|
22
|
+
if (rates.acted_90d >= 3 && hitRate >= 0.4)
|
|
23
|
+
return 'hot';
|
|
24
|
+
// warm: surfaced_90d >= 3 AND acted_90d >= 1
|
|
25
|
+
if (rates.surfaced_90d >= 3 && rates.acted_90d >= 1)
|
|
26
|
+
return 'warm';
|
|
27
|
+
// cold: matched_90d >= 1 AND surfaced_90d == 0
|
|
28
|
+
if (rates.matched_90d >= 1 && rates.surfaced_90d === 0)
|
|
29
|
+
return 'cold';
|
|
30
|
+
// fallback
|
|
31
|
+
return 'cold';
|
|
32
|
+
}
|
|
33
|
+
/** ~/.forgen/me/solutions/*.md 전체를 분류하여 반환 */
|
|
34
|
+
export function classifySolutions() {
|
|
35
|
+
const results = [];
|
|
36
|
+
let files;
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(ME_SOLUTIONS))
|
|
39
|
+
return [];
|
|
40
|
+
files = fs.readdirSync(ME_SOLUTIONS).filter(f => f.endsWith('.md'));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
// queryHitRate() 는 전체 결과를 한 번에 가져옴
|
|
46
|
+
const rateMap = new Map();
|
|
47
|
+
try {
|
|
48
|
+
const rows = queryHitRate();
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
rateMap.set(row.solutionId, row);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// fail-open: DB 없으면 빈 map
|
|
55
|
+
}
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const filePath = path.join(ME_SOLUTIONS, file);
|
|
59
|
+
let content;
|
|
60
|
+
try {
|
|
61
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const fm = parseFrontmatterOnly(content);
|
|
67
|
+
if (!fm)
|
|
68
|
+
continue;
|
|
69
|
+
const solutionId = fm.name;
|
|
70
|
+
// ageDays: frontmatter created date 에서 계산
|
|
71
|
+
let ageDays = 999;
|
|
72
|
+
try {
|
|
73
|
+
const createdMs = new Date(fm.created).getTime();
|
|
74
|
+
if (!isNaN(createdMs)) {
|
|
75
|
+
ageDays = Math.floor((now - createdMs) / (24 * 60 * 60 * 1000));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// keep 999 (treat as old)
|
|
80
|
+
}
|
|
81
|
+
const rates = rateMap.get(solutionId) ?? {
|
|
82
|
+
solutionId,
|
|
83
|
+
matched_30d: 0, surfaced_30d: 0, acted_30d: 0,
|
|
84
|
+
matched_90d: 0, surfaced_90d: 0, acted_90d: 0,
|
|
85
|
+
matched_180d: 0, surfaced_180d: 0, acted_180d: 0,
|
|
86
|
+
last_event_ts: 0,
|
|
87
|
+
};
|
|
88
|
+
const lifecycle = classifyOne(solutionId, ageDays, rates);
|
|
89
|
+
const hitRate = rates.surfaced_90d > 0
|
|
90
|
+
? rates.acted_90d / rates.surfaced_90d
|
|
91
|
+
: null;
|
|
92
|
+
results.push({
|
|
93
|
+
solutionId,
|
|
94
|
+
lifecycle,
|
|
95
|
+
hitRate,
|
|
96
|
+
matched_90d: rates.matched_90d,
|
|
97
|
+
surfaced_90d: rates.surfaced_90d,
|
|
98
|
+
acted_90d: rates.acted_90d,
|
|
99
|
+
matched_180d: rates.matched_180d,
|
|
100
|
+
ageDays,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return results;
|
|
104
|
+
}
|
package/dist/core/notify.js
CHANGED
|
@@ -37,6 +37,9 @@ function notifyDarwin(title, body) {
|
|
|
37
37
|
try {
|
|
38
38
|
const script = `display notification "${escapeForOsascript(body)}" with title "${escapeForOsascript(title)}"`;
|
|
39
39
|
const child = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
|
|
40
|
+
// ENOENT 등 spawn 실패는 동기 throw 가 아니라 'error' event 로 emit 됨.
|
|
41
|
+
// 핸들러 없으면 unhandled 로 process crash → headless CI 회귀 가드.
|
|
42
|
+
child.on('error', (e) => log.debug('osascript notification 실패 (event)', e));
|
|
40
43
|
child.unref();
|
|
41
44
|
}
|
|
42
45
|
catch (e) {
|
|
@@ -47,6 +50,10 @@ function notifyDarwin(title, body) {
|
|
|
47
50
|
function notifyLinux(title, body) {
|
|
48
51
|
try {
|
|
49
52
|
const child = spawn('notify-send', [title, body], { detached: true, stdio: 'ignore' });
|
|
53
|
+
// headless 환경 (CI, Docker) 에서 notify-send 부재 시 ENOENT 가 'error' event 로 emit.
|
|
54
|
+
// 핸들러 없으면 unhandled 로 caller 프로세스 죽음 — rate-limit-spawn-integration
|
|
55
|
+
// CI 실패의 사전 존재 원인.
|
|
56
|
+
child.on('error', (e) => log.debug('notify-send 실패 (event)', e));
|
|
50
57
|
child.unref();
|
|
51
58
|
}
|
|
52
59
|
catch (e) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Observability Backfill (Phase 2)
|
|
3
|
+
*
|
|
4
|
+
* 기존 JSONL 상태 파일에서 solution_events 를 소급 생성한다.
|
|
5
|
+
* Phase A (결정론적): match-eval-log, implicit-feedback, compound-usage, outcomes
|
|
6
|
+
* Phase B (휴리스틱): transcript 스캔 — CLI --phase B|all 로만 활성
|
|
7
|
+
*
|
|
8
|
+
* 안전성:
|
|
9
|
+
* - 기본: events 가 이미 있으면 reject (--force 필요)
|
|
10
|
+
* - signal_source 에 '-backfill' prefix 로 실시간 emit 과 구분
|
|
11
|
+
* - BEGIN/COMMIT 단위 트랜잭션
|
|
12
|
+
* - fail-open: 파일 누락은 조용히 skip
|
|
13
|
+
*/
|
|
14
|
+
export interface BackfillOptions {
|
|
15
|
+
force?: boolean;
|
|
16
|
+
phase?: 'A' | 'B' | 'all';
|
|
17
|
+
dryRun?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface BackfillResult {
|
|
20
|
+
phaseA: {
|
|
21
|
+
matched: number;
|
|
22
|
+
surfaced: number;
|
|
23
|
+
acted_on: number;
|
|
24
|
+
};
|
|
25
|
+
phaseB: {
|
|
26
|
+
acted_on: number;
|
|
27
|
+
};
|
|
28
|
+
total: number;
|
|
29
|
+
durationMs: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function runBackfill(opts?: BackfillOptions): Promise<BackfillResult>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Observability Backfill (Phase 2)
|
|
3
|
+
*
|
|
4
|
+
* 기존 JSONL 상태 파일에서 solution_events 를 소급 생성한다.
|
|
5
|
+
* Phase A (결정론적): match-eval-log, implicit-feedback, compound-usage, outcomes
|
|
6
|
+
* Phase B (휴리스틱): transcript 스캔 — CLI --phase B|all 로만 활성
|
|
7
|
+
*
|
|
8
|
+
* 안전성:
|
|
9
|
+
* - 기본: events 가 이미 있으면 reject (--force 필요)
|
|
10
|
+
* - signal_source 에 '-backfill' prefix 로 실시간 emit 과 구분
|
|
11
|
+
* - BEGIN/COMMIT 단위 트랜잭션
|
|
12
|
+
* - fail-open: 파일 누락은 조용히 skip
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { STATE_DIR, MATCH_EVAL_LOG_PATH, FORGEN_HOME } from './paths.js';
|
|
18
|
+
import { createLogger } from './logger.js';
|
|
19
|
+
import { emitSolutionEvent } from './observability-store.js';
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const log = createLogger('observability-backfill');
|
|
22
|
+
const DB_PATH = path.join(FORGEN_HOME, 'sessions.db');
|
|
23
|
+
const COMPOUND_USAGE_LOG = path.join(STATE_DIR, 'compound-usage.jsonl');
|
|
24
|
+
const IMPLICIT_FEEDBACK_LOG = path.join(STATE_DIR, 'implicit-feedback.jsonl');
|
|
25
|
+
const OUTCOMES_DIR = path.join(STATE_DIR, 'outcomes');
|
|
26
|
+
function readJsonlLines(filePath) {
|
|
27
|
+
if (!fs.existsSync(filePath))
|
|
28
|
+
return [];
|
|
29
|
+
try {
|
|
30
|
+
return fs.readFileSync(filePath, 'utf-8')
|
|
31
|
+
.split('\n')
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.map(l => { try {
|
|
34
|
+
return JSON.parse(l);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
} })
|
|
39
|
+
.filter((x) => x !== null);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function hasExistingEvents() {
|
|
46
|
+
try {
|
|
47
|
+
if (!fs.existsSync(DB_PATH))
|
|
48
|
+
return false;
|
|
49
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
50
|
+
const db = new DatabaseSync(DB_PATH);
|
|
51
|
+
try {
|
|
52
|
+
const row = db.prepare(`SELECT COUNT(*) AS cnt FROM solution_events`).get();
|
|
53
|
+
return (row?.cnt ?? 0) > 0;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
db.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Phase A: 결정론적 소급 */
|
|
64
|
+
async function runPhaseA(opts) {
|
|
65
|
+
const counts = { matched: 0, surfaced: 0, acted_on: 0 };
|
|
66
|
+
// 1. match-eval-log.jsonl → matched events
|
|
67
|
+
const matchLines = readJsonlLines(MATCH_EVAL_LOG_PATH);
|
|
68
|
+
for (const line of matchLines) {
|
|
69
|
+
const sessionId = line.sessionId ?? line.session_id ?? undefined;
|
|
70
|
+
const topN = line.rankedTopN ?? line.candidates?.slice(0, 5).map(c => c.name) ?? [];
|
|
71
|
+
const candidateMap = new Map((line.candidates ?? []).map(c => [c.name, c.relevance]));
|
|
72
|
+
for (const name of topN) {
|
|
73
|
+
if (!name)
|
|
74
|
+
continue;
|
|
75
|
+
const score = candidateMap.get(name) ?? null;
|
|
76
|
+
if (!opts.dryRun) {
|
|
77
|
+
emitSolutionEvent({
|
|
78
|
+
sessionId: sessionId ?? null,
|
|
79
|
+
solutionId: name,
|
|
80
|
+
eventType: 'matched',
|
|
81
|
+
signalSource: 'matcher-backfill',
|
|
82
|
+
signalScore: score,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
counts.matched++;
|
|
86
|
+
if (counts.matched % 1000 === 0)
|
|
87
|
+
process.stderr.write(`[backfill] matched: ${counts.matched}\n`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// 2. implicit-feedback.jsonl type='recommendation_surfaced' → surfaced
|
|
91
|
+
const feedbackLines = readJsonlLines(IMPLICIT_FEEDBACK_LOG);
|
|
92
|
+
for (const line of feedbackLines) {
|
|
93
|
+
if (line.type !== 'recommendation_surfaced')
|
|
94
|
+
continue;
|
|
95
|
+
const sid = line.solutionId ?? line.solution_id;
|
|
96
|
+
if (!sid)
|
|
97
|
+
continue;
|
|
98
|
+
const sessionId = line.sessionId ?? line.session_id ?? undefined;
|
|
99
|
+
if (!opts.dryRun) {
|
|
100
|
+
emitSolutionEvent({
|
|
101
|
+
sessionId: sessionId ?? null,
|
|
102
|
+
solutionId: sid,
|
|
103
|
+
eventType: 'surfaced',
|
|
104
|
+
signalSource: 'hook-backfill',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
counts.surfaced++;
|
|
108
|
+
}
|
|
109
|
+
// 3. compound-usage.jsonl → acted_on (signalSource='mcp-read-backfill')
|
|
110
|
+
const usageLines = readJsonlLines(COMPOUND_USAGE_LOG);
|
|
111
|
+
for (const line of usageLines) {
|
|
112
|
+
if (!line.name)
|
|
113
|
+
continue;
|
|
114
|
+
if (!opts.dryRun) {
|
|
115
|
+
emitSolutionEvent({
|
|
116
|
+
sessionId: null,
|
|
117
|
+
solutionId: line.name,
|
|
118
|
+
eventType: 'acted_on',
|
|
119
|
+
signalSource: 'mcp-read-backfill',
|
|
120
|
+
signalScore: 0.30,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
counts.acted_on++;
|
|
124
|
+
if (counts.acted_on % 1000 === 0)
|
|
125
|
+
process.stderr.write(`[backfill] acted_on: ${counts.acted_on}\n`);
|
|
126
|
+
}
|
|
127
|
+
// 4. outcomes/<sid>.jsonl outcome='accept' → acted_on (signalSource='outcome-accept-backfill')
|
|
128
|
+
if (fs.existsSync(OUTCOMES_DIR)) {
|
|
129
|
+
const outFiles = fs.readdirSync(OUTCOMES_DIR).filter(f => f.endsWith('.jsonl'));
|
|
130
|
+
for (const file of outFiles) {
|
|
131
|
+
const lines = readJsonlLines(path.join(OUTCOMES_DIR, file));
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
if (line.outcome !== 'accept')
|
|
134
|
+
continue;
|
|
135
|
+
const sid = line.solution_id ?? line.solutionId;
|
|
136
|
+
if (!sid)
|
|
137
|
+
continue;
|
|
138
|
+
const sessionId = line.session_id ?? line.sessionId ?? undefined;
|
|
139
|
+
if (!opts.dryRun) {
|
|
140
|
+
emitSolutionEvent({
|
|
141
|
+
sessionId: sessionId ?? null,
|
|
142
|
+
solutionId: sid,
|
|
143
|
+
eventType: 'acted_on',
|
|
144
|
+
signalSource: 'outcome-accept-backfill',
|
|
145
|
+
signalScore: 0.15,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
counts.acted_on++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return counts;
|
|
153
|
+
}
|
|
154
|
+
export async function runBackfill(opts = {}) {
|
|
155
|
+
const start = Date.now();
|
|
156
|
+
const phase = opts.phase ?? 'A';
|
|
157
|
+
if (!opts.force && !opts.dryRun) {
|
|
158
|
+
if (hasExistingEvents()) {
|
|
159
|
+
throw new Error('solution_events 테이블에 기존 이벤트가 있습니다. ' +
|
|
160
|
+
'--force 플래그를 사용하면 강행합니다.');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const phaseA = { matched: 0, surfaced: 0, acted_on: 0 };
|
|
164
|
+
const phaseB = { acted_on: 0 };
|
|
165
|
+
if (phase === 'A' || phase === 'all') {
|
|
166
|
+
const aResult = await runPhaseA(opts);
|
|
167
|
+
phaseA.matched = aResult.matched;
|
|
168
|
+
phaseA.surfaced = aResult.surfaced;
|
|
169
|
+
phaseA.acted_on = aResult.acted_on;
|
|
170
|
+
}
|
|
171
|
+
// Phase B: transcript 스캔 — 1차 release 는 opt-in 만
|
|
172
|
+
if (phase === 'B' || phase === 'all') {
|
|
173
|
+
log.debug('Phase B (transcript scan) 은 현재 미구현 — 향후 릴리스에서 활성화');
|
|
174
|
+
}
|
|
175
|
+
const total = phaseA.matched + phaseA.surfaced + phaseA.acted_on + phaseB.acted_on;
|
|
176
|
+
const durationMs = Date.now() - start;
|
|
177
|
+
return { phaseA, phaseB, total, durationMs };
|
|
178
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Observability Store (Phase 1)
|
|
3
|
+
*
|
|
4
|
+
* solution_events 테이블에 matched/surfaced/acted_on 이벤트를 기록.
|
|
5
|
+
* Fail-open: 모든 함수는 내부 오류를 삼키고 절대 throw 하지 않는다.
|
|
6
|
+
*/
|
|
7
|
+
export type EventType = 'matched' | 'surfaced' | 'acted_on';
|
|
8
|
+
export interface EmitOptions {
|
|
9
|
+
ts?: number;
|
|
10
|
+
sessionId?: string | null;
|
|
11
|
+
solutionId: string;
|
|
12
|
+
eventType: EventType;
|
|
13
|
+
signalSource: string;
|
|
14
|
+
signalScore?: number | null;
|
|
15
|
+
meta?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
export interface SurfacedEvent {
|
|
18
|
+
id: number;
|
|
19
|
+
ts: number;
|
|
20
|
+
sessionId: string | null;
|
|
21
|
+
solutionId: string;
|
|
22
|
+
signalSource: string;
|
|
23
|
+
signalScore: number | null;
|
|
24
|
+
meta: Record<string, unknown> | null;
|
|
25
|
+
}
|
|
26
|
+
export interface HitRateRow {
|
|
27
|
+
solutionId: string;
|
|
28
|
+
matched_30d: number;
|
|
29
|
+
surfaced_30d: number;
|
|
30
|
+
acted_30d: number;
|
|
31
|
+
matched_90d: number;
|
|
32
|
+
surfaced_90d: number;
|
|
33
|
+
acted_90d: number;
|
|
34
|
+
matched_180d: number;
|
|
35
|
+
surfaced_180d: number;
|
|
36
|
+
acted_180d: number;
|
|
37
|
+
last_event_ts: number;
|
|
38
|
+
}
|
|
39
|
+
interface SqliteDb {
|
|
40
|
+
exec(sql: string): void;
|
|
41
|
+
prepare(sql: string): {
|
|
42
|
+
run(...args: unknown[]): {
|
|
43
|
+
lastInsertRowid: number;
|
|
44
|
+
};
|
|
45
|
+
get(...args: unknown[]): unknown;
|
|
46
|
+
all(...args: unknown[]): unknown[];
|
|
47
|
+
};
|
|
48
|
+
close(): void;
|
|
49
|
+
}
|
|
50
|
+
/** solution_events 스키마 및 schema_version 초기화. idempotent. */
|
|
51
|
+
export declare function ensureObservabilitySchema(db: SqliteDb): void;
|
|
52
|
+
/** solution_events 에 이벤트를 기록한다. fail-open (throw 없음). */
|
|
53
|
+
export declare function emitSolutionEvent(opts: EmitOptions): void;
|
|
54
|
+
/** 특정 세션에서 지정 시간 window 내 surfaced 이벤트 조회. */
|
|
55
|
+
export declare function querySurfacedWithin(sessionId: string, minutesWindow: number): SurfacedEvent[];
|
|
56
|
+
/** 30/90/180일 hit-rate 집계. solutionId 지정 시 단일 솔루션만. */
|
|
57
|
+
export declare function queryHitRate(solutionId?: string): HitRateRow[];
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Observability Store (Phase 1)
|
|
3
|
+
*
|
|
4
|
+
* solution_events 테이블에 matched/surfaced/acted_on 이벤트를 기록.
|
|
5
|
+
* Fail-open: 모든 함수는 내부 오류를 삼키고 절대 throw 하지 않는다.
|
|
6
|
+
*/
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { createLogger } from './logger.js';
|
|
11
|
+
import { FORGEN_HOME } from './paths.js';
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const log = createLogger('observability');
|
|
14
|
+
const DB_PATH = path.join(FORGEN_HOME, 'sessions.db');
|
|
15
|
+
const DEDUP_WINDOW_MS = 5 * 60 * 1000;
|
|
16
|
+
/** DB 파일을 열고 SqliteDb 반환. 실패 시 null. */
|
|
17
|
+
function openObsDb() {
|
|
18
|
+
try {
|
|
19
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
20
|
+
const dbDir = path.dirname(DB_PATH);
|
|
21
|
+
if (!fs.existsSync(dbDir))
|
|
22
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
23
|
+
const db = new DatabaseSync(DB_PATH);
|
|
24
|
+
db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;`);
|
|
25
|
+
return db;
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
log.debug('openObsDb 실패', e);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** solution_events 스키마 및 schema_version 초기화. idempotent. */
|
|
33
|
+
export function ensureObservabilitySchema(db) {
|
|
34
|
+
try {
|
|
35
|
+
db.exec(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
37
|
+
component TEXT PRIMARY KEY,
|
|
38
|
+
version INTEGER NOT NULL,
|
|
39
|
+
applied_at INTEGER NOT NULL
|
|
40
|
+
);
|
|
41
|
+
CREATE TABLE IF NOT EXISTS solution_events (
|
|
42
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
43
|
+
ts INTEGER NOT NULL,
|
|
44
|
+
session_id TEXT,
|
|
45
|
+
solution_id TEXT NOT NULL,
|
|
46
|
+
event_type TEXT NOT NULL CHECK(event_type IN ('matched','surfaced','acted_on')),
|
|
47
|
+
signal_source TEXT,
|
|
48
|
+
signal_score REAL,
|
|
49
|
+
meta TEXT
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_se_solution ON solution_events(solution_id, ts);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_se_session ON solution_events(session_id, ts);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_se_type ON solution_events(event_type, ts);
|
|
54
|
+
`);
|
|
55
|
+
const row = db.prepare(`SELECT version FROM schema_version WHERE component = 'observability'`).get();
|
|
56
|
+
if (!row) {
|
|
57
|
+
db.prepare(`INSERT INTO schema_version (component, version, applied_at) VALUES ('observability', 1, ?)`).run(Date.now());
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
log.debug('ensureObservabilitySchema 실패', e);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** solution_events 에 이벤트를 기록한다. fail-open (throw 없음). */
|
|
65
|
+
export function emitSolutionEvent(opts) {
|
|
66
|
+
const ts = opts.ts ?? Date.now();
|
|
67
|
+
let db = null;
|
|
68
|
+
try {
|
|
69
|
+
db = openObsDb();
|
|
70
|
+
if (!db)
|
|
71
|
+
return;
|
|
72
|
+
ensureObservabilitySchema(db);
|
|
73
|
+
// dedup: 직전 5분 내 동일 (session, solution, source) 존재 시 skip
|
|
74
|
+
if (opts.sessionId) {
|
|
75
|
+
const dup = db.prepare(`
|
|
76
|
+
SELECT id FROM solution_events
|
|
77
|
+
WHERE session_id = ? AND solution_id = ? AND signal_source = ?
|
|
78
|
+
AND ts > ?
|
|
79
|
+
LIMIT 1
|
|
80
|
+
`).get(opts.sessionId, opts.solutionId, opts.signalSource, ts - DEDUP_WINDOW_MS);
|
|
81
|
+
if (dup)
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
db.prepare(`
|
|
85
|
+
INSERT INTO solution_events (ts, session_id, solution_id, event_type, signal_source, signal_score, meta)
|
|
86
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
87
|
+
`).run(ts, opts.sessionId ?? null, opts.solutionId, opts.eventType, opts.signalSource, opts.signalScore ?? null, opts.meta ? JSON.stringify(opts.meta) : null);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
log.debug('emitSolutionEvent 실패', e);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
try {
|
|
94
|
+
db?.close();
|
|
95
|
+
}
|
|
96
|
+
catch { /* ignore */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** 특정 세션에서 지정 시간 window 내 surfaced 이벤트 조회. */
|
|
100
|
+
export function querySurfacedWithin(sessionId, minutesWindow) {
|
|
101
|
+
let db = null;
|
|
102
|
+
try {
|
|
103
|
+
db = openObsDb();
|
|
104
|
+
if (!db)
|
|
105
|
+
return [];
|
|
106
|
+
ensureObservabilitySchema(db);
|
|
107
|
+
const since = Date.now() - minutesWindow * 60 * 1000;
|
|
108
|
+
const rows = db.prepare(`
|
|
109
|
+
SELECT id, ts, session_id, solution_id, signal_source, signal_score, meta
|
|
110
|
+
FROM solution_events
|
|
111
|
+
WHERE session_id = ? AND event_type = 'surfaced' AND ts >= ?
|
|
112
|
+
ORDER BY ts DESC
|
|
113
|
+
`).all(sessionId, since);
|
|
114
|
+
return rows.map(r => ({
|
|
115
|
+
id: r.id,
|
|
116
|
+
ts: r.ts,
|
|
117
|
+
sessionId: r.session_id,
|
|
118
|
+
solutionId: r.solution_id,
|
|
119
|
+
signalSource: r.signal_source,
|
|
120
|
+
signalScore: r.signal_score,
|
|
121
|
+
meta: r.meta ? JSON.parse(r.meta) : null,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
log.debug('querySurfacedWithin 실패', e);
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
try {
|
|
130
|
+
db?.close();
|
|
131
|
+
}
|
|
132
|
+
catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** 30/90/180일 hit-rate 집계. solutionId 지정 시 단일 솔루션만. */
|
|
136
|
+
export function queryHitRate(solutionId) {
|
|
137
|
+
let db = null;
|
|
138
|
+
try {
|
|
139
|
+
db = openObsDb();
|
|
140
|
+
if (!db)
|
|
141
|
+
return [];
|
|
142
|
+
ensureObservabilitySchema(db);
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
const cutoff30 = now - 30 * 24 * 60 * 60 * 1000;
|
|
145
|
+
const cutoff90 = now - 90 * 24 * 60 * 60 * 1000;
|
|
146
|
+
const cutoff180 = now - 180 * 24 * 60 * 60 * 1000;
|
|
147
|
+
const where = solutionId ? `WHERE solution_id = ?` : '';
|
|
148
|
+
const baseParams = [
|
|
149
|
+
cutoff30, cutoff30, cutoff30,
|
|
150
|
+
cutoff90, cutoff90, cutoff90,
|
|
151
|
+
cutoff180, cutoff180, cutoff180,
|
|
152
|
+
];
|
|
153
|
+
const params = solutionId ? [...baseParams, solutionId] : baseParams;
|
|
154
|
+
const rows = db.prepare(`
|
|
155
|
+
SELECT
|
|
156
|
+
solution_id,
|
|
157
|
+
SUM(CASE WHEN event_type='matched' AND ts > ? THEN 1 ELSE 0 END) AS matched_30d,
|
|
158
|
+
SUM(CASE WHEN event_type='surfaced' AND ts > ? THEN 1 ELSE 0 END) AS surfaced_30d,
|
|
159
|
+
SUM(CASE WHEN event_type='acted_on' AND ts > ? THEN 1 ELSE 0 END) AS acted_30d,
|
|
160
|
+
SUM(CASE WHEN event_type='matched' AND ts > ? THEN 1 ELSE 0 END) AS matched_90d,
|
|
161
|
+
SUM(CASE WHEN event_type='surfaced' AND ts > ? THEN 1 ELSE 0 END) AS surfaced_90d,
|
|
162
|
+
SUM(CASE WHEN event_type='acted_on' AND ts > ? THEN 1 ELSE 0 END) AS acted_90d,
|
|
163
|
+
SUM(CASE WHEN event_type='matched' AND ts > ? THEN 1 ELSE 0 END) AS matched_180d,
|
|
164
|
+
SUM(CASE WHEN event_type='surfaced' AND ts > ? THEN 1 ELSE 0 END) AS surfaced_180d,
|
|
165
|
+
SUM(CASE WHEN event_type='acted_on' AND ts > ? THEN 1 ELSE 0 END) AS acted_180d,
|
|
166
|
+
MAX(ts) AS last_event_ts
|
|
167
|
+
FROM solution_events
|
|
168
|
+
${where}
|
|
169
|
+
GROUP BY solution_id
|
|
170
|
+
`).all(...params);
|
|
171
|
+
return rows.map(r => ({
|
|
172
|
+
solutionId: r.solution_id,
|
|
173
|
+
matched_30d: r.matched_30d,
|
|
174
|
+
surfaced_30d: r.surfaced_30d,
|
|
175
|
+
acted_30d: r.acted_30d,
|
|
176
|
+
matched_90d: r.matched_90d,
|
|
177
|
+
surfaced_90d: r.surfaced_90d,
|
|
178
|
+
acted_90d: r.acted_90d,
|
|
179
|
+
matched_180d: r.matched_180d,
|
|
180
|
+
surfaced_180d: r.surfaced_180d,
|
|
181
|
+
acted_180d: r.acted_180d,
|
|
182
|
+
last_event_ts: r.last_event_ts,
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
log.debug('queryHitRate 실패', e);
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
try {
|
|
191
|
+
db?.close();
|
|
192
|
+
}
|
|
193
|
+
catch { /* ignore */ }
|
|
194
|
+
}
|
|
195
|
+
}
|