dsh-scout 0.9.0 → 0.9.2
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/CHANGELOG.md +91 -0
- package/README.en.md +237 -39
- package/README.md +131 -26
- package/assets/icon.svg +7 -0
- package/dist/i18n.d.ts +115 -0
- package/dist/i18n.js +299 -0
- package/dist/index.d.ts +14 -9
- package/dist/index.js +682 -206
- package/dist/model.d.ts +58 -8
- package/dist/model.js +260 -107
- package/docs/dsh-scout-product-contract.md +51 -5
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +15 -7
- package/scripts/check-release.mjs +219 -4
- package/scripts/profile-smoke.mjs +1118 -41
- package/tests/acceptance.test.mjs +1684 -0
- package/tests/model.test.mjs +496 -778
- package/tests/package.test.mjs +246 -0
- package/tests/tools.test.mjs +974 -0
package/dist/model.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { formatScoutString, normalizeScoutLanguage, scoutStrings, } from './i18n.js';
|
|
2
|
+
export { formatScoutString, isScoutLanguage, normalizeScoutLanguage, SCOUT_LANGUAGES, scoutStrings, } from './i18n.js';
|
|
1
3
|
export const EVIDENCE_LEVELS = ['E0', 'E1', 'E2', 'E3'];
|
|
2
4
|
export const CLAIM_STATUSES = [
|
|
3
5
|
'verified',
|
|
@@ -33,6 +35,15 @@ export const CLAIM_DIMENSIONS = [
|
|
|
33
35
|
'risk',
|
|
34
36
|
'role_fit',
|
|
35
37
|
];
|
|
38
|
+
export const DECISION_REASON_CODES = [
|
|
39
|
+
'identity',
|
|
40
|
+
'unresolved',
|
|
41
|
+
'missing_dimension',
|
|
42
|
+
'proceed',
|
|
43
|
+
];
|
|
44
|
+
export function isDecisionReasonCode(value) {
|
|
45
|
+
return typeof value === 'string' && DECISION_REASON_CODES.includes(value);
|
|
46
|
+
}
|
|
36
47
|
export const EVENT_TYPES = [
|
|
37
48
|
'case_started',
|
|
38
49
|
'source_added',
|
|
@@ -41,6 +52,7 @@ export const EVENT_TYPES = [
|
|
|
41
52
|
'claim_verified',
|
|
42
53
|
'case_exported',
|
|
43
54
|
'case_imported',
|
|
55
|
+
'case_rehydrated',
|
|
44
56
|
];
|
|
45
57
|
const IDENTITY_SOURCE_TYPES = new Set([
|
|
46
58
|
'company_registry',
|
|
@@ -81,13 +93,22 @@ const REGISTRY_HOST_SUFFIXES = [
|
|
|
81
93
|
'szse.cn',
|
|
82
94
|
'neeq.com.cn',
|
|
83
95
|
];
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the render language: an explicit option wins, then the case language,
|
|
98
|
+
* then the `zh-CN` default. Unknown values always fall back to `zh-CN`.
|
|
99
|
+
*/
|
|
100
|
+
function reportLanguage(explicit, fallback) {
|
|
101
|
+
return normalizeScoutLanguage(explicit ?? fallback);
|
|
102
|
+
}
|
|
84
103
|
export function createCase(input) {
|
|
104
|
+
const language = normalizeScoutLanguage(input.language);
|
|
105
|
+
const t = scoutStrings(language);
|
|
85
106
|
return {
|
|
86
107
|
schemaVersion: 'dsh-scout.case.v0',
|
|
87
108
|
caseId: input.caseId,
|
|
88
109
|
title: `${input.companyName} — ${input.roleTitle}`,
|
|
89
|
-
language
|
|
90
|
-
decisionObjective: input.decisionObjective ??
|
|
110
|
+
language,
|
|
111
|
+
decisionObjective: input.decisionObjective ?? t.defaults.decisionObjective,
|
|
91
112
|
subject: {
|
|
92
113
|
name: input.companyName,
|
|
93
114
|
identitySourceIds: [],
|
|
@@ -96,20 +117,14 @@ export function createCase(input) {
|
|
|
96
117
|
role: {
|
|
97
118
|
title: input.roleTitle,
|
|
98
119
|
location: input.location,
|
|
99
|
-
fitQuestion:
|
|
120
|
+
fitQuestion: t.defaults.fitQuestion,
|
|
100
121
|
},
|
|
101
122
|
decision: 'VERIFY',
|
|
102
|
-
decisionReason:
|
|
123
|
+
decisionReason: t.defaults.initialDecisionReason,
|
|
103
124
|
sources: [],
|
|
104
125
|
claims: [],
|
|
105
126
|
events: [],
|
|
106
|
-
interviewQuestions: [
|
|
107
|
-
'这个岗位在前三个月最重要的业务结果是什么?',
|
|
108
|
-
'该岗位直接向谁汇报,在哪些事项上拥有最终决定权?',
|
|
109
|
-
'现有团队、预算和外部供应商资源分别是什么?',
|
|
110
|
-
'当前最影响业务增长的组织问题是什么?',
|
|
111
|
-
'六个月后,哪些可观察结果代表这个岗位取得成功?',
|
|
112
|
-
],
|
|
127
|
+
interviewQuestions: [...t.defaults.interviewQuestions],
|
|
113
128
|
};
|
|
114
129
|
}
|
|
115
130
|
export function addSource(scoutCase, source, authoritySuffixes = DEFAULT_AUTHORITY_HOST_SUFFIXES) {
|
|
@@ -173,6 +188,37 @@ export function inferEvidenceLevel(url, type, explicit, authoritySuffixes = DEFA
|
|
|
173
188
|
}
|
|
174
189
|
return { evidenceLevel: 'E2', inferred: true };
|
|
175
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Normalize a source URL for deduplication: trim, lowercase the host, drop a
|
|
193
|
+
* trailing slash and any fragment. Empty or unparsable URLs return `undefined`.
|
|
194
|
+
*/
|
|
195
|
+
export function normalizeSourceUrl(value) {
|
|
196
|
+
if (typeof value !== 'string')
|
|
197
|
+
return undefined;
|
|
198
|
+
const trimmed = value.trim();
|
|
199
|
+
if (!trimmed)
|
|
200
|
+
return undefined;
|
|
201
|
+
let parsed;
|
|
202
|
+
try {
|
|
203
|
+
parsed = new URL(trimmed);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
const hostname = parsed.hostname.toLowerCase().replace(/\.$/, '');
|
|
209
|
+
if (!hostname)
|
|
210
|
+
return undefined;
|
|
211
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
212
|
+
const port = parsed.port ? `:${parsed.port}` : '';
|
|
213
|
+
return `${parsed.protocol.toLowerCase()}//${hostname}${port}${path}${parsed.search}`;
|
|
214
|
+
}
|
|
215
|
+
/** Find a source by normalized URL (trim + trailing-slash + host case insensitive). */
|
|
216
|
+
export function findSourceByUrl(scoutCase, url) {
|
|
217
|
+
const target = normalizeSourceUrl(url);
|
|
218
|
+
if (!target)
|
|
219
|
+
return undefined;
|
|
220
|
+
return scoutCase.sources.find(source => normalizeSourceUrl(source.url) === target);
|
|
221
|
+
}
|
|
176
222
|
export function addClaim(scoutCase, claim) {
|
|
177
223
|
if (scoutCase.claims.some(item => item.claimId === claim.claimId)) {
|
|
178
224
|
throw new Error(`Duplicate claim: ${claim.claimId}`);
|
|
@@ -269,89 +315,145 @@ export function verifyIdentity(scoutCase, input, authoritySuffixes = DEFAULT_AUT
|
|
|
269
315
|
},
|
|
270
316
|
};
|
|
271
317
|
}
|
|
272
|
-
|
|
273
|
-
|
|
318
|
+
const REQUIRED_DIMENSIONS = ['role_existence', 'reporting_line', 'mandate'];
|
|
319
|
+
/** Recompute the whole decision basis from the subject and claims (pure). */
|
|
320
|
+
function assessDecision(scoutCase) {
|
|
274
321
|
const blockingContradiction = scoutCase.claims.some(claim => claim.impact === 'blocking' && claim.status === 'contradicted');
|
|
322
|
+
const identityUnverified = scoutCase.subject.identityStatus !== 'verified';
|
|
275
323
|
const unresolvedMaterialClaim = scoutCase.claims.some(claim => claim.impact !== 'informational' && claim.status !== 'verified');
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
if (blockingContradiction)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
324
|
+
const missingRequiredDimension = REQUIRED_DIMENSIONS.find(dimension => !scoutCase.claims.some(claim => claim.dimension === dimension && claim.status === 'verified'));
|
|
325
|
+
let reasonCode;
|
|
326
|
+
if (blockingContradiction)
|
|
327
|
+
reasonCode = 'unresolved';
|
|
328
|
+
else if (identityUnverified)
|
|
329
|
+
reasonCode = 'identity';
|
|
330
|
+
else if (unresolvedMaterialClaim)
|
|
331
|
+
reasonCode = 'unresolved';
|
|
332
|
+
else if (missingRequiredDimension)
|
|
333
|
+
reasonCode = 'missing_dimension';
|
|
334
|
+
else
|
|
335
|
+
reasonCode = 'proceed';
|
|
336
|
+
const decision = blockingContradiction
|
|
337
|
+
? 'STOP'
|
|
338
|
+
: reasonCode === 'proceed'
|
|
339
|
+
? 'PROCEED'
|
|
340
|
+
: 'VERIFY';
|
|
341
|
+
return { decision, reasonCode, blockingContradiction, missingRequiredDimension };
|
|
342
|
+
}
|
|
343
|
+
/** Pure decision reason code recomputed from the subject and claims, without stored state. */
|
|
344
|
+
export function explainDecision(scoutCase) {
|
|
345
|
+
return assessDecision(scoutCase).reasonCode;
|
|
346
|
+
}
|
|
347
|
+
export function decideCase(scoutCase, opts) {
|
|
348
|
+
const t = scoutStrings(reportLanguage(opts?.language, scoutCase.language));
|
|
349
|
+
const { decision, reasonCode, blockingContradiction, missingRequiredDimension } = assessDecision(scoutCase);
|
|
350
|
+
const decisionReason = reasonCode === 'proceed'
|
|
351
|
+
? t.decisionReasons.proceed
|
|
352
|
+
: reasonCode === 'identity'
|
|
353
|
+
? t.decisionReasons.identity
|
|
354
|
+
: reasonCode === 'unresolved'
|
|
355
|
+
? (blockingContradiction ? t.decisionReasons.blockingContradiction : t.decisionReasons.unresolved)
|
|
356
|
+
: formatScoutString(t.decisionReasons.missingDimension, { dimension: missingRequiredDimension ?? '' });
|
|
357
|
+
return { ...scoutCase, decision, decisionReason, decisionReasonCode: reasonCode };
|
|
358
|
+
}
|
|
359
|
+
/** Investigation coverage per dimension, in `CLAIM_DIMENSIONS` order. */
|
|
360
|
+
export function computeCoverage(scoutCase) {
|
|
361
|
+
return CLAIM_DIMENSIONS.map(dimension => {
|
|
362
|
+
const claims = scoutCase.claims.filter(claim => claim.dimension === dimension);
|
|
363
|
+
const verified = claims.filter(claim => claim.status === 'verified').length;
|
|
286
364
|
return {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
: unresolvedMaterialClaim
|
|
292
|
-
? '仍有阻断级或重要结论未完成核验,不能直接推进。'
|
|
293
|
-
: `缺少已核验的必需维度:${missingRequiredDimension}。`,
|
|
365
|
+
dimension,
|
|
366
|
+
verified,
|
|
367
|
+
open: claims.length - verified,
|
|
368
|
+
untouched: claims.length === 0,
|
|
294
369
|
};
|
|
295
|
-
}
|
|
296
|
-
return {
|
|
297
|
-
...scoutCase,
|
|
298
|
-
decision: 'PROCEED',
|
|
299
|
-
decisionReason: '当前关键结论均有来源支撑,建议进入下一轮并继续现场核验。',
|
|
300
|
-
};
|
|
370
|
+
});
|
|
301
371
|
}
|
|
302
372
|
const IMPACT_RANK = { blocking: 0, material: 1, informational: 2 };
|
|
303
373
|
function byImpact(claims) {
|
|
304
374
|
return [...claims].sort((a, b) => IMPACT_RANK[a.impact] - IMPACT_RANK[b.impact]);
|
|
305
375
|
}
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
mandate: '该岗位在哪些事项上拥有最终决定权?团队与预算规模如何?',
|
|
310
|
-
};
|
|
311
|
-
/** Derive a deduplicated, prioritized interview question list from the case. */
|
|
312
|
-
export function generateInterviewQuestions(scoutCase) {
|
|
313
|
-
const nextActions = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified'))
|
|
314
|
-
.map(claim => claim.nextAction.trim())
|
|
315
|
-
.filter((action, index, all) => action && all.indexOf(action) === index);
|
|
316
|
-
const missingDimensions = Object.keys(MISSING_DIMENSION_QUESTIONS).filter(dimension => !scoutCase.claims.some(claim => claim.dimension === dimension && claim.status === 'verified'));
|
|
317
|
-
const questions = [
|
|
318
|
-
...nextActions,
|
|
319
|
-
...missingDimensions.map(dimension => MISSING_DIMENSION_QUESTIONS[dimension]),
|
|
320
|
-
...scoutCase.interviewQuestions,
|
|
321
|
-
];
|
|
376
|
+
const INTERVIEW_QUESTION_LIMIT = 12;
|
|
377
|
+
/** Trim-key dedupe that keeps the first occurrence and drops blank questions. */
|
|
378
|
+
function dedupeQuestions(questions) {
|
|
322
379
|
const seen = new Set();
|
|
323
|
-
|
|
380
|
+
const unique = [];
|
|
381
|
+
for (const question of questions) {
|
|
324
382
|
const key = question.trim();
|
|
325
383
|
if (!key || seen.has(key))
|
|
326
|
-
|
|
384
|
+
continue;
|
|
327
385
|
seen.add(key);
|
|
328
|
-
|
|
329
|
-
}
|
|
386
|
+
unique.push(question);
|
|
387
|
+
}
|
|
388
|
+
return unique;
|
|
330
389
|
}
|
|
331
|
-
|
|
390
|
+
/**
|
|
391
|
+
* Derive a deduplicated, prioritized interview question list from the case.
|
|
392
|
+
* Order: pending next actions, missing required dimensions, untouched
|
|
393
|
+
* dimensions, then the case's own curated questions. The curated questions
|
|
394
|
+
* reserve their full quota, so an uninvestigated case cannot crowd them out of
|
|
395
|
+
* the 12-item cap.
|
|
396
|
+
*/
|
|
397
|
+
export function generateInterviewQuestions(scoutCase, opts) {
|
|
398
|
+
const t = scoutStrings(reportLanguage(opts?.language, scoutCase.language));
|
|
399
|
+
const nextActions = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified'))
|
|
400
|
+
.map(claim => claim.nextAction.trim())
|
|
401
|
+
.filter((action, index, all) => action && all.indexOf(action) === index);
|
|
402
|
+
const missingDimensions = REQUIRED_DIMENSIONS.filter(dimension => !scoutCase.claims.some(claim => claim.dimension === dimension && claim.status === 'verified'));
|
|
403
|
+
const untouchedDimensions = computeCoverage(scoutCase)
|
|
404
|
+
.filter(item => item.untouched)
|
|
405
|
+
.map(item => item.dimension);
|
|
406
|
+
const head = dedupeQuestions([
|
|
407
|
+
...nextActions,
|
|
408
|
+
...missingDimensions.map(dimension => t.questions[dimension]),
|
|
409
|
+
...untouchedDimensions.map(dimension => t.questions[dimension]),
|
|
410
|
+
]);
|
|
411
|
+
const curated = scoutCase.interviewQuestions;
|
|
412
|
+
const headBudget = Math.max(0, INTERVIEW_QUESTION_LIMIT - curated.length);
|
|
413
|
+
return dedupeQuestions([...head.slice(0, headBudget), ...curated]).slice(0, INTERVIEW_QUESTION_LIMIT);
|
|
414
|
+
}
|
|
415
|
+
export function renderReport(scoutCase, opts) {
|
|
416
|
+
const t = scoutStrings(reportLanguage(opts?.language, scoutCase.language));
|
|
332
417
|
const sourceLine = (source) => {
|
|
333
418
|
const url = source.url ? ` — ${source.url}` : '';
|
|
334
419
|
return `- ${source.sourceId}: ${source.title} [${source.type} / ${source.evidenceLevel}]${url}`;
|
|
335
420
|
};
|
|
336
421
|
const sources = scoutCase.sources.length
|
|
337
422
|
? scoutCase.sources.map(sourceLine).join('\n')
|
|
338
|
-
:
|
|
423
|
+
: t.empty.noSources;
|
|
339
424
|
const claimLine = (claim) => {
|
|
340
|
-
const sourceIds = claim.sourceIds.length ? claim.sourceIds.join(', ') :
|
|
341
|
-
const history = claim.history?.length
|
|
342
|
-
|
|
425
|
+
const sourceIds = claim.sourceIds.length ? claim.sourceIds.join(', ') : t.claim.noSourceIds;
|
|
426
|
+
const history = claim.history?.length
|
|
427
|
+
? formatScoutString(t.claim.history, { count: claim.history.length })
|
|
428
|
+
: '';
|
|
429
|
+
return formatScoutString(t.claim.line, {
|
|
430
|
+
status: claim.status,
|
|
431
|
+
evidenceLevel: claim.evidenceLevel,
|
|
432
|
+
impact: claim.impact,
|
|
433
|
+
text: claim.text,
|
|
434
|
+
dimension: claim.dimension,
|
|
435
|
+
sourceIds,
|
|
436
|
+
confidenceNote: claim.confidenceNote,
|
|
437
|
+
nextAction: claim.nextAction,
|
|
438
|
+
history,
|
|
439
|
+
});
|
|
343
440
|
};
|
|
344
441
|
const claims = scoutCase.claims.length
|
|
345
442
|
? scoutCase.claims.map(claimLine).join('\n')
|
|
346
|
-
:
|
|
443
|
+
: t.empty.noClaims;
|
|
347
444
|
const supports = byImpact(scoutCase.claims.filter(claim => claim.status === 'verified')).slice(0, 3);
|
|
348
445
|
const risks = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified')).slice(0, 3);
|
|
349
446
|
const roleHypotheses = byImpact(scoutCase.claims.filter(claim => ['role_existence', 'reporting_line', 'mandate'].includes(claim.dimension))).slice(0, 3);
|
|
350
447
|
const checklist = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified'));
|
|
351
|
-
const checklistLine = (claim) =>
|
|
448
|
+
const checklistLine = (claim) => formatScoutString(t.claim.checklistLine, {
|
|
449
|
+
impact: claim.impact,
|
|
450
|
+
claimId: claim.claimId,
|
|
451
|
+
text: claim.text,
|
|
452
|
+
nextAction: claim.nextAction,
|
|
453
|
+
});
|
|
352
454
|
const checklistBlock = checklist.length
|
|
353
455
|
? checklist.map(checklistLine).join('\n')
|
|
354
|
-
:
|
|
456
|
+
: t.empty.noChecklistItems;
|
|
355
457
|
const statusCounts = scoutCase.claims.reduce((counts, claim) => {
|
|
356
458
|
counts[claim.status] = (counts[claim.status] ?? 0) + 1;
|
|
357
459
|
return counts;
|
|
@@ -359,43 +461,64 @@ export function renderReport(scoutCase) {
|
|
|
359
461
|
const summary = Object.entries(statusCounts)
|
|
360
462
|
.map(([status, count]) => `${count} ${status}`)
|
|
361
463
|
.join(' / ');
|
|
464
|
+
const coverage = computeCoverage(scoutCase);
|
|
465
|
+
const coverageLines = coverage.map(item => formatScoutString(t.coverage.entry, {
|
|
466
|
+
dimension: item.dimension,
|
|
467
|
+
label: t.dimensions[item.dimension],
|
|
468
|
+
verified: item.verified,
|
|
469
|
+
open: item.open,
|
|
470
|
+
untouched: item.untouched ? t.coverage.yes : t.coverage.no,
|
|
471
|
+
}));
|
|
472
|
+
const untouchedDimensions = coverage.filter(item => item.untouched).map(item => item.dimension);
|
|
473
|
+
const untouchedLine = formatScoutString(t.coverage.untouchedLine, {
|
|
474
|
+
count: untouchedDimensions.length,
|
|
475
|
+
dimensions: untouchedDimensions.length
|
|
476
|
+
? untouchedDimensions.join(t.coverage.separator)
|
|
477
|
+
: t.coverage.none,
|
|
478
|
+
});
|
|
362
479
|
return [
|
|
363
480
|
`# ${scoutCase.title}`,
|
|
364
481
|
'',
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
...(summary ? [
|
|
482
|
+
formatScoutString(t.header.decision, { decision: scoutCase.decision }),
|
|
483
|
+
formatScoutString(t.header.reason, { reason: scoutCase.decisionReason }),
|
|
484
|
+
formatScoutString(t.header.objective, { objective: scoutCase.decisionObjective }),
|
|
485
|
+
...(summary ? [formatScoutString(t.header.evidenceSummary, { summary })] : []),
|
|
486
|
+
'',
|
|
487
|
+
t.headings.coverage,
|
|
488
|
+
'',
|
|
489
|
+
...coverageLines,
|
|
490
|
+
'',
|
|
491
|
+
untouchedLine,
|
|
369
492
|
'',
|
|
370
|
-
|
|
493
|
+
t.headings.supportingEvidence,
|
|
371
494
|
'',
|
|
372
|
-
supports.length ? supports.map(claimLine).join('\n') :
|
|
495
|
+
supports.length ? supports.map(claimLine).join('\n') : t.empty.noSupportingEvidence,
|
|
373
496
|
'',
|
|
374
|
-
|
|
497
|
+
t.headings.unresolvedRisks,
|
|
375
498
|
'',
|
|
376
|
-
risks.length ? risks.map(claimLine).join('\n') :
|
|
499
|
+
risks.length ? risks.map(claimLine).join('\n') : t.empty.noUnresolvedRisks,
|
|
377
500
|
'',
|
|
378
|
-
|
|
501
|
+
t.headings.roleHypotheses,
|
|
379
502
|
'',
|
|
380
|
-
roleHypotheses.length ? roleHypotheses.map(claimLine).join('\n') :
|
|
503
|
+
roleHypotheses.length ? roleHypotheses.map(claimLine).join('\n') : t.empty.noRoleHypotheses,
|
|
381
504
|
'',
|
|
382
|
-
|
|
505
|
+
t.headings.verificationChecklist,
|
|
383
506
|
'',
|
|
384
507
|
checklistBlock,
|
|
385
508
|
'',
|
|
386
|
-
|
|
509
|
+
t.headings.claimLedger,
|
|
387
510
|
'',
|
|
388
511
|
claims,
|
|
389
512
|
'',
|
|
390
|
-
|
|
513
|
+
t.headings.sources,
|
|
391
514
|
'',
|
|
392
515
|
sources,
|
|
393
516
|
'',
|
|
394
|
-
|
|
517
|
+
t.headings.interviewQuestions,
|
|
395
518
|
'',
|
|
396
519
|
scoutCase.interviewQuestions.map(question => `- ${question}`).join('\n'),
|
|
397
520
|
'',
|
|
398
|
-
|
|
521
|
+
t.reportDisclaimer,
|
|
399
522
|
'',
|
|
400
523
|
].join('\n');
|
|
401
524
|
}
|
|
@@ -413,8 +536,16 @@ export const EXPORT_FILE_NAMES = [
|
|
|
413
536
|
'events.jsonl',
|
|
414
537
|
'report.md',
|
|
415
538
|
];
|
|
416
|
-
/**
|
|
417
|
-
export
|
|
539
|
+
/**
|
|
540
|
+
* Serialize a case into the durable five-file export payloads (no I/O).
|
|
541
|
+
*
|
|
542
|
+
* `case.json` keeps the case's own `language` field, which records the language
|
|
543
|
+
* the case was created in; the language used to render `report.md` is decided by
|
|
544
|
+
* the caller through `opts.language` (falling back to the case language), so a
|
|
545
|
+
* case imported across a language boundary can still be exported in the
|
|
546
|
+
* configured language.
|
|
547
|
+
*/
|
|
548
|
+
export function exportCaseFiles(scoutCase, opts) {
|
|
418
549
|
const base = {
|
|
419
550
|
schemaVersion: scoutCase.schemaVersion,
|
|
420
551
|
caseId: scoutCase.caseId,
|
|
@@ -425,6 +556,7 @@ export function exportCaseFiles(scoutCase) {
|
|
|
425
556
|
role: scoutCase.role,
|
|
426
557
|
decision: scoutCase.decision,
|
|
427
558
|
decisionReason: scoutCase.decisionReason,
|
|
559
|
+
...(scoutCase.decisionReasonCode ? { decisionReasonCode: scoutCase.decisionReasonCode } : {}),
|
|
428
560
|
interviewQuestions: scoutCase.interviewQuestions,
|
|
429
561
|
};
|
|
430
562
|
const events = scoutCase.events ?? [];
|
|
@@ -433,7 +565,7 @@ export function exportCaseFiles(scoutCase) {
|
|
|
433
565
|
'sources.json': `${JSON.stringify(scoutCase.sources, null, 2)}\n`,
|
|
434
566
|
'claims.json': `${JSON.stringify(scoutCase.claims, null, 2)}\n`,
|
|
435
567
|
'events.jsonl': events.length ? `${events.map(event => JSON.stringify(event)).join('\n')}\n` : '',
|
|
436
|
-
'report.md': renderReport(scoutCase),
|
|
568
|
+
'report.md': renderReport(scoutCase, opts),
|
|
437
569
|
};
|
|
438
570
|
}
|
|
439
571
|
/** Rebuild a case from the five-file export payloads, validating structure. */
|
|
@@ -472,64 +604,85 @@ export function importCaseFromFiles(files) {
|
|
|
472
604
|
: [];
|
|
473
605
|
if (!Array.isArray(events))
|
|
474
606
|
throw new Error('Invalid events.jsonl export');
|
|
607
|
+
const decisionReasonCode = isDecisionReasonCode(base.decisionReasonCode)
|
|
608
|
+
? base.decisionReasonCode
|
|
609
|
+
: undefined;
|
|
475
610
|
return {
|
|
476
611
|
schemaVersion: base.schemaVersion,
|
|
477
612
|
caseId: base.caseId,
|
|
478
613
|
title: base.title,
|
|
479
|
-
language: base.language,
|
|
614
|
+
language: typeof base.language === 'string' && base.language ? base.language : 'zh-CN',
|
|
480
615
|
decisionObjective: base.decisionObjective,
|
|
481
616
|
subject: base.subject,
|
|
482
617
|
role: base.role,
|
|
483
618
|
decision: base.decision,
|
|
484
619
|
decisionReason: base.decisionReason,
|
|
620
|
+
...(decisionReasonCode ? { decisionReasonCode } : {}),
|
|
485
621
|
sources: sources,
|
|
486
622
|
claims: claims,
|
|
487
623
|
interviewQuestions: base.interviewQuestions,
|
|
488
624
|
events,
|
|
489
625
|
};
|
|
490
626
|
}
|
|
491
|
-
const CLAIM_STATUS_LABELS = {
|
|
492
|
-
verified: '已核验',
|
|
493
|
-
reported: '转述',
|
|
494
|
-
inferred: '推断',
|
|
495
|
-
contradicted: '矛盾',
|
|
496
|
-
unknown: '未知',
|
|
497
|
-
needs_verification: '待核验',
|
|
498
|
-
};
|
|
499
627
|
/** Render a side-by-side comparison report for two or more cases. */
|
|
500
|
-
export function renderComparison(cases) {
|
|
628
|
+
export function renderComparison(cases, opts) {
|
|
501
629
|
if (cases.length < 2)
|
|
502
630
|
throw new Error('Comparison requires at least two cases');
|
|
503
631
|
if (cases.length > 5)
|
|
504
632
|
throw new Error('Comparison supports at most five cases');
|
|
633
|
+
const language = reportLanguage(opts?.language, cases[0].language);
|
|
634
|
+
const t = scoutStrings(language);
|
|
505
635
|
const countByStatus = (scoutCase) => scoutCase.claims.reduce((counts, claim) => {
|
|
506
636
|
counts[claim.status] = (counts[claim.status] ?? 0) + 1;
|
|
507
637
|
return counts;
|
|
508
638
|
}, {});
|
|
509
639
|
const summaryLine = (scoutCase) => {
|
|
510
640
|
const counts = countByStatus(scoutCase);
|
|
511
|
-
const identity = scoutCase.subject.identityStatus === 'verified'
|
|
641
|
+
const identity = scoutCase.subject.identityStatus === 'verified'
|
|
642
|
+
? t.comparison.identityVerified
|
|
643
|
+
: t.comparison.identityUnverified;
|
|
512
644
|
const countsText = Object.entries(counts)
|
|
513
|
-
.map(([status, count]) => `${count} ${
|
|
645
|
+
.map(([status, count]) => `${count} ${t.statuses[status] ?? status}`)
|
|
514
646
|
.join(' / ');
|
|
515
|
-
const countsSuffix = countsText
|
|
516
|
-
|
|
647
|
+
const countsSuffix = countsText
|
|
648
|
+
? formatScoutString(t.comparison.countsSuffix, { counts: countsText })
|
|
649
|
+
: '';
|
|
650
|
+
return formatScoutString(t.comparison.summary, {
|
|
651
|
+
title: scoutCase.title,
|
|
652
|
+
decision: scoutCase.decision,
|
|
653
|
+
identity,
|
|
654
|
+
claimCount: scoutCase.claims.length,
|
|
655
|
+
countsSuffix,
|
|
656
|
+
});
|
|
517
657
|
};
|
|
518
658
|
const compareTable = [
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
...cases.map(scoutCase =>
|
|
659
|
+
t.comparison.tableHeader,
|
|
660
|
+
t.comparison.tableSeparator,
|
|
661
|
+
...cases.map(scoutCase => formatScoutString(t.comparison.tableRow, {
|
|
662
|
+
title: scoutCase.title,
|
|
663
|
+
decision: scoutCase.decision,
|
|
664
|
+
identity: scoutCase.subject.identityStatus === 'verified'
|
|
665
|
+
? t.comparison.verifiedMark
|
|
666
|
+
: t.comparison.unverifiedMark,
|
|
667
|
+
claimCount: scoutCase.claims.length,
|
|
668
|
+
})),
|
|
522
669
|
];
|
|
523
670
|
const sections = [];
|
|
524
671
|
for (const scoutCase of cases) {
|
|
525
672
|
const verified = byImpact(scoutCase.claims.filter(claim => claim.status === 'verified')).slice(0, 3);
|
|
526
673
|
const open = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified')).slice(0, 3);
|
|
527
|
-
sections.push(
|
|
674
|
+
sections.push(formatScoutString(t.comparison.detailTitle, { title: scoutCase.title, caseId: scoutCase.caseId }), '', t.comparison.verifiedHeading, verified.length ? verified.map(claim => `- ${claim.text}`).join('\n') : t.comparison.noVerified, '', t.comparison.openHeading, open.length
|
|
675
|
+
? open.map(claim => formatScoutString(t.comparison.openItem, {
|
|
676
|
+
impact: claim.impact,
|
|
677
|
+
text: claim.text,
|
|
678
|
+
nextAction: claim.nextAction,
|
|
679
|
+
})).join('\n')
|
|
680
|
+
: t.comparison.noOpen, '');
|
|
528
681
|
}
|
|
529
682
|
const mergedQuestions = [];
|
|
530
683
|
const seen = new Set();
|
|
531
684
|
for (const scoutCase of cases) {
|
|
532
|
-
for (const question of generateInterviewQuestions(scoutCase)) {
|
|
685
|
+
for (const question of generateInterviewQuestions(scoutCase, { language })) {
|
|
533
686
|
const key = question.trim();
|
|
534
687
|
if (!key || seen.has(key))
|
|
535
688
|
continue;
|
|
@@ -542,24 +695,24 @@ export function renderComparison(cases) {
|
|
|
542
695
|
break;
|
|
543
696
|
}
|
|
544
697
|
return [
|
|
545
|
-
|
|
698
|
+
formatScoutString(t.comparison.title, { count: cases.length }),
|
|
546
699
|
'',
|
|
547
|
-
|
|
700
|
+
t.comparison.overviewHeading,
|
|
548
701
|
'',
|
|
549
702
|
...cases.map(summaryLine),
|
|
550
703
|
'',
|
|
551
|
-
|
|
704
|
+
t.comparison.decisionTableHeading,
|
|
552
705
|
'',
|
|
553
706
|
...compareTable,
|
|
554
707
|
'',
|
|
555
|
-
|
|
708
|
+
t.comparison.detailHeading,
|
|
556
709
|
'',
|
|
557
710
|
...sections,
|
|
558
|
-
|
|
711
|
+
t.comparison.questionsHeading,
|
|
559
712
|
'',
|
|
560
713
|
...mergedQuestions.map(question => `- ${question}`),
|
|
561
714
|
'',
|
|
562
|
-
|
|
715
|
+
t.comparison.disclaimer,
|
|
563
716
|
'',
|
|
564
717
|
].join('\n');
|
|
565
718
|
}
|