dsh-scout 0.9.2 → 0.9.4
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 +76 -1
- package/README.en.md +140 -47
- package/README.md +140 -47
- package/dist/i18n.d.ts +157 -1
- package/dist/i18n.js +354 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +360 -88
- package/dist/model.d.ts +62 -9
- package/dist/model.js +655 -134
- package/docs/dsh-scout-product-contract.md +22 -1
- package/package.json +4 -1
- package/scripts/acceptance-report.mjs +622 -0
- package/scripts/check-release.mjs +228 -0
- package/scripts/profile-smoke.mjs +12 -15
- package/scripts/smoke-tool-call.mjs +43 -0
- package/tests/acceptance.test.mjs +118 -42
- package/tests/model.test.mjs +301 -69
- package/tests/package.test.mjs +572 -3
- package/tests/tools.test.mjs +382 -16
package/dist/model.js
CHANGED
|
@@ -47,6 +47,7 @@ export function isDecisionReasonCode(value) {
|
|
|
47
47
|
export const EVENT_TYPES = [
|
|
48
48
|
'case_started',
|
|
49
49
|
'source_added',
|
|
50
|
+
'source_updated',
|
|
50
51
|
'claim_added',
|
|
51
52
|
'identity_verified',
|
|
52
53
|
'claim_verified',
|
|
@@ -68,6 +69,45 @@ const JOB_PLATFORM_HOST_HINTS = [
|
|
|
68
69
|
'nowcoder.com',
|
|
69
70
|
'jobs.feishu.cn',
|
|
70
71
|
];
|
|
72
|
+
/** Product/app-store listings are first-party company statements, not media. */
|
|
73
|
+
const APP_STORE_HOST_HINTS = [
|
|
74
|
+
'apps.apple.com',
|
|
75
|
+
'play.google.com',
|
|
76
|
+
'appgallery.huawei.com',
|
|
77
|
+
];
|
|
78
|
+
/** Public business-registry aggregators: independent data, never E3. */
|
|
79
|
+
const PUBLIC_DATABASE_HOST_HINTS = [
|
|
80
|
+
'qixin.com',
|
|
81
|
+
'qcc.com',
|
|
82
|
+
'tianyancha.com',
|
|
83
|
+
'qichamao.com',
|
|
84
|
+
'likuso.com',
|
|
85
|
+
'mingluji.com',
|
|
86
|
+
'11467.com',
|
|
87
|
+
'58.com',
|
|
88
|
+
'shuidi.cn',
|
|
89
|
+
'innohere.com',
|
|
90
|
+
'patentguru.com',
|
|
91
|
+
'aiqicha.baidu.com',
|
|
92
|
+
];
|
|
93
|
+
/** Query parameters that identify a campaign, not the document; dropped when deduping URLs. */
|
|
94
|
+
const TRACKING_QUERY_PARAMETERS = [
|
|
95
|
+
'hmsr',
|
|
96
|
+
'hmpl',
|
|
97
|
+
'hmcu',
|
|
98
|
+
'hmkw',
|
|
99
|
+
'hmci',
|
|
100
|
+
'spm',
|
|
101
|
+
'from',
|
|
102
|
+
'source',
|
|
103
|
+
'ref',
|
|
104
|
+
'share_token',
|
|
105
|
+
'scene',
|
|
106
|
+
'clicktime',
|
|
107
|
+
'enterid',
|
|
108
|
+
'suid',
|
|
109
|
+
'share_source',
|
|
110
|
+
];
|
|
71
111
|
/** Default trusted authority host suffixes used by the E3 evidence policy. */
|
|
72
112
|
export const DEFAULT_AUTHORITY_HOST_SUFFIXES = [
|
|
73
113
|
'gov',
|
|
@@ -93,6 +133,28 @@ const REGISTRY_HOST_SUFFIXES = [
|
|
|
93
133
|
'szse.cn',
|
|
94
134
|
'neeq.com.cn',
|
|
95
135
|
];
|
|
136
|
+
/** The five identity fields a caller asserts when verifying the subject. */
|
|
137
|
+
export const IDENTITY_FIELDS = [
|
|
138
|
+
'legalEntity',
|
|
139
|
+
'registrationNumber',
|
|
140
|
+
'registeredRegion',
|
|
141
|
+
'legalRepresentative',
|
|
142
|
+
'brandRelationship',
|
|
143
|
+
];
|
|
144
|
+
/**
|
|
145
|
+
* Identity confidence tiers.
|
|
146
|
+
*
|
|
147
|
+
* - `verified`: an E3 registry/regulator/official-filing source backs the
|
|
148
|
+
* registration number itself (the strongest claim a due-diligence tool can make).
|
|
149
|
+
* - `corroborated`: no E3 registration evidence, but at least two independent
|
|
150
|
+
* sources cover every identity field consistently (typically 工商 aggregators).
|
|
151
|
+
* This is a *decision-useful* tier, never presented as official verification.
|
|
152
|
+
* - `needs_verification`: anything weaker.
|
|
153
|
+
*/
|
|
154
|
+
export const IDENTITY_STATUSES = ['verified', 'corroborated', 'needs_verification'];
|
|
155
|
+
export function isIdentityStatus(value) {
|
|
156
|
+
return typeof value === 'string' && IDENTITY_STATUSES.includes(value);
|
|
157
|
+
}
|
|
96
158
|
/**
|
|
97
159
|
* Resolve the render language: an explicit option wins, then the case language,
|
|
98
160
|
* then the `zh-CN` default. Unknown values always fall back to `zh-CN`.
|
|
@@ -160,15 +222,23 @@ export function inferSourceType(url, explicit, authoritySuffixes = DEFAULT_AUTHO
|
|
|
160
222
|
if (url) {
|
|
161
223
|
try {
|
|
162
224
|
const hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, '');
|
|
225
|
+
const matches = (hint) => hostname === hint || hostname.endsWith(`.${hint}`);
|
|
163
226
|
// Trusted authority origins (official registry/regulator) take priority.
|
|
164
227
|
if (isTrustedAuthorityUrl(url, authoritySuffixes)) {
|
|
165
228
|
const isRegistry = REGISTRY_HOST_SUFFIXES.some(suffix => hostname === suffix || hostname.endsWith(`.${suffix}`));
|
|
166
229
|
return { type: isRegistry ? 'company_registry' : 'regulator', inferred: true };
|
|
167
230
|
}
|
|
231
|
+
// First-party product listings.
|
|
232
|
+
if (APP_STORE_HOST_HINTS.some(matches)) {
|
|
233
|
+
return { type: 'company_official', inferred: true };
|
|
234
|
+
}
|
|
168
235
|
// Exact host or subdomain match for known job platforms (no substring false positives).
|
|
169
|
-
if (JOB_PLATFORM_HOST_HINTS.some(
|
|
236
|
+
if (JOB_PLATFORM_HOST_HINTS.some(matches)) {
|
|
170
237
|
return { type: 'job_posting', inferred: true };
|
|
171
238
|
}
|
|
239
|
+
if (PUBLIC_DATABASE_HOST_HINTS.some(matches)) {
|
|
240
|
+
return { type: 'public_database', inferred: true };
|
|
241
|
+
}
|
|
172
242
|
}
|
|
173
243
|
catch {
|
|
174
244
|
// fall through to 'other'
|
|
@@ -190,7 +260,9 @@ export function inferEvidenceLevel(url, type, explicit, authoritySuffixes = DEFA
|
|
|
190
260
|
}
|
|
191
261
|
/**
|
|
192
262
|
* Normalize a source URL for deduplication: trim, lowercase the host, drop a
|
|
193
|
-
* trailing slash
|
|
263
|
+
* trailing slash, the fragment, and known campaign/tracking query parameters.
|
|
264
|
+
* Query parameters that can select the document itself (e.g. `?id=42`) are kept.
|
|
265
|
+
* Empty or unparsable URLs return `undefined`.
|
|
194
266
|
*/
|
|
195
267
|
export function normalizeSourceUrl(value) {
|
|
196
268
|
if (typeof value !== 'string')
|
|
@@ -210,7 +282,17 @@ export function normalizeSourceUrl(value) {
|
|
|
210
282
|
return undefined;
|
|
211
283
|
const path = parsed.pathname.replace(/\/+$/, '');
|
|
212
284
|
const port = parsed.port ? `:${parsed.port}` : '';
|
|
213
|
-
|
|
285
|
+
const search = new URLSearchParams();
|
|
286
|
+
const sorted = [...parsed.searchParams.entries()]
|
|
287
|
+
.filter(([key]) => {
|
|
288
|
+
const normalized = key.toLowerCase();
|
|
289
|
+
return !normalized.startsWith('utm_') && !TRACKING_QUERY_PARAMETERS.includes(normalized);
|
|
290
|
+
})
|
|
291
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
292
|
+
for (const [key, entry] of sorted)
|
|
293
|
+
search.append(key, entry);
|
|
294
|
+
const query = search.toString();
|
|
295
|
+
return `${parsed.protocol.toLowerCase()}//${hostname}${port}${path}${query ? `?${query}` : ''}`;
|
|
214
296
|
}
|
|
215
297
|
/** Find a source by normalized URL (trim + trailing-slash + host case insensitive). */
|
|
216
298
|
export function findSourceByUrl(scoutCase, url) {
|
|
@@ -275,6 +357,32 @@ export function verifyClaim(scoutCase, input) {
|
|
|
275
357
|
claims[claimIndex] = verified;
|
|
276
358
|
return { ...scoutCase, claims };
|
|
277
359
|
}
|
|
360
|
+
/** Source host, used to decide whether two sources are independent (not mirrors). */
|
|
361
|
+
function sourceHost(source) {
|
|
362
|
+
if (!source.url)
|
|
363
|
+
return '';
|
|
364
|
+
try {
|
|
365
|
+
return new URL(source.url).hostname.toLowerCase();
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return '';
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** An E3 registry/regulator/official-filing source over HTTPS. */
|
|
372
|
+
function isAuthoritativeIdentitySource(source, authoritySuffixes) {
|
|
373
|
+
return source.evidenceLevel === 'E3'
|
|
374
|
+
&& IDENTITY_SOURCE_TYPES.has(source.type)
|
|
375
|
+
&& isTrustedAuthorityUrl(source.url, authoritySuffixes);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Verify the legal subject.
|
|
379
|
+
*
|
|
380
|
+
* Reaching `verified` requires an E3 registry/regulator/official-filing source
|
|
381
|
+
* that backs the registration number — a page that merely mentions the company
|
|
382
|
+
* does not count. Without that, two independent sources covering every identity
|
|
383
|
+
* field yield `corroborated`: decision-useful and honestly labelled, never
|
|
384
|
+
* presented as official verification.
|
|
385
|
+
*/
|
|
278
386
|
export function verifyIdentity(scoutCase, input, authoritySuffixes = DEFAULT_AUTHORITY_HOST_SUFFIXES) {
|
|
279
387
|
const requiredValues = [
|
|
280
388
|
input.legalEntity,
|
|
@@ -295,11 +403,54 @@ export function verifyIdentity(scoutCase, input, authoritySuffixes = DEFAULT_AUT
|
|
|
295
403
|
throw new Error(`Unknown source: ${sourceId}`);
|
|
296
404
|
return source;
|
|
297
405
|
});
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
406
|
+
const sourceById = new Map(sources.map(source => [source.sourceId, source]));
|
|
407
|
+
const authoritative = sources.filter(source => isAuthoritativeIdentitySource(source, authoritySuffixes));
|
|
408
|
+
let identityStatus;
|
|
409
|
+
let identityEvidence;
|
|
410
|
+
if (input.fieldSources && Object.keys(input.fieldSources).length > 0) {
|
|
411
|
+
const evidence = {};
|
|
412
|
+
for (const field of IDENTITY_FIELDS) {
|
|
413
|
+
const ids = (input.fieldSources[field] ?? []).map(value => value.trim()).filter(Boolean);
|
|
414
|
+
for (const id of ids) {
|
|
415
|
+
if (!sourceById.has(id)) {
|
|
416
|
+
throw new Error(`Unknown source for identity field ${field}: ${id} (all field sources must also appear in sourceIds)`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (ids.length)
|
|
420
|
+
evidence[field] = [...new Set(ids)];
|
|
421
|
+
}
|
|
422
|
+
const missing = IDENTITY_FIELDS.filter(field => !evidence[field]?.length);
|
|
423
|
+
if (missing.length) {
|
|
424
|
+
throw new Error(`Identity evidence is missing for: ${missing.join(', ')}`);
|
|
425
|
+
}
|
|
426
|
+
const registrationSources = evidence.registrationNumber.map(id => sourceById.get(id));
|
|
427
|
+
const registrationAuthoritative = registrationSources.some(source => isAuthoritativeIdentitySource(source, authoritySuffixes));
|
|
428
|
+
const used = [...new Set(IDENTITY_FIELDS.flatMap(field => evidence[field]))];
|
|
429
|
+
const distinctHosts = new Set(used.map(id => sourceHost(sourceById.get(id))).filter(Boolean));
|
|
430
|
+
if (registrationAuthoritative) {
|
|
431
|
+
identityStatus = 'verified';
|
|
432
|
+
}
|
|
433
|
+
else if (used.length >= 2 && distinctHosts.size >= 2) {
|
|
434
|
+
identityStatus = 'corroborated';
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
throw new Error('Company identity verification requires an E3 registry/regulator/official-filing source for the '
|
|
438
|
+
+ 'registration number, or at least two independent sources covering every identity field');
|
|
439
|
+
}
|
|
440
|
+
identityEvidence = evidence;
|
|
441
|
+
}
|
|
442
|
+
else if (authoritative.length > 0) {
|
|
443
|
+
identityStatus = 'verified';
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
const distinctHosts = new Set(sources.map(sourceHost).filter(Boolean));
|
|
447
|
+
if (sources.length >= 2 && distinctHosts.size >= 2) {
|
|
448
|
+
identityStatus = 'corroborated';
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
throw new Error('Company identity verification requires a linked E3 registry, regulator, or official filing source '
|
|
452
|
+
+ '(or at least two independent sources for a corroborated identity)');
|
|
453
|
+
}
|
|
303
454
|
}
|
|
304
455
|
return {
|
|
305
456
|
...scoutCase,
|
|
@@ -311,12 +462,20 @@ export function verifyIdentity(scoutCase, input, authoritySuffixes = DEFAULT_AUT
|
|
|
311
462
|
legalRepresentative: input.legalRepresentative,
|
|
312
463
|
brandRelationship: input.brandRelationship,
|
|
313
464
|
identitySourceIds: [...input.sourceIds],
|
|
314
|
-
identityStatus
|
|
465
|
+
identityStatus,
|
|
466
|
+
...(identityEvidence ? { identityEvidence } : {}),
|
|
315
467
|
},
|
|
316
468
|
};
|
|
317
469
|
}
|
|
318
470
|
const REQUIRED_DIMENSIONS = ['role_existence', 'reporting_line', 'mandate'];
|
|
319
|
-
/**
|
|
471
|
+
/**
|
|
472
|
+
* Recompute the whole decision basis from the subject and claims (pure).
|
|
473
|
+
*
|
|
474
|
+
* Identity is blocking at every tier below `verified`: a corroborated identity
|
|
475
|
+
* is decision-useful but is never silently upgraded to official verification.
|
|
476
|
+
* PROCEED additionally requires every non-informational claim to be verified
|
|
477
|
+
* and the three required role dimensions to be verified.
|
|
478
|
+
*/
|
|
320
479
|
function assessDecision(scoutCase) {
|
|
321
480
|
const blockingContradiction = scoutCase.claims.some(claim => claim.impact === 'blocking' && claim.status === 'contradicted');
|
|
322
481
|
const identityUnverified = scoutCase.subject.identityStatus !== 'verified';
|
|
@@ -350,7 +509,11 @@ export function decideCase(scoutCase, opts) {
|
|
|
350
509
|
const decisionReason = reasonCode === 'proceed'
|
|
351
510
|
? t.decisionReasons.proceed
|
|
352
511
|
: reasonCode === 'identity'
|
|
353
|
-
?
|
|
512
|
+
? (scoutCase.subject.identityStatus === 'corroborated'
|
|
513
|
+
? (scoutCase.claims.some(claim => claim.impact !== 'informational' && claim.status !== 'verified')
|
|
514
|
+
? t.decisionReasons.identityCorroborated
|
|
515
|
+
: t.decisionReasons.identityCorroboratedClear)
|
|
516
|
+
: t.decisionReasons.identity)
|
|
354
517
|
: reasonCode === 'unresolved'
|
|
355
518
|
? (blockingContradiction ? t.decisionReasons.blockingContradiction : t.decisionReasons.unresolved)
|
|
356
519
|
: formatScoutString(t.decisionReasons.missingDimension, { dimension: missingRequiredDimension ?? '' });
|
|
@@ -389,138 +552,454 @@ function dedupeQuestions(questions) {
|
|
|
389
552
|
}
|
|
390
553
|
/**
|
|
391
554
|
* Derive a deduplicated, prioritized interview question list from the case.
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
555
|
+
*
|
|
556
|
+
* Only two sources feed the list now:
|
|
557
|
+
* - explicit `claim.interviewQuestion` values (what the model actually decided
|
|
558
|
+
* is worth asking), ordered by claim impact;
|
|
559
|
+
* - one dimension-default question for missing required dimensions, dimensions
|
|
560
|
+
* with open claims, and untouched dimensions.
|
|
561
|
+
*
|
|
562
|
+
* `claim.nextAction` is deliberately excluded — it is a research instruction
|
|
563
|
+
* ("verify this claim"), not something a candidate can ask in an interview.
|
|
564
|
+
* The case's curated questions reserve their full quota and are always kept.
|
|
396
565
|
*/
|
|
397
566
|
export function generateInterviewQuestions(scoutCase, opts) {
|
|
398
567
|
const t = scoutStrings(reportLanguage(opts?.language, scoutCase.language));
|
|
399
|
-
const
|
|
400
|
-
.map(claim => claim.
|
|
401
|
-
.filter((action, index, all) => action && all.indexOf(action) === index);
|
|
568
|
+
const askable = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified' && claim.interviewQuestion?.trim()))
|
|
569
|
+
.map(claim => claim.interviewQuestion.trim());
|
|
402
570
|
const missingDimensions = REQUIRED_DIMENSIONS.filter(dimension => !scoutCase.claims.some(claim => claim.dimension === dimension && claim.status === 'verified'));
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
571
|
+
const coverage = computeCoverage(scoutCase);
|
|
572
|
+
const untouchedDimensions = coverage.filter(item => item.untouched).map(item => item.dimension);
|
|
573
|
+
const openDimensions = coverage
|
|
574
|
+
.filter(item => !item.untouched && item.open > 0)
|
|
575
|
+
.map(item => item.dimension)
|
|
576
|
+
.filter(dimension => !missingDimensions.includes(dimension));
|
|
406
577
|
const head = dedupeQuestions([
|
|
407
|
-
...
|
|
578
|
+
...askable,
|
|
408
579
|
...missingDimensions.map(dimension => t.questions[dimension]),
|
|
580
|
+
...openDimensions.map(dimension => t.questions[dimension]),
|
|
409
581
|
...untouchedDimensions.map(dimension => t.questions[dimension]),
|
|
410
582
|
]);
|
|
411
583
|
const curated = scoutCase.interviewQuestions;
|
|
412
584
|
const headBudget = Math.max(0, INTERVIEW_QUESTION_LIMIT - curated.length);
|
|
413
585
|
return dedupeQuestions([...head.slice(0, headBudget), ...curated]).slice(0, INTERVIEW_QUESTION_LIMIT);
|
|
414
586
|
}
|
|
587
|
+
/** Trim a long string for list/table use. */
|
|
588
|
+
const clipText = (text, limit) => text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
589
|
+
/**
|
|
590
|
+
* Short human source label derived from the source title.
|
|
591
|
+
* The report body never shows internal source ids; ids stay in the appendices.
|
|
592
|
+
*/
|
|
593
|
+
function shortSourceLabel(source) {
|
|
594
|
+
const cleaned = source.title.replace(/([^)]*)|\([^)]*\)/g, ' ').trim();
|
|
595
|
+
const colon = cleaned.split(/[::]/);
|
|
596
|
+
if (colon.length > 1 && colon[0].trim())
|
|
597
|
+
return clipText(colon[0].trim(), 16);
|
|
598
|
+
const dash = cleaned.split(' - ');
|
|
599
|
+
const candidate = dash.length > 1 ? dash[dash.length - 1].trim() : cleaned;
|
|
600
|
+
return clipText(candidate || source.title, 16);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Stage-based evidence report (0.9.4 readability rework).
|
|
604
|
+
*
|
|
605
|
+
* The body speaks the reader's language: no E-codes, no source ids, no internal
|
|
606
|
+
* status enums, and no item rendered five times. Sections follow the decision
|
|
607
|
+
* stages (verdict -> subject -> confirmed -> open -> risks -> questions -> gap),
|
|
608
|
+
* with a technical audit trail in the appendices.
|
|
609
|
+
*/
|
|
415
610
|
export function renderReport(scoutCase, opts) {
|
|
416
611
|
const t = scoutStrings(reportLanguage(opts?.language, scoutCase.language));
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
text: claim.text,
|
|
434
|
-
dimension: claim.dimension,
|
|
435
|
-
sourceIds,
|
|
436
|
-
confidenceNote: claim.confidenceNote,
|
|
437
|
-
nextAction: claim.nextAction,
|
|
438
|
-
history,
|
|
612
|
+
const r = t.report;
|
|
613
|
+
const zh = t.language === 'zh-CN';
|
|
614
|
+
const metaSep = zh ? ' | ' : ' | ';
|
|
615
|
+
const labelValue = (label, value) => `${label}${zh ? ':' : ': '}${value}`;
|
|
616
|
+
const table = (headers, rows) => [
|
|
617
|
+
`| ${headers.join(' | ')} |`,
|
|
618
|
+
`|${headers.map(() => '---').join('|')}|`,
|
|
619
|
+
...rows.map(cells => `| ${cells.map(cell => cell.replace(/\|/g, '/')).join(' | ')} |`),
|
|
620
|
+
];
|
|
621
|
+
const sourceById = new Map(scoutCase.sources.map(source => [source.sourceId, source]));
|
|
622
|
+
const sourcesLabel = (ids) => {
|
|
623
|
+
if (!ids.length)
|
|
624
|
+
return t.claim.noSourceIds;
|
|
625
|
+
const labels = ids.map(id => {
|
|
626
|
+
const source = sourceById.get(id);
|
|
627
|
+
return source ? shortSourceLabel(source) : id;
|
|
439
628
|
});
|
|
629
|
+
return labels.join(t.coverage.separator);
|
|
440
630
|
};
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
''
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
formatScoutString(
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
'',
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
631
|
+
const latestCaptured = (ids) => {
|
|
632
|
+
const dates = ids
|
|
633
|
+
.map(id => sourceById.get(id)?.capturedAt)
|
|
634
|
+
.filter((value) => typeof value === 'string' && value.length >= 10)
|
|
635
|
+
.map(value => value.slice(0, 10))
|
|
636
|
+
.sort();
|
|
637
|
+
return dates.length ? dates[dates.length - 1] : '';
|
|
638
|
+
};
|
|
639
|
+
const capturedText = (ids) => {
|
|
640
|
+
const date = latestCaptured(ids);
|
|
641
|
+
return date ? formatScoutString(r.captured, { date }) : '';
|
|
642
|
+
};
|
|
643
|
+
const statusText = (claim) => t.statuses[claim.status];
|
|
644
|
+
const levelText = (level) => r.levelLabels[level];
|
|
645
|
+
const impactText = (impact) => t.impacts[impact];
|
|
646
|
+
const dimensionText = (dimension) => t.dimensions[dimension];
|
|
647
|
+
const claims = scoutCase.claims;
|
|
648
|
+
const verified = claims.filter(claim => claim.status === 'verified');
|
|
649
|
+
const openClaims = claims.filter(claim => claim.status !== 'verified');
|
|
650
|
+
const materialOpen = openClaims.filter(claim => claim.impact !== 'informational');
|
|
651
|
+
const requiredDimensions = ['role_existence', 'reporting_line', 'mandate'];
|
|
652
|
+
const requiredOpen = openClaims.filter(claim => requiredDimensions.includes(claim.dimension));
|
|
653
|
+
const fundingOpen = openClaims.find(claim => claim.dimension === 'financing_and_commercialization');
|
|
654
|
+
const riskOpen = openClaims.find(claim => claim.dimension === 'risk');
|
|
655
|
+
const subject = scoutCase.subject;
|
|
656
|
+
const entityName = subject.name;
|
|
657
|
+
const openCount = materialOpen.length;
|
|
658
|
+
const decisionSentence = scoutCase.decision === 'STOP'
|
|
659
|
+
? r.decisionSentences.stop
|
|
660
|
+
: scoutCase.decision === 'PROCEED'
|
|
661
|
+
? r.decisionSentences.proceed
|
|
662
|
+
: scoutCase.decisionReasonCode === 'identity'
|
|
663
|
+
? (subject.identityStatus === 'corroborated'
|
|
664
|
+
? formatScoutString(r.decisionSentences.corroborated, { open: openCount })
|
|
665
|
+
: r.decisionSentences.identity)
|
|
666
|
+
: (openCount
|
|
667
|
+
? formatScoutString(r.decisionSentences.unresolved, { open: openCount })
|
|
668
|
+
: (subject.identityStatus !== 'verified' ? r.decisionSentences.identity : r.decisionSentences.proceed));
|
|
669
|
+
// 30-second verdict facts.
|
|
670
|
+
const businessClaim = verified.find(claim => claim.dimension === 'product_and_technology')
|
|
671
|
+
?? claims.find(claim => claim.dimension === 'product_and_technology');
|
|
672
|
+
const fundingClaims = claims.filter(claim => claim.dimension === 'financing_and_commercialization');
|
|
673
|
+
const entityFact = subject.possibleLegalEntity
|
|
674
|
+
? formatScoutString(r.quick.entity, {
|
|
675
|
+
entity: subject.possibleLegalEntity,
|
|
676
|
+
registrationNumber: subject.registrationNumber ?? '—',
|
|
677
|
+
region: subject.registeredRegion ?? '—',
|
|
678
|
+
representative: subject.legalRepresentative ?? '—',
|
|
679
|
+
})
|
|
680
|
+
: formatScoutString(r.quick.entityUnverified, { entity: entityName });
|
|
681
|
+
const businessFact = businessClaim
|
|
682
|
+
? formatScoutString(r.quick.business, { text: clipText(businessClaim.text.replace(/。$/, ''), 80) })
|
|
683
|
+
: r.quick.businessUnknown;
|
|
684
|
+
const capitalFact = fundingClaims.length
|
|
685
|
+
? formatScoutString(r.quick.capital, {
|
|
686
|
+
text: clipText(fundingClaims[0].text.replace(/。$/, ''), 80),
|
|
687
|
+
suffix: fundingClaims.length > 1
|
|
688
|
+
? formatScoutString(r.quick.capitalSuffix, { count: fundingClaims.length - 1 })
|
|
689
|
+
: '',
|
|
690
|
+
})
|
|
691
|
+
: r.quick.capitalUnknown;
|
|
692
|
+
// Top confirmations: identity -> role -> funding -> reputation.
|
|
693
|
+
const confirmations = [];
|
|
694
|
+
if (subject.identityStatus !== 'verified')
|
|
695
|
+
confirmations.push(r.quick.confirmIdentity);
|
|
696
|
+
if (requiredOpen.length)
|
|
697
|
+
confirmations.push(r.quick.confirmRole);
|
|
698
|
+
if (fundingOpen?.interviewQuestion)
|
|
699
|
+
confirmations.push(clipText(fundingOpen.interviewQuestion, 44));
|
|
700
|
+
if (riskOpen)
|
|
701
|
+
confirmations.push(r.quick.confirmRisk);
|
|
702
|
+
const topConfirmations = confirmations.slice(0, 3);
|
|
703
|
+
const lines = [];
|
|
704
|
+
lines.push(`# ${entityName}`);
|
|
705
|
+
lines.push('');
|
|
706
|
+
const metaParts = [
|
|
707
|
+
labelValue(r.meta.role, scoutCase.role.title),
|
|
708
|
+
labelValue(r.meta.location, scoutCase.role.location ?? r.meta.unknownLocation),
|
|
709
|
+
labelValue(r.meta.decision, `${t.decisions[scoutCase.decision]}${zh ? '(' : ' ('}${scoutCase.decision}${zh ? ')' : ')'}`),
|
|
710
|
+
];
|
|
711
|
+
const evidenceDate = scoutCase.sources.map(source => source.capturedAt).filter(Boolean).map(value => String(value).slice(0, 10)).sort().at(-1) ?? '';
|
|
712
|
+
if (evidenceDate)
|
|
713
|
+
metaParts.splice(2, 0, labelValue(r.meta.evidenceAsOf, evidenceDate));
|
|
714
|
+
lines.push(`> ${metaParts.join(metaSep)}`);
|
|
715
|
+
lines.push(`> ${labelValue(r.meta.objective, scoutCase.decisionObjective)}`);
|
|
716
|
+
lines.push(r.readingGuide);
|
|
717
|
+
lines.push(r.confidenceLegend);
|
|
718
|
+
lines.push('');
|
|
719
|
+
// S0 verdict.
|
|
720
|
+
lines.push(r.quick.heading);
|
|
721
|
+
lines.push('');
|
|
722
|
+
lines.push(`**${t.decisions[scoutCase.decision]}**:${decisionSentence}`.replace(':', zh ? ':' : ': '));
|
|
723
|
+
lines.push('');
|
|
724
|
+
lines.push(`- ${entityFact}`);
|
|
725
|
+
lines.push(`- ${businessFact}`);
|
|
726
|
+
lines.push(`- ${capitalFact}`);
|
|
727
|
+
lines.push('');
|
|
728
|
+
if (topConfirmations.length) {
|
|
729
|
+
lines.push(r.quick.confirmHeading);
|
|
730
|
+
lines.push('');
|
|
731
|
+
topConfirmations.forEach((item, index) => lines.push(`${index + 1}. ${item}`));
|
|
732
|
+
lines.push('');
|
|
733
|
+
}
|
|
734
|
+
// S1 identity.
|
|
735
|
+
lines.push(r.sections.identity);
|
|
736
|
+
lines.push('');
|
|
737
|
+
const tierText = subject.identityStatus === 'verified'
|
|
738
|
+
? r.tier.verified
|
|
739
|
+
: subject.identityStatus === 'corroborated'
|
|
740
|
+
? r.tier.corroborated
|
|
741
|
+
: r.tier.unverified;
|
|
742
|
+
const identityRows = [
|
|
743
|
+
[t.identity.fieldLabels.legalEntity, subject.possibleLegalEntity ?? t.identity.unknown],
|
|
744
|
+
[t.identity.fieldLabels.registrationNumber, subject.registrationNumber ?? t.identity.unknown],
|
|
745
|
+
[t.identity.fieldLabels.registeredRegion, subject.registeredRegion ?? t.identity.unknown],
|
|
746
|
+
[t.identity.fieldLabels.legalRepresentative, subject.legalRepresentative ?? t.identity.unknown],
|
|
747
|
+
[t.identity.fieldLabels.brandRelationship, subject.brandRelationship ?? t.identity.unknown],
|
|
748
|
+
[r.labels.tier, tierText],
|
|
749
|
+
];
|
|
750
|
+
lines.push(...table([r.tableHeaders.item, r.tableHeaders.value], identityRows));
|
|
751
|
+
lines.push('');
|
|
752
|
+
lines.push(`**${r.labels.means}**:${subject.identityStatus === 'verified' ? r.tier.meansVerified : r.tier.meansOther}`
|
|
753
|
+
.replace(':', zh ? ':' : ': '));
|
|
754
|
+
lines.push('');
|
|
755
|
+
// S2 confirmed facts by theme.
|
|
756
|
+
const themeDefs = [
|
|
757
|
+
{ title: r.themes.business, dimensions: ['product_and_technology', 'market_and_competition'] },
|
|
758
|
+
{ title: r.themes.capital, dimensions: ['financing_and_commercialization'] },
|
|
759
|
+
{ title: r.themes.organization, dimensions: ['organization_and_hr', 'company_and_founder'] },
|
|
760
|
+
];
|
|
761
|
+
lines.push(r.sections.confirmed);
|
|
762
|
+
lines.push('');
|
|
763
|
+
let confirmedPrinted = 0;
|
|
764
|
+
for (const theme of themeDefs) {
|
|
765
|
+
const group = verified.filter(claim => theme.dimensions.includes(claim.dimension));
|
|
766
|
+
if (!group.length)
|
|
767
|
+
continue;
|
|
768
|
+
confirmedPrinted += group.length;
|
|
769
|
+
lines.push(`### ${theme.title}`);
|
|
770
|
+
lines.push('');
|
|
771
|
+
for (const claim of group) {
|
|
772
|
+
lines.push(formatScoutString(r.confirmedItem, {
|
|
773
|
+
text: claim.text,
|
|
774
|
+
status: statusText(claim),
|
|
775
|
+
level: levelText(claim.evidenceLevel),
|
|
776
|
+
sources: sourcesLabel(claim.sourceIds),
|
|
777
|
+
captured: capturedText(claim.sourceIds),
|
|
778
|
+
}));
|
|
779
|
+
}
|
|
780
|
+
lines.push('');
|
|
781
|
+
}
|
|
782
|
+
if (!confirmedPrinted) {
|
|
783
|
+
lines.push(r.empty.noVerified);
|
|
784
|
+
lines.push('');
|
|
785
|
+
}
|
|
786
|
+
// S3 leads and open items.
|
|
787
|
+
lines.push(r.sections.open);
|
|
788
|
+
lines.push('');
|
|
789
|
+
const interviewItems = openClaims.filter(claim => claim.interviewQuestion?.trim() && claim.dimension !== 'risk');
|
|
790
|
+
const externalItems = openClaims.filter(claim => !claim.interviewQuestion?.trim() && claim.dimension !== 'risk' && claim.dimension !== 'identity');
|
|
791
|
+
const conflictItems = claims.filter(claim => claim.status === 'contradicted');
|
|
792
|
+
let openPrinted = 0;
|
|
793
|
+
if (interviewItems.length) {
|
|
794
|
+
openPrinted += interviewItems.length;
|
|
795
|
+
lines.push(r.openSubheads.interview);
|
|
796
|
+
lines.push('');
|
|
797
|
+
for (const claim of interviewItems) {
|
|
798
|
+
lines.push(`- ${clipText(claim.text, 120)}`);
|
|
799
|
+
lines.push(` - ${formatScoutString(r.openInterviewLine, {
|
|
800
|
+
status: statusText(claim),
|
|
801
|
+
level: levelText(claim.evidenceLevel),
|
|
802
|
+
ask: r.labels.ask,
|
|
803
|
+
question: claim.interviewQuestion.trim(),
|
|
804
|
+
})}`);
|
|
805
|
+
}
|
|
806
|
+
lines.push('');
|
|
807
|
+
}
|
|
808
|
+
if (externalItems.length) {
|
|
809
|
+
openPrinted += externalItems.length;
|
|
810
|
+
lines.push(r.openSubheads.external);
|
|
811
|
+
lines.push('');
|
|
812
|
+
for (const claim of externalItems) {
|
|
813
|
+
lines.push(`- ${clipText(claim.text, 120)}`);
|
|
814
|
+
lines.push(` - ${formatScoutString(r.openExternalLine, {
|
|
815
|
+
status: statusText(claim),
|
|
816
|
+
level: levelText(claim.evidenceLevel),
|
|
817
|
+
next: r.labels.nextStep,
|
|
818
|
+
action: claim.nextAction,
|
|
819
|
+
})}`);
|
|
820
|
+
}
|
|
821
|
+
lines.push('');
|
|
822
|
+
}
|
|
823
|
+
if (conflictItems.length) {
|
|
824
|
+
openPrinted += conflictItems.length;
|
|
825
|
+
lines.push(r.openSubheads.conflict);
|
|
826
|
+
lines.push('');
|
|
827
|
+
for (const claim of conflictItems)
|
|
828
|
+
lines.push(`- ${clipText(claim.text, 140)}`);
|
|
829
|
+
lines.push('');
|
|
830
|
+
}
|
|
831
|
+
if (!openPrinted) {
|
|
832
|
+
lines.push(r.empty.noOpen);
|
|
833
|
+
lines.push('');
|
|
834
|
+
}
|
|
835
|
+
// S4 risks grouped by severity.
|
|
836
|
+
lines.push(r.sections.risk);
|
|
837
|
+
lines.push('');
|
|
838
|
+
const riskClaims = claims.filter(claim => claim.dimension === 'risk');
|
|
839
|
+
let riskPrinted = 0;
|
|
840
|
+
for (const impact of ['blocking', 'material', 'informational']) {
|
|
841
|
+
const group = riskClaims.filter(claim => claim.impact === impact);
|
|
842
|
+
if (!group.length)
|
|
843
|
+
continue;
|
|
844
|
+
riskPrinted += group.length;
|
|
845
|
+
lines.push(`**${impactText(impact)}**`);
|
|
846
|
+
lines.push('');
|
|
847
|
+
for (const claim of group) {
|
|
848
|
+
lines.push(formatScoutString(r.riskLine, {
|
|
849
|
+
text: claim.text,
|
|
850
|
+
status: statusText(claim),
|
|
851
|
+
level: levelText(claim.evidenceLevel),
|
|
852
|
+
advice: r.labels.advice,
|
|
853
|
+
action: claim.nextAction,
|
|
854
|
+
}));
|
|
855
|
+
}
|
|
856
|
+
lines.push('');
|
|
857
|
+
}
|
|
858
|
+
if (!riskPrinted) {
|
|
859
|
+
lines.push(r.empty.noRisks);
|
|
860
|
+
lines.push('');
|
|
861
|
+
}
|
|
862
|
+
// S5 interview questions grouped by theme.
|
|
863
|
+
lines.push(r.sections.interview);
|
|
864
|
+
lines.push('');
|
|
865
|
+
const questions = generateInterviewQuestions(scoutCase, { language: t.language });
|
|
866
|
+
const questionGroups = [
|
|
867
|
+
{ title: r.questionThemes.role, keywords: r.questionKeywords.role },
|
|
868
|
+
{ title: r.questionThemes.capital, keywords: r.questionKeywords.capital },
|
|
869
|
+
{ title: r.questionThemes.product, keywords: r.questionKeywords.product },
|
|
870
|
+
{ title: r.questionThemes.process, keywords: r.questionKeywords.process },
|
|
871
|
+
].map(group => ({ ...group, regex: new RegExp(group.keywords.join('|'), 'i'), matched: [] }));
|
|
872
|
+
const generalQuestions = [];
|
|
873
|
+
for (const question of questions) {
|
|
874
|
+
const group = questionGroups.find(item => item.regex.test(question));
|
|
875
|
+
if (group)
|
|
876
|
+
group.matched.push(question);
|
|
877
|
+
else
|
|
878
|
+
generalQuestions.push(question);
|
|
879
|
+
}
|
|
880
|
+
let questionPrinted = 0;
|
|
881
|
+
for (const group of questionGroups) {
|
|
882
|
+
if (!group.matched.length)
|
|
883
|
+
continue;
|
|
884
|
+
questionPrinted += group.matched.length;
|
|
885
|
+
lines.push(`### ${group.title}`);
|
|
886
|
+
lines.push('');
|
|
887
|
+
for (const question of group.matched)
|
|
888
|
+
lines.push(`- ${question}`);
|
|
889
|
+
lines.push('');
|
|
890
|
+
}
|
|
891
|
+
if (generalQuestions.length) {
|
|
892
|
+
questionPrinted += generalQuestions.length;
|
|
893
|
+
lines.push(`### ${r.questionThemes.general}`);
|
|
894
|
+
lines.push('');
|
|
895
|
+
for (const question of generalQuestions)
|
|
896
|
+
lines.push(`- ${question}`);
|
|
897
|
+
lines.push('');
|
|
898
|
+
}
|
|
899
|
+
if (!questionPrinted) {
|
|
900
|
+
lines.push(r.empty.noQuestions);
|
|
901
|
+
lines.push('');
|
|
902
|
+
}
|
|
903
|
+
// S6 what stands between here and the next step.
|
|
904
|
+
lines.push(r.sections.gap);
|
|
905
|
+
lines.push('');
|
|
906
|
+
lines.push(formatScoutString(r.gapLine.why, { decision: t.decisions[scoutCase.decision], sentence: decisionSentence }));
|
|
907
|
+
const toProceed = [];
|
|
908
|
+
if (subject.identityStatus !== 'verified')
|
|
909
|
+
toProceed.push(r.gapLine.identityItem);
|
|
910
|
+
for (const claim of requiredOpen) {
|
|
911
|
+
toProceed.push(`${dimensionText(claim.dimension)}${zh ? ':' : ': '}${clipText(claim.interviewQuestion ?? claim.text, 56)}`);
|
|
912
|
+
}
|
|
913
|
+
for (const claim of materialOpen.filter(item => !requiredOpen.includes(item) && item.dimension !== 'risk')) {
|
|
914
|
+
toProceed.push(`${dimensionText(claim.dimension)}${zh ? ':' : ': '}${clipText(claim.nextAction, 56)}`);
|
|
915
|
+
}
|
|
916
|
+
if (toProceed.length) {
|
|
917
|
+
lines.push(r.gapLine.toProceed);
|
|
918
|
+
for (const item of toProceed)
|
|
919
|
+
lines.push(` - ${item}`);
|
|
920
|
+
}
|
|
921
|
+
if (topConfirmations.length) {
|
|
922
|
+
lines.push(r.gapLine.minActions);
|
|
923
|
+
topConfirmations.forEach((item, index) => lines.push(` ${index + 1}. ${item}`));
|
|
924
|
+
}
|
|
925
|
+
lines.push('');
|
|
926
|
+
// Appendices: the technical audit trail.
|
|
927
|
+
lines.push(r.sections.appendixSources);
|
|
928
|
+
lines.push('');
|
|
929
|
+
if (scoutCase.sources.length) {
|
|
930
|
+
lines.push(...table([r.tableHeaders.id, r.tableHeaders.source, r.tableHeaders.type, r.tableHeaders.level, r.tableHeaders.capturedAt], scoutCase.sources.map(source => [
|
|
931
|
+
source.sourceId,
|
|
932
|
+
clipText(source.title, 42),
|
|
933
|
+
r.sourceTypes[source.type],
|
|
934
|
+
r.levelLabels[source.evidenceLevel],
|
|
935
|
+
source.capturedAt?.slice(0, 10) ?? '—',
|
|
936
|
+
])));
|
|
937
|
+
for (const source of scoutCase.sources) {
|
|
938
|
+
if (source.url)
|
|
939
|
+
lines.push(`- ${source.sourceId}: ${source.url}`);
|
|
940
|
+
}
|
|
941
|
+
if (scoutCase.subject.identityEvidence) {
|
|
942
|
+
const provenance = IDENTITY_FIELDS
|
|
943
|
+
.map(field => {
|
|
944
|
+
const ids = scoutCase.subject.identityEvidence?.[field];
|
|
945
|
+
return ids?.length ? `${t.identity.fieldLabels[field]}=${ids.join(', ')}` : '';
|
|
946
|
+
})
|
|
947
|
+
.filter(Boolean)
|
|
948
|
+
.join(zh ? ';' : '; ');
|
|
949
|
+
if (provenance)
|
|
950
|
+
lines.push(`- ${labelValue(r.labels.identityEvidence, provenance)}`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
lines.push(t.empty.noSources);
|
|
955
|
+
}
|
|
956
|
+
lines.push('');
|
|
957
|
+
lines.push(r.sections.appendixLedger);
|
|
958
|
+
lines.push('');
|
|
959
|
+
if (claims.length) {
|
|
960
|
+
lines.push(...table([r.tableHeaders.summary, r.tableHeaders.stage, r.tableHeaders.impact, r.tableHeaders.source, r.tableHeaders.next], claims.map(claim => [
|
|
961
|
+
clipText(claim.text, 42),
|
|
962
|
+
`${statusText(claim)} · ${levelText(claim.evidenceLevel)}`,
|
|
963
|
+
impactText(claim.impact),
|
|
964
|
+
claim.sourceIds.join(', ') || '—',
|
|
965
|
+
clipText(claim.nextAction, 26),
|
|
966
|
+
])));
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
lines.push(t.empty.noClaims);
|
|
970
|
+
}
|
|
971
|
+
lines.push('');
|
|
972
|
+
lines.push(r.sections.appendixCoverage);
|
|
973
|
+
lines.push('');
|
|
974
|
+
for (const dimension of CLAIM_DIMENSIONS) {
|
|
975
|
+
const group = claims.filter(claim => claim.dimension === dimension);
|
|
976
|
+
const verifiedCount = group.filter(claim => claim.status === 'verified').length;
|
|
977
|
+
const openCountForDimension = group.length - verifiedCount;
|
|
978
|
+
const state = !group.length
|
|
979
|
+
? r.coverage.untouched
|
|
980
|
+
: verifiedCount
|
|
981
|
+
? (openCountForDimension
|
|
982
|
+
? formatScoutString(r.coverage.withOpen, { verified: verifiedCount, open: openCountForDimension })
|
|
983
|
+
: formatScoutString(r.coverage.verified, { verified: verifiedCount }))
|
|
984
|
+
: formatScoutString(r.coverage.openOnly, { open: openCountForDimension });
|
|
985
|
+
lines.push(`- ${dimensionText(dimension)}${zh ? ':' : ': '}${state}`);
|
|
986
|
+
}
|
|
987
|
+
lines.push('');
|
|
988
|
+
lines.push(r.sections.appendixGlossary);
|
|
989
|
+
lines.push('');
|
|
990
|
+
const glossaryRows = ['E3', 'E2', 'E1', 'E0'].map(level => [
|
|
991
|
+
level,
|
|
992
|
+
r.levelLabels[level],
|
|
993
|
+
r.levelMeanings[level],
|
|
994
|
+
]);
|
|
995
|
+
for (const status of ['verified', 'reported', 'inferred', 'contradicted', 'unknown', 'needs_verification']) {
|
|
996
|
+
glossaryRows.push([status, t.statuses[status], r.statusMeanings[status] ?? '']);
|
|
997
|
+
}
|
|
998
|
+
lines.push(...table([r.tableHeaders.code, r.tableHeaders.label, r.tableHeaders.meaning], glossaryRows));
|
|
999
|
+
lines.push('');
|
|
1000
|
+
lines.push(t.reportDisclaimer);
|
|
1001
|
+
lines.push('');
|
|
1002
|
+
return lines.join('\n');
|
|
524
1003
|
}
|
|
525
1004
|
export function appendEvent(scoutCase, event) {
|
|
526
1005
|
const events = scoutCase.events ?? [];
|
|
@@ -596,9 +1075,37 @@ export function importCaseFromFiles(files) {
|
|
|
596
1075
|
if (!sources.every(item => item && typeof item.sourceId === 'string' && typeof item.title === 'string')) {
|
|
597
1076
|
throw new Error('Invalid source entry in sources.json');
|
|
598
1077
|
}
|
|
1078
|
+
for (const [index, source] of sources.entries()) {
|
|
1079
|
+
if (!SOURCE_TYPES.includes(source.type)) {
|
|
1080
|
+
throw new Error(`Invalid source type at sources[${index}]: ${String(source.type)}`);
|
|
1081
|
+
}
|
|
1082
|
+
if (!EVIDENCE_LEVELS.includes(source.evidenceLevel)) {
|
|
1083
|
+
throw new Error(`Invalid evidence level at sources[${index}]: ${String(source.evidenceLevel)}`);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
599
1086
|
if (!claims.every(item => item && typeof item.claimId === 'string' && typeof item.text === 'string' && typeof item.nextAction === 'string')) {
|
|
600
1087
|
throw new Error('Invalid claim entry in claims.json');
|
|
601
1088
|
}
|
|
1089
|
+
for (const [index, claim] of claims.entries()) {
|
|
1090
|
+
if (!CLAIM_STATUSES.includes(claim.status)) {
|
|
1091
|
+
throw new Error(`Invalid claim status at claims[${index}]: ${String(claim.status)}`);
|
|
1092
|
+
}
|
|
1093
|
+
if (!CLAIM_DIMENSIONS.includes(claim.dimension)) {
|
|
1094
|
+
throw new Error(`Invalid claim dimension at claims[${index}]: ${String(claim.dimension)}`);
|
|
1095
|
+
}
|
|
1096
|
+
if (!CLAIM_IMPACTS.includes(claim.impact)) {
|
|
1097
|
+
throw new Error(`Invalid claim impact at claims[${index}]: ${String(claim.impact)}`);
|
|
1098
|
+
}
|
|
1099
|
+
if (!EVIDENCE_LEVELS.includes(claim.evidenceLevel)) {
|
|
1100
|
+
throw new Error(`Invalid claim evidence level at claims[${index}]: ${String(claim.evidenceLevel)}`);
|
|
1101
|
+
}
|
|
1102
|
+
if (!Array.isArray(claim.sourceIds) || !claim.sourceIds.every(id => typeof id === 'string')) {
|
|
1103
|
+
throw new Error(`Invalid claim sourceIds at claims[${index}]`);
|
|
1104
|
+
}
|
|
1105
|
+
if (claim.interviewQuestion !== undefined && typeof claim.interviewQuestion !== 'string') {
|
|
1106
|
+
throw new Error(`Invalid claim interviewQuestion at claims[${index}]`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
602
1109
|
const events = files['events.jsonl']?.trim()
|
|
603
1110
|
? files['events.jsonl'].trim().split('\n').map(line => JSON.parse(line))
|
|
604
1111
|
: [];
|
|
@@ -607,13 +1114,22 @@ export function importCaseFromFiles(files) {
|
|
|
607
1114
|
const decisionReasonCode = isDecisionReasonCode(base.decisionReasonCode)
|
|
608
1115
|
? base.decisionReasonCode
|
|
609
1116
|
: undefined;
|
|
1117
|
+
const subject = base.subject;
|
|
610
1118
|
return {
|
|
611
1119
|
schemaVersion: base.schemaVersion,
|
|
612
1120
|
caseId: base.caseId,
|
|
613
1121
|
title: base.title,
|
|
614
1122
|
language: typeof base.language === 'string' && base.language ? base.language : 'zh-CN',
|
|
615
1123
|
decisionObjective: base.decisionObjective,
|
|
616
|
-
subject:
|
|
1124
|
+
subject: {
|
|
1125
|
+
...subject,
|
|
1126
|
+
identityStatus: isIdentityStatus(subject?.identityStatus)
|
|
1127
|
+
? subject.identityStatus
|
|
1128
|
+
: 'needs_verification',
|
|
1129
|
+
identitySourceIds: Array.isArray(subject?.identitySourceIds)
|
|
1130
|
+
? subject.identitySourceIds.filter((id) => typeof id === 'string')
|
|
1131
|
+
: [],
|
|
1132
|
+
},
|
|
617
1133
|
role: base.role,
|
|
618
1134
|
decision: base.decision,
|
|
619
1135
|
decisionReason: base.decisionReason,
|
|
@@ -636,11 +1152,14 @@ export function renderComparison(cases, opts) {
|
|
|
636
1152
|
counts[claim.status] = (counts[claim.status] ?? 0) + 1;
|
|
637
1153
|
return counts;
|
|
638
1154
|
}, {});
|
|
1155
|
+
const identityLabel = (scoutCase) => scoutCase.subject.identityStatus === 'verified'
|
|
1156
|
+
? t.comparison.identityVerified
|
|
1157
|
+
: scoutCase.subject.identityStatus === 'corroborated'
|
|
1158
|
+
? t.comparison.identityCorroborated
|
|
1159
|
+
: t.comparison.identityUnverified;
|
|
639
1160
|
const summaryLine = (scoutCase) => {
|
|
640
1161
|
const counts = countByStatus(scoutCase);
|
|
641
|
-
const identity = scoutCase
|
|
642
|
-
? t.comparison.identityVerified
|
|
643
|
-
: t.comparison.identityUnverified;
|
|
1162
|
+
const identity = identityLabel(scoutCase);
|
|
644
1163
|
const countsText = Object.entries(counts)
|
|
645
1164
|
.map(([status, count]) => `${count} ${t.statuses[status] ?? status}`)
|
|
646
1165
|
.join(' / ');
|
|
@@ -661,9 +1180,11 @@ export function renderComparison(cases, opts) {
|
|
|
661
1180
|
...cases.map(scoutCase => formatScoutString(t.comparison.tableRow, {
|
|
662
1181
|
title: scoutCase.title,
|
|
663
1182
|
decision: scoutCase.decision,
|
|
664
|
-
identity: scoutCase.subject.identityStatus === '
|
|
665
|
-
? t.comparison.
|
|
666
|
-
:
|
|
1183
|
+
identity: scoutCase.subject.identityStatus === 'needs_verification'
|
|
1184
|
+
? t.comparison.unverifiedMark
|
|
1185
|
+
: scoutCase.subject.identityStatus === 'corroborated'
|
|
1186
|
+
? `⚠️ ${t.comparison.identityCorroborated}`
|
|
1187
|
+
: t.comparison.verifiedMark,
|
|
667
1188
|
claimCount: scoutCase.claims.length,
|
|
668
1189
|
})),
|
|
669
1190
|
];
|