argus-decision-mcp 1.0.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 +168 -0
- package/SECURITY.md +35 -0
- package/dist/index.js +13 -0
- package/dist/lib/argus-dir.js +85 -0
- package/dist/lib/atomic-write.js +19 -0
- package/dist/lib/continuity.js +31 -0
- package/dist/lib/deBom.js +3 -0
- package/dist/lib/discipline.js +42 -0
- package/dist/lib/due-note.js +54 -0
- package/dist/lib/elicit.js +30 -0
- package/dist/lib/envelope.js +13 -0
- package/dist/lib/layout.js +32 -0
- package/dist/lib/ledger-append.js +27 -0
- package/dist/lib/ledger-replay.js +295 -0
- package/dist/lib/locale.js +20 -0
- package/dist/lib/log.js +14 -0
- package/dist/lib/numeric-drift.js +32 -0
- package/dist/lib/overfire-gate.js +39 -0
- package/dist/lib/premises.js +153 -0
- package/dist/lib/privacy.js +22 -0
- package/dist/lib/push-account.js +70 -0
- package/dist/lib/receipt.js +83 -0
- package/dist/lib/render-receipt.js +144 -0
- package/dist/lib/resolve-contract.js +17 -0
- package/dist/lib/resolve-today.js +47 -0
- package/dist/lib/review/ids.js +29 -0
- package/dist/lib/review/index.js +18 -0
- package/dist/lib/review/ingest.js +294 -0
- package/dist/lib/review/lenses.js +132 -0
- package/dist/lib/review/prompts.js +155 -0
- package/dist/lib/review/render.js +77 -0
- package/dist/lib/review/reviewability.js +83 -0
- package/dist/lib/review/routing.js +147 -0
- package/dist/lib/review/schema.js +36 -0
- package/dist/lib/safe-path.js +70 -0
- package/dist/lib/spine.js +79 -0
- package/dist/lib/state-machine.js +80 -0
- package/dist/lib/surfaces.js +106 -0
- package/dist/lib/validate-crux.js +35 -0
- package/dist/lib/validate-seal.js +48 -0
- package/dist/prompts.js +72 -0
- package/dist/resources.js +122 -0
- package/dist/server.js +107 -0
- package/dist/tools/amend-dismiss.js +90 -0
- package/dist/tools/check-in.js +158 -0
- package/dist/tools/errors.js +23 -0
- package/dist/tools/index.js +14 -0
- package/dist/tools/init-config.js +99 -0
- package/dist/tools/open-decision.js +128 -0
- package/dist/tools/premises.js +209 -0
- package/dist/tools/recall.js +157 -0
- package/dist/tools/recheck.js +147 -0
- package/dist/tools/review.js +182 -0
- package/dist/tools/seal.js +152 -0
- package/dist/tools/settle.js +126 -0
- package/dist/tools/sync.js +129 -0
- package/dist/tools/tool-types.js +32 -0
- package/package.json +64 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { deBom } from './deBom.js';
|
|
3
|
+
import { atomicWriteJson } from './atomic-write.js';
|
|
4
|
+
import { receiptPath } from './layout.js';
|
|
5
|
+
import { SCHEMA_VERSION, SPINE_INVARIANTS } from './spine.js';
|
|
6
|
+
/**
|
|
7
|
+
* The Judgment Receipt (blueprint §2.3 + addendum C/E/N7). The differentiated
|
|
8
|
+
* asset: a falsifiable prediction sealed, brought back, and checked against
|
|
9
|
+
* reality, with AI-verdict literally null.
|
|
10
|
+
*
|
|
11
|
+
* The seal-time fields (real_question / unverified_assumption / human_only /
|
|
12
|
+
* human_judgment) are captured at SEAL — without them the receipt's
|
|
13
|
+
* "...made by Me. (not the model)" line is blank, which is the whole point.
|
|
14
|
+
*/
|
|
15
|
+
/** Sentinel for a judgment field the user chose not to name (addendum: explicit
|
|
16
|
+
* skip trace, not a silent blank and not a forced gate). */
|
|
17
|
+
export const SKIPPED = '(skipped)';
|
|
18
|
+
/** Write the seal-time receipt. Single write (no read-merge race, E).
|
|
19
|
+
* Judgment fields left unnamed are recorded as an explicit skip, not a silent
|
|
20
|
+
* blank — the spine keeps the escape (you can still seal) but the omission is
|
|
21
|
+
* honest and visible. */
|
|
22
|
+
export async function writeSealReceipt(argusDir, seed, now) {
|
|
23
|
+
const skipped = [];
|
|
24
|
+
const field = (name, value) => {
|
|
25
|
+
if (typeof value === 'string' && value.trim().length > 0)
|
|
26
|
+
return value;
|
|
27
|
+
skipped.push(name);
|
|
28
|
+
return SKIPPED;
|
|
29
|
+
};
|
|
30
|
+
const receipt = {
|
|
31
|
+
v: SCHEMA_VERSION,
|
|
32
|
+
id: seed.id,
|
|
33
|
+
created_at: now,
|
|
34
|
+
real_question: field('real_question', seed.real_question),
|
|
35
|
+
unverified_assumption: field('unverified_assumption', seed.unverified_assumption),
|
|
36
|
+
human_only: field('human_only', seed.human_only),
|
|
37
|
+
human_judgment: field('human_judgment', seed.human_judgment),
|
|
38
|
+
human_judgment_owner: 'user',
|
|
39
|
+
skipped,
|
|
40
|
+
predicate: seed.predicate,
|
|
41
|
+
check_by: seed.check_by,
|
|
42
|
+
...(seed.basis ? { basis: seed.basis } : {}),
|
|
43
|
+
ai_verdict: SPINE_INVARIANTS.aiVerdict,
|
|
44
|
+
};
|
|
45
|
+
await atomicWriteJson(receiptPath(argusDir, seed.id), receipt);
|
|
46
|
+
return receipt;
|
|
47
|
+
}
|
|
48
|
+
/** Patch the receipt at settle time. Reads the seal-time receipt, adds the reality fields, single atomic write. */
|
|
49
|
+
export async function writeSettleReceipt(argusDir, id, patch,
|
|
50
|
+
/** Ledger-replay fallback when the seal-time receipt file was lost (11 S7):
|
|
51
|
+
* without it the rebuilt receipt printed empty quotes for the prediction. */
|
|
52
|
+
fallback) {
|
|
53
|
+
const existing = readReceipt(argusDir, id);
|
|
54
|
+
const base = existing ?? {
|
|
55
|
+
v: SCHEMA_VERSION, id, created_at: patch.settled_at,
|
|
56
|
+
real_question: SKIPPED, unverified_assumption: SKIPPED, human_only: SKIPPED, human_judgment: SKIPPED,
|
|
57
|
+
human_judgment_owner: 'user', skipped: ['real_question', 'unverified_assumption', 'human_only', 'human_judgment'],
|
|
58
|
+
predicate: fallback?.predicate ?? '', check_by: fallback?.check_by ?? '',
|
|
59
|
+
ai_verdict: SPINE_INVARIANTS.aiVerdict,
|
|
60
|
+
};
|
|
61
|
+
const assumption_held = patch.outcome === 'held' ? true :
|
|
62
|
+
patch.outcome === 'avoided' || patch.outcome === 'partial' ? false :
|
|
63
|
+
null;
|
|
64
|
+
const merged = {
|
|
65
|
+
...base,
|
|
66
|
+
settled_at: patch.settled_at,
|
|
67
|
+
what_happened: patch.what_happened,
|
|
68
|
+
outcome: patch.outcome,
|
|
69
|
+
outcome_source: 'user_stated',
|
|
70
|
+
assumption_held,
|
|
71
|
+
ai_verdict: SPINE_INVARIANTS.aiVerdict,
|
|
72
|
+
};
|
|
73
|
+
await atomicWriteJson(receiptPath(argusDir, id), merged);
|
|
74
|
+
return merged;
|
|
75
|
+
}
|
|
76
|
+
export function readReceipt(argusDir, id) {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(deBom(fs.readFileSync(receiptPath(argusDir, id), 'utf8')));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { SURFACES } from './surfaces.js';
|
|
2
|
+
export function renderReceipt(r, premises) {
|
|
3
|
+
const L = [];
|
|
4
|
+
const sealed = r.created_at ? r.created_at.slice(0, 10) : '—';
|
|
5
|
+
const settled = r.settled_at ? r.settled_at.slice(0, 10) : '(open)';
|
|
6
|
+
L.push('┌─ ARGUS · JUDGMENT RECEIPT ────────────────────────────────┐');
|
|
7
|
+
L.push(` Sealed ${sealed} Settled ${settled}`);
|
|
8
|
+
const skipped = new Set(r.skipped ?? []);
|
|
9
|
+
const show = (v, field) => (skipped.has(field) ? '— (you skipped naming this)' : wrap(v));
|
|
10
|
+
L.push('');
|
|
11
|
+
L.push(' THE REAL QUESTION');
|
|
12
|
+
L.push(` ${show(r.real_question, 'real_question')}`);
|
|
13
|
+
L.push(' THE UNVERIFIED ASSUMPTION');
|
|
14
|
+
const assumptionSkipped = skipped.has('unverified_assumption');
|
|
15
|
+
if (assumptionSkipped && premises?.headline) {
|
|
16
|
+
// The premise set is canonical — a tracked load-bearing premise stands in
|
|
17
|
+
// for a skipped seal-time field (plan v5 §5.4).
|
|
18
|
+
L.push(` ${wrap(premises.headline)}`);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
L.push(` ${show(r.unverified_assumption, 'unverified_assumption')}`);
|
|
22
|
+
}
|
|
23
|
+
if (premises && premises.tracked > 0) {
|
|
24
|
+
L.push(` (+${premises.tracked} premise(s) tracked · ${premises.changed_at_recheck} changed at re-check — argus_recall view=premises)`);
|
|
25
|
+
}
|
|
26
|
+
L.push(` HUMAN-ONLY CALL ${show(r.human_only, 'human_only')}`);
|
|
27
|
+
L.push(' …made by Me. (not the model)');
|
|
28
|
+
if (r.basis) {
|
|
29
|
+
L.push(` …called as ${r.basis}`);
|
|
30
|
+
}
|
|
31
|
+
L.push('');
|
|
32
|
+
L.push(` YOU PREDICTED "${wrap(r.predicate)}" (check-by ${r.check_by})`);
|
|
33
|
+
if (r.what_happened) {
|
|
34
|
+
L.push(` WHAT HAPPENED ${wrap(r.what_happened)}`);
|
|
35
|
+
}
|
|
36
|
+
L.push(' ─────────────────────────────────────────────────────────');
|
|
37
|
+
L.push(' AI VERDICT ON THIS DECISION ······················ NONE');
|
|
38
|
+
L.push(' The model never graded you. Reality did.');
|
|
39
|
+
L.push('└──────────────────────────────── argus · seal → settle ⚓ ─┘');
|
|
40
|
+
return L.join('\n');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* renderSeal — the sealing confirmation `data.seal_text` (P1-E2 = 12 §3.1).
|
|
44
|
+
*
|
|
45
|
+
* The terminal twin of the webapp's seal certificate plate (P1-A3 S4): the
|
|
46
|
+
* user's predicate as a quote block, an HONEST provenance line, the two date
|
|
47
|
+
* rows, and the "not a grade" closing — same copy family, text stage.
|
|
48
|
+
*
|
|
49
|
+
* Spine rules baked in:
|
|
50
|
+
* - provenance is a FACT statement: 'user' → "these words are yours";
|
|
51
|
+
* 'ai_surfaced' → "Argus drafted these words — you have not yet made them
|
|
52
|
+
* yours". Never a false ownership narrative, and never a gate (sealing
|
|
53
|
+
* as-is stays possible).
|
|
54
|
+
* - zero hype, and one signature: the closing anchor ⚓ at the footer — the
|
|
55
|
+
* founder's "period mark" (2026-07-03), stamped only where a loop is tied
|
|
56
|
+
* (seal footer, settle receipt footer). Never sprinkled elsewhere.
|
|
57
|
+
* - the day diff comes from resolveToday's `today`, not a fresh wall clock.
|
|
58
|
+
*/
|
|
59
|
+
export function renderSeal(opts) {
|
|
60
|
+
const S = SURFACES[opts.locale].seal;
|
|
61
|
+
const L = [];
|
|
62
|
+
const top = '┌─ ' + S.header + ' ' + '─'.repeat(Math.max(2, 56 - S.header.length)) + '┐';
|
|
63
|
+
const bottom = '└' + '─'.repeat(Math.max(2, 54 - S.footer.length)) + ' ' + S.footer + ' ─┘';
|
|
64
|
+
L.push(top);
|
|
65
|
+
L.push('');
|
|
66
|
+
// the quote block — the user's own falsifiable sentence (continuation lines
|
|
67
|
+
// sit inside the opening quote)
|
|
68
|
+
L.push(` "${wrap(opts.predicate, 50).split('\n ').join('\n ')}"`);
|
|
69
|
+
L.push('');
|
|
70
|
+
const ownerLine = ` ${opts.predicate_owner === 'user' ? S.owner_user : S.owner_ai}`;
|
|
71
|
+
const ownerTag = `(predicate_owner: ${opts.predicate_owner})`;
|
|
72
|
+
L.push(ownerLine.length >= 40 ? `${ownerLine} ${ownerTag}` : ownerLine.padEnd(40) + ownerTag);
|
|
73
|
+
L.push('');
|
|
74
|
+
const labelWidth = Math.max(S.sealed_label.length, S.answers_label.length) + 4;
|
|
75
|
+
const days = Math.round((Date.parse(opts.check_by) - Date.parse(opts.today)) / 86400000);
|
|
76
|
+
const daysOut = Number.isFinite(days) && days > 0 ? ` ${S.days_out(days)}` : '';
|
|
77
|
+
L.push(` ${S.sealed_label.padEnd(labelWidth)}${opts.sealed_on}`);
|
|
78
|
+
L.push(` ${S.answers_label.padEnd(labelWidth)}${opts.check_by}${daysOut}`);
|
|
79
|
+
L.push('');
|
|
80
|
+
L.push(` ${S.closing[0]}`);
|
|
81
|
+
L.push(` ${S.closing[1]}`);
|
|
82
|
+
L.push('');
|
|
83
|
+
L.push(bottom);
|
|
84
|
+
return L.join('\n');
|
|
85
|
+
}
|
|
86
|
+
const WAKE_TOP = 5; // per-group visible lines — the due_premises TOP=5 convention
|
|
87
|
+
export function renderWake(contracts, stats, today, locale,
|
|
88
|
+
/** YYYY-MM-DD of the oldest ledger event (LedgerState.oldest_ts). */
|
|
89
|
+
recordSince) {
|
|
90
|
+
const W = SURFACES[locale].wake;
|
|
91
|
+
const WIDTH = 60;
|
|
92
|
+
const byCheckBy = (a, b) => (a.check_by || '9999-99-99') < (b.check_by || '9999-99-99') ? -1 : 1;
|
|
93
|
+
const sealed = contracts.filter((c) => c.status === 'sealed').sort(byCheckBy);
|
|
94
|
+
const settled = contracts.filter((c) => c.status === 'settled').sort(byCheckBy);
|
|
95
|
+
const overdue = sealed.filter((c) => c.check_by && c.check_by <= today);
|
|
96
|
+
const waiting = sealed.filter((c) => !c.check_by || c.check_by > today);
|
|
97
|
+
const mmdd = (d) => (d && d.length >= 10 ? d.slice(5, 10) : d || '—');
|
|
98
|
+
const label = (c) => {
|
|
99
|
+
const raw = (c.predicate || c.text || '').replace(/\s+/g, ' ').trim();
|
|
100
|
+
return raw.length > 24 ? raw.slice(0, 23) + '…' : raw;
|
|
101
|
+
};
|
|
102
|
+
const idCol = (id) => (id.length > 10 ? id.slice(0, 9) + '…' : id).padEnd(10);
|
|
103
|
+
const L = [];
|
|
104
|
+
const headText = W.header + ' ';
|
|
105
|
+
const countText = ' ' + W.counts(contracts.length, sealed.length, settled.length) + ' ';
|
|
106
|
+
L.push('┌─ ' + headText + '─'.repeat(Math.max(2, WIDTH - 5 - headText.length - countText.length)) + countText + '─┐');
|
|
107
|
+
const pushGroup = (rows, head, line, hint) => {
|
|
108
|
+
if (rows.length === 0)
|
|
109
|
+
return;
|
|
110
|
+
L.push('');
|
|
111
|
+
L.push(hint ? ` ${head}`.padEnd(WIDTH - 2 - hint.length) + hint : ` ${head}`);
|
|
112
|
+
for (const c of rows.slice(0, WAKE_TOP))
|
|
113
|
+
L.push(` ${line(c)}`);
|
|
114
|
+
if (rows.length > WAKE_TOP)
|
|
115
|
+
L.push(` ${W.more(rows.length - WAKE_TOP)}`);
|
|
116
|
+
};
|
|
117
|
+
pushGroup(overdue, W.overdue_group(overdue.length), (c) => {
|
|
118
|
+
const days = Math.max(0, Math.round((Date.parse(today) - Date.parse(c.check_by)) / 86400000));
|
|
119
|
+
return `${idCol(c.id)} "${label(c)}" ${mmdd(c.check_by)} · ${W.days_past(days)}`;
|
|
120
|
+
}, W.overdue_hint);
|
|
121
|
+
pushGroup(waiting, W.waiting_group(waiting.length), (c) => `${idCol(c.id)} "${label(c)}" ${W.answer_on(mmdd(c.check_by))}`);
|
|
122
|
+
pushGroup(settled, W.settled_group(settled.length, stats.held, stats.avoided, stats.partial), (c) => `${idCol(c.id)} ${(c.outcome || '—').padEnd(9)} ${mmdd(c.settled_on || c.check_by)} "${label(c)}"`);
|
|
123
|
+
L.push('');
|
|
124
|
+
const foot = recordSince ? ' ' + W.record_since(recordSince) + ' ' : '';
|
|
125
|
+
L.push('└' + '─'.repeat(Math.max(2, WIDTH - 2 - foot.length)) + foot + '─┘');
|
|
126
|
+
return L.join('\n');
|
|
127
|
+
}
|
|
128
|
+
function wrap(s, width = 54) {
|
|
129
|
+
const words = s.split(/\s+/);
|
|
130
|
+
const lines = [];
|
|
131
|
+
let cur = '';
|
|
132
|
+
for (const w of words) {
|
|
133
|
+
if ((cur + ' ' + w).trim().length > width) {
|
|
134
|
+
lines.push(cur.trim());
|
|
135
|
+
cur = w;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
cur += ' ' + w;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (cur.trim())
|
|
142
|
+
lines.push(cur.trim());
|
|
143
|
+
return lines.join('\n ');
|
|
144
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { replayLedger } from './ledger-replay.js';
|
|
2
|
+
import { deriveState } from './state-machine.js';
|
|
3
|
+
import { receiptPath } from './layout.js';
|
|
4
|
+
import { readReceipt } from './receipt.js';
|
|
5
|
+
export function resolveContract(argusDir, id, today) {
|
|
6
|
+
const ledger = replayLedger(argusDir, today);
|
|
7
|
+
const entry = ledger.contracts.get(id);
|
|
8
|
+
return {
|
|
9
|
+
id,
|
|
10
|
+
state: deriveState(entry, today),
|
|
11
|
+
entry,
|
|
12
|
+
predicate: entry?.predicate,
|
|
13
|
+
check_by: entry?.check_by,
|
|
14
|
+
receiptPath: receiptPath(argusDir, id),
|
|
15
|
+
receipt: readReceipt(argusDir, id),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic "today" (blueprint §3.5 / addendum M4 + N).
|
|
3
|
+
*
|
|
4
|
+
* The old `localToday()` read `new Date()` local fields, so DUE computation
|
|
5
|
+
* depended on the server's local timezone and ignored every override. This
|
|
6
|
+
* replaces it with one source: an explicit override wins, else a fixed tz
|
|
7
|
+
* (ARGUS_TZ env, default UTC) formats the wall clock once per request.
|
|
8
|
+
*
|
|
9
|
+
* `Date.now()` is the only ambient time read; everything downstream takes the
|
|
10
|
+
* resulting YYYY-MM-DD string so it is fully testable.
|
|
11
|
+
*/
|
|
12
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
13
|
+
export function resolveToday(opts) {
|
|
14
|
+
const override = opts?.override;
|
|
15
|
+
if (typeof override === 'string' && DATE_RE.test(override))
|
|
16
|
+
return override;
|
|
17
|
+
const tz = opts?.tz || process.env['ARGUS_TZ'] || 'UTC';
|
|
18
|
+
return formatInTz(new Date(), tz);
|
|
19
|
+
}
|
|
20
|
+
function formatInTz(d, tz) {
|
|
21
|
+
try {
|
|
22
|
+
// en-CA yields YYYY-MM-DD; timeZone applies the fixed zone deterministically.
|
|
23
|
+
const fmt = new Intl.DateTimeFormat('en-CA', {
|
|
24
|
+
timeZone: tz,
|
|
25
|
+
year: 'numeric',
|
|
26
|
+
month: '2-digit',
|
|
27
|
+
day: '2-digit',
|
|
28
|
+
});
|
|
29
|
+
const parts = fmt.format(d); // "2026-07-01"
|
|
30
|
+
if (DATE_RE.test(parts))
|
|
31
|
+
return parts;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// invalid tz — fall through to UTC
|
|
35
|
+
}
|
|
36
|
+
return d.toISOString().slice(0, 10);
|
|
37
|
+
}
|
|
38
|
+
export function asDate(value) {
|
|
39
|
+
if (typeof value !== 'string')
|
|
40
|
+
return null;
|
|
41
|
+
const m = value.match(/(\d{4})-(\d{2})-(\d{2})/);
|
|
42
|
+
return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
|
|
43
|
+
}
|
|
44
|
+
export function isFutureDate(check, today) {
|
|
45
|
+
const c = asDate(check);
|
|
46
|
+
return !!c && c > today;
|
|
47
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic ids + fingerprints for the review pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Fingerprints are deterministic (same text → same hash) so we can cache
|
|
5
|
+
* analysis and detect version drift when a user re-uploads a document. Ids that
|
|
6
|
+
* must be unique per object use the app's generateId(); ids that must be stable
|
|
7
|
+
* across re-runs (units, claims) derive from content via djb2 — the same hash
|
|
8
|
+
* the codebase already uses for stablePredicateId.
|
|
9
|
+
*/
|
|
10
|
+
/** djb2 — stable, fast, non-cryptographic. Matches decision-contract.ts. */
|
|
11
|
+
export function djb2(input) {
|
|
12
|
+
let hash = 5381;
|
|
13
|
+
for (let i = 0; i < input.length; i++) {
|
|
14
|
+
hash = ((hash << 5) + hash + input.charCodeAt(i)) & 0xffffffff;
|
|
15
|
+
}
|
|
16
|
+
// unsigned hex
|
|
17
|
+
return (hash >>> 0).toString(16);
|
|
18
|
+
}
|
|
19
|
+
/** Normalizes whitespace so trivial reformatting doesn't change the fingerprint. */
|
|
20
|
+
export function normalizeForFingerprint(text) {
|
|
21
|
+
return text.replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
|
22
|
+
}
|
|
23
|
+
export function fingerprint(text) {
|
|
24
|
+
return djb2(normalizeForFingerprint(text));
|
|
25
|
+
}
|
|
26
|
+
/** Stable id for a content-derived object (unit/claim), scoped by a prefix. */
|
|
27
|
+
export function stableId(prefix, ...parts) {
|
|
28
|
+
return `${prefix}_${djb2(parts.join(''))}`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argus review core — ported verbatim from the webapp's src/lib/review so the
|
|
3
|
+
* MCP and the webapp share ONE ingest / anchoring / reviewability / routing /
|
|
4
|
+
* prompt brain (design doc §"웹앱과 MCP는 같은 Judgment Receipt"). The only
|
|
5
|
+
* difference is the `.js` import extensions NodeNext requires. A drift guard
|
|
6
|
+
* (webapp review-mcp-drift.test.ts) fails CI if the two copies diverge.
|
|
7
|
+
*
|
|
8
|
+
* NOT ported: llm-adapter.ts + pipeline.ts. In the MCP the host agent IS the
|
|
9
|
+
* model, so the tool hands it the SSOT prompts instead of calling an LLM itself.
|
|
10
|
+
*/
|
|
11
|
+
export * from './schema.js';
|
|
12
|
+
export { ingest } from './ingest.js';
|
|
13
|
+
export { scoreReviewability } from './reviewability.js';
|
|
14
|
+
export { LENSES, getLens, ALL_LENS_IDS, LENS_VERSION } from './lenses.js';
|
|
15
|
+
export { routeLenses, applies } from './routing.js';
|
|
16
|
+
export { buildExtractionPrompt, buildLensPrompt, buildSynthesisPrompt, renderUnits } from './prompts.js';
|
|
17
|
+
export { receiptToMarkdown } from './render.js';
|
|
18
|
+
export { fingerprint, stableId, djb2 } from './ids.js';
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact-first ingestion: every input normalizes into ONE CanonicalArtifact
|
|
3
|
+
* before analysis (design doc §"입력 아키텍처: Artifact-first Ingestion").
|
|
4
|
+
*
|
|
5
|
+
* Support tiers (design doc §"MVP가 여전히 크다"):
|
|
6
|
+
* - Tier 0 (full): paste, markdown, txt, llm_answer, transcript, pr_diff.
|
|
7
|
+
* Real structural extraction with line/char/section anchors.
|
|
8
|
+
* - Tier 1/2 (data-model only): pdf, docx, pptx. The schema accepts them from
|
|
9
|
+
* day one, but until a real parser is wired we degrade HONESTLY to
|
|
10
|
+
* `unsupported` with a recovery hint — never a confident fake review.
|
|
11
|
+
* A caller that already has extracted text (a parser, or the MCP host) can
|
|
12
|
+
* pass `pre_extracted` and it flows through the same anchored pipeline.
|
|
13
|
+
*
|
|
14
|
+
* The honesty rule (design doc §"실패 UX"): a low/unsupported extraction is a
|
|
15
|
+
* first-class state that reaches the UI, not a swallowed error.
|
|
16
|
+
*/
|
|
17
|
+
import { fingerprint, stableId } from './ids.js';
|
|
18
|
+
const TIER0 = ['paste', 'markdown', 'txt', 'llm_answer', 'transcript', 'pr_diff'];
|
|
19
|
+
const BINARY = ['pdf', 'docx', 'pptx'];
|
|
20
|
+
export function ingest(input) {
|
|
21
|
+
const privacy_mode = input.privacy_mode ?? 'receipt_only';
|
|
22
|
+
const title = (input.title || '').trim() || defaultTitle(input.source_kind);
|
|
23
|
+
// Pre-anchored units from a real parser (pdf pages / pptx slides): trust the
|
|
24
|
+
// parser's page/slide numbers rather than re-flattening to text.
|
|
25
|
+
if (input.pre_extracted_units && input.pre_extracted_units.length > 0) {
|
|
26
|
+
return fromUnits(input, title, privacy_mode);
|
|
27
|
+
}
|
|
28
|
+
// Binary format with no extracted text → honest degrade, data model intact.
|
|
29
|
+
if (BINARY.includes(input.source_kind) && !input.pre_extracted && !input.text) {
|
|
30
|
+
return degradedArtifact(input.source_kind, title, privacy_mode);
|
|
31
|
+
}
|
|
32
|
+
const text = (input.pre_extracted ?? input.text ?? '').replace(/\r\n/g, '\n');
|
|
33
|
+
if (!text.trim()) {
|
|
34
|
+
return degradedArtifact(input.source_kind, title, privacy_mode, 'empty');
|
|
35
|
+
}
|
|
36
|
+
const isDeck = input.source_kind === 'pptx';
|
|
37
|
+
const units = input.source_kind === 'transcript'
|
|
38
|
+
? extractTranscript(text)
|
|
39
|
+
: extractStructured(text, isDeck);
|
|
40
|
+
const fp = fingerprint(text);
|
|
41
|
+
// Extraction quality: Tier-0 text is high; binary-but-pre-extracted is medium
|
|
42
|
+
// (we trust the caller's parser but can't verify layout fidelity).
|
|
43
|
+
const quality = input.extraction_quality ?? (TIER0.includes(input.source_kind) ? 'high' : 'medium');
|
|
44
|
+
const notes = [...(input.extraction_notes ?? [])];
|
|
45
|
+
if (BINARY.includes(input.source_kind) && notes.length === 0) {
|
|
46
|
+
notes.push(input.source_kind === 'pptx'
|
|
47
|
+
? '이 deck은 슬라이드 텍스트와 순서를 기준으로 검수했습니다. 차트/이미지 해석은 제한적입니다.'
|
|
48
|
+
: '텍스트를 기준으로 검수했습니다. 표/이미지 일부는 분석에서 빠질 수 있습니다.');
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
artifact_id: `art_${fp}`,
|
|
52
|
+
source_kind: input.source_kind,
|
|
53
|
+
source_title: title,
|
|
54
|
+
source_fingerprint: fp,
|
|
55
|
+
extraction_quality: quality,
|
|
56
|
+
privacy_mode,
|
|
57
|
+
units,
|
|
58
|
+
detected_structure: {
|
|
59
|
+
page_count: undefined,
|
|
60
|
+
slide_count: isDeck ? countKind(units, 'slide_title') || undefined : undefined,
|
|
61
|
+
section_count: countKind(units, 'heading') || undefined,
|
|
62
|
+
heading_count: countKind(units, 'heading') || undefined,
|
|
63
|
+
table_count: countKind(units, 'table') || undefined,
|
|
64
|
+
is_deck: isDeck,
|
|
65
|
+
},
|
|
66
|
+
extraction_notes: notes,
|
|
67
|
+
source_caps: input.source_caps,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build a CanonicalArtifact from a parser's already-anchored units (pdf pages /
|
|
72
|
+
* pptx slides). The parser carries page/slide numbers that a flattened-text
|
|
73
|
+
* re-extraction would lose, so we keep them verbatim and only derive structure.
|
|
74
|
+
*/
|
|
75
|
+
function fromUnits(input, title, privacy_mode) {
|
|
76
|
+
const units = input.pre_extracted_units;
|
|
77
|
+
const joined = units.map((u) => u.text).join('\n');
|
|
78
|
+
const fp = fingerprint(joined || title);
|
|
79
|
+
const isDeck = input.source_kind === 'pptx' || units.some((u) => u.source_anchor.slide !== undefined);
|
|
80
|
+
const pages = new Set();
|
|
81
|
+
const slides = new Set();
|
|
82
|
+
for (const u of units) {
|
|
83
|
+
if (u.source_anchor.page !== undefined)
|
|
84
|
+
pages.add(u.source_anchor.page);
|
|
85
|
+
if (u.source_anchor.slide !== undefined)
|
|
86
|
+
slides.add(u.source_anchor.slide);
|
|
87
|
+
}
|
|
88
|
+
const notes = [...(input.extraction_notes ?? [])];
|
|
89
|
+
if (notes.length === 0) {
|
|
90
|
+
notes.push(isDeck
|
|
91
|
+
? '이 deck은 슬라이드 텍스트와 순서를 기준으로 검수했습니다. 차트/이미지 해석은 제한적입니다.'
|
|
92
|
+
: '텍스트를 기준으로 검수했습니다. 표/이미지 일부는 분석에서 빠질 수 있습니다.');
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
artifact_id: `art_${fp}`,
|
|
96
|
+
source_kind: input.source_kind,
|
|
97
|
+
source_title: title,
|
|
98
|
+
source_fingerprint: fp,
|
|
99
|
+
extraction_quality: input.extraction_quality ?? 'medium',
|
|
100
|
+
privacy_mode,
|
|
101
|
+
units,
|
|
102
|
+
detected_structure: {
|
|
103
|
+
page_count: pages.size || undefined,
|
|
104
|
+
slide_count: slides.size || undefined,
|
|
105
|
+
section_count: countKind(units, 'heading') || undefined,
|
|
106
|
+
heading_count: countKind(units, 'heading') || undefined,
|
|
107
|
+
table_count: countKind(units, 'table') || undefined,
|
|
108
|
+
is_deck: isDeck,
|
|
109
|
+
},
|
|
110
|
+
extraction_notes: notes,
|
|
111
|
+
source_caps: input.source_caps,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function defaultTitle(kind) {
|
|
115
|
+
const map = {
|
|
116
|
+
paste: '붙여넣은 문서',
|
|
117
|
+
markdown: 'Markdown 문서',
|
|
118
|
+
txt: '텍스트 문서',
|
|
119
|
+
pdf: 'PDF 문서',
|
|
120
|
+
docx: 'DOCX 문서',
|
|
121
|
+
pptx: '슬라이드 덱',
|
|
122
|
+
transcript: '회의록',
|
|
123
|
+
mcp_file: '파일',
|
|
124
|
+
pr_diff: 'PR diff',
|
|
125
|
+
llm_answer: 'AI 답변',
|
|
126
|
+
};
|
|
127
|
+
return map[kind];
|
|
128
|
+
}
|
|
129
|
+
function degradedArtifact(kind, title, privacy_mode, reason = 'unsupported') {
|
|
130
|
+
const note = reason === 'empty'
|
|
131
|
+
? '문서에서 텍스트를 찾지 못했습니다.'
|
|
132
|
+
: kind === 'pdf'
|
|
133
|
+
? '이 PDF는 아직 자동 텍스트 추출을 지원하지 않습니다. 본문 텍스트를 붙여넣으면 검수할 수 있습니다.'
|
|
134
|
+
: kind === 'pptx'
|
|
135
|
+
? '이 deck은 아직 자동 추출을 지원하지 않습니다. 슬라이드 텍스트를 붙여넣으면 검수할 수 있습니다.'
|
|
136
|
+
: '이 파일은 아직 자동 텍스트 추출을 지원하지 않습니다. 본문을 붙여넣어 주세요.';
|
|
137
|
+
return {
|
|
138
|
+
artifact_id: `art_${stableId(kind, title, reason)}`,
|
|
139
|
+
source_kind: kind,
|
|
140
|
+
source_title: title,
|
|
141
|
+
source_fingerprint: stableId('fp', kind, title),
|
|
142
|
+
extraction_quality: 'unsupported',
|
|
143
|
+
privacy_mode,
|
|
144
|
+
units: [],
|
|
145
|
+
detected_structure: { is_deck: kind === 'pptx' },
|
|
146
|
+
extraction_notes: [note],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function countKind(units, kind) {
|
|
150
|
+
return units.filter((u) => u.kind === kind).length;
|
|
151
|
+
}
|
|
152
|
+
function extractStructured(text, isDeck) {
|
|
153
|
+
const lines = text.split('\n');
|
|
154
|
+
const blocks = [];
|
|
155
|
+
const sectionStack = [];
|
|
156
|
+
// char offset of the start of each line
|
|
157
|
+
const lineOffsets = [];
|
|
158
|
+
let running = 0;
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
lineOffsets.push(running);
|
|
161
|
+
running += line.length + 1; // + newline
|
|
162
|
+
}
|
|
163
|
+
let paragraphBuf = [];
|
|
164
|
+
let paraStartLine = 0;
|
|
165
|
+
const flushParagraph = (endLine) => {
|
|
166
|
+
const joined = paragraphBuf.join('\n').trim();
|
|
167
|
+
if (joined) {
|
|
168
|
+
blocks.push(makeBlock('paragraph', joined, paraStartLine, endLine, lineOffsets, lines, currentPath(sectionStack)));
|
|
169
|
+
}
|
|
170
|
+
paragraphBuf = [];
|
|
171
|
+
};
|
|
172
|
+
for (let i = 0; i < lines.length; i++) {
|
|
173
|
+
const raw = lines[i];
|
|
174
|
+
const line = raw.trim();
|
|
175
|
+
// Deck slide separator (--- / ===): a boundary only; the slide's title and
|
|
176
|
+
// body are captured by the heading/paragraph branches below.
|
|
177
|
+
if (isDeck && /^(-{3,}|={3,})$/.test(line)) {
|
|
178
|
+
flushParagraph(i - 1);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (!line) {
|
|
182
|
+
flushParagraph(i - 1);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// Heading
|
|
186
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
187
|
+
if (h) {
|
|
188
|
+
flushParagraph(i - 1);
|
|
189
|
+
const level = h[1].length;
|
|
190
|
+
const titleText = h[2].trim();
|
|
191
|
+
while (sectionStack.length && sectionStack[sectionStack.length - 1].level >= level) {
|
|
192
|
+
sectionStack.pop();
|
|
193
|
+
}
|
|
194
|
+
const path = currentPath(sectionStack);
|
|
195
|
+
const kind = isDeck ? 'slide_title' : 'heading';
|
|
196
|
+
blocks.push(makeBlock(kind, titleText, i, i, lineOffsets, lines, path));
|
|
197
|
+
sectionStack.push({ level, title: titleText });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// Bullet
|
|
201
|
+
if (/^([-*+]|\d+[.)])\s+/.test(line)) {
|
|
202
|
+
flushParagraph(i - 1);
|
|
203
|
+
const bulletText = line.replace(/^([-*+]|\d+[.)])\s+/, '');
|
|
204
|
+
const kind = isDeck ? 'slide_body' : 'bullet';
|
|
205
|
+
blocks.push(makeBlock(kind, bulletText, i, i, lineOffsets, lines, currentPath(sectionStack)));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// Blockquote
|
|
209
|
+
if (line.startsWith('>')) {
|
|
210
|
+
flushParagraph(i - 1);
|
|
211
|
+
blocks.push(makeBlock('quote', line.replace(/^>\s?/, ''), i, i, lineOffsets, lines, currentPath(sectionStack)));
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
// Table row
|
|
215
|
+
if (line.includes('|') && line.replace(/[^|]/g, '').length >= 2) {
|
|
216
|
+
flushParagraph(i - 1);
|
|
217
|
+
// skip markdown separator rows (|---|---|)
|
|
218
|
+
if (!/^\|?[\s:|-]+\|?$/.test(line)) {
|
|
219
|
+
blocks.push(makeBlock('table', line, i, i, lineOffsets, lines, currentPath(sectionStack)));
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// Paragraph accumulation
|
|
224
|
+
if (paragraphBuf.length === 0)
|
|
225
|
+
paraStartLine = i;
|
|
226
|
+
paragraphBuf.push(raw);
|
|
227
|
+
}
|
|
228
|
+
flushParagraph(lines.length - 1);
|
|
229
|
+
let paraIndex = 0;
|
|
230
|
+
return blocks.map((b) => {
|
|
231
|
+
const anchor = {
|
|
232
|
+
line_start: b.line_start + 1, // 1-based for humans
|
|
233
|
+
line_end: b.line_end + 1,
|
|
234
|
+
char_start: b.char_start,
|
|
235
|
+
char_end: b.char_end,
|
|
236
|
+
section_path: b.section_path.length ? b.section_path : undefined,
|
|
237
|
+
};
|
|
238
|
+
if (b.kind === 'paragraph')
|
|
239
|
+
anchor.paragraph_index = paraIndex++;
|
|
240
|
+
if (isDeck && (b.kind === 'slide_title' || b.kind === 'slide_body')) {
|
|
241
|
+
anchor.slide = slideNumberFor(blocks, b);
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
unit_id: stableId('u', b.kind, b.char_start, b.text.slice(0, 40)),
|
|
245
|
+
kind: b.kind,
|
|
246
|
+
text: b.text,
|
|
247
|
+
source_anchor: anchor,
|
|
248
|
+
confidence: 1,
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function slideNumberFor(blocks, target) {
|
|
253
|
+
let n = 0;
|
|
254
|
+
for (const b of blocks) {
|
|
255
|
+
if (b.kind === 'slide_title')
|
|
256
|
+
n++;
|
|
257
|
+
if (b === target)
|
|
258
|
+
return Math.max(1, n);
|
|
259
|
+
}
|
|
260
|
+
return Math.max(1, n);
|
|
261
|
+
}
|
|
262
|
+
function makeBlock(kind, text, lineStart, lineEnd, lineOffsets, lines, section_path) {
|
|
263
|
+
const safeEnd = Math.max(lineStart, lineEnd);
|
|
264
|
+
const char_start = lineOffsets[lineStart] ?? 0;
|
|
265
|
+
const lastLen = (lines[safeEnd] ?? '').length;
|
|
266
|
+
const char_end = (lineOffsets[safeEnd] ?? char_start) + lastLen;
|
|
267
|
+
return { kind, text, line_start: lineStart, line_end: safeEnd, char_start, char_end, section_path };
|
|
268
|
+
}
|
|
269
|
+
function currentPath(stack) {
|
|
270
|
+
return stack.map((s) => s.title);
|
|
271
|
+
}
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Transcript extraction: one unit per speaker turn.
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
function extractTranscript(text) {
|
|
276
|
+
const lines = text.split('\n');
|
|
277
|
+
const units = [];
|
|
278
|
+
let offset = 0;
|
|
279
|
+
lines.forEach((line, i) => {
|
|
280
|
+
const trimmed = line.trim();
|
|
281
|
+
const start = offset;
|
|
282
|
+
offset += line.length + 1;
|
|
283
|
+
if (!trimmed)
|
|
284
|
+
return;
|
|
285
|
+
units.push({
|
|
286
|
+
unit_id: stableId('u', 'turn', start, trimmed.slice(0, 40)),
|
|
287
|
+
kind: 'transcript_turn',
|
|
288
|
+
text: trimmed,
|
|
289
|
+
source_anchor: { line_start: i + 1, line_end: i + 1, char_start: start, char_end: start + line.length },
|
|
290
|
+
confidence: 1,
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
return units;
|
|
294
|
+
}
|