argus-decision-mcp 1.2.0 → 1.3.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.
Files changed (82) hide show
  1. package/README.md +44 -60
  2. package/SECURITY.md +3 -3
  3. package/dist/a0/account-connect.js +180 -0
  4. package/dist/a0/account-credentials.js +86 -0
  5. package/dist/index.js +35 -1
  6. package/dist/lib/ambient-elicit.js +225 -0
  7. package/dist/lib/argus-dir.js +26 -8
  8. package/dist/lib/calendar.js +2 -2
  9. package/dist/lib/due-note.js +3 -3
  10. package/dist/lib/elicit.js +13 -1
  11. package/dist/lib/ledger-replay.js +19 -3
  12. package/dist/lib/locale-mismatch.js +52 -0
  13. package/dist/lib/locale.js +81 -10
  14. package/dist/lib/localize-result.js +120 -0
  15. package/dist/lib/localized-message.js +13 -0
  16. package/dist/lib/premises.js +0 -2
  17. package/dist/lib/push-account.js +4 -3
  18. package/dist/lib/review/extract-core.js +116 -0
  19. package/dist/lib/review/extract-file-node.js +145 -29
  20. package/dist/lib/review/index.js +1 -1
  21. package/dist/lib/review/ingest.js +84 -20
  22. package/dist/lib/review/lenses.js +18 -0
  23. package/dist/lib/review/prompts.js +199 -22
  24. package/dist/lib/review/reviewability.js +8 -7
  25. package/dist/lib/review/routing.js +38 -17
  26. package/dist/lib/review-path.js +2 -2
  27. package/dist/lib/spine.js +13 -21
  28. package/dist/lib/state-machine.js +12 -12
  29. package/dist/lib/surfaces.js +90 -94
  30. package/dist/lib/tool-presentation.js +35 -0
  31. package/dist/resources.js +52 -14
  32. package/dist/server.js +85 -31
  33. package/dist/tools/candidates.js +3 -3
  34. package/dist/tools/check-in.js +24 -12
  35. package/dist/tools/errors.js +17 -3
  36. package/dist/tools/index.js +34 -2
  37. package/dist/tools/init-config.js +39 -11
  38. package/dist/tools/open-decision.js +58 -25
  39. package/dist/tools/premises.js +18 -18
  40. package/dist/tools/public-tools.js +382 -0
  41. package/dist/tools/recall.js +99 -24
  42. package/dist/tools/recheck.js +3 -3
  43. package/dist/tools/review.js +133 -29
  44. package/dist/tools/seal.js +62 -26
  45. package/dist/tools/semantic-record.js +225 -0
  46. package/dist/tools/settle.js +49 -8
  47. package/dist/tools/sync.js +2 -2
  48. package/dist/tools/tool-types.js +174 -0
  49. package/dist/tools/watch.js +1 -1
  50. package/dist/v2/bridge.js +2 -2
  51. package/dist/v2/brief.js +1 -1
  52. package/dist/v2/candidate-capture.js +150 -0
  53. package/dist/v2/capture-cli.js +72 -0
  54. package/dist/v2/capture-runtime.js +73 -0
  55. package/dist/v2/connection-io.js +47 -0
  56. package/dist/v2/connection.js +93 -0
  57. package/dist/v2/evidence.js +5 -1
  58. package/dist/v2/harvest.js +26 -51
  59. package/dist/v2/lifecycle-cli.js +50 -0
  60. package/dist/v2/lifecycle.js +298 -2
  61. package/dist/v2/logbook.js +14 -14
  62. package/dist/v2/mirror.js +2 -2
  63. package/dist/v2/queue.js +71 -12
  64. package/dist/v2/v1-reader.js +6 -3
  65. package/dist/v3/fixtures/dkk-corpus.js +55 -0
  66. package/dist/v3/fixtures/p5-measurement-plan.js +42 -0
  67. package/dist/v3/index.js +5 -0
  68. package/dist/v3/legacy-v2.js +169 -0
  69. package/dist/v3/p5-gate.js +123 -0
  70. package/dist/v3/reducer.js +290 -0
  71. package/dist/v3/store.js +133 -0
  72. package/dist/v3/types.js +193 -0
  73. package/dist/v4/index.js +5 -0
  74. package/dist/v4/reducer.js +361 -0
  75. package/dist/v4/relation-validation.js +40 -0
  76. package/dist/v4/shadow.js +22 -0
  77. package/dist/v4/types.js +326 -0
  78. package/dist/v4/watch.js +18 -0
  79. package/package.json +6 -3
  80. package/dist/lib/discipline.js +0 -42
  81. package/dist/prompts.js +0 -72
  82. package/snippets/claude-code-watch.md +0 -47
@@ -0,0 +1,120 @@
1
+ import { resolveResponseLocale } from './surfaces.js';
2
+ const KO_ERRORS = {
3
+ INVALID_INPUT: { message: '입력값이 올바르지 않습니다.', recovery: '오류가 표시된 인자를 고친 뒤 같은 도구를 다시 호출하세요. 사용자가 정해야 할 값은 추측하지 마세요.' },
4
+ INVALID_LOCALE: { message: '지원하지 않는 언어입니다.', recovery: 'locale에는 "ko" 또는 "en"을 사용하세요.' },
5
+ ALREADY_CLOSED: { message: '이미 진행 중이거나 닫힌 결정입니다.', recovery: '실제 결과를 기록하려면 argus_resolve를 사용하세요. 닫힌 결정은 다시 열지 않습니다.' },
6
+ CAPTURE_NOT_FOUND: { message: '일치하는 내부 메모를 찾지 못했습니다.', recovery: '전제 문장을 text에 직접 전달하세요.' },
7
+ AMBIGUOUS_REF: { message: '여러 내부 메모와 일치해 대상을 정할 수 없습니다.', recovery: '전제 문장을 text에 직접 전달하세요.' },
8
+ PROVENANCE_REQUIRED: { message: '문장의 출처를 확인해야 합니다.', recovery: '사용자가 쓴 문장이면 user_stated, AI가 제기한 문장이면 ai_surfaced와 원문을 전달하세요.' },
9
+ PREMISES_REQUIRED: { message: '추가할 전제가 없습니다.', recovery: 'text, kind, external, load_bearing, source를 포함한 전제 1~5개를 전달하세요.' },
10
+ PREMISE_ID_COLLISION: { message: '다른 전제가 같은 식별자를 사용하고 있습니다.', recovery: '전제 문장을 조금 다르게 표현한 뒤 다시 추가하세요.' },
11
+ PREMISE_CAP: { message: '이 결정에서 추적할 수 있는 전제 수를 넘었습니다.', recovery: '기존 전제 하나를 정리하거나, 덜 중요한 전제를 핵심 전제에 합치세요.' },
12
+ AMEND_NEEDS_REF: { message: '수정할 전제 번호와 작업이 필요합니다.', recovery: 'argus_patterns view="decision_context"로 목록을 확인한 뒤 ref와 action을 전달하세요.' },
13
+ AMEND_NEEDS_TEXT: { message: '수정된 전제 문장이 필요합니다.', recovery: '사용자의 표현을 그대로 text에 전달하세요. 다시 요약하지 마세요.' },
14
+ PREMISE_RETIRED: { message: '이미 닫힌 전제라 수정할 수 없습니다.', recovery: '기존 기록은 그대로 두고 필요한 경우 새 전제를 추가하세요.' },
15
+ RESOLVE_NEEDS_REF: { message: '답을 닫을 미결 질문 번호가 필요합니다.', recovery: 'argus_patterns view="decision_context"로 목록을 확인한 뒤 ref를 전달하세요.' },
16
+ STILL_OPEN_NEEDS_REF: { message: '열어둘 미결 질문 번호가 필요합니다.', recovery: 'argus_patterns view="decision_context"로 목록을 확인한 뒤 ref를 전달하세요.' },
17
+ NOT_AN_OPEN_QUESTION: { message: '이 항목은 미결 질문이 아니라 전제입니다.', recovery: '전제는 argus_capture action="update_fact"로 현실과 다시 확인하고, 미결 질문만 사용자의 말로 닫으세요.' },
18
+ RESOLVE_NEEDS_DECISION: { message: '미결 질문은 사용자가 직접 내린 판단으로만 닫을 수 있습니다.', recovery: '사용자에게 판단을 물어 decision에 그대로 전달하세요. 아직 결정하지 못했다면 열린 채로 둘 수 있습니다.' },
19
+ RECEIPT_NEEDS_ID: { message: '판단 영수증을 불러올 결정 id가 필요합니다.', recovery: '결정 id를 전달하세요.' },
20
+ RECEIPT_NOT_FOUND: { message: '해당 결정의 판단 영수증을 찾지 못했습니다.', recovery: 'argus_patterns view="all"에서 id를 확인하거나 먼저 예측을 저장하세요.' },
21
+ PREMISES_NEEDS_ID: { message: '전제를 불러올 결정 id가 필요합니다.', recovery: '결정 id를 전달하세요.' },
22
+ NOT_RECHECKABLE: { message: '이 항목은 현실과 재확인할 전제가 아니라 사용자가 답할 미결 질문입니다.', recovery: 'argus_capture action="answer_question"에 사용자의 판단을 그대로 전달하세요.' },
23
+ RECHECK_NEEDS_ASSERTION: { message: '이전 기준과 비교할 수 있는 확인 결과가 필요합니다.', recovery: '수치 사실이면 numeric_value를, 문장형 사실이면 changed=true/false를 명시하세요.' },
24
+ TOO_LARGE: { message: '문서가 처리 가능한 크기를 넘었습니다.', recovery: '판단에 가장 중요한 부분만 검수하거나 문서를 나누세요.' },
25
+ READ_FAILED: { message: '문서 파일을 읽지 못했습니다.', recovery: '절대 경로를 확인하거나 문서 내용을 text에 붙여 넣으세요.' },
26
+ EXTRACT_FAILED: { message: '문서에서 텍스트를 추출하지 못했습니다.', recovery: '문서 내용을 text에 붙여 넣거나 markdown/txt로 변환하세요.' },
27
+ EMPTY: { message: '검수할 수 있는 문서 내용이 없습니다.', recovery: '20자 이상의 text 또는 읽을 수 있는 file_path를 전달하세요.' },
28
+ OUTCOME_REQUIRED: { message: '현실에서 실제로 어떻게 됐는지 결과가 필요합니다.', recovery: '사용자에게 결과를 물어 outcome에 전달하세요. 결과를 추론하지 마세요.' },
29
+ PREMATURE_SETTLE: { message: '아직 확인일이 되지 않았습니다.', recovery: '확인일까지 기다리거나 일정이 바뀌었다면 확인일을 수정하세요.' },
30
+ WHAT_HAPPENED_REQUIRED: { message: '실제로 일어난 일을 기록해야 합니다.', recovery: '사용자에게 실제 결과를 물어 what_happened에 그대로 전달하세요.' },
31
+ DEFER_DATE_REQUIRED: { message: '다시 확인할 날짜가 필요합니다.', recovery: '사용자에게 날짜를 물어 defer_to에 YYYY-MM-DD로 전달하세요. 더는 중요하지 않다면 argus_capture action="close"를 사용하세요.' },
32
+ NOT_CONNECTED: { message: '이 터미널은 Argus 계정과 연결돼 있지 않습니다.', recovery: '웹 설정에서 동기화 토큰을 발급하고 MCP 설정의 ARGUS_TOKEN에 넣으세요.' },
33
+ SYNC_FAILED: { message: 'Argus 계정과 동기화하지 못했습니다.', recovery: '네트워크와 ARGUS_API_URL을 확인한 뒤 다시 시도하세요. 로컬 기록은 영향을 받지 않습니다.' },
34
+ TEXT_REQUIRED: { message: '기록할 문장이 필요합니다.', recovery: '사용자의 문장을 고치거나 요약하지 말고 그대로 text에 전달하세요.' },
35
+ SEAL_INVALID: { message: '다시 쓴 예측 문장의 길이가 맞지 않습니다 (8~400자).', recovery: '사용자에게 예측 문장을 8~400자로 다시 물어 그대로 저장하세요.' },
36
+ INTERNAL_ERROR: { message: '내부 오류가 발생했습니다.', recovery: '같은 작업을 다시 시도하세요. 반복되면 MCP 서버 로그를 확인하세요.' },
37
+ UNKNOWN_TOOL: { message: '알 수 없는 도구입니다.', recovery: 'tools/list에 나온 정확한 도구 이름을 사용하세요.' },
38
+ };
39
+ export const LOCALIZED_ERROR_CODES = new Set(Object.keys(KO_ERRORS));
40
+ /** Translate one Zod issue into a Korean, actionable reason. Keeps the English
41
+ * argument NAME (models and users see arg names in English), but says in Korean
42
+ * WHY it failed — the piece the generic message threw away. */
43
+ function koReason(issue) {
44
+ const unit = issue.origin === 'string' ? '자' : issue.origin === 'array' ? '개' : '';
45
+ switch (issue.code) {
46
+ case 'too_small':
47
+ if (issue.origin === 'string')
48
+ return `너무 짧습니다 (최소 ${issue.minimum}자)`;
49
+ if (issue.origin === 'array')
50
+ return `항목이 부족합니다 (최소 ${issue.minimum}개)`;
51
+ return `너무 작습니다 (최소 ${issue.minimum}${unit})`;
52
+ case 'too_big':
53
+ if (issue.origin === 'string')
54
+ return `너무 깁니다 (최대 ${issue.maximum}자)`;
55
+ if (issue.origin === 'array')
56
+ return `항목이 너무 많습니다 (최대 ${issue.maximum}개)`;
57
+ return `너무 큽니다 (최대 ${issue.maximum}${unit})`;
58
+ case 'invalid_type':
59
+ return issue.expected === undefined ? '형식이 올바르지 않습니다' : `필수이거나 형식이 올바르지 않습니다 (${issue.expected} 필요)`;
60
+ case 'invalid_value':
61
+ case 'invalid_enum_value':
62
+ return '허용되지 않는 값입니다';
63
+ case 'unrecognized_keys':
64
+ return '알 수 없는 항목입니다';
65
+ case 'invalid_format':
66
+ case 'invalid_string':
67
+ return '형식이 올바르지 않습니다 (예: 날짜는 YYYY-MM-DD)';
68
+ default:
69
+ return '값을 확인해 주세요';
70
+ }
71
+ }
72
+ /** Korean INVALID_INPUT that NAMES each offending argument and why. */
73
+ function localizeInvalidInput(fields) {
74
+ if (!fields.length)
75
+ return KO_ERRORS.INVALID_INPUT;
76
+ const parts = fields.slice(0, 4).map((f) => `${f.field === '(root)' ? '요청' : f.field}: ${koReason(f)}`);
77
+ return {
78
+ message: `입력값이 올바르지 않습니다 — ${parts.join(', ')}.`,
79
+ recovery: '위에 표시된 인자를 고친 뒤 같은 도구를 다시 호출하세요. 사용자가 정해야 할 값은 추측하지 마세요.',
80
+ };
81
+ }
82
+ const REPRESENTATIVE_FIELDS = [
83
+ 'decision', 'predicate', 'what_happened', 'finding', 'text', 'question',
84
+ 'human_judgment', 'note', 'title', 'biggest_worry',
85
+ ];
86
+ function responseLocale(args) {
87
+ const sample = REPRESENTATIVE_FIELDS
88
+ .map((key) => args[key])
89
+ .filter((value) => typeof value === 'string' && value.trim().length > 0)
90
+ .join('\n');
91
+ const dir = typeof args['argus_dir'] === 'string' ? args['argus_dir'] : null;
92
+ return resolveResponseLocale(dir, sample);
93
+ }
94
+ /**
95
+ * Dispatch-level locale safety net. Tool handlers retain precise English
96
+ * diagnostics for development, while every Korean MCP call receives a Korean
97
+ * user surface even on validation, guard, and rare failure paths.
98
+ */
99
+ export function localizeToolResult(args, result) {
100
+ if (!result.isError || responseLocale(args) !== 'ko')
101
+ return result;
102
+ const sc = result.structuredContent;
103
+ if (!sc || sc['ok'] !== false)
104
+ return result;
105
+ const code = String(sc['error_code'] ?? 'INTERNAL_ERROR');
106
+ const copy = code === 'INVALID_INPUT' && Array.isArray(sc['invalid_fields'])
107
+ ? localizeInvalidInput(sc['invalid_fields'])
108
+ : KO_ERRORS[code] ?? {
109
+ message: '요청을 처리하지 못했습니다.',
110
+ recovery: '입력값과 현재 결정 상태를 확인한 뒤 다시 시도하세요.',
111
+ };
112
+ const localized = {
113
+ ...sc,
114
+ message: copy.message,
115
+ ...(copy.recovery ? { recovery: copy.recovery } : {}),
116
+ };
117
+ result.structuredContent = localized;
118
+ result.content = [{ type: 'text', text: JSON.stringify(localized, null, 2) }];
119
+ return result;
120
+ }
@@ -0,0 +1,13 @@
1
+ import { resolveResponseLocale } from './surfaces.js';
2
+ /**
3
+ * Resolve one user-facing MCP sentence with the same precedence used by tool
4
+ * surfaces: explicit config first, then representative user text, then host
5
+ * locale. Machine tokens, ids, enum values, and tool names remain unchanged.
6
+ */
7
+ export function localizedMessage(argusDir, sample, copy) {
8
+ return resolveResponseLocale(argusDir, sample) === 'ko' ? copy.ko : copy.en;
9
+ }
10
+ /** Pair a localized error message with its actionable recovery sentence. */
11
+ export function localizedErrorCopy(argusDir, sample, copy) {
12
+ return resolveResponseLocale(argusDir, sample) === 'ko' ? copy.ko : copy.en;
13
+ }
@@ -123,8 +123,6 @@ export function groupDuePremises(due) {
123
123
  }
124
124
  return [...byText.values()];
125
125
  }
126
- /** The living-premises summary a receipt renders from (plan v5 §3.3): the
127
- * premise set is canonical — headline = first active load-bearing premise. */
128
126
  export function receiptPremisesInfo(entry) {
129
127
  const list = entry?.premises ?? [];
130
128
  if (list.length === 0)
@@ -15,6 +15,7 @@
15
15
  * ARGUS_TOKEN argus_pat_… (issued in the webapp; same token as `argus push`)
16
16
  * ARGUS_API_URL optional, defaults to https://argus.voyage
17
17
  */
18
+ import { resolveAccountApiUrl, resolveAccountToken } from '../a0/account-credentials.js';
18
19
  const TIMEOUT_MS = 5000;
19
20
  /**
20
21
  * Resolve the account API base, enforcing https so the Bearer token never
@@ -23,7 +24,7 @@ const TIMEOUT_MS = 5000;
23
24
  * returns null → callers skip the send rather than leak the token over http.
24
25
  */
25
26
  function resolveApiBase() {
26
- const raw = (process.env.ARGUS_API_URL || 'https://argus.voyage').replace(/\/+$/, '');
27
+ const raw = resolveAccountApiUrl().replace(/\/+$/, '');
27
28
  try {
28
29
  const u = new URL(raw);
29
30
  const localhost = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1';
@@ -37,7 +38,7 @@ function resolveApiBase() {
37
38
  }
38
39
  /** Pull the account's receipts (the sync's read side). No token ⇒ empty. */
39
40
  export async function fetchAccountReceipts() {
40
- const token = (process.env.ARGUS_TOKEN || '').trim();
41
+ const token = resolveAccountToken();
41
42
  if (!token || !token.startsWith('argus_pat_'))
42
43
  return { ok: false, reason: 'no_token', receipts: [] };
43
44
  const base = resolveApiBase();
@@ -63,7 +64,7 @@ export async function fetchAccountReceipts() {
63
64
  }
64
65
  }
65
66
  export async function pushToAccount(payload) {
66
- const token = (process.env.ARGUS_TOKEN || '').trim();
67
+ const token = resolveAccountToken();
67
68
  if (!token)
68
69
  return { synced: false, reason: 'no_token' }; // local-only (default)
69
70
  if (!token.startsWith('argus_pat_'))
@@ -9,9 +9,13 @@
9
9
  * data pdf.js / jszip already handed back (text items, slide XML strings), so it
10
10
  * runs identically in both runtimes.
11
11
  */
12
+ import { stableId } from './ids.js';
12
13
  /** Hard caps shared by both extractors. */
13
14
  export const MAX_UNITS = 400;
14
15
  export const PAGE_CAP = 120;
16
+ /** Target size for a PDF paragraph unit — keeps anchors granular instead of one
17
+ * page-sized blob (pdf.js emits no blank lines, so we split by size + headings). */
18
+ export const PDF_PARA_CHARS = 1200;
15
19
  /** Same y-tolerance the original single-column reconstructor used. */
16
20
  const Y_TOL = 3;
17
21
  function partOf(it) {
@@ -195,6 +199,118 @@ export function groupBlocks(lines) {
195
199
  return blocks;
196
200
  }
197
201
  // ==========================================================================
202
+ // PDF unit segmentation — shared by both extractors (browser + Node).
203
+ // ==========================================================================
204
+ /**
205
+ * Recognize a numbered/keyword section header in one reconstructed PDF line.
206
+ * Conservative on purpose — requires a structural marker (section number, roman
207
+ * numeral, or a known heading word) AND a short length, so running prose is never
208
+ * mislabeled a heading. Returns the header text, or null for body.
209
+ *
210
+ * NOTE: a plain \b after a Korean keyword never fires (Korean chars aren't \w),
211
+ * so an explicit boundary lookahead is used instead — else "제 3 장", "부록 A"
212
+ * would be missed.
213
+ */
214
+ export function pdfHeadingTitle(text) {
215
+ const t = text.trim();
216
+ if (t.length < 2 || t.length > 50)
217
+ return null;
218
+ if (t.split(/\s+/).length > 10)
219
+ return null;
220
+ if (/[.。!?…]$/.test(t) && !/^\d+(\.\d+)*\.?$/.test(t.split(/\s+/)[0]))
221
+ return null;
222
+ const marked = /^(제\s*\d+\s*(장|절|부)|chapter\s+\d+|section\s+\d+|appendix|부록|요약|개요|executive\s+summary)(?=\s|$|[:·.)])/i.test(t) ||
223
+ /^\d+(\.\d+){0,3}[.)]?\s+\S/.test(t) ||
224
+ /^[Ⅰ-Ⅹ]+\.\s+\S/.test(t) ||
225
+ /^[IVX]+\.\s+\S/.test(t);
226
+ return marked ? t : null;
227
+ }
228
+ /**
229
+ * Turn one page's reconstructed lines into units. pdf.js emits no blank lines
230
+ * between paragraphs, so a naive groupBlocks collapsed the ENTIRE page into one
231
+ * unit — burying every heading and leaving findings only a bare page number.
232
+ * Segmenting per line lets a heading line become its own unit (with a
233
+ * section_path anchor) and keeps paragraph units granular (~PDF_PARA_CHARS).
234
+ * Pushes onto `units`, returns the running section title, and calls `onCap` when
235
+ * the shared MAX_UNITS ceiling is hit.
236
+ */
237
+ export function emitPdfUnits(lines, page, units, sectionIn, onCap, maxUnits = MAX_UNITS, paraCharTarget = PDF_PARA_CHARS) {
238
+ let currentSection = sectionIn;
239
+ let para = [];
240
+ // Flush the accumulated paragraph. Returns false only when the unit ceiling is
241
+ // hit (caller stops); true means "kept going" (pushed, or nothing to push).
242
+ const flush = () => {
243
+ const text = para.join(' ').replace(/\s+/g, ' ').trim();
244
+ para = [];
245
+ if (text.length < 2)
246
+ return true;
247
+ if (units.length >= maxUnits) {
248
+ onCap();
249
+ return false;
250
+ }
251
+ units.push({
252
+ unit_id: stableId('u', 'pdf', page, text.slice(0, 40)),
253
+ kind: 'paragraph',
254
+ text,
255
+ source_anchor: currentSection ? { page, section_path: [currentSection] } : { page },
256
+ confidence: 0.8,
257
+ });
258
+ return true;
259
+ };
260
+ for (const line of lines) {
261
+ const lt = line.trim();
262
+ if (!lt) {
263
+ if (!flush())
264
+ return currentSection;
265
+ continue;
266
+ }
267
+ const heading = pdfHeadingTitle(lt);
268
+ if (heading) {
269
+ if (!flush())
270
+ return currentSection;
271
+ if (units.length >= maxUnits) {
272
+ onCap();
273
+ return currentSection;
274
+ }
275
+ currentSection = heading;
276
+ units.push({
277
+ unit_id: stableId('u', 'pdf', page, heading.slice(0, 40)),
278
+ kind: 'heading',
279
+ text: heading,
280
+ source_anchor: { page, section_path: [heading] },
281
+ confidence: 0.7,
282
+ });
283
+ continue;
284
+ }
285
+ // A tabular row (reconstructPage joins detected cells with ' | ') becomes its
286
+ // own unit — so a finding can cite a specific table row, and detected_structure
287
+ // counts the table instead of burying it inside a paragraph.
288
+ if (/\S \| \S/.test(lt)) {
289
+ if (!flush())
290
+ return currentSection;
291
+ if (units.length >= maxUnits) {
292
+ onCap();
293
+ return currentSection;
294
+ }
295
+ units.push({
296
+ unit_id: stableId('u', 'pdf', page, lt.slice(0, 40)),
297
+ kind: 'table',
298
+ text: lt,
299
+ source_anchor: currentSection ? { page, section_path: [currentSection] } : { page },
300
+ confidence: 0.75,
301
+ });
302
+ continue;
303
+ }
304
+ para.push(lt);
305
+ if (para.join(' ').length >= paraCharTarget) {
306
+ if (!flush())
307
+ return currentSection;
308
+ }
309
+ }
310
+ flush();
311
+ return currentSection;
312
+ }
313
+ // ==========================================================================
198
314
  // PPTX slide XML — pure string parsing (jszip already gave us the XML text).
199
315
  // ==========================================================================
200
316
  /** Extract one joined string per <a:p> paragraph from slide XML. */
@@ -10,26 +10,74 @@
10
10
  */
11
11
  import fs from 'fs';
12
12
  import { stableId } from './ids.js';
13
- import { MAX_UNITS, PAGE_CAP, reconstructPage, groupBlocks, paragraphsFromSlideXml, slideNum, } from './extract-core.js';
13
+ import { MAX_UNITS, PAGE_CAP, reconstructPage, emitPdfUnits, paragraphsFromSlideXml, slideNum, } from './extract-core.js';
14
+ function classifyExtractError(e, kind) {
15
+ const name = e?.name ?? '';
16
+ const m = String(e?.message ?? e ?? '').toLowerCase();
17
+ if (name === 'PasswordException' || m.includes('password')) {
18
+ return { note: '이 PDF는 암호로 보호되어 있어 열 수 없습니다. 암호를 해제해 다시 저장한 뒤 검수하세요.', error_kind: 'encrypted' };
19
+ }
20
+ if (name === 'InvalidPDFException' || m.includes('invalid pdf') || m.includes('missing pdf')) {
21
+ return { note: 'PDF 파일이 손상된 것 같습니다. 원본을 다시 받아 검수하거나 본문을 붙여넣으세요.', error_kind: 'corrupt' };
22
+ }
23
+ if (m.includes('end of central directory') || m.includes("can't find") || m.includes('corrupted zip') || m.includes('not a zip')) {
24
+ return { note: '파일이 손상됐거나 형식이 올바르지 않습니다 (열 수 없는 문서 구조).', error_kind: 'corrupt' };
25
+ }
26
+ const label = kind === 'pdf' ? 'PDF' : kind === 'docx' ? 'Word 문서' : kind === 'pptx' ? '슬라이드' : kind === 'hwpx' ? '한글 문서' : '문서';
27
+ return { note: `이 ${label}에서 내용을 읽지 못했습니다. 파일이 손상됐거나 형식이 확장자와 다를 수 있습니다.`, error_kind: 'unknown' };
28
+ }
14
29
  /** Extract a binary document at `filePath`. Only pdf/docx/pptx are handled here;
15
- * text formats are read directly by the caller (review.ts). */
30
+ * text formats are read directly by the caller (review.ts). Never throws — a
31
+ * corrupt/encrypted/empty file returns an honest quality:'unsupported' + reason. */
16
32
  export async function extractFileFromPath(filePath, kind) {
17
- const buf = fs.readFileSync(filePath);
18
- if (kind === 'docx')
19
- return extractDocx(buf);
20
- if (kind === 'pptx')
21
- return extractPptx(buf);
22
- if (kind === 'pdf')
23
- return extractPdf(buf);
24
- return { text: '', quality: 'unsupported' };
33
+ if (kind !== 'docx' && kind !== 'pptx' && kind !== 'pdf' && kind !== 'hwpx') {
34
+ return { text: '', quality: 'unsupported' };
35
+ }
36
+ let buf;
37
+ try {
38
+ buf = fs.readFileSync(filePath);
39
+ }
40
+ catch {
41
+ return { text: '', quality: 'unsupported', note: '파일을 읽지 못했습니다.', error_kind: 'unknown' };
42
+ }
43
+ if (buf.length === 0) {
44
+ return { text: '', quality: 'unsupported', note: '빈 파일입니다 (0바이트).', error_kind: 'empty' };
45
+ }
46
+ try {
47
+ if (kind === 'docx')
48
+ return await extractDocx(buf);
49
+ if (kind === 'pptx')
50
+ return await extractPptx(buf);
51
+ if (kind === 'hwpx')
52
+ return await extractHwpx(buf);
53
+ return await extractPdf(buf);
54
+ }
55
+ catch (e) {
56
+ const { note, error_kind } = classifyExtractError(e, kind);
57
+ return { text: '', quality: 'unsupported', note, error_kind };
58
+ }
25
59
  }
26
60
  // --------------------------------------------------------------------------
27
61
  // DOCX — mammoth to raw text.
28
62
  // --------------------------------------------------------------------------
29
63
  async function extractDocx(buf) {
30
64
  const mammoth = await import('mammoth');
31
- const { value, messages } = await mammoth.extractRawText({ buffer: buf });
32
- const text = (value || '').trim();
65
+ // Markdown (not raw text) so Word headings/lists/tables survive into the same
66
+ // markdown-aware ingest path (parity with the browser extractor). convertToMarkdown
67
+ // exists at runtime (mammoth 1.x) but is missing from the shipped types.
68
+ const toMarkdown = mammoth.convertToMarkdown;
69
+ let messages = [];
70
+ let text = '';
71
+ if (toMarkdown) {
72
+ const r = await toMarkdown({ buffer: buf });
73
+ messages = r.messages;
74
+ text = stripDocxMarkdownNoise(r.value || '');
75
+ }
76
+ if (text.length < 40) {
77
+ const raw = await mammoth.extractRawText({ buffer: buf });
78
+ text = (raw.value || '').trim();
79
+ messages = raw.messages;
80
+ }
33
81
  const lost = messages.some((m) => m.type === 'warning');
34
82
  return {
35
83
  text,
@@ -37,6 +85,16 @@ async function extractDocx(buf) {
37
85
  note: lost ? '일부 표/이미지 서식은 텍스트로 변환되지 않았습니다.' : undefined,
38
86
  };
39
87
  }
88
+ /** Clean mammoth markdown: drop embedded image data-URIs, unescape the
89
+ * backslash escapes mammoth adds to punctuation (else a heading reads
90
+ * "1\. 개요"), and collapse blank runs. */
91
+ function stripDocxMarkdownNoise(md) {
92
+ return md
93
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
94
+ .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, '$1')
95
+ .replace(/\n{3,}/g, '\n\n')
96
+ .trim();
97
+ }
40
98
  // --------------------------------------------------------------------------
41
99
  // PPTX — a zip of slide XML; pull <a:t> runs per <a:p>, keep slide order.
42
100
  // --------------------------------------------------------------------------
@@ -105,6 +163,74 @@ async function extractPptx(buf) {
105
163
  return { text: units.map((u) => u.text).join('\n'), units, quality: 'medium', ...caps };
106
164
  }
107
165
  // --------------------------------------------------------------------------
166
+ // HWPX — Hancom OWPML: a zip of Contents/sectionN.xml. Text runs live in <hp:t>
167
+ // grouped by <hp:p> paragraphs. Walk runs + paragraph closings in document order
168
+ // so each paragraph becomes a line (parity with the browser extractor). Old
169
+ // binary .hwp is a CFB blob with no Node parser — degrade honestly.
170
+ // --------------------------------------------------------------------------
171
+ async function extractHwpx(buf) {
172
+ const JSZip = (await import('jszip')).default;
173
+ const zip = await JSZip.loadAsync(buf);
174
+ const sectionPaths = Object.keys(zip.files)
175
+ .filter((p) => /^Contents\/section\d+\.xml$/i.test(p))
176
+ .sort((a, b) => hwpxSectionNum(a) - hwpxSectionNum(b));
177
+ if (!sectionPaths.length) {
178
+ return {
179
+ text: '', quality: 'unsupported', error_kind: 'corrupt',
180
+ note: '한글 문서 구조를 찾지 못했습니다. 구버전 .hwp이거나 파일이 손상됐을 수 있습니다 — HWPX로 다시 저장하거나 본문을 붙여넣으세요.',
181
+ };
182
+ }
183
+ const lines = [];
184
+ for (const path of sectionPaths) {
185
+ const xml = await zip.files[path].async('string');
186
+ lines.push(...hwpxParagraphs(xml));
187
+ if (lines.length >= MAX_UNITS)
188
+ break;
189
+ }
190
+ const text = lines.join('\n').trim();
191
+ if (text.length < 40) {
192
+ return { text: '', quality: 'low', note: '한글 문서에서 텍스트를 거의 찾지 못했습니다 (이미지 위주일 수 있습니다).' };
193
+ }
194
+ return { text, quality: 'medium', note: text.length < 400 ? '추출된 텍스트가 적습니다. 핵심 본문은 붙여넣으면 더 정확합니다.' : undefined };
195
+ }
196
+ function hwpxSectionNum(p) {
197
+ return parseInt(/section(\d+)/i.exec(p)?.[1] ?? '0', 10);
198
+ }
199
+ function hwpxParagraphs(xml) {
200
+ const lines = [];
201
+ let cur = '';
202
+ const re = /<(?:\w+:)?t\b[^>]*>([\s\S]*?)<\/(?:\w+:)?t>|<\/(?:\w+:)?p>/g;
203
+ let m;
204
+ while ((m = re.exec(xml))) {
205
+ if (m[1] !== undefined) {
206
+ cur += decodeXmlEntities(m[1]);
207
+ }
208
+ else {
209
+ const line = cur.replace(/\s+/g, ' ').trim();
210
+ if (line)
211
+ lines.push(line);
212
+ cur = '';
213
+ }
214
+ }
215
+ const tail = cur.replace(/\s+/g, ' ').trim();
216
+ if (tail)
217
+ lines.push(tail);
218
+ return lines;
219
+ }
220
+ function decodeXmlEntities(s) {
221
+ return s
222
+ .replace(/&lt;/g, '<')
223
+ .replace(/&gt;/g, '>')
224
+ .replace(/&quot;/g, '"')
225
+ .replace(/&apos;/g, "'")
226
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => codePoint(parseInt(h, 16)))
227
+ .replace(/&#(\d+);/g, (_, d) => codePoint(parseInt(d, 10)))
228
+ .replace(/&amp;/g, '&');
229
+ }
230
+ function codePoint(n) {
231
+ return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : '';
232
+ }
233
+ // --------------------------------------------------------------------------
108
234
  // PDF — pdf.js (legacy Node build, main-thread), column-aware line rebuild.
109
235
  // --------------------------------------------------------------------------
110
236
  async function extractPdf(buf) {
@@ -119,6 +245,7 @@ async function extractPdf(buf) {
119
245
  let capped = false;
120
246
  let multiColumn = false;
121
247
  let hasTable = false;
248
+ let currentSection = null;
122
249
  for (let p = 1; p <= pageCount; p++) {
123
250
  if (units.length >= MAX_UNITS) {
124
251
  capped = true;
@@ -132,23 +259,12 @@ async function extractPdf(buf) {
132
259
  multiColumn = true;
133
260
  if (layout.hasTable)
134
261
  hasTable = true;
135
- const blocks = groupBlocks(layout.lines);
136
- for (const block of blocks) {
137
- if (units.length >= MAX_UNITS) {
138
- capped = true;
139
- break;
140
- }
141
- const t = block.trim();
142
- if (t.length < 2)
143
- continue;
144
- units.push({
145
- unit_id: stableId('u', 'pdf', p, t.slice(0, 40)),
146
- kind: 'paragraph',
147
- text: t,
148
- source_anchor: { page: p },
149
- confidence: 0.8,
150
- });
151
- }
262
+ // Line-level segmentation (see extract-core.emitPdfUnits): pdf.js emits no
263
+ // blank lines, so a whole page used to collapse into one unit with every
264
+ // heading buried. This splits per line — headings become their own units.
265
+ currentSection = emitPdfUnits(layout.lines, p, units, currentSection, () => { capped = true; });
266
+ if (capped)
267
+ break;
152
268
  }
153
269
  const caps = {
154
270
  pages_total: doc.numPages,
@@ -11,7 +11,7 @@
11
11
  export * from './schema.js';
12
12
  export { ingest } from './ingest.js';
13
13
  export { scoreReviewability } from './reviewability.js';
14
- export { LENSES, getLens, ALL_LENS_IDS, LENS_VERSION } from './lenses.js';
14
+ export { LENSES, getLens, ALL_LENS_IDS, LENS_VERSION, lensLabel } from './lenses.js';
15
15
  export { routeLenses, applies } from './routing.js';
16
16
  export { buildExtractionPrompt, buildLensPrompt, buildSynthesisPrompt, renderUnits } from './prompts.js';
17
17
  export { receiptToMarkdown } from './render.js';