dsh-scout 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ export declare const EVIDENCE_LEVELS: readonly ["E0", "E1", "E2", "E3"];
2
+ export type EvidenceLevel = typeof EVIDENCE_LEVELS[number];
3
+ export declare const CLAIM_STATUSES: readonly ["verified", "reported", "inferred", "contradicted", "unknown", "needs_verification"];
4
+ export type ClaimStatus = typeof CLAIM_STATUSES[number];
5
+ export declare const SOURCE_TYPES: readonly ["company_registry", "regulator", "official_filing", "company_official", "job_posting", "independent_media", "public_database", "user_provided", "user_provided_historical_report", "other"];
6
+ export type SourceType = typeof SOURCE_TYPES[number];
7
+ export declare const CLAIM_IMPACTS: readonly ["blocking", "material", "informational"];
8
+ export type ClaimImpact = typeof CLAIM_IMPACTS[number];
9
+ export declare const CLAIM_DIMENSIONS: readonly ["identity", "role_existence", "reporting_line", "mandate", "company_and_founder", "product_and_technology", "market_and_competition", "financing_and_commercialization", "organization_and_hr", "risk", "role_fit"];
10
+ export type ClaimDimension = typeof CLAIM_DIMENSIONS[number];
11
+ export type Decision = 'PROCEED' | 'VERIFY' | 'STOP';
12
+ export declare const EVENT_TYPES: readonly ["case_started", "source_added", "claim_added", "identity_verified", "claim_verified", "case_exported", "case_imported"];
13
+ export type ScoutEventType = typeof EVENT_TYPES[number];
14
+ export interface ScoutEvent {
15
+ eventId: string;
16
+ type: ScoutEventType;
17
+ at: string;
18
+ detail: Record<string, unknown>;
19
+ }
20
+ export interface ScoutSource {
21
+ sourceId: string;
22
+ type: SourceType;
23
+ title: string;
24
+ url: string | null;
25
+ capturedAt: string | null;
26
+ evidenceLevel: EvidenceLevel;
27
+ status: string;
28
+ note?: string;
29
+ }
30
+ export interface ScoutClaim {
31
+ claimId: string;
32
+ text: string;
33
+ status: ClaimStatus;
34
+ evidenceLevel: EvidenceLevel;
35
+ dimension: ClaimDimension;
36
+ impact: ClaimImpact;
37
+ sourceIds: string[];
38
+ nextAction: string;
39
+ confidenceNote: string;
40
+ history?: Array<{
41
+ status: ClaimStatus;
42
+ evidenceLevel: EvidenceLevel;
43
+ sourceIds: string[];
44
+ nextAction: string;
45
+ confidenceNote: string;
46
+ }>;
47
+ }
48
+ export interface ScoutCase {
49
+ schemaVersion: 'dsh-scout.case.v0';
50
+ caseId: string;
51
+ title: string;
52
+ language: string;
53
+ decisionObjective: string;
54
+ subject: {
55
+ name: string;
56
+ possibleLegalEntity?: string;
57
+ registrationNumber?: string;
58
+ registeredRegion?: string;
59
+ legalRepresentative?: string;
60
+ brandRelationship?: string;
61
+ identitySourceIds: string[];
62
+ identityStatus: 'verified' | 'needs_verification';
63
+ };
64
+ role: {
65
+ title: string;
66
+ location?: string;
67
+ fitQuestion: string;
68
+ };
69
+ decision: Decision;
70
+ decisionReason: string;
71
+ sources: ScoutSource[];
72
+ claims: ScoutClaim[];
73
+ interviewQuestions: string[];
74
+ events?: ScoutEvent[];
75
+ }
76
+ export declare function createCase(input: {
77
+ caseId: string;
78
+ companyName: string;
79
+ roleTitle: string;
80
+ location?: string;
81
+ decisionObjective?: string;
82
+ }): ScoutCase;
83
+ export declare function addSource(scoutCase: ScoutCase, source: ScoutSource): ScoutCase;
84
+ export declare function isTrustedAuthorityUrl(value: string | null): boolean;
85
+ /** Guess a source category from its URL when the caller did not provide one. */
86
+ export declare function inferSourceType(url: string | null, explicit?: SourceType): {
87
+ type: SourceType;
88
+ inferred: boolean;
89
+ };
90
+ /** Guess an evidence level from the URL and category when not provided explicitly. */
91
+ export declare function inferEvidenceLevel(url: string | null, type: SourceType, explicit?: EvidenceLevel): {
92
+ evidenceLevel: EvidenceLevel;
93
+ inferred: boolean;
94
+ };
95
+ export declare function addClaim(scoutCase: ScoutCase, claim: ScoutClaim): ScoutCase;
96
+ export declare function verifyClaim(scoutCase: ScoutCase, input: {
97
+ claimId: string;
98
+ evidenceLevel: EvidenceLevel;
99
+ sourceIds: string[];
100
+ nextAction: string;
101
+ confidenceNote: string;
102
+ }): ScoutCase;
103
+ export declare function verifyIdentity(scoutCase: ScoutCase, input: {
104
+ legalEntity: string;
105
+ registrationNumber: string;
106
+ registeredRegion: string;
107
+ legalRepresentative: string;
108
+ brandRelationship: string;
109
+ sourceIds: string[];
110
+ }): ScoutCase;
111
+ export declare function decideCase(scoutCase: ScoutCase): ScoutCase;
112
+ /** Derive a deduplicated, prioritized interview question list from the case. */
113
+ export declare function generateInterviewQuestions(scoutCase: ScoutCase): string[];
114
+ export declare function renderReport(scoutCase: ScoutCase): string;
115
+ export declare function appendEvent(scoutCase: ScoutCase, event: ScoutEvent): ScoutCase;
116
+ export declare const EXPORT_FILE_NAMES: readonly ["case.json", "sources.json", "claims.json", "events.jsonl", "report.md"];
117
+ /** Serialize a case into the durable five-file export payloads (no I/O). */
118
+ export declare function exportCaseFiles(scoutCase: ScoutCase): Record<string, string>;
119
+ /** Rebuild a case from the five-file export payloads, validating structure. */
120
+ export declare function importCaseFromFiles(files: Record<string, string>): ScoutCase;
121
+ /** Render a side-by-side comparison report for two or more cases. */
122
+ export declare function renderComparison(cases: ScoutCase[]): string;
package/dist/model.js ADDED
@@ -0,0 +1,564 @@
1
+ export const EVIDENCE_LEVELS = ['E0', 'E1', 'E2', 'E3'];
2
+ export const CLAIM_STATUSES = [
3
+ 'verified',
4
+ 'reported',
5
+ 'inferred',
6
+ 'contradicted',
7
+ 'unknown',
8
+ 'needs_verification',
9
+ ];
10
+ export const SOURCE_TYPES = [
11
+ 'company_registry',
12
+ 'regulator',
13
+ 'official_filing',
14
+ 'company_official',
15
+ 'job_posting',
16
+ 'independent_media',
17
+ 'public_database',
18
+ 'user_provided',
19
+ 'user_provided_historical_report',
20
+ 'other',
21
+ ];
22
+ export const CLAIM_IMPACTS = ['blocking', 'material', 'informational'];
23
+ export const CLAIM_DIMENSIONS = [
24
+ 'identity',
25
+ 'role_existence',
26
+ 'reporting_line',
27
+ 'mandate',
28
+ 'company_and_founder',
29
+ 'product_and_technology',
30
+ 'market_and_competition',
31
+ 'financing_and_commercialization',
32
+ 'organization_and_hr',
33
+ 'risk',
34
+ 'role_fit',
35
+ ];
36
+ export const EVENT_TYPES = [
37
+ 'case_started',
38
+ 'source_added',
39
+ 'claim_added',
40
+ 'identity_verified',
41
+ 'claim_verified',
42
+ 'case_exported',
43
+ 'case_imported',
44
+ ];
45
+ const IDENTITY_SOURCE_TYPES = new Set([
46
+ 'company_registry',
47
+ 'regulator',
48
+ 'official_filing',
49
+ ]);
50
+ const JOB_PLATFORM_HOST_HINTS = [
51
+ 'liepin.com',
52
+ 'zhipin.com',
53
+ '51job.com',
54
+ 'zhaopin.com',
55
+ 'quanzhi.com',
56
+ 'nowcoder.com',
57
+ 'jobs.feishu.cn',
58
+ ];
59
+ const AUTHORITY_HOST_SUFFIXES = [
60
+ 'gov',
61
+ 'gov.cn',
62
+ 'gov.uk',
63
+ 'gov.hk',
64
+ 'gov.au',
65
+ 'gc.ca',
66
+ 'brreg.no',
67
+ 'handelsregister.de',
68
+ 'hkexnews.hk',
69
+ 'sse.com.cn',
70
+ 'szse.cn',
71
+ 'neeq.com.cn',
72
+ ];
73
+ /** Registrar/registry hostnames that map to company_registry rather than regulator. */
74
+ const REGISTRY_HOST_SUFFIXES = [
75
+ 'gsxt.gov.cn',
76
+ 'brreg.no',
77
+ 'handelsregister.de',
78
+ 'hkexnews.hk',
79
+ 'sse.com.cn',
80
+ 'szse.cn',
81
+ 'neeq.com.cn',
82
+ ];
83
+ export function createCase(input) {
84
+ return {
85
+ schemaVersion: 'dsh-scout.case.v0',
86
+ caseId: input.caseId,
87
+ title: `${input.companyName} — ${input.roleTitle}`,
88
+ language: 'zh-CN',
89
+ decisionObjective: input.decisionObjective ?? '判断是否值得进入下一轮,并准备面试反问',
90
+ subject: {
91
+ name: input.companyName,
92
+ identitySourceIds: [],
93
+ identityStatus: 'needs_verification',
94
+ },
95
+ role: {
96
+ title: input.roleTitle,
97
+ location: input.location,
98
+ fitQuestion: '该岗位是否拥有足够的业务授权,值得继续推进?',
99
+ },
100
+ decision: 'VERIFY',
101
+ decisionReason: '尚未完成公司主体和岗位授权核验。',
102
+ sources: [],
103
+ claims: [],
104
+ events: [],
105
+ interviewQuestions: [
106
+ '这个岗位在前三个月最重要的业务结果是什么?',
107
+ '该岗位直接向谁汇报,在哪些事项上拥有最终决定权?',
108
+ '现有团队、预算和外部供应商资源分别是什么?',
109
+ '当前最影响业务增长的组织问题是什么?',
110
+ '六个月后,哪些可观察结果代表这个岗位取得成功?',
111
+ ],
112
+ };
113
+ }
114
+ export function addSource(scoutCase, source) {
115
+ if (scoutCase.sources.some(item => item.sourceId === source.sourceId)) {
116
+ throw new Error(`Duplicate source: ${source.sourceId}`);
117
+ }
118
+ if (source.evidenceLevel === 'E3'
119
+ && (!IDENTITY_SOURCE_TYPES.has(source.type) || !isTrustedAuthorityUrl(source.url))) {
120
+ throw new Error('E3 sources require a supported registry, regulator, or official filing HTTPS origin');
121
+ }
122
+ return { ...scoutCase, sources: [...scoutCase.sources, source] };
123
+ }
124
+ export function isTrustedAuthorityUrl(value) {
125
+ if (!value)
126
+ return false;
127
+ try {
128
+ const url = new URL(value);
129
+ if (url.protocol !== 'https:' || url.username || url.password)
130
+ return false;
131
+ if (url.port && url.port !== '443')
132
+ return false;
133
+ const hostname = url.hostname.toLowerCase().replace(/\.$/, '');
134
+ return AUTHORITY_HOST_SUFFIXES.some(suffix => hostname === suffix || hostname.endsWith(`.${suffix}`));
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ /** Guess a source category from its URL when the caller did not provide one. */
141
+ export function inferSourceType(url, explicit) {
142
+ if (explicit && SOURCE_TYPES.includes(explicit))
143
+ return { type: explicit, inferred: false };
144
+ if (url) {
145
+ try {
146
+ const hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, '');
147
+ // Trusted authority origins (official registry/regulator) take priority.
148
+ if (isTrustedAuthorityUrl(url)) {
149
+ const isRegistry = REGISTRY_HOST_SUFFIXES.some(suffix => hostname === suffix || hostname.endsWith(`.${suffix}`));
150
+ return { type: isRegistry ? 'company_registry' : 'regulator', inferred: true };
151
+ }
152
+ // Exact host or subdomain match for known job platforms (no substring false positives).
153
+ if (JOB_PLATFORM_HOST_HINTS.some(hint => hostname === hint || hostname.endsWith(`.${hint}`))) {
154
+ return { type: 'job_posting', inferred: true };
155
+ }
156
+ }
157
+ catch {
158
+ // fall through to 'other'
159
+ }
160
+ }
161
+ return { type: 'other', inferred: true };
162
+ }
163
+ /** Guess an evidence level from the URL and category when not provided explicitly. */
164
+ export function inferEvidenceLevel(url, type, explicit) {
165
+ if (explicit && EVIDENCE_LEVELS.includes(explicit))
166
+ return { evidenceLevel: explicit, inferred: false };
167
+ if (IDENTITY_SOURCE_TYPES.has(type) && isTrustedAuthorityUrl(url)) {
168
+ return { evidenceLevel: 'E3', inferred: true };
169
+ }
170
+ if (type === 'user_provided') {
171
+ return { evidenceLevel: 'E1', inferred: true };
172
+ }
173
+ return { evidenceLevel: 'E2', inferred: true };
174
+ }
175
+ export function addClaim(scoutCase, claim) {
176
+ if (scoutCase.claims.some(item => item.claimId === claim.claimId)) {
177
+ throw new Error(`Duplicate claim: ${claim.claimId}`);
178
+ }
179
+ validateClaimEvidence(scoutCase, claim);
180
+ return { ...scoutCase, claims: [...scoutCase.claims, claim] };
181
+ }
182
+ function validateClaimEvidence(scoutCase, claim) {
183
+ const knownSources = new Set(scoutCase.sources.map(source => source.sourceId));
184
+ const missingSource = claim.sourceIds.find(sourceId => !knownSources.has(sourceId));
185
+ if (missingSource)
186
+ throw new Error(`Unknown source: ${missingSource}`);
187
+ if (claim.sourceIds.length === 0
188
+ && claim.status !== 'unknown'
189
+ && claim.status !== 'needs_verification') {
190
+ throw new Error('Claims without sources must remain unknown or needs_verification');
191
+ }
192
+ if (claim.status === 'verified' && claim.evidenceLevel === 'E0') {
193
+ throw new Error('Verified claims require at least E1 evidence');
194
+ }
195
+ const evidenceRank = { E0: 0, E1: 1, E2: 2, E3: 3 };
196
+ const strongestSourceRank = scoutCase.sources
197
+ .filter(source => claim.sourceIds.includes(source.sourceId))
198
+ .reduce((rank, source) => Math.max(rank, evidenceRank[source.evidenceLevel]), 0);
199
+ if (evidenceRank[claim.evidenceLevel] > strongestSourceRank) {
200
+ throw new Error(`Claim evidence ${claim.evidenceLevel} exceeds its strongest source`);
201
+ }
202
+ }
203
+ export function verifyClaim(scoutCase, input) {
204
+ const claimIndex = scoutCase.claims.findIndex(claim => claim.claimId === input.claimId);
205
+ if (claimIndex < 0)
206
+ throw new Error(`Unknown claim: ${input.claimId}`);
207
+ const current = scoutCase.claims[claimIndex];
208
+ const verified = {
209
+ ...current,
210
+ status: 'verified',
211
+ evidenceLevel: input.evidenceLevel,
212
+ sourceIds: [...input.sourceIds],
213
+ nextAction: input.nextAction,
214
+ confidenceNote: input.confidenceNote,
215
+ history: [
216
+ ...(current.history ?? []),
217
+ {
218
+ status: current.status,
219
+ evidenceLevel: current.evidenceLevel,
220
+ sourceIds: [...current.sourceIds],
221
+ nextAction: current.nextAction,
222
+ confidenceNote: current.confidenceNote,
223
+ },
224
+ ],
225
+ };
226
+ validateClaimEvidence(scoutCase, verified);
227
+ const claims = [...scoutCase.claims];
228
+ claims[claimIndex] = verified;
229
+ return { ...scoutCase, claims };
230
+ }
231
+ export function verifyIdentity(scoutCase, input) {
232
+ const requiredValues = [
233
+ input.legalEntity,
234
+ input.registrationNumber,
235
+ input.registeredRegion,
236
+ input.legalRepresentative,
237
+ input.brandRelationship,
238
+ ];
239
+ if (requiredValues.some(value => value.trim().length === 0)) {
240
+ throw new Error('Company identity fields must not be blank');
241
+ }
242
+ if (input.sourceIds.length === 0) {
243
+ throw new Error('Company identity verification requires at least one source');
244
+ }
245
+ const sources = input.sourceIds.map(sourceId => {
246
+ const source = scoutCase.sources.find(item => item.sourceId === sourceId);
247
+ if (!source)
248
+ throw new Error(`Unknown source: ${sourceId}`);
249
+ return source;
250
+ });
251
+ const hasAuthoritativeSource = sources.some(source => source.evidenceLevel === 'E3'
252
+ && IDENTITY_SOURCE_TYPES.has(source.type)
253
+ && isTrustedAuthorityUrl(source.url));
254
+ if (!hasAuthoritativeSource) {
255
+ throw new Error('Company identity verification requires a linked E3 registry, regulator, or official filing source');
256
+ }
257
+ return {
258
+ ...scoutCase,
259
+ subject: {
260
+ ...scoutCase.subject,
261
+ possibleLegalEntity: input.legalEntity,
262
+ registrationNumber: input.registrationNumber,
263
+ registeredRegion: input.registeredRegion,
264
+ legalRepresentative: input.legalRepresentative,
265
+ brandRelationship: input.brandRelationship,
266
+ identitySourceIds: [...input.sourceIds],
267
+ identityStatus: 'verified',
268
+ },
269
+ };
270
+ }
271
+ export function decideCase(scoutCase) {
272
+ const identityUnverified = scoutCase.subject.identityStatus !== 'verified';
273
+ const blockingContradiction = scoutCase.claims.some(claim => claim.impact === 'blocking' && claim.status === 'contradicted');
274
+ const unresolvedMaterialClaim = scoutCase.claims.some(claim => claim.impact !== 'informational' && claim.status !== 'verified');
275
+ const requiredDimensions = ['role_existence', 'reporting_line', 'mandate'];
276
+ const missingRequiredDimension = requiredDimensions.find(dimension => !scoutCase.claims.some(claim => claim.dimension === dimension && claim.status === 'verified'));
277
+ if (blockingContradiction) {
278
+ return {
279
+ ...scoutCase,
280
+ decision: 'STOP',
281
+ decisionReason: '存在相互矛盾的关键事实,需先解决冲突。',
282
+ };
283
+ }
284
+ if (identityUnverified || unresolvedMaterialClaim || missingRequiredDimension) {
285
+ return {
286
+ ...scoutCase,
287
+ decision: 'VERIFY',
288
+ decisionReason: identityUnverified
289
+ ? '公司主体仍未核验,不能直接推进。'
290
+ : unresolvedMaterialClaim
291
+ ? '仍有阻断级或重要结论未完成核验,不能直接推进。'
292
+ : `缺少已核验的必需维度:${missingRequiredDimension}。`,
293
+ };
294
+ }
295
+ return {
296
+ ...scoutCase,
297
+ decision: 'PROCEED',
298
+ decisionReason: '当前关键结论均有来源支撑,建议进入下一轮并继续现场核验。',
299
+ };
300
+ }
301
+ const IMPACT_RANK = { blocking: 0, material: 1, informational: 2 };
302
+ function byImpact(claims) {
303
+ return [...claims].sort((a, b) => IMPACT_RANK[a.impact] - IMPACT_RANK[b.impact]);
304
+ }
305
+ const MISSING_DIMENSION_QUESTIONS = {
306
+ role_existence: '该岗位当前是否真实在招?招聘状态与到岗时间如何?',
307
+ reporting_line: '该岗位直接向谁汇报?虚线/实线如何划分?',
308
+ mandate: '该岗位在哪些事项上拥有最终决定权?团队与预算规模如何?',
309
+ };
310
+ /** Derive a deduplicated, prioritized interview question list from the case. */
311
+ export function generateInterviewQuestions(scoutCase) {
312
+ const nextActions = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified'))
313
+ .map(claim => claim.nextAction.trim())
314
+ .filter((action, index, all) => action && all.indexOf(action) === index);
315
+ const missingDimensions = Object.keys(MISSING_DIMENSION_QUESTIONS).filter(dimension => !scoutCase.claims.some(claim => claim.dimension === dimension && claim.status === 'verified'));
316
+ const questions = [
317
+ ...nextActions,
318
+ ...missingDimensions.map(dimension => MISSING_DIMENSION_QUESTIONS[dimension]),
319
+ ...scoutCase.interviewQuestions,
320
+ ];
321
+ const seen = new Set();
322
+ return questions.filter(question => {
323
+ const key = question.trim();
324
+ if (!key || seen.has(key))
325
+ return false;
326
+ seen.add(key);
327
+ return true;
328
+ }).slice(0, 12);
329
+ }
330
+ export function renderReport(scoutCase) {
331
+ const sourceLine = (source) => {
332
+ const url = source.url ? ` — ${source.url}` : '';
333
+ return `- ${source.sourceId}: ${source.title} [${source.type} / ${source.evidenceLevel}]${url}`;
334
+ };
335
+ const sources = scoutCase.sources.length
336
+ ? scoutCase.sources.map(sourceLine).join('\n')
337
+ : '- 暂无来源';
338
+ const claimLine = (claim) => {
339
+ const sourceIds = claim.sourceIds.length ? claim.sourceIds.join(', ') : '无来源';
340
+ const history = claim.history?.length ? `;历史修订:${claim.history.length}` : '';
341
+ return `- **${claim.status} / ${claim.evidenceLevel} / ${claim.impact}** ${claim.text}(维度:${claim.dimension};来源:${sourceIds};置信说明:${claim.confidenceNote};下一步:${claim.nextAction}${history})`;
342
+ };
343
+ const claims = scoutCase.claims.length
344
+ ? scoutCase.claims.map(claimLine).join('\n')
345
+ : '- 暂无结论';
346
+ const supports = byImpact(scoutCase.claims.filter(claim => claim.status === 'verified')).slice(0, 3);
347
+ const risks = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified')).slice(0, 3);
348
+ const roleHypotheses = byImpact(scoutCase.claims.filter(claim => ['role_existence', 'reporting_line', 'mandate'].includes(claim.dimension))).slice(0, 3);
349
+ const checklist = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified'));
350
+ const checklistLine = (claim) => `- [${claim.impact}] ${claim.claimId}: ${claim.text} → 下一步:${claim.nextAction}`;
351
+ const checklistBlock = checklist.length
352
+ ? checklist.map(checklistLine).join('\n')
353
+ : '- 无待核验的阻断级或重要事项';
354
+ const statusCounts = scoutCase.claims.reduce((counts, claim) => {
355
+ counts[claim.status] = (counts[claim.status] ?? 0) + 1;
356
+ return counts;
357
+ }, {});
358
+ const summary = Object.entries(statusCounts)
359
+ .map(([status, count]) => `${count} ${status}`)
360
+ .join(' / ');
361
+ return [
362
+ `# ${scoutCase.title}`,
363
+ '',
364
+ `- 判断:**${scoutCase.decision}**`,
365
+ `- 理由:${scoutCase.decisionReason}`,
366
+ `- 目标:${scoutCase.decisionObjective}`,
367
+ ...(summary ? [`- 证据概况:${summary}`] : []),
368
+ '',
369
+ '## Key supporting evidence (up to 3)',
370
+ '',
371
+ supports.length ? supports.map(claimLine).join('\n') : '- 暂无已核验的支持证据',
372
+ '',
373
+ '## Blocking or unresolved risks (up to 3)',
374
+ '',
375
+ risks.length ? risks.map(claimLine).join('\n') : '- 暂无阻断级或重要待核验风险',
376
+ '',
377
+ '## Role task and authority hypotheses',
378
+ '',
379
+ roleHypotheses.length ? roleHypotheses.map(claimLine).join('\n') : '- 岗位真实性、汇报关系和授权边界尚未建模',
380
+ '',
381
+ '## Verification checklist',
382
+ '',
383
+ checklistBlock,
384
+ '',
385
+ '## Claim ledger',
386
+ '',
387
+ claims,
388
+ '',
389
+ '## Sources',
390
+ '',
391
+ sources,
392
+ '',
393
+ '## Interview questions',
394
+ '',
395
+ scoutCase.interviewQuestions.map(question => `- ${question}`).join('\n'),
396
+ '',
397
+ '> 本报告只表达当前证据边界内的判断,不替代法律、投资或医疗意见。',
398
+ '',
399
+ ].join('\n');
400
+ }
401
+ export function appendEvent(scoutCase, event) {
402
+ const events = scoutCase.events ?? [];
403
+ if (events.some(item => item.eventId === event.eventId)) {
404
+ throw new Error(`Duplicate event: ${event.eventId}`);
405
+ }
406
+ return { ...scoutCase, events: [...events, event] };
407
+ }
408
+ export const EXPORT_FILE_NAMES = [
409
+ 'case.json',
410
+ 'sources.json',
411
+ 'claims.json',
412
+ 'events.jsonl',
413
+ 'report.md',
414
+ ];
415
+ /** Serialize a case into the durable five-file export payloads (no I/O). */
416
+ export function exportCaseFiles(scoutCase) {
417
+ const base = {
418
+ schemaVersion: scoutCase.schemaVersion,
419
+ caseId: scoutCase.caseId,
420
+ title: scoutCase.title,
421
+ language: scoutCase.language,
422
+ decisionObjective: scoutCase.decisionObjective,
423
+ subject: scoutCase.subject,
424
+ role: scoutCase.role,
425
+ decision: scoutCase.decision,
426
+ decisionReason: scoutCase.decisionReason,
427
+ interviewQuestions: scoutCase.interviewQuestions,
428
+ };
429
+ const events = scoutCase.events ?? [];
430
+ return {
431
+ 'case.json': `${JSON.stringify(base, null, 2)}\n`,
432
+ 'sources.json': `${JSON.stringify(scoutCase.sources, null, 2)}\n`,
433
+ 'claims.json': `${JSON.stringify(scoutCase.claims, null, 2)}\n`,
434
+ 'events.jsonl': events.length ? `${events.map(event => JSON.stringify(event)).join('\n')}\n` : '',
435
+ 'report.md': renderReport(scoutCase),
436
+ };
437
+ }
438
+ /** Rebuild a case from the five-file export payloads, validating structure. */
439
+ export function importCaseFromFiles(files) {
440
+ const missing = EXPORT_FILE_NAMES.filter(name => !(name in files) && name !== 'report.md');
441
+ if (missing.length)
442
+ throw new Error(`Missing export files: ${missing.join(', ')}`);
443
+ const parseJson = (name) => {
444
+ try {
445
+ return JSON.parse(files[name]);
446
+ }
447
+ catch (error) {
448
+ throw new Error(`Invalid ${name}: ${error instanceof Error ? error.message : String(error)}`);
449
+ }
450
+ };
451
+ const base = parseJson('case.json');
452
+ if (base.schemaVersion !== 'dsh-scout.case.v0') {
453
+ throw new Error(`Unsupported schema version: ${String(base.schemaVersion)}`);
454
+ }
455
+ if (!base.caseId || !base.subject || !base.role || !Array.isArray(base.interviewQuestions)) {
456
+ throw new Error('Invalid case.json export');
457
+ }
458
+ const sources = parseJson('sources.json');
459
+ const claims = parseJson('claims.json');
460
+ if (!Array.isArray(sources) || !Array.isArray(claims)) {
461
+ throw new Error('Invalid sources.json or claims.json export');
462
+ }
463
+ if (!sources.every(item => item && typeof item.sourceId === 'string' && typeof item.title === 'string')) {
464
+ throw new Error('Invalid source entry in sources.json');
465
+ }
466
+ if (!claims.every(item => item && typeof item.claimId === 'string' && typeof item.text === 'string' && typeof item.nextAction === 'string')) {
467
+ throw new Error('Invalid claim entry in claims.json');
468
+ }
469
+ const events = files['events.jsonl']?.trim()
470
+ ? files['events.jsonl'].trim().split('\n').map(line => JSON.parse(line))
471
+ : [];
472
+ if (!Array.isArray(events))
473
+ throw new Error('Invalid events.jsonl export');
474
+ return {
475
+ schemaVersion: base.schemaVersion,
476
+ caseId: base.caseId,
477
+ title: base.title,
478
+ language: base.language,
479
+ decisionObjective: base.decisionObjective,
480
+ subject: base.subject,
481
+ role: base.role,
482
+ decision: base.decision,
483
+ decisionReason: base.decisionReason,
484
+ sources: sources,
485
+ claims: claims,
486
+ interviewQuestions: base.interviewQuestions,
487
+ events,
488
+ };
489
+ }
490
+ const CLAIM_STATUS_LABELS = {
491
+ verified: '已核验',
492
+ reported: '转述',
493
+ inferred: '推断',
494
+ contradicted: '矛盾',
495
+ unknown: '未知',
496
+ needs_verification: '待核验',
497
+ };
498
+ /** Render a side-by-side comparison report for two or more cases. */
499
+ export function renderComparison(cases) {
500
+ if (cases.length < 2)
501
+ throw new Error('Comparison requires at least two cases');
502
+ if (cases.length > 5)
503
+ throw new Error('Comparison supports at most five cases');
504
+ const countByStatus = (scoutCase) => scoutCase.claims.reduce((counts, claim) => {
505
+ counts[claim.status] = (counts[claim.status] ?? 0) + 1;
506
+ return counts;
507
+ }, {});
508
+ const summaryLine = (scoutCase) => {
509
+ const counts = countByStatus(scoutCase);
510
+ const identity = scoutCase.subject.identityStatus === 'verified' ? '已核验' : '待核验';
511
+ const countsText = Object.entries(counts)
512
+ .map(([status, count]) => `${count} ${CLAIM_STATUS_LABELS[status] ?? status}`)
513
+ .join(' / ');
514
+ const countsSuffix = countsText ? `(${countsText})` : '';
515
+ return `- **${scoutCase.title}**:决策 **${scoutCase.decision}**;主体${identity};主张 ${scoutCase.claims.length} 条${countsSuffix}`;
516
+ };
517
+ const compareTable = [
518
+ '| 案例 | 决策 | 主体核验 | 主张数 |',
519
+ '|---|---|---|---|',
520
+ ...cases.map(scoutCase => `| ${scoutCase.title} | ${scoutCase.decision} | ${scoutCase.subject.identityStatus === 'verified' ? '✅ 已核验' : '⚠️ 待核验'} | ${scoutCase.claims.length} |`),
521
+ ];
522
+ const sections = [];
523
+ for (const scoutCase of cases) {
524
+ const verified = byImpact(scoutCase.claims.filter(claim => claim.status === 'verified')).slice(0, 3);
525
+ const open = byImpact(scoutCase.claims.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified')).slice(0, 3);
526
+ 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') : '- 无', '');
527
+ }
528
+ const mergedQuestions = [];
529
+ const seen = new Set();
530
+ for (const scoutCase of cases) {
531
+ for (const question of generateInterviewQuestions(scoutCase)) {
532
+ const key = question.trim();
533
+ if (!key || seen.has(key))
534
+ continue;
535
+ seen.add(key);
536
+ mergedQuestions.push(key);
537
+ if (mergedQuestions.length >= 12)
538
+ break;
539
+ }
540
+ if (mergedQuestions.length >= 12)
541
+ break;
542
+ }
543
+ return [
544
+ `# 公司/岗位对比(${cases.length} 个案例)`,
545
+ '',
546
+ '## 总览',
547
+ '',
548
+ ...cases.map(summaryLine),
549
+ '',
550
+ '## 决策与主体核验对比',
551
+ '',
552
+ ...compareTable,
553
+ '',
554
+ '## 各案例详情',
555
+ '',
556
+ ...sections,
557
+ '## 面试问题(合并去重,前 12 条)',
558
+ '',
559
+ ...mergedQuestions.map(question => `- ${question}`),
560
+ '',
561
+ '> 对比只呈现各案例证据边界内的状态,不替代法律、投资或医疗意见;关键差异请以原始来源人工复核。',
562
+ '',
563
+ ].join('\n');
564
+ }