@kansei-link/bantou 0.2.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/LICENSE +21 -0
- package/README.md +110 -0
- package/data/exclusion-rules/README.md +104 -0
- package/data/exclusion-rules/jp-tax-baseline-v1.json +185 -0
- package/data/exclusion-rules-schema.json +109 -0
- package/data/keyword-dict/README.md +91 -0
- package/data/keyword-dict/jp-tax-baseline-v1.json +398 -0
- package/data/keyword-dict-schema.json +117 -0
- package/data/tax-rules/jp-tax-rules-v1.json +170 -0
- package/dist/adapters/csv-parser.d.ts +11 -0
- package/dist/adapters/csv-parser.js +133 -0
- package/dist/adapters/freee-csv-adapter.d.ts +14 -0
- package/dist/adapters/freee-csv-adapter.js +67 -0
- package/dist/adapters/generic-adapter.d.ts +20 -0
- package/dist/adapters/generic-adapter.js +73 -0
- package/dist/adapters/index.d.ts +23 -0
- package/dist/adapters/index.js +386 -0
- package/dist/adapters/types.d.ts +111 -0
- package/dist/adapters/types.js +9 -0
- package/dist/adapters/yayoi-adapter.d.ts +46 -0
- package/dist/adapters/yayoi-adapter.js +181 -0
- package/dist/bin/freee-doctor.d.ts +3 -0
- package/dist/bin/freee-doctor.js +15 -0
- package/dist/classifier/claude-classifier.d.ts +24 -0
- package/dist/classifier/claude-classifier.js +154 -0
- package/dist/classifier/keyword-classifier.d.ts +22 -0
- package/dist/classifier/keyword-classifier.js +124 -0
- package/dist/classifier/keyword-match.d.ts +21 -0
- package/dist/classifier/keyword-match.js +57 -0
- package/dist/classifier/normalize.d.ts +3 -0
- package/dist/classifier/normalize.js +27 -0
- package/dist/classifier/two-stage-classifier.d.ts +21 -0
- package/dist/classifier/two-stage-classifier.js +51 -0
- package/dist/classifier/types.d.ts +31 -0
- package/dist/classifier/types.js +3 -0
- package/dist/connectors/freee.d.ts +115 -0
- package/dist/connectors/freee.js +177 -0
- package/dist/exclusion/exclusion-checker.d.ts +10 -0
- package/dist/exclusion/exclusion-checker.js +162 -0
- package/dist/freee-doctor.d.ts +26 -0
- package/dist/freee-doctor.js +82 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +656 -0
- package/dist/memory/cockpit-memory.d.ts +73 -0
- package/dist/memory/cockpit-memory.js +473 -0
- package/dist/memory/types.d.ts +114 -0
- package/dist/memory/types.js +11 -0
- package/dist/pipeline/confidence-router.d.ts +38 -0
- package/dist/pipeline/confidence-router.js +129 -0
- package/dist/pipeline/nightly-pipeline.d.ts +44 -0
- package/dist/pipeline/nightly-pipeline.js +497 -0
- package/dist/pipeline/types.d.ts +84 -0
- package/dist/pipeline/types.js +12 -0
- package/dist/reports/monthly-report.d.ts +64 -0
- package/dist/reports/monthly-report.js +230 -0
- package/dist/secrets.d.ts +14 -0
- package/dist/secrets.js +86 -0
- package/dist/tax-rules/tax-rule-engine.d.ts +103 -0
- package/dist/tax-rules/tax-rule-engine.js +449 -0
- package/dist/tax-rules/types.d.ts +103 -0
- package/dist/tax-rules/types.js +7 -0
- package/package.json +74 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Transaction } from '../classifier/types.js';
|
|
2
|
+
import { MemoryStats, ClassificationPattern, CorrectionRecord, CompanyMemoryConfig, PatternRecallResult, CorrectionInput } from './types.js';
|
|
3
|
+
export declare class CockpitMemory {
|
|
4
|
+
private store;
|
|
5
|
+
private storePath;
|
|
6
|
+
private dirty;
|
|
7
|
+
constructor(storePath?: string);
|
|
8
|
+
/**
|
|
9
|
+
* Recall a past classification pattern for a transaction.
|
|
10
|
+
*
|
|
11
|
+
* Priority: corrections (caveat) > patterns (implementation).
|
|
12
|
+
* Rule: "過去パターン一致 → AI 処理 OK"
|
|
13
|
+
*/
|
|
14
|
+
recallPattern(tx: Transaction): PatternRecallResult;
|
|
15
|
+
/**
|
|
16
|
+
* Recall company-specific routing config.
|
|
17
|
+
*/
|
|
18
|
+
recallCompanyConfig(companyId: number): CompanyMemoryConfig | null;
|
|
19
|
+
/**
|
|
20
|
+
* Remember a classification result for future pattern matching.
|
|
21
|
+
* Called after successful classification (auto_register or auto_register_with_log).
|
|
22
|
+
*/
|
|
23
|
+
rememberClassification(tx: Transaction, categoryId: string, categoryNameJa: string, confidence: string, source: 'keyword' | 'claude', freeeAccountCode?: number, taxCode?: number): void;
|
|
24
|
+
/**
|
|
25
|
+
* Remember a tax accountant correction (caveat layer = never forgotten).
|
|
26
|
+
* Corrections override future pattern matches.
|
|
27
|
+
*/
|
|
28
|
+
rememberCorrection(input: CorrectionInput): CorrectionRecord;
|
|
29
|
+
/**
|
|
30
|
+
* Set company-specific routing configuration (context layer).
|
|
31
|
+
*/
|
|
32
|
+
setCompanyConfig(config: CompanyMemoryConfig): void;
|
|
33
|
+
/** Flush dirty store to disk. Call after each company batch. */
|
|
34
|
+
save(): void;
|
|
35
|
+
/** Get current stats. */
|
|
36
|
+
getStats(): MemoryStats;
|
|
37
|
+
/** Get total pattern count. */
|
|
38
|
+
getPatternCount(): number;
|
|
39
|
+
/** Get total correction count. */
|
|
40
|
+
getCorrectionCount(): number;
|
|
41
|
+
/** Get all corrections (for display/export). */
|
|
42
|
+
getCorrections(): CorrectionRecord[];
|
|
43
|
+
/** Get all patterns for a partner key (for display/export). */
|
|
44
|
+
getPatterns(partnerKey?: string): ClassificationPattern[];
|
|
45
|
+
private findCorrection;
|
|
46
|
+
private findPattern;
|
|
47
|
+
private bestPatternMatch;
|
|
48
|
+
private findExactPattern;
|
|
49
|
+
private loadStore;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create a normalized lookup key from partner name or memo.
|
|
53
|
+
*
|
|
54
|
+
* Priority: partner_name (if non-empty) → first 3 significant memo keywords.
|
|
55
|
+
* Uses the same normalization as the keyword classifier for consistency.
|
|
56
|
+
*/
|
|
57
|
+
export declare function makePartnerKey(partnerName?: string, memo?: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* Extract significant keywords from a memo string.
|
|
60
|
+
* Splits on whitespace/punctuation, normalizes, removes short words.
|
|
61
|
+
*/
|
|
62
|
+
export declare function extractKeywords(memo: string): string[];
|
|
63
|
+
/**
|
|
64
|
+
* Check if amount is within ±tolerance of typical amount.
|
|
65
|
+
* Practitioner rule: ±5%.
|
|
66
|
+
*/
|
|
67
|
+
export declare function amountInRange(actual: number, typical: number, tolerance?: number): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Check if a date is within the recency window.
|
|
70
|
+
* Practitioner rule: 3 months.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isRecent(dateStr: string, monthsBack?: number): boolean;
|
|
73
|
+
//# sourceMappingURL=cockpit-memory.d.ts.map
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// Cockpit Memory — persistent classification pattern store.
|
|
2
|
+
//
|
|
3
|
+
// Implements the "判断の境界線" pattern:
|
|
4
|
+
// "過去 3 ヶ月以内に同じ取引先 + 同じ金額 ±5% で同じ category → AI 自動 OK"
|
|
5
|
+
// "新規 → 確認"
|
|
6
|
+
// "修正 → 次回以降は修正後の分類を使う"
|
|
7
|
+
//
|
|
8
|
+
// Storage: JSON file at ~/.cockpit-mcp/memory.json (configurable).
|
|
9
|
+
// Data model mirrors Linksee's 6-layer model:
|
|
10
|
+
// implementation → classification patterns
|
|
11
|
+
// caveat → corrections (never forgotten)
|
|
12
|
+
// context → company-specific configs
|
|
13
|
+
//
|
|
14
|
+
// Thread safety: Node.js single-threaded + sequential deal processing = safe.
|
|
15
|
+
// Save strategy: caller invokes save() after each company batch.
|
|
16
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
17
|
+
import { join, dirname } from 'path';
|
|
18
|
+
import { homedir } from 'os';
|
|
19
|
+
import { normalizeMemo } from '../classifier/normalize.js';
|
|
20
|
+
// ============================================================
|
|
21
|
+
// Constants
|
|
22
|
+
// ============================================================
|
|
23
|
+
const DEFAULT_STORE_DIR = join(homedir(), '.cockpit-mcp');
|
|
24
|
+
const DEFAULT_STORE_PATH = join(DEFAULT_STORE_DIR, 'memory.json');
|
|
25
|
+
const STORE_VERSION = '1.0.0';
|
|
26
|
+
/** Practitioner rule: patterns older than 3 months are stale */
|
|
27
|
+
const PATTERN_RECENCY_MONTHS = 3;
|
|
28
|
+
/** Practitioner rule: ±5% amount tolerance */
|
|
29
|
+
const AMOUNT_TOLERANCE = 0.05;
|
|
30
|
+
/** Minimum keyword overlap for memo-based matching */
|
|
31
|
+
const MIN_KEYWORD_OVERLAP = 2;
|
|
32
|
+
// ============================================================
|
|
33
|
+
// CockpitMemory
|
|
34
|
+
// ============================================================
|
|
35
|
+
export class CockpitMemory {
|
|
36
|
+
store;
|
|
37
|
+
storePath;
|
|
38
|
+
dirty = false;
|
|
39
|
+
constructor(storePath) {
|
|
40
|
+
this.storePath = storePath || DEFAULT_STORE_PATH;
|
|
41
|
+
this.store = this.loadStore();
|
|
42
|
+
}
|
|
43
|
+
// ──────────────────────────────────────────────────────────
|
|
44
|
+
// Recall operations
|
|
45
|
+
// ──────────────────────────────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* Recall a past classification pattern for a transaction.
|
|
48
|
+
*
|
|
49
|
+
* Priority: corrections (caveat) > patterns (implementation).
|
|
50
|
+
* Rule: "過去パターン一致 → AI 処理 OK"
|
|
51
|
+
*/
|
|
52
|
+
recallPattern(tx) {
|
|
53
|
+
const partnerKey = makePartnerKey(tx.partner_name, tx.memo);
|
|
54
|
+
// 1. Check corrections first (caveat layer = never forgotten)
|
|
55
|
+
const correction = this.findCorrection(partnerKey, tx.memo, tx.company_id);
|
|
56
|
+
if (correction) {
|
|
57
|
+
this.store.stats.correction_hits++;
|
|
58
|
+
this.dirty = true;
|
|
59
|
+
return {
|
|
60
|
+
found: true,
|
|
61
|
+
correction,
|
|
62
|
+
source: 'correction',
|
|
63
|
+
confidence: 'high', // corrections are always high confidence
|
|
64
|
+
reason: `修正パターン一致: "${correction.memo_pattern}" → ${correction.to_category_name_ja} (理由: ${correction.reason})`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// 2. Check patterns (implementation layer)
|
|
68
|
+
const pattern = this.findPattern(partnerKey, tx.amount, tx.memo, tx.company_id);
|
|
69
|
+
if (pattern) {
|
|
70
|
+
this.store.stats.pattern_hits++;
|
|
71
|
+
this.dirty = true;
|
|
72
|
+
return {
|
|
73
|
+
found: true,
|
|
74
|
+
pattern,
|
|
75
|
+
source: 'pattern',
|
|
76
|
+
confidence: pattern.match_count >= 3 ? 'high' : 'medium',
|
|
77
|
+
reason: `過去パターン一致: "${pattern.partner_key}" × ${pattern.match_count}回 → ${pattern.category_name_ja}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// 3. No match
|
|
81
|
+
this.store.stats.cache_misses++;
|
|
82
|
+
this.dirty = true;
|
|
83
|
+
return {
|
|
84
|
+
found: false,
|
|
85
|
+
source: 'none',
|
|
86
|
+
confidence: 'low',
|
|
87
|
+
reason: '過去パターンなし — 通常分類フローへ',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Recall company-specific routing config.
|
|
92
|
+
*/
|
|
93
|
+
recallCompanyConfig(companyId) {
|
|
94
|
+
return this.store.company_configs[companyId] || null;
|
|
95
|
+
}
|
|
96
|
+
// ──────────────────────────────────────────────────────────
|
|
97
|
+
// Remember operations
|
|
98
|
+
// ──────────────────────────────────────────────────────────
|
|
99
|
+
/**
|
|
100
|
+
* Remember a classification result for future pattern matching.
|
|
101
|
+
* Called after successful classification (auto_register or auto_register_with_log).
|
|
102
|
+
*/
|
|
103
|
+
rememberClassification(tx, categoryId, categoryNameJa, confidence, source, freeeAccountCode, taxCode) {
|
|
104
|
+
const partnerKey = makePartnerKey(tx.partner_name, tx.memo);
|
|
105
|
+
const memoKeywords = extractKeywords(tx.memo);
|
|
106
|
+
// Look for existing pattern with same partner_key + category_id
|
|
107
|
+
const existing = this.findExactPattern(partnerKey, categoryId);
|
|
108
|
+
if (existing) {
|
|
109
|
+
// Update existing pattern
|
|
110
|
+
existing.match_count++;
|
|
111
|
+
existing.last_seen = new Date().toISOString().slice(0, 10);
|
|
112
|
+
existing.amount_typical = rollingAverage(existing.amount_typical, tx.amount, existing.match_count);
|
|
113
|
+
if (tx.company_id && !existing.company_ids.includes(tx.company_id)) {
|
|
114
|
+
existing.company_ids.push(tx.company_id);
|
|
115
|
+
}
|
|
116
|
+
// Merge keywords
|
|
117
|
+
for (const kw of memoKeywords) {
|
|
118
|
+
if (!existing.memo_keywords.includes(kw)) {
|
|
119
|
+
existing.memo_keywords.push(kw);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// Create new pattern
|
|
125
|
+
const pattern = {
|
|
126
|
+
partner_key: partnerKey,
|
|
127
|
+
partner_name: tx.partner_name,
|
|
128
|
+
memo_sample: tx.memo,
|
|
129
|
+
memo_keywords: memoKeywords,
|
|
130
|
+
category_id: categoryId,
|
|
131
|
+
category_name_ja: categoryNameJa,
|
|
132
|
+
freee_account_code: freeeAccountCode,
|
|
133
|
+
tax_code: taxCode,
|
|
134
|
+
amount_typical: tx.amount,
|
|
135
|
+
confidence_source: source,
|
|
136
|
+
match_count: 1,
|
|
137
|
+
first_seen: new Date().toISOString().slice(0, 10),
|
|
138
|
+
last_seen: new Date().toISOString().slice(0, 10),
|
|
139
|
+
company_ids: tx.company_id ? [tx.company_id] : [],
|
|
140
|
+
};
|
|
141
|
+
if (!this.store.patterns[partnerKey]) {
|
|
142
|
+
this.store.patterns[partnerKey] = [];
|
|
143
|
+
}
|
|
144
|
+
this.store.patterns[partnerKey].push(pattern);
|
|
145
|
+
this.store.stats.total_patterns++;
|
|
146
|
+
}
|
|
147
|
+
this.dirty = true;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Remember a tax accountant correction (caveat layer = never forgotten).
|
|
151
|
+
* Corrections override future pattern matches.
|
|
152
|
+
*/
|
|
153
|
+
rememberCorrection(input) {
|
|
154
|
+
const partnerKey = makePartnerKey(input.partner_name, input.memo_pattern);
|
|
155
|
+
const id = `corr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
156
|
+
const record = {
|
|
157
|
+
id,
|
|
158
|
+
partner_key: partnerKey,
|
|
159
|
+
memo_pattern: input.memo_pattern,
|
|
160
|
+
from_category_id: input.from_category_id,
|
|
161
|
+
from_category_name_ja: input.from_category_name_ja,
|
|
162
|
+
to_category_id: input.to_category_id,
|
|
163
|
+
to_category_name_ja: input.to_category_name_ja,
|
|
164
|
+
to_freee_account_code: input.to_freee_account_code,
|
|
165
|
+
to_tax_code: input.to_tax_code,
|
|
166
|
+
reason: input.reason,
|
|
167
|
+
corrected_at: new Date().toISOString(),
|
|
168
|
+
company_id: input.company_id,
|
|
169
|
+
active: true,
|
|
170
|
+
};
|
|
171
|
+
this.store.corrections.push(record);
|
|
172
|
+
this.store.stats.total_corrections++;
|
|
173
|
+
this.dirty = true;
|
|
174
|
+
// Also update/create a pattern with correction source so pattern recall
|
|
175
|
+
// picks it up for amount matching
|
|
176
|
+
if (!this.store.patterns[partnerKey]) {
|
|
177
|
+
this.store.patterns[partnerKey] = [];
|
|
178
|
+
}
|
|
179
|
+
// Check if we already have a pattern for the corrected category
|
|
180
|
+
const existingCorrPattern = this.store.patterns[partnerKey]
|
|
181
|
+
.find(p => p.category_id === input.to_category_id && p.confidence_source === 'correction');
|
|
182
|
+
if (!existingCorrPattern) {
|
|
183
|
+
this.store.patterns[partnerKey].push({
|
|
184
|
+
partner_key: partnerKey,
|
|
185
|
+
partner_name: input.partner_name,
|
|
186
|
+
memo_sample: input.memo_pattern,
|
|
187
|
+
memo_keywords: extractKeywords(input.memo_pattern),
|
|
188
|
+
category_id: input.to_category_id,
|
|
189
|
+
category_name_ja: input.to_category_name_ja,
|
|
190
|
+
freee_account_code: input.to_freee_account_code,
|
|
191
|
+
tax_code: input.to_tax_code,
|
|
192
|
+
amount_typical: 0, // will be updated on first match
|
|
193
|
+
confidence_source: 'correction',
|
|
194
|
+
match_count: 1,
|
|
195
|
+
first_seen: new Date().toISOString().slice(0, 10),
|
|
196
|
+
last_seen: new Date().toISOString().slice(0, 10),
|
|
197
|
+
company_ids: input.company_id ? [input.company_id] : [],
|
|
198
|
+
});
|
|
199
|
+
this.store.stats.total_patterns++;
|
|
200
|
+
}
|
|
201
|
+
return record;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Set company-specific routing configuration (context layer).
|
|
205
|
+
*/
|
|
206
|
+
setCompanyConfig(config) {
|
|
207
|
+
this.store.company_configs[config.company_id] = {
|
|
208
|
+
...config,
|
|
209
|
+
updated_at: new Date().toISOString(),
|
|
210
|
+
};
|
|
211
|
+
this.dirty = true;
|
|
212
|
+
}
|
|
213
|
+
// ──────────────────────────────────────────────────────────
|
|
214
|
+
// Persistence
|
|
215
|
+
// ──────────────────────────────────────────────────────────
|
|
216
|
+
/** Flush dirty store to disk. Call after each company batch. */
|
|
217
|
+
save() {
|
|
218
|
+
if (!this.dirty)
|
|
219
|
+
return;
|
|
220
|
+
this.store.updated_at = new Date().toISOString();
|
|
221
|
+
this.store.stats.last_save = this.store.updated_at;
|
|
222
|
+
const dir = dirname(this.storePath);
|
|
223
|
+
if (!existsSync(dir)) {
|
|
224
|
+
mkdirSync(dir, { recursive: true });
|
|
225
|
+
}
|
|
226
|
+
writeFileSync(this.storePath, JSON.stringify(this.store, null, 2), 'utf-8');
|
|
227
|
+
this.dirty = false;
|
|
228
|
+
}
|
|
229
|
+
/** Get current stats. */
|
|
230
|
+
getStats() {
|
|
231
|
+
return { ...this.store.stats };
|
|
232
|
+
}
|
|
233
|
+
/** Get total pattern count. */
|
|
234
|
+
getPatternCount() {
|
|
235
|
+
return this.store.stats.total_patterns;
|
|
236
|
+
}
|
|
237
|
+
/** Get total correction count. */
|
|
238
|
+
getCorrectionCount() {
|
|
239
|
+
return this.store.stats.total_corrections;
|
|
240
|
+
}
|
|
241
|
+
/** Get all corrections (for display/export). */
|
|
242
|
+
getCorrections() {
|
|
243
|
+
return this.store.corrections.filter(c => c.active);
|
|
244
|
+
}
|
|
245
|
+
/** Get all patterns for a partner key (for display/export). */
|
|
246
|
+
getPatterns(partnerKey) {
|
|
247
|
+
if (partnerKey) {
|
|
248
|
+
return this.store.patterns[partnerKey] || [];
|
|
249
|
+
}
|
|
250
|
+
return Object.values(this.store.patterns).flat();
|
|
251
|
+
}
|
|
252
|
+
// ──────────────────────────────────────────────────────────
|
|
253
|
+
// Internal: pattern matching
|
|
254
|
+
// ──────────────────────────────────────────────────────────
|
|
255
|
+
findCorrection(partnerKey, memo, companyId) {
|
|
256
|
+
const normalizedMemo = normalizeMemo(memo);
|
|
257
|
+
// Search corrections in reverse chronological order (newest first)
|
|
258
|
+
for (let i = this.store.corrections.length - 1; i >= 0; i--) {
|
|
259
|
+
const corr = this.store.corrections[i];
|
|
260
|
+
if (!corr.active)
|
|
261
|
+
continue;
|
|
262
|
+
// Company filter: correction applies if company_id matches or is null (= global)
|
|
263
|
+
if (corr.company_id && companyId && corr.company_id !== companyId)
|
|
264
|
+
continue;
|
|
265
|
+
// Match by partner_key OR memo_pattern.
|
|
266
|
+
// Corrections are the caveat layer — must match broadly to prevent repeat errors.
|
|
267
|
+
// Partner key match (exact or prefix — correction key may be shorter)
|
|
268
|
+
const keyMatch = corr.partner_key === partnerKey
|
|
269
|
+
|| partnerKey.startsWith(corr.partner_key + '_')
|
|
270
|
+
|| corr.partner_key.startsWith(partnerKey + '_');
|
|
271
|
+
// Memo pattern match (substring in either direction)
|
|
272
|
+
const normalizedPattern = normalizeMemo(corr.memo_pattern);
|
|
273
|
+
const memoMatch = normalizedPattern.length >= 2
|
|
274
|
+
&& (normalizedMemo.includes(normalizedPattern) || normalizedPattern.includes(normalizedMemo));
|
|
275
|
+
if (keyMatch || memoMatch) {
|
|
276
|
+
return corr;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
findPattern(partnerKey, amount, memo, companyId) {
|
|
282
|
+
// 1. Direct partner_key lookup
|
|
283
|
+
const candidates = this.store.patterns[partnerKey];
|
|
284
|
+
if (candidates && candidates.length > 0) {
|
|
285
|
+
const match = this.bestPatternMatch(candidates, amount, companyId);
|
|
286
|
+
if (match)
|
|
287
|
+
return match;
|
|
288
|
+
}
|
|
289
|
+
// 2. Memo keyword fallback (when partner_key didn't match directly)
|
|
290
|
+
const memoKeywords = extractKeywords(memo);
|
|
291
|
+
if (memoKeywords.length < MIN_KEYWORD_OVERLAP)
|
|
292
|
+
return null;
|
|
293
|
+
for (const [key, patterns] of Object.entries(this.store.patterns)) {
|
|
294
|
+
if (key === partnerKey)
|
|
295
|
+
continue; // already checked
|
|
296
|
+
for (const p of patterns) {
|
|
297
|
+
const overlap = countKeywordOverlap(memoKeywords, p.memo_keywords);
|
|
298
|
+
if (overlap >= MIN_KEYWORD_OVERLAP && isRecent(p.last_seen)) {
|
|
299
|
+
// Also check amount range for keyword-based matches
|
|
300
|
+
if (p.amount_typical > 0 && amountInRange(amount, p.amount_typical)) {
|
|
301
|
+
return p;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
bestPatternMatch(candidates, amount, companyId) {
|
|
309
|
+
let best = null;
|
|
310
|
+
let bestScore = -1;
|
|
311
|
+
for (const p of candidates) {
|
|
312
|
+
// Recency check
|
|
313
|
+
if (!isRecent(p.last_seen))
|
|
314
|
+
continue;
|
|
315
|
+
// Amount range check (skip if amount_typical is 0 = correction placeholder)
|
|
316
|
+
if (p.amount_typical > 0 && !amountInRange(amount, p.amount_typical))
|
|
317
|
+
continue;
|
|
318
|
+
// Company filter: if pattern is a company-specific correction and query
|
|
319
|
+
// company doesn't match, skip it (correction for different company)
|
|
320
|
+
if (p.confidence_source === 'correction'
|
|
321
|
+
&& p.company_ids.length > 0
|
|
322
|
+
&& companyId
|
|
323
|
+
&& !p.company_ids.includes(companyId)) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
// Score: correction source > match_count > recency
|
|
327
|
+
let score = p.match_count;
|
|
328
|
+
if (p.confidence_source === 'correction')
|
|
329
|
+
score += 1000; // corrections always win
|
|
330
|
+
if (companyId && p.company_ids.includes(companyId))
|
|
331
|
+
score += 100; // company-specific bonus
|
|
332
|
+
if (score > bestScore) {
|
|
333
|
+
bestScore = score;
|
|
334
|
+
best = p;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return best;
|
|
338
|
+
}
|
|
339
|
+
findExactPattern(partnerKey, categoryId) {
|
|
340
|
+
const patterns = this.store.patterns[partnerKey];
|
|
341
|
+
if (!patterns)
|
|
342
|
+
return null;
|
|
343
|
+
return patterns.find(p => p.category_id === categoryId) || null;
|
|
344
|
+
}
|
|
345
|
+
// ──────────────────────────────────────────────────────────
|
|
346
|
+
// Internal: persistence
|
|
347
|
+
// ──────────────────────────────────────────────────────────
|
|
348
|
+
loadStore() {
|
|
349
|
+
try {
|
|
350
|
+
if (existsSync(this.storePath)) {
|
|
351
|
+
const raw = readFileSync(this.storePath, 'utf-8');
|
|
352
|
+
const parsed = JSON.parse(raw);
|
|
353
|
+
// Validate version
|
|
354
|
+
if (parsed.version === STORE_VERSION) {
|
|
355
|
+
return parsed;
|
|
356
|
+
}
|
|
357
|
+
// Version mismatch — migrate or start fresh
|
|
358
|
+
console.error(`[cockpit-memory] Store version mismatch: ${parsed.version} vs ${STORE_VERSION}, starting fresh`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
console.error(`[cockpit-memory] Failed to load store: ${err}`);
|
|
363
|
+
}
|
|
364
|
+
return emptyStore();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// ============================================================
|
|
368
|
+
// Pure helper functions
|
|
369
|
+
// ============================================================
|
|
370
|
+
function emptyStore() {
|
|
371
|
+
return {
|
|
372
|
+
version: STORE_VERSION,
|
|
373
|
+
created_at: new Date().toISOString(),
|
|
374
|
+
updated_at: new Date().toISOString(),
|
|
375
|
+
patterns: {},
|
|
376
|
+
corrections: [],
|
|
377
|
+
company_configs: {},
|
|
378
|
+
stats: {
|
|
379
|
+
total_patterns: 0,
|
|
380
|
+
total_corrections: 0,
|
|
381
|
+
pattern_hits: 0,
|
|
382
|
+
correction_hits: 0,
|
|
383
|
+
cache_misses: 0,
|
|
384
|
+
last_save: '',
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Create a normalized lookup key from partner name or memo.
|
|
390
|
+
*
|
|
391
|
+
* Priority: partner_name (if non-empty) → first 3 significant memo keywords.
|
|
392
|
+
* Uses the same normalization as the keyword classifier for consistency.
|
|
393
|
+
*/
|
|
394
|
+
export function makePartnerKey(partnerName, memo) {
|
|
395
|
+
if (partnerName && partnerName.trim()) {
|
|
396
|
+
let key = normalizeMemo(partnerName.trim());
|
|
397
|
+
// Remove common suffixes (株式会社, (株), 有限会社 etc.)
|
|
398
|
+
key = key
|
|
399
|
+
.replace(/[((]株[))]/g, '')
|
|
400
|
+
.replace(/株式会社/g, '')
|
|
401
|
+
.replace(/有限会社/g, '')
|
|
402
|
+
.replace(/合同会社/g, '')
|
|
403
|
+
.trim();
|
|
404
|
+
return key || '_unknown';
|
|
405
|
+
}
|
|
406
|
+
// Fallback: extract significant keywords from memo
|
|
407
|
+
const keywords = extractKeywords(memo || '');
|
|
408
|
+
if (keywords.length > 0) {
|
|
409
|
+
return keywords.slice(0, 3).join('_');
|
|
410
|
+
}
|
|
411
|
+
return '_unknown';
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Extract significant keywords from a memo string.
|
|
415
|
+
* Splits on whitespace/punctuation, normalizes, removes short words.
|
|
416
|
+
*/
|
|
417
|
+
export function extractKeywords(memo) {
|
|
418
|
+
const normalized = normalizeMemo(memo);
|
|
419
|
+
return normalized
|
|
420
|
+
.split(/[\s・\/\-_,、。]+/)
|
|
421
|
+
.map(w => w.trim())
|
|
422
|
+
.filter(w => w.length >= 2)
|
|
423
|
+
.filter(w => !STOP_WORDS.has(w));
|
|
424
|
+
}
|
|
425
|
+
/** Common words that don't help with matching. */
|
|
426
|
+
const STOP_WORDS = new Set([
|
|
427
|
+
'the', 'and', 'for', 'from', 'with', 'this', 'that',
|
|
428
|
+
'から', 'まで', 'への', 'です', 'ます', 'した', 'する',
|
|
429
|
+
'分', '月', '月分', '件', '回',
|
|
430
|
+
]);
|
|
431
|
+
/**
|
|
432
|
+
* Check if amount is within ±tolerance of typical amount.
|
|
433
|
+
* Practitioner rule: ±5%.
|
|
434
|
+
*/
|
|
435
|
+
export function amountInRange(actual, typical, tolerance = AMOUNT_TOLERANCE) {
|
|
436
|
+
if (typical === 0)
|
|
437
|
+
return true; // no typical amount recorded yet
|
|
438
|
+
const lower = typical * (1 - tolerance);
|
|
439
|
+
const upper = typical * (1 + tolerance);
|
|
440
|
+
return actual >= lower && actual <= upper;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Check if a date is within the recency window.
|
|
444
|
+
* Practitioner rule: 3 months.
|
|
445
|
+
*/
|
|
446
|
+
export function isRecent(dateStr, monthsBack = PATTERN_RECENCY_MONTHS) {
|
|
447
|
+
const date = new Date(dateStr);
|
|
448
|
+
if (isNaN(date.getTime()))
|
|
449
|
+
return false;
|
|
450
|
+
const cutoff = new Date();
|
|
451
|
+
cutoff.setMonth(cutoff.getMonth() - monthsBack);
|
|
452
|
+
return date >= cutoff;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Count how many keywords from queryWords appear in patternWords.
|
|
456
|
+
*/
|
|
457
|
+
function countKeywordOverlap(queryWords, patternWords) {
|
|
458
|
+
let count = 0;
|
|
459
|
+
for (const qw of queryWords) {
|
|
460
|
+
if (patternWords.includes(qw))
|
|
461
|
+
count++;
|
|
462
|
+
}
|
|
463
|
+
return count;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Rolling average that converges toward recent values.
|
|
467
|
+
*/
|
|
468
|
+
function rollingAverage(current, newVal, count) {
|
|
469
|
+
// Weighted toward recent: weight = min(0.3, 1/count)
|
|
470
|
+
const weight = Math.min(0.3, 1 / count);
|
|
471
|
+
return current * (1 - weight) + newVal * weight;
|
|
472
|
+
}
|
|
473
|
+
//# sourceMappingURL=cockpit-memory.js.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** A learned classification pattern — "we classified this before, here's what we decided." */
|
|
2
|
+
export interface ClassificationPattern {
|
|
3
|
+
/** Normalized lookup key (from partner_name or memo keywords) */
|
|
4
|
+
partner_key: string;
|
|
5
|
+
/** Original partner name (for display) */
|
|
6
|
+
partner_name?: string;
|
|
7
|
+
/** First memo that created this pattern (for reference) */
|
|
8
|
+
memo_sample: string;
|
|
9
|
+
/** Extracted keywords from memo (for fuzzy matching when partner is absent) */
|
|
10
|
+
memo_keywords: string[];
|
|
11
|
+
/** Category assigned */
|
|
12
|
+
category_id: string;
|
|
13
|
+
category_name_ja: string;
|
|
14
|
+
/** freee-specific codes (if known) */
|
|
15
|
+
freee_account_code?: number;
|
|
16
|
+
tax_code?: number;
|
|
17
|
+
/** Typical amount for this pattern */
|
|
18
|
+
amount_typical: number;
|
|
19
|
+
/** Where this classification came from originally */
|
|
20
|
+
confidence_source: 'keyword' | 'claude' | 'correction';
|
|
21
|
+
/** How many times this pattern has been matched */
|
|
22
|
+
match_count: number;
|
|
23
|
+
/** Timestamps */
|
|
24
|
+
first_seen: string;
|
|
25
|
+
last_seen: string;
|
|
26
|
+
/** Companies where this pattern was observed */
|
|
27
|
+
company_ids: number[];
|
|
28
|
+
}
|
|
29
|
+
/** A tax accountant correction — "this was wrong, use this instead." */
|
|
30
|
+
export interface CorrectionRecord {
|
|
31
|
+
/** Unique ID */
|
|
32
|
+
id: string;
|
|
33
|
+
/** Normalized partner key (for lookup) */
|
|
34
|
+
partner_key: string;
|
|
35
|
+
/** Memo substring that triggers this correction */
|
|
36
|
+
memo_pattern: string;
|
|
37
|
+
/** What it was classified as (wrong) */
|
|
38
|
+
from_category_id?: string;
|
|
39
|
+
from_category_name_ja?: string;
|
|
40
|
+
/** What it should be (correct) */
|
|
41
|
+
to_category_id: string;
|
|
42
|
+
to_category_name_ja: string;
|
|
43
|
+
to_freee_account_code?: number;
|
|
44
|
+
to_tax_code?: number;
|
|
45
|
+
/** Why the correction was made */
|
|
46
|
+
reason: string;
|
|
47
|
+
/** When the correction was recorded */
|
|
48
|
+
corrected_at: string;
|
|
49
|
+
/** Company-specific (null = applies to all companies) */
|
|
50
|
+
company_id?: number;
|
|
51
|
+
/** Whether this correction is still active */
|
|
52
|
+
active: boolean;
|
|
53
|
+
}
|
|
54
|
+
/** Per-company routing overrides — "this company needs special treatment." */
|
|
55
|
+
export interface CompanyMemoryConfig {
|
|
56
|
+
company_id: number;
|
|
57
|
+
company_name?: string;
|
|
58
|
+
/** Override: transactions above this amount → human_review */
|
|
59
|
+
high_amount_threshold?: number;
|
|
60
|
+
/** Override: minimum confidence for auto_register */
|
|
61
|
+
auto_register_min_confidence?: 'high' | 'medium';
|
|
62
|
+
/** Freeform notes */
|
|
63
|
+
notes?: string;
|
|
64
|
+
updated_at: string;
|
|
65
|
+
}
|
|
66
|
+
/** Result of recalling a past pattern or correction for a transaction. */
|
|
67
|
+
export interface PatternRecallResult {
|
|
68
|
+
/** Whether a pattern or correction was found */
|
|
69
|
+
found: boolean;
|
|
70
|
+
/** The matched pattern (if any) */
|
|
71
|
+
pattern?: ClassificationPattern;
|
|
72
|
+
/** The matched correction (if any — takes priority over pattern) */
|
|
73
|
+
correction?: CorrectionRecord;
|
|
74
|
+
/** What was used: 'correction' > 'pattern' > 'none' */
|
|
75
|
+
source: 'pattern' | 'correction' | 'none';
|
|
76
|
+
/** Effective confidence for routing */
|
|
77
|
+
confidence: 'high' | 'medium' | 'low';
|
|
78
|
+
/** Human-readable explanation */
|
|
79
|
+
reason: string;
|
|
80
|
+
}
|
|
81
|
+
export interface MemoryStore {
|
|
82
|
+
version: string;
|
|
83
|
+
created_at: string;
|
|
84
|
+
updated_at: string;
|
|
85
|
+
/** Patterns indexed by partner_key. Multiple patterns per key (different amounts/categories). */
|
|
86
|
+
patterns: Record<string, ClassificationPattern[]>;
|
|
87
|
+
/** All corrections (searched sequentially; corrections are rare, linear scan is fine). */
|
|
88
|
+
corrections: CorrectionRecord[];
|
|
89
|
+
/** Company-specific configs indexed by company_id. */
|
|
90
|
+
company_configs: Record<number, CompanyMemoryConfig>;
|
|
91
|
+
/** Aggregate stats for monitoring. */
|
|
92
|
+
stats: MemoryStats;
|
|
93
|
+
}
|
|
94
|
+
export interface MemoryStats {
|
|
95
|
+
total_patterns: number;
|
|
96
|
+
total_corrections: number;
|
|
97
|
+
pattern_hits: number;
|
|
98
|
+
correction_hits: number;
|
|
99
|
+
cache_misses: number;
|
|
100
|
+
last_save: string;
|
|
101
|
+
}
|
|
102
|
+
export interface CorrectionInput {
|
|
103
|
+
memo_pattern: string;
|
|
104
|
+
partner_name?: string;
|
|
105
|
+
from_category_id?: string;
|
|
106
|
+
from_category_name_ja?: string;
|
|
107
|
+
to_category_id: string;
|
|
108
|
+
to_category_name_ja: string;
|
|
109
|
+
to_freee_account_code?: number;
|
|
110
|
+
to_tax_code?: number;
|
|
111
|
+
reason: string;
|
|
112
|
+
company_id?: number;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Memory types for Cockpit MCP's Linksee Memory integration.
|
|
2
|
+
//
|
|
3
|
+
// Inspired by Linksee's 6-layer model:
|
|
4
|
+
// - implementation layer → ClassificationPattern (HOW we classified before)
|
|
5
|
+
// - caveat layer → CorrectionRecord (PAIN: never repeat this mistake)
|
|
6
|
+
// - context layer → CompanyMemoryConfig (WHY-THIS-NOW per company)
|
|
7
|
+
//
|
|
8
|
+
// The memory enables the "判断の境界線" pattern:
|
|
9
|
+
// "過去パターン一致 → AI 処理 OK、新規 → 確認"
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=types.js.map
|