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
@@ -15,23 +15,39 @@
15
15
  * first-class state that reaches the UI, not a swallowed error.
16
16
  */
17
17
  import { fingerprint, stableId } from './ids.js';
18
+ const deckNote = (lang, isDeck) => lang === 'en'
19
+ ? isDeck
20
+ ? 'This deck was reviewed by its slide text and order. Chart/image interpretation is limited.'
21
+ : 'Reviewed by text. Some tables/images may be left out of the analysis.'
22
+ : isDeck
23
+ ? '이 deck은 슬라이드 텍스트와 순서를 기준으로 검수했습니다. 차트/이미지 해석은 제한적입니다.'
24
+ : '텍스트를 기준으로 검수했습니다. 표/이미지 일부는 분석에서 빠질 수 있습니다.';
18
25
  const TIER0 = ['paste', 'markdown', 'txt', 'llm_answer', 'transcript', 'pr_diff'];
19
- const BINARY = ['pdf', 'docx', 'pptx'];
26
+ const BINARY = ['pdf', 'docx', 'pptx', 'hwpx'];
20
27
  export function ingest(input) {
21
28
  const privacy_mode = input.privacy_mode ?? 'receipt_only';
22
- const title = (input.title || '').trim() || defaultTitle(input.source_kind);
29
+ const lang = input.locale ?? 'ko';
30
+ const title = (input.title || '').trim() || defaultTitle(input.source_kind, lang);
23
31
  // Pre-anchored units from a real parser (pdf pages / pptx slides): trust the
24
32
  // parser's page/slide numbers rather than re-flattening to text.
25
33
  if (input.pre_extracted_units && input.pre_extracted_units.length > 0) {
26
- return fromUnits(input, title, privacy_mode);
34
+ return fromUnits(input, title, privacy_mode, lang);
35
+ }
36
+ // A pure image has no text and no structure — it exists ONLY to be reviewed
37
+ // visually. Build a clean units-less artifact (never the discouraging
38
+ // `unsupported` degrade, which would trip Gate 0); the pipeline reviews it
39
+ // straight from the attached image via the vision path. Anchors are pages the
40
+ // model reads from the image itself, not extracted units.
41
+ if (input.source_kind === 'image') {
42
+ return imageArtifact(input, title, privacy_mode, lang);
27
43
  }
28
44
  // Binary format with no extracted text → honest degrade, data model intact.
29
45
  if (BINARY.includes(input.source_kind) && !input.pre_extracted && !input.text) {
30
- return degradedArtifact(input.source_kind, title, privacy_mode);
46
+ return degradedArtifact(input.source_kind, title, privacy_mode, lang);
31
47
  }
32
48
  const text = (input.pre_extracted ?? input.text ?? '').replace(/\r\n/g, '\n');
33
49
  if (!text.trim()) {
34
- return degradedArtifact(input.source_kind, title, privacy_mode, 'empty');
50
+ return degradedArtifact(input.source_kind, title, privacy_mode, lang, 'empty');
35
51
  }
36
52
  const isDeck = input.source_kind === 'pptx';
37
53
  const units = input.source_kind === 'transcript'
@@ -43,9 +59,7 @@ export function ingest(input) {
43
59
  const quality = input.extraction_quality ?? (TIER0.includes(input.source_kind) ? 'high' : 'medium');
44
60
  const notes = [...(input.extraction_notes ?? [])];
45
61
  if (BINARY.includes(input.source_kind) && notes.length === 0) {
46
- notes.push(input.source_kind === 'pptx'
47
- ? '이 deck은 슬라이드 텍스트와 순서를 기준으로 검수했습니다. 차트/이미지 해석은 제한적입니다.'
48
- : '텍스트를 기준으로 검수했습니다. 표/이미지 일부는 분석에서 빠질 수 있습니다.');
62
+ notes.push(deckNote(lang, input.source_kind === 'pptx'));
49
63
  }
50
64
  return {
51
65
  artifact_id: `art_${fp}`,
@@ -72,7 +86,7 @@ export function ingest(input) {
72
86
  * pptx slides). The parser carries page/slide numbers that a flattened-text
73
87
  * re-extraction would lose, so we keep them verbatim and only derive structure.
74
88
  */
75
- function fromUnits(input, title, privacy_mode) {
89
+ function fromUnits(input, title, privacy_mode, lang) {
76
90
  const units = input.pre_extracted_units;
77
91
  const joined = units.map((u) => u.text).join('\n');
78
92
  const fp = fingerprint(joined || title);
@@ -87,9 +101,7 @@ function fromUnits(input, title, privacy_mode) {
87
101
  }
88
102
  const notes = [...(input.extraction_notes ?? [])];
89
103
  if (notes.length === 0) {
90
- notes.push(isDeck
91
- ? '이 deck은 슬라이드 텍스트와 순서를 기준으로 검수했습니다. 차트/이미지 해석은 제한적입니다.'
92
- : '텍스트를 기준으로 검수했습니다. 표/이미지 일부는 분석에서 빠질 수 있습니다.');
104
+ notes.push(deckNote(lang, isDeck));
93
105
  }
94
106
  return {
95
107
  artifact_id: `art_${fp}`,
@@ -111,29 +123,81 @@ function fromUnits(input, title, privacy_mode) {
111
123
  source_caps: input.source_caps,
112
124
  };
113
125
  }
114
- function defaultTitle(kind) {
115
- const map = {
126
+ /**
127
+ * A units-less artifact for a pure image (png/jpg/webp). It carries no text and
128
+ * no anchorable units — the review runs entirely through the vision path, where
129
+ * the model reads the image directly. Quality is `medium` (a real, reviewable
130
+ * input) rather than `unsupported` (which Gate 0 rejects). extraction_notes
131
+ * states plainly that this is a visual review so the receipt never implies text
132
+ * extraction happened.
133
+ */
134
+ function imageArtifact(input, title, privacy_mode, lang) {
135
+ const note = lang === 'en'
136
+ ? 'Reviewed the image visually (the model reads the image itself, not extracted text).'
137
+ : '이미지를 눈으로 검수했습니다 (텍스트 추출이 아니라 이미지를 직접 읽습니다).';
138
+ const fp = input.source_caps ? stableId('img', title, JSON.stringify(input.source_caps)) : stableId('img', title);
139
+ return {
140
+ artifact_id: `art_${fp}`,
141
+ source_kind: 'image',
142
+ source_title: title,
143
+ source_fingerprint: fp,
144
+ extraction_quality: input.extraction_quality ?? 'medium',
145
+ privacy_mode,
146
+ units: [],
147
+ detected_structure: { is_deck: false },
148
+ extraction_notes: [...(input.extraction_notes ?? []), note],
149
+ source_caps: input.source_caps,
150
+ };
151
+ }
152
+ function defaultTitle(kind, lang) {
153
+ const ko = {
116
154
  paste: '붙여넣은 문서',
117
155
  markdown: 'Markdown 문서',
118
156
  txt: '텍스트 문서',
119
157
  pdf: 'PDF 문서',
120
158
  docx: 'DOCX 문서',
121
159
  pptx: '슬라이드 덱',
160
+ hwpx: '한글 문서',
161
+ image: '이미지',
122
162
  transcript: '회의록',
123
163
  mcp_file: '파일',
124
164
  pr_diff: 'PR diff',
125
165
  llm_answer: 'AI 답변',
126
166
  };
127
- return map[kind];
167
+ const en = {
168
+ paste: 'Pasted document',
169
+ markdown: 'Markdown document',
170
+ txt: 'Text document',
171
+ pdf: 'PDF document',
172
+ docx: 'DOCX document',
173
+ pptx: 'Slide deck',
174
+ hwpx: 'Hangul document',
175
+ image: 'Image',
176
+ transcript: 'Meeting notes',
177
+ mcp_file: 'File',
178
+ pr_diff: 'PR diff',
179
+ llm_answer: 'AI answer',
180
+ };
181
+ return (lang === 'en' ? en : ko)[kind];
128
182
  }
129
- function degradedArtifact(kind, title, privacy_mode, reason = 'unsupported') {
183
+ function degradedArtifact(kind, title, privacy_mode, lang, reason = 'unsupported') {
130
184
  const note = reason === 'empty'
131
- ? '문서에서 텍스트를 찾지 못했습니다.'
185
+ ? (lang === 'en' ? 'No text could be found in the document.' : '문서에서 텍스트를 찾지 못했습니다.')
132
186
  : kind === 'pdf'
133
- ? '이 PDF는 아직 자동 텍스트 추출을 지원하지 않습니다. 본문 텍스트를 붙여넣으면 검수할 수 있습니다.'
187
+ ? (lang === 'en'
188
+ ? 'This PDF does not yet support automatic text extraction. Paste the body text and it can be reviewed.'
189
+ : '이 PDF는 아직 자동 텍스트 추출을 지원하지 않습니다. 본문 텍스트를 붙여넣으면 검수할 수 있습니다.')
134
190
  : kind === 'pptx'
135
- ? '이 deck은 아직 자동 추출을 지원하지 않습니다. 슬라이드 텍스트를 붙여넣으면 검수할 수 있습니다.'
136
- : ' 파일은 아직 자동 텍스트 추출을 지원하지 않습니다. 본문을 붙여넣어 주세요.';
191
+ ? (lang === 'en'
192
+ ? 'This deck does not yet support automatic extraction. Paste the slide text and it can be reviewed.'
193
+ : '이 deck은 아직 자동 추출을 지원하지 않습니다. 슬라이드 텍스트를 붙여넣으면 검수할 수 있습니다.')
194
+ : kind === 'hwpx'
195
+ ? (lang === 'en'
196
+ ? 'No text could be read from this Hangul document. Paste the body text and it can be reviewed.'
197
+ : '이 한글 문서에서 텍스트를 찾지 못했습니다. 본문을 붙여넣으면 검수할 수 있습니다.')
198
+ : (lang === 'en'
199
+ ? 'This file does not yet support automatic text extraction. Please paste the body text.'
200
+ : '이 파일은 아직 자동 텍스트 추출을 지원하지 않습니다. 본문을 붙여넣어 주세요.');
137
201
  return {
138
202
  artifact_id: `art_${stableId(kind, title, reason)}`,
139
203
  source_kind: kind,
@@ -7,6 +7,24 @@
7
7
  * So keep this list small and let routing.ts choose.
8
8
  */
9
9
  export const LENS_VERSION = '1';
10
+ /** User-facing lens names in English. The Korean names live on each lens's
11
+ * `label` (also used inside the Korean lens prompt); this map is the display
12
+ * name for an English-locale reader. Keep the two in sync when adding a lens. */
13
+ export const LENS_LABEL_EN = {
14
+ core_question: 'Core question',
15
+ claim_evidence: 'Claim vs evidence',
16
+ hidden_assumption: 'Hidden assumptions',
17
+ human_judgment: 'Human judgment',
18
+ stakeholder_objection: 'Stakeholder objections',
19
+ execution_risk: 'Execution risk',
20
+ reversibility: 'Reversibility',
21
+ falsifiable_followup: 'Falsifiable follow-up',
22
+ deck_narrative: 'Deck narrative',
23
+ };
24
+ /** Localized, user-facing label for a lens. */
25
+ export function lensLabel(id, lang) {
26
+ return lang === 'en' ? LENS_LABEL_EN[id] : LENSES[id].label;
27
+ }
10
28
  export const LENSES = {
11
29
  core_question: {
12
30
  id: 'core_question',
@@ -10,7 +10,7 @@
10
10
  * - cite the source by unit_id (anchors) — an unanchored finding is worthless.
11
11
  * - no generic advice ("리스크를 고려하세요") — must reference a specific claim.
12
12
  * - never decide for the user; never invent facts not in the units.
13
- * - Korean output, pure JSON.
13
+ * - output in the document's language, pure JSON.
14
14
  */
15
15
  const SPINE = `너는 Argus의 판단 검수기다. 사용자의 결정을 대신 내리지 않는다.
16
16
  - 절대 "이 전략은 틀렸다", "진행하세요" 같은 평결을 내지 않는다.
@@ -20,7 +20,24 @@ const SPINE = `너는 Argus의 판단 검수기다. 사용자의 결정을 대
20
20
  식별자(u_...)를 문장에 절대 노출하지 않는다. unit_id는 오직 unit_ids 배열에만 넣는다.
21
21
  - "리스크를 고려하세요", "더 조사하세요" 같은 일반론은 금지한다. 무엇을 확인할지 구체적으로 쓴다.
22
22
  - 원문에 없는 사실을 지어내지 않는다.
23
- - 출력은 순수 JSON. 마크다운/설명 없이 { 시작해 } 끝난다. 한국어로 쓴다.`;
23
+ - 지적의 "유형"을 다양하게 본다. "근거가 부족하다"만 반복하지 말고, 해당되면 날카로운 유형을 짚는다:
24
+ · 섹션 간 모순(A절은 X라는데 B절은 반대로 기술) · 미충족 선결조건(전제가 아직 안 됐는데 그 위에서 결론)
25
+ · 비현실적/검증 안 된 가정 · 의존관계 역전(선행 과제가 후행보다 늦게 배치) · 숫자·근거 불일치
26
+ · 이해관계자 반론(승인·예산을 쥔 사람이 먼저 걸 지점). 같은 문서의 findings 제목이 서로 붙여넣기처럼 보이면 실패다.
27
+ - 짧고 날카롭게. title은 한 줄(대략 40자 이내), detail은 2문장 이내. 장황한 서술 금지.
28
+ - 출력은 순수 JSON. 마크다운/설명 없이 { 로 시작해 } 로 끝난다.`;
29
+ /**
30
+ * Output-language directive — appended LAST to every stage's system prompt so it
31
+ * wins over the Korean prompt scaffolding above it. Without this, the Korean
32
+ * instructions drag the model into Korean output even for an English document
33
+ * read by an English-locale user. This makes the review answer in the reader's
34
+ * locale (`lang`), not the prompt's language or the document's.
35
+ */
36
+ function langDirective(lang) {
37
+ return lang === 'en'
38
+ ? `OUTPUT LANGUAGE — HIGHEST PRIORITY, overrides everything above: write EVERY user-facing string value in the JSON (every title, detail, rationale, question, heading, statement, label, text, if_false, suggested_action, …) in ENGLISH. The source document and these instructions may be in Korean; your output values MUST still be in English, and must never mix the two. Structural tokens stay exactly as specified: unit_id / lens_id values, the status/severity/confidence/kind enums, and YYYY-MM-DD dates.`
39
+ : `출력 언어 — 최우선, 위의 모든 지시보다 우선한다: JSON의 모든 사용자 노출 문자열 값(title·detail·rationale·question·heading·statement·label·text·if_false·suggested_action 등)을 한국어로 쓴다. 원문이나 이 지시가 영어라도 출력 값은 한국어로 쓰고, 두 언어를 섞지 않는다. 구조 토큰은 지정된 형식 그대로 둔다: unit_id / lens_id 값, status·severity·confidence·kind enum, YYYY-MM-DD 날짜.`;
40
+ }
24
41
  export function renderUnits(units, limit) {
25
42
  return units
26
43
  .slice(0, limit)
@@ -28,19 +45,54 @@ export function renderUnits(units, limit) {
28
45
  const a = u.source_anchor;
29
46
  const loc = a.slide !== undefined
30
47
  ? `slide ${a.slide}`
31
- : a.section_path?.length
32
- ? a.section_path.join(' › ')
33
- : a.line_start !== undefined
34
- ? `L${a.line_start}`
35
- : '';
48
+ : a.page !== undefined
49
+ ? a.section_path?.length
50
+ ? `${a.page}쪽 · ${a.section_path.join(' › ')}`
51
+ : `${a.page}쪽`
52
+ : a.section_path?.length
53
+ ? a.section_path.join(' › ')
54
+ : a.line_start !== undefined
55
+ ? `L${a.line_start}`
56
+ : '';
36
57
  return `[${u.unit_id}] (${u.kind}${loc ? ' · ' + loc : ''}) ${u.text}`;
37
58
  })
38
59
  .join('\n');
39
60
  }
61
+ /**
62
+ * A compact, ordered outline of the WHOLE document — its headings and slide
63
+ * titles with their location — so a single map-reduce chunk can see where it
64
+ * sits and flag a genuine conflict with a section it does not itself contain
65
+ * (the "the section-2 claim contradicts the section-8 conclusion" case a chunked
66
+ * review otherwise misses, because each chunk is judged in isolation). This is
67
+ * context only: a chunk may still cite ONLY its own units. Returns '' when the
68
+ * document has no headings/slide titles to outline (flat prose).
69
+ */
70
+ export function buildDocumentOutline(units, maxChars = 1500) {
71
+ const lines = [];
72
+ for (const u of units) {
73
+ if (u.kind !== 'heading' && u.kind !== 'slide_title')
74
+ continue;
75
+ const a = u.source_anchor;
76
+ const loc = a.slide !== undefined ? `slide ${a.slide}`
77
+ : a.page !== undefined ? `${a.page}쪽`
78
+ : a.section_path?.length ? a.section_path.join(' › ')
79
+ : a.line_start !== undefined ? `L${a.line_start}`
80
+ : '';
81
+ const text = u.text.replace(/\s+/g, ' ').trim().slice(0, 80);
82
+ if (!text)
83
+ continue;
84
+ lines.push(`- ${loc ? `[${loc}] ` : ''}${text}`);
85
+ if (lines.join('\n').length > maxChars) {
86
+ lines.push('- …');
87
+ break;
88
+ }
89
+ }
90
+ return lines.join('\n');
91
+ }
40
92
  // ---------------------------------------------------------------------------
41
93
  // 1. Extraction: Canonical Artifact → profile + Document Judgment Map
42
94
  // ---------------------------------------------------------------------------
43
- export function buildExtractionPrompt(units, ctx, unitLimit) {
95
+ export function buildExtractionPrompt(units, ctx, unitLimit, lang) {
44
96
  const concerns = ctx.concerns?.length ? `사용자 관심사: ${ctx.concerns.join(', ')}.` : '';
45
97
  const hints = [
46
98
  ctx.audience_hint && `대상 독자 힌트: ${ctx.audience_hint}`,
@@ -57,7 +109,9 @@ document_type, intent, audience, stakes, artifact_maturity를 추론하고, 확
57
109
  논지들 사이의 의존 구조도 표시한다: main_claims에 나온 순서가 곧 번호다(첫 번째=C1, 두 번째=C2 …).
58
110
  어떤 논거가 특정 논지를 뒷받침하면 그 논거의 supports_claim_ids에 논지 번호(예: "C1")를 넣고,
59
111
  어떤 논지가 다른 논지가 참이어야 성립하면 그 논지의 depends_on_claim_ids에 기대는 논지 번호를 넣는다.
60
- 확실하지 않으면 링크를 억지로 만들지 말고 생략한다(빈 배열).`;
112
+ 확실하지 않으면 링크를 억지로 만들지 말고 생략한다(빈 배열).
113
+
114
+ ${langDirective(lang)}`;
61
115
  const user = `아래는 검수할 문서의 단위(unit)들이다. 각 줄은 [unit_id] (종류 · 위치) 텍스트 형식이다.
62
116
 
63
117
  ${renderUnits(units, unitLimit)}
@@ -85,7 +139,7 @@ ${concerns}
85
139
  // ---------------------------------------------------------------------------
86
140
  // Quick Review: one bounded call for short documents
87
141
  // ---------------------------------------------------------------------------
88
- export function buildQuickReviewPrompt(units, ctx, unitLimit, today) {
142
+ export function buildQuickReviewPrompt(units, ctx, unitLimit, today, lang) {
89
143
  const context = [
90
144
  ctx.audience_hint && `Audience hint: ${ctx.audience_hint}`,
91
145
  ctx.decision_wanted && `Decision wanted: ${ctx.decision_wanted}`,
@@ -100,8 +154,6 @@ judgment spine: core_question, claim_evidence, hidden_assumption,
100
154
  human_judgment, falsifiable_followup.
101
155
 
102
156
  Keep the result selective and concise:
103
- - Write every user-facing value in Korean, even though these instructions and
104
- JSON keys are in English.
105
157
  - When the document contains unsupported causal claims, untested assumptions,
106
158
  or a human-only decision, return 2 to 5 material findings. Return zero only
107
159
  when there is genuinely no material issue. Do not manufacture one per lens.
@@ -111,20 +163,142 @@ Keep the result selective and concise:
111
163
  - Separate claims, evidence, and assumptions. Do not treat repetition as proof.
112
164
  - current_heading describes the document's present direction without deciding
113
165
  for the user.
166
+ - A judgment obligation is a DECISION the human must make, NOT a restatement of
167
+ a finding. Never repeat a finding as an obligation. Keep obligations distinct
168
+ from each other and from the findings.
114
169
  - Return at most 3 judgment obligations and 3 falsifiable followups.
115
170
  - followup check_by must be a real date after ${today}.
116
- - Complete every JSON field below; use empty arrays instead of filler.`;
171
+ - Complete every JSON field below; use empty arrays instead of filler.
172
+
173
+ ${langDirective(lang)}`;
117
174
  const user = `Document units:
118
175
  ${renderUnits(units, unitLimit)}
119
176
 
120
177
  ${context}
121
178
 
122
- Return this JSON shape:
179
+ Return this JSON shape. The FIRST product fields — findings, judgment_obligations,
180
+ followups — are what the user reads: fill them completely and specifically before
181
+ the map fields below them. The trailing map fields (evidence_items, tradeoffs,
182
+ stakeholders, open_questions, missing_sections) are secondary scaffolding — keep
183
+ them brief so they never crowd out the product fields.
123
184
  {
124
185
  "profile": { "document_type": "...", "intent": "...", "audience": "...", "stakes": "low|medium|high", "artifact_maturity": "...", "source_confidence": 0.0 },
125
186
  "core_question": "the actual decision question",
126
187
  "explicit_recommendation": "stated recommendation or empty",
127
188
  "implicit_recommendation": "implied recommendation or empty",
189
+ "findings": [ { "lens_id": "core_question|claim_evidence|hidden_assumption|human_judgment|falsifiable_followup", "title": "...", "detail": "...", "severity": "minor|caution|critical", "confidence": "low|medium|high", "suggested_action": "a concrete check", "unit_ids": ["..."] } ],
190
+ "judgment_obligations": [ { "statement": "...", "owner": "...", "why_human": "...", "decision_needed_by": "...", "evidence_needed": "...", "unit_ids": ["..."] } ],
191
+ "followups": [ { "predicate": "...", "pass_condition": "...", "fail_condition": "...", "check_by": "YYYY-MM-DD" } ],
192
+ "current_heading": "neutral summary of the document's current direction",
193
+ "main_claims": [ { "text": "...", "status": "supported|weak|unsupported|human_check|contradicted", "unit_ids": ["..."], "rationale": "...", "evidence_needed": "...", "fix_suggestion": "...", "depends_on_claim_ids": ["C1"] } ],
194
+ "assumptions": [ { "text": "...", "unit_ids": ["..."], "if_false": "..." } ],
195
+ "decision_points": [ { "text": "...", "human_only": true, "unit_ids": ["..."] } ],
196
+ "evidence_items": [ { "text": "...", "unit_ids": ["..."], "kind": "internal|external_cited|asserted", "supports_claim_ids": ["C1"] } ],
197
+ "tradeoffs": [ { "text": "...", "unit_ids": ["..."] } ],
198
+ "stakeholders": [ { "role": "...", "likely_objection": "...", "unit_ids": ["..."] } ],
199
+ "open_questions": [ { "text": "...", "unit_ids": ["..."] } ],
200
+ "missing_sections": [ { "label": "...", "why_it_matters": "..." } ]
201
+ }`;
202
+ return { system, user };
203
+ }
204
+ // ---------------------------------------------------------------------------
205
+ // Vision review: a single multimodal pass. The model SEES the document (a PDF
206
+ // rendered to pages, or a deck's embedded images) alongside the extracted text,
207
+ // so it can catch what the text extractor drops — a chart that contradicts the
208
+ // prose, a number living inside an image, a layout that changes the meaning.
209
+ // Anchors are PAGE/SLIDE numbers (the model sees pages, not our unit ids).
210
+ // ---------------------------------------------------------------------------
211
+ export function buildVisionReviewPrompt(units, ctx, unitLimit, today, lang, isDeck) {
212
+ const context = [
213
+ ctx.audience_hint && `Audience hint: ${ctx.audience_hint}`,
214
+ ctx.decision_wanted && `Decision wanted: ${ctx.decision_wanted}`,
215
+ ctx.biggest_worry && `Biggest worry: ${ctx.biggest_worry}`,
216
+ ctx.concerns?.length && `Requested concerns: ${ctx.concerns.join(', ')}`,
217
+ ].filter(Boolean).join('\n');
218
+ const anchorWord = isDeck ? 'slide' : 'page';
219
+ const system = `${SPINE}
220
+
221
+ You can SEE the document itself — ${isDeck ? "the deck's images (charts/diagrams) are attached" : 'the PDF pages are attached and rendered'} — in ADDITION to the extracted text below. Use both: read the visuals (charts, tables, figures, numbers inside images, layout) that plain text extraction misses, and cross-check them against the prose. Apply the complete five-part judgment spine: core_question, claim_evidence, hidden_assumption, human_judgment, falsifiable_followup.
222
+
223
+ Keep the result selective and concise:
224
+ - Return 2 to 5 material findings; zero only when genuinely nothing is material. Do not manufacture one per lens.
225
+ - Prefer findings that only the VISUAL reveals (a chart trend that contradicts a claim, a number shown only in an image) — that is the point of this pass.
226
+ - A judgment obligation is a DECISION the human must make, NOT a restatement of a finding. Keep obligations distinct from each other and from the findings.
227
+ - Each attached image is preceded by a "— Page N —" text label (a rendered PDF may arrive in several batches, so N is the ABSOLUTE page). Reference locations in prose the human way ("slide 4의 시장규모 차트", "3쪽 표") and, in the JSON, cite that page label's number in the "pages" array (e.g. [4] or [4,7]).
228
+ - followup check_by must be a real date after ${today}. Return at most 3 obligations and 3 followups.
229
+
230
+ ${langDirective(lang)}`;
231
+ const user = `Extracted text (for cross-reference — the attached ${isDeck ? 'images' : 'pages'} are the source of truth for anything visual):
232
+ ${renderUnits(units, unitLimit)}
233
+
234
+ ${context}
235
+
236
+ Return this JSON shape (findings/obligations/followups first — they are the product; cite ${anchorWord} numbers in "pages"):
237
+ {
238
+ "profile": { "document_type": "...", "intent": "...", "audience": "...", "stakes": "low|medium|high", "artifact_maturity": "...", "source_confidence": 0.0 },
239
+ "core_question": "the actual decision question",
240
+ "explicit_recommendation": "stated recommendation or empty",
241
+ "findings": [ { "lens_id": "core_question|claim_evidence|hidden_assumption|human_judgment|falsifiable_followup", "title": "...", "detail": "...", "severity": "minor|caution|critical", "confidence": "low|medium|high", "suggested_action": "a concrete check", "seen_in_visual": true, "pages": [1] } ],
242
+ "judgment_obligations": [ { "statement": "...", "owner": "...", "why_human": "...", "decision_needed_by": "...", "evidence_needed": "...", "pages": [1] } ],
243
+ "followups": [ { "predicate": "...", "pass_condition": "...", "fail_condition": "...", "check_by": "YYYY-MM-DD" } ],
244
+ "current_heading": "neutral summary of the document's current direction",
245
+ "main_claims": [ { "text": "...", "status": "supported|weak|unsupported|human_check|contradicted", "pages": [1], "rationale": "..." } ],
246
+ "assumptions": [ { "text": "...", "pages": [1], "if_false": "..." } ],
247
+ "decision_points": [ { "text": "...", "human_only": true, "pages": [1] } ]
248
+ }`;
249
+ return { system, user };
250
+ }
251
+ // ---------------------------------------------------------------------------
252
+ // Map step: one chunk of a long document → partial judgment map + findings.
253
+ // The reduce step (synthesis) merges chunks and de-dups, so a chunk extracts
254
+ // ONLY what its own text supports and never restates the whole document.
255
+ // ---------------------------------------------------------------------------
256
+ export function buildMapPrompt(units, ctx, chunkIndex, chunkCount, today, lang, outline = '') {
257
+ const context = [
258
+ ctx.audience_hint && `Audience hint: ${ctx.audience_hint}`,
259
+ ctx.decision_wanted && `Decision wanted: ${ctx.decision_wanted}`,
260
+ ctx.biggest_worry && `Biggest worry: ${ctx.biggest_worry}`,
261
+ ctx.concerns?.length && `Requested concerns: ${ctx.concerns.join(', ')}`,
262
+ ].filter(Boolean).join('\n');
263
+ // Contextual header (Anthropic "Contextual Retrieval" pattern, adapted): a
264
+ // chunk judged in isolation cannot see a section it does not contain, so it
265
+ // misses cross-section conflicts. Give it the whole-document outline as
266
+ // CONTEXT — never as new material to review — so it can flag when its own
267
+ // content genuinely contradicts another section, but still cites only its own units.
268
+ const outlineBlock = outline
269
+ ? `
270
+
271
+ 전체 문서 개요(맥락용 참고 — 이 구간 밖 항목은 그 자체로 지적하지 말 것):
272
+ ${outline}
273
+
274
+ 이 구간의 내용이 위 개요의 다른 항목과 **명백히 모순**될 때에만 그 관계를 finding으로
275
+ 낸다(그때도 unit_ids에는 이 구간의 unit_id만 넣는다). 개요 항목 자체를 지적 대상으로 삼지 않는다.`
276
+ : '';
277
+ const system = `${SPINE}
278
+
279
+ 이 문서는 길어서 여러 구간으로 나눠 검수한다. 지금은 ${chunkCount}개 구간 중
280
+ ${chunkIndex + 1}번째 구간이다. **이 구간의 원문 단위(units)에 실제로 담긴 것만**
281
+ 지도로 뽑는다 — 다른 구간의 내용이나 문서 전체 요약을 지어내지 않는다.${outlineBlock}
282
+
283
+ - 이 구간이 뒷받침하는 main_claims / assumptions / decision_points / findings만 낸다.
284
+ - 판단 스파인 5개(core_question, claim_evidence, hidden_assumption, human_judgment,
285
+ falsifiable_followup)를 이 구간에 적용한다. finding의 lens_id에 이 다섯 중 하나를
286
+ snake_case 그대로 넣는다(번역·결합·생략 금지).
287
+ - 이 구간에 실질 이슈가 있을 때만 finding 0~4개. 없으면 빈 배열. 렌즈마다 억지로 만들지 않는다.
288
+ - 모든 claim·assumption·finding은 이 구간의 unit_id를 하나 이상 인용한다.
289
+ - core_question은 이 구간에서 읽히는 결정 질문의 최선 추정이다(뒤 구간에서 갱신될 수 있다).
290
+ - obligations·followups는 여기서 내지 않는다(전체를 합친 뒤 한 번에 만든다).
291
+
292
+ ${langDirective(lang)}`;
293
+ const user = `구간 ${chunkIndex + 1}/${chunkCount}의 원문 단위:
294
+ ${renderUnits(units, units.length)}
295
+
296
+ ${context}
297
+
298
+ 다음 JSON을 출력하라(이 구간에 없는 항목은 빈 배열):
299
+ {
300
+ "profile": { "document_type": "...", "intent": "...", "audience": "...", "stakes": "low|medium|high", "artifact_maturity": "...", "source_confidence": 0.0 },
301
+ "core_question": "이 구간에서 읽히는 결정 질문(최선 추정)",
128
302
  "main_claims": [ { "text": "...", "status": "supported|weak|unsupported|human_check|contradicted", "unit_ids": ["..."], "rationale": "...", "evidence_needed": "...", "fix_suggestion": "...", "depends_on_claim_ids": ["C1"] } ],
129
303
  "evidence_items": [ { "text": "...", "unit_ids": ["..."], "kind": "internal|external_cited|asserted", "supports_claim_ids": ["C1"] } ],
130
304
  "assumptions": [ { "text": "...", "unit_ids": ["..."], "if_false": "..." } ],
@@ -133,17 +307,16 @@ Return this JSON shape:
133
307
  "open_questions": [ { "text": "...", "unit_ids": ["..."] } ],
134
308
  "decision_points": [ { "text": "...", "human_only": true, "unit_ids": ["..."] } ],
135
309
  "missing_sections": [ { "label": "...", "why_it_matters": "..." } ],
136
- "findings": [ { "lens_id": "core_question|claim_evidence|hidden_assumption|human_judgment|falsifiable_followup", "title": "...", "detail": "...", "severity": "minor|caution|critical", "confidence": "low|medium|high", "suggested_action": "a concrete check", "unit_ids": ["..."] } ],
137
- "current_heading": "neutral summary of the document's current direction",
138
- "judgment_obligations": [ { "statement": "...", "owner": "...", "why_human": "...", "decision_needed_by": "...", "evidence_needed": "...", "unit_ids": ["..."] } ],
139
- "followups": [ { "predicate": "...", "pass_condition": "...", "fail_condition": "...", "check_by": "YYYY-MM-DD" } ]
310
+ "findings": [ { "lens_id": "core_question|claim_evidence|hidden_assumption|human_judgment|falsifiable_followup", "title": "...", "detail": "...", "severity": "minor|caution|critical", "confidence": "low|medium|high", "suggested_action": "구체적 확인", "unit_ids": ["..."] } ],
311
+ "current_heading": " 구간의 중립적 방향 "
140
312
  }`;
313
+ void today;
141
314
  return { system, user };
142
315
  }
143
316
  // ---------------------------------------------------------------------------
144
317
  // 2. Lens Review: one lens → findings only
145
318
  // ---------------------------------------------------------------------------
146
- export function buildLensPrompt(lens, mapSummary, units, unitLimit) {
319
+ export function buildLensPrompt(lens, mapSummary, units, unitLimit, lang) {
147
320
  const system = `${SPINE}
148
321
 
149
322
  너는 "${lens.label}" 렌즈다. 목적: ${lens.purpose}
@@ -154,7 +327,9 @@ ${lens.review_questions.map((q) => `- ${q}`).join('\n')}
154
327
  ${lens.failure_modes.map((f) => `- "${f}"`).join('\n')}
155
328
 
156
329
  너의 렌즈에 해당하는 finding만 낸다. 전체 결론이나 다른 렌즈의 영역은 건드리지 않는다.
157
- finding이 없으면 빈 배열을 낸다(억지로 만들지 않는다).`;
330
+ finding이 없으면 빈 배열을 낸다(억지로 만들지 않는다).
331
+
332
+ ${langDirective(lang)}`;
158
333
  const user = `문서 판단 지도 요약:
159
334
  ${mapSummary}
160
335
 
@@ -179,14 +354,16 @@ ${renderUnits(units, unitLimit)}
179
354
  // ---------------------------------------------------------------------------
180
355
  // 3. Synthesis: lens findings + map → receipt-level fields
181
356
  // ---------------------------------------------------------------------------
182
- export function buildSynthesisPrompt(mapSummary, findingsSummary, ctx, today) {
357
+ export function buildSynthesisPrompt(mapSummary, findingsSummary, ctx, today, lang) {
183
358
  const system = `${SPINE}
184
359
 
185
360
  이번 단계는 "종합"이다. 렌즈 결과를 Judgment Receipt 필드로 압축한다.
186
361
  - current_heading은 중립적 방향 한 줄이다. "진행하세요" 같은 평결이 아니다.
187
362
  - judgment_obligations는 사람이 직접 판단해야 하는 항목이다. AI가 대신 결론내지 않는다.
188
363
  - 사용자가 결론을 냈다고 말하지 않는다.
189
- - follow-up predicate는 나중에 현실이 pass/fail로 답할 수 있어야 한다. check_by는 ${today} 이후의 미래 날짜(YYYY-MM-DD)로 제안한다.`;
364
+ - follow-up predicate는 나중에 현실이 pass/fail로 답할 수 있어야 한다. check_by는 ${today} 이후의 미래 날짜(YYYY-MM-DD)로 제안한다.
365
+
366
+ ${langDirective(lang)}`;
190
367
  const worry = ctx.biggest_worry ? `사용자가 가장 불안해한 부분: ${ctx.biggest_worry}` : '';
191
368
  const user = `문서 판단 지도 요약:
192
369
  ${mapSummary}
@@ -17,22 +17,23 @@ const EXTRACTION_WEIGHT = {
17
17
  low: 35,
18
18
  unsupported: 0,
19
19
  };
20
- export function scoreReviewability(artifact, map) {
20
+ export function scoreReviewability(artifact, map, lang = 'ko') {
21
+ const t = (ko, en) => (lang === 'en' ? en : ko);
21
22
  const reasons = [];
22
23
  // 1. extraction
23
24
  const extraction = EXTRACTION_WEIGHT[artifact.extraction_quality];
24
25
  if (artifact.extraction_quality === 'unsupported') {
25
- reasons.push('텍스트를 추출하지 못해 전체 검수가 어렵습니다.');
26
+ reasons.push(t('텍스트를 추출하지 못해 전체 검수가 어렵습니다.', 'Text could not be extracted, so a full review is hard.'));
26
27
  }
27
28
  else if (artifact.extraction_quality === 'low') {
28
- reasons.push('텍스트 추출 품질이 낮습니다.');
29
+ reasons.push(t('텍스트 추출 품질이 낮습니다.', 'Text extraction quality is low.'));
29
30
  }
30
31
  // 2. structure — headings/sections/slides give the reviewer scaffolding
31
32
  const s = artifact.detected_structure;
32
33
  const structuralUnits = (s.heading_count ?? 0) + (s.slide_count ?? 0) + (s.section_count ?? 0);
33
34
  const structure = clamp(structuralUnits === 0 ? 40 : 60 + Math.min(40, structuralUnits * 8));
34
35
  if (structuralUnits === 0 && artifact.units.length > 0) {
35
- reasons.push('구조(제목/섹션)가 없어 근거 위치를 짚기 어렵습니다.');
36
+ reasons.push(t('구조(제목/섹션)가 없어 근거 위치를 짚기 어렵습니다.', 'Without structure (headings/sections) it is hard to pin down where the evidence sits.'));
36
37
  }
37
38
  // 3. anchor coverage — share of units that carry a usable anchor
38
39
  const anchored = artifact.units.filter((u) => hasAnchor(u.source_anchor)).length;
@@ -45,7 +46,7 @@ export function scoreReviewability(artifact, map) {
45
46
  const decisionPoints = map.decision_points.length;
46
47
  decision_clarity = clamp((hasCore ? 55 : 20) + Math.min(45, decisionPoints * 15));
47
48
  if (!hasCore)
48
- reasons.push('문서에서 결정할 질문이 뚜렷하지 않습니다.');
49
+ reasons.push(t('문서에서 결정할 질문이 뚜렷하지 않습니다.', 'The document does not make the decision question clear.'));
49
50
  const claims = map.main_claims.length || 1;
50
51
  const withEvidence = map.main_claims.filter((c) => c.anchors.length > 0 || c.status === 'supported').length;
51
52
  evidence_availability = clamp(Math.round((withEvidence / claims) * 100));
@@ -57,10 +58,10 @@ export function scoreReviewability(artifact, map) {
57
58
  evidence_availability * 0.15);
58
59
  const band = reviewabilityBand(score);
59
60
  if (band === 'insufficient') {
60
- reasons.push('전체 receipt 대신 "무엇이 빠졌는지"를 먼저 보여줍니다.');
61
+ reasons.push(t('전체 receipt 대신 "무엇이 빠졌는지"를 먼저 보여줍니다.', 'Instead of a full receipt, it shows "what is missing" first.'));
61
62
  }
62
63
  else if (band === 'limited') {
63
- reasons.push('제한적으로만 검수하고 부족한 맥락을 요청합니다.');
64
+ reasons.push(t('제한적으로만 검수하고 부족한 맥락을 요청합니다.', 'It reviews only partially and asks for the missing context.'));
64
65
  }
65
66
  return {
66
67
  score,