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/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: 'zh-CN',
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
- export function decideCase(scoutCase) {
273
- const identityUnverified = scoutCase.subject.identityStatus !== 'verified';
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 requiredDimensions = ['role_existence', 'reporting_line', 'mandate'];
277
- const missingRequiredDimension = requiredDimensions.find(dimension => !scoutCase.claims.some(claim => claim.dimension === dimension && claim.status === 'verified'));
278
- if (blockingContradiction) {
279
- return {
280
- ...scoutCase,
281
- decision: 'STOP',
282
- decisionReason: '存在相互矛盾的关键事实,需先解决冲突。',
283
- };
284
- }
285
- if (identityUnverified || unresolvedMaterialClaim || missingRequiredDimension) {
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
- ...scoutCase,
288
- decision: 'VERIFY',
289
- decisionReason: identityUnverified
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 MISSING_DIMENSION_QUESTIONS = {
307
- role_existence: '该岗位当前是否真实在招?招聘状态与到岗时间如何?',
308
- reporting_line: '该岗位直接向谁汇报?虚线/实线如何划分?',
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
- return questions.filter(question => {
380
+ const unique = [];
381
+ for (const question of questions) {
324
382
  const key = question.trim();
325
383
  if (!key || seen.has(key))
326
- return false;
384
+ continue;
327
385
  seen.add(key);
328
- return true;
329
- }).slice(0, 12);
386
+ unique.push(question);
387
+ }
388
+ return unique;
330
389
  }
331
- export function renderReport(scoutCase) {
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 ? `;历史修订:${claim.history.length}` : '';
342
- return `- **${claim.status} / ${claim.evidenceLevel} / ${claim.impact}** ${claim.text}(维度:${claim.dimension};来源:${sourceIds};置信说明:${claim.confidenceNote};下一步:${claim.nextAction}${history})`;
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) => `- [${claim.impact}] ${claim.claimId}: ${claim.text} → 下一步:${claim.nextAction}`;
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
- `- 判断:**${scoutCase.decision}**`,
366
- `- 理由:${scoutCase.decisionReason}`,
367
- `- 目标:${scoutCase.decisionObjective}`,
368
- ...(summary ? [`- 证据概况:${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
- '## Key supporting evidence (up to 3)',
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
- '## Blocking or unresolved risks (up to 3)',
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
- '## Role task and authority hypotheses',
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
- '## Verification checklist',
505
+ t.headings.verificationChecklist,
383
506
  '',
384
507
  checklistBlock,
385
508
  '',
386
- '## Claim ledger',
509
+ t.headings.claimLedger,
387
510
  '',
388
511
  claims,
389
512
  '',
390
- '## Sources',
513
+ t.headings.sources,
391
514
  '',
392
515
  sources,
393
516
  '',
394
- '## Interview questions',
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
- /** Serialize a case into the durable five-file export payloads (no I/O). */
417
- export function exportCaseFiles(scoutCase) {
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} ${CLAIM_STATUS_LABELS[status] ?? status}`)
645
+ .map(([status, count]) => `${count} ${t.statuses[status] ?? status}`)
514
646
  .join(' / ');
515
- const countsSuffix = countsText ? `(${countsText})` : '';
516
- return `- **${scoutCase.title}**:决策 **${scoutCase.decision}**;主体${identity};主张 ${scoutCase.claims.length} 条${countsSuffix}`;
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 => `| ${scoutCase.title} | ${scoutCase.decision} | ${scoutCase.subject.identityStatus === 'verified' ? '✅ 已核验' : '⚠️ 待核验'} | ${scoutCase.claims.length} |`),
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(`### ${scoutCase.title}(${scoutCase.caseId})`, '', '**关键已核验结论**:', verified.length ? verified.map(claim => `- ${claim.text}`).join('\n') : '- 暂无已核验结论', '', '**待核验风险/未知项**:', open.length ? open.map(claim => `- [${claim.impact}] ${claim.text} → ${claim.nextAction}`).join('\n') : '- 无', '');
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
- `# 公司/岗位对比(${cases.length} 个案例)`,
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
- '## 面试问题(合并去重,前 12 条)',
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
  }