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/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(hint => hostname === hint || hostname.endsWith(`.${hint}`))) {
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 and any fragment. Empty or unparsable URLs return `undefined`.
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
- return `${parsed.protocol.toLowerCase()}//${hostname}${port}${path}${parsed.search}`;
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 hasAuthoritativeSource = sources.some(source => source.evidenceLevel === 'E3'
299
- && IDENTITY_SOURCE_TYPES.has(source.type)
300
- && isTrustedAuthorityUrl(source.url, authoritySuffixes));
301
- if (!hasAuthoritativeSource) {
302
- throw new Error('Company identity verification requires a linked E3 registry, regulator, or official filing source');
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: 'verified',
465
+ identityStatus,
466
+ ...(identityEvidence ? { identityEvidence } : {}),
315
467
  },
316
468
  };
317
469
  }
318
470
  const REQUIRED_DIMENSIONS = ['role_existence', 'reporting_line', 'mandate'];
319
- /** Recompute the whole decision basis from the subject and claims (pure). */
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
- ? t.decisionReasons.identity
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
- * 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.
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 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);
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 untouchedDimensions = computeCoverage(scoutCase)
404
- .filter(item => item.untouched)
405
- .map(item => item.dimension);
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
- ...nextActions,
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 sourceLine = (source) => {
418
- const url = source.url ? ` — ${source.url}` : '';
419
- return `- ${source.sourceId}: ${source.title} [${source.type} / ${source.evidenceLevel}]${url}`;
420
- };
421
- const sources = scoutCase.sources.length
422
- ? scoutCase.sources.map(sourceLine).join('\n')
423
- : t.empty.noSources;
424
- const claimLine = (claim) => {
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,
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 claims = scoutCase.claims.length
442
- ? scoutCase.claims.map(claimLine).join('\n')
443
- : t.empty.noClaims;
444
- const supports = byImpact(scoutCase.claims.filter(claim => claim.status === 'verified')).slice(0, 3);
445
- const risks = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified')).slice(0, 3);
446
- const roleHypotheses = byImpact(scoutCase.claims.filter(claim => ['role_existence', 'reporting_line', 'mandate'].includes(claim.dimension))).slice(0, 3);
447
- const checklist = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified'));
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
- });
454
- const checklistBlock = checklist.length
455
- ? checklist.map(checklistLine).join('\n')
456
- : t.empty.noChecklistItems;
457
- const statusCounts = scoutCase.claims.reduce((counts, claim) => {
458
- counts[claim.status] = (counts[claim.status] ?? 0) + 1;
459
- return counts;
460
- }, {});
461
- const summary = Object.entries(statusCounts)
462
- .map(([status, count]) => `${count} ${status}`)
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
- });
479
- return [
480
- `# ${scoutCase.title}`,
481
- '',
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,
492
- '',
493
- t.headings.supportingEvidence,
494
- '',
495
- supports.length ? supports.map(claimLine).join('\n') : t.empty.noSupportingEvidence,
496
- '',
497
- t.headings.unresolvedRisks,
498
- '',
499
- risks.length ? risks.map(claimLine).join('\n') : t.empty.noUnresolvedRisks,
500
- '',
501
- t.headings.roleHypotheses,
502
- '',
503
- roleHypotheses.length ? roleHypotheses.map(claimLine).join('\n') : t.empty.noRoleHypotheses,
504
- '',
505
- t.headings.verificationChecklist,
506
- '',
507
- checklistBlock,
508
- '',
509
- t.headings.claimLedger,
510
- '',
511
- claims,
512
- '',
513
- t.headings.sources,
514
- '',
515
- sources,
516
- '',
517
- t.headings.interviewQuestions,
518
- '',
519
- scoutCase.interviewQuestions.map(question => `- ${question}`).join('\n'),
520
- '',
521
- t.reportDisclaimer,
522
- '',
523
- ].join('\n');
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: base.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.subject.identityStatus === 'verified'
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 === 'verified'
665
- ? t.comparison.verifiedMark
666
- : t.comparison.unverifiedMark,
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
  ];