hoi4-agent-tools 3.6.0 → 3.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.
- package/CHANGELOG.md +23 -0
- package/README.md +124 -28
- package/dist/hoi4_agent_tools/core/configuration.js +2 -0
- package/dist/hoi4_agent_tools/core/engine.js +7 -7
- package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
- package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
- package/dist/hoi4_agent_tools/core/job-store.js +19 -40
- package/dist/hoi4_agent_tools/core/job-worker-host.js +84 -19
- package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
- package/dist/hoi4_agent_tools/core/operation-tasks.js +2 -7
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
- package/dist/hoi4_agent_tools/core/scanner.js +136 -47
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +27 -13
- package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
- package/dist/hoi4_agent_tools/core/windows-sharing.js +42 -0
- package/dist/hoi4_agent_tools/core/workspace.js +114 -8
- package/dist/hoi4_agent_tools/event/artifact-validation.js +1 -1
- package/dist/hoi4_agent_tools/event/service.js +1 -1
- package/dist/hoi4_agent_tools/focus/service.js +2 -2
- package/dist/hoi4_agent_tools/gui/studio.js +16 -3
- package/dist/hoi4_agent_tools/map/model.js +81 -2
- package/dist/hoi4_agent_tools/map/service.js +3 -2
- package/dist/hoi4_agent_tools/map/validation.js +60 -4
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
- package/dist/hoi4_agent_tools/package-check/service.js +1 -1
- package/dist/hoi4_agent_tools/reference/lines.js +12 -0
- package/dist/hoi4_agent_tools/reference/operations.js +45 -2
- package/dist/hoi4_agent_tools/reference/ranking.js +309 -0
- package/dist/hoi4_agent_tools/reference/script-validation.js +267 -0
- package/dist/hoi4_agent_tools/reference/service.js +285 -177
- package/dist/hoi4_agent_tools/reference/source-locate.js +156 -0
- package/dist/hoi4_agent_tools/reference/source-lookup.js +125 -18
- package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
- package/dist/hoi4_agent_tools/reference/suggestions.js +38 -0
- package/dist/hoi4_agent_tools/schemas/reference.js +135 -2
- package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
- package/dist/hoi4_agent_tools/schemas/transaction.js +1 -1
- package/dist/hoi4_agent_tools/technology/service.js +1 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/development.md +6 -0
- package/docs/examples.md +3 -13
- package/docs/gui.md +1 -15
- package/docs/map.md +10 -0
- package/docs/reference.md +47 -4
- package/docs/research/reference-benchmark.md +80 -0
- package/docs/setup.md +15 -0
- package/docs/tools.md +37 -36
- package/npm-shrinkwrap.json +7101 -0
- package/package.json +8 -7
- package/schemas/configuration.schema.json +95 -88
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +9 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +92 -0
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +144 -15
- package/server.json +2 -2
|
@@ -546,6 +546,36 @@ function* validateDefinitions(index, diagnostics, options) {
|
|
|
546
546
|
}
|
|
547
547
|
for (const [definitionIndex, definition] of index.definitions.entries()) {
|
|
548
548
|
yield* cancellationCheckpoint(options.signal, definitionIndex, 256);
|
|
549
|
+
const terrain = index.terrainCategories?.get(definition.terrain);
|
|
550
|
+
if (index.terrainCategories !== undefined && terrain === undefined) {
|
|
551
|
+
addDiagnostic(diagnostics, options, {
|
|
552
|
+
code: 'MAP_TERRAIN_UNKNOWN',
|
|
553
|
+
severity: 'error',
|
|
554
|
+
category: 'map',
|
|
555
|
+
message: `Province ${definition.id} uses terrain ${definition.terrain}, which no common/terrain category defines`,
|
|
556
|
+
location: definitionLocation(definition),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
if (definition.type === 'land' &&
|
|
560
|
+
index.continents !== undefined &&
|
|
561
|
+
definition.continent > index.continents.length) {
|
|
562
|
+
addDiagnostic(diagnostics, options, {
|
|
563
|
+
code: 'MAP_CONTINENT_UNKNOWN',
|
|
564
|
+
severity: 'error',
|
|
565
|
+
category: 'map',
|
|
566
|
+
message: `Land province ${definition.id} uses continent ${definition.continent}, but continent.txt defines ${index.continents.length}`,
|
|
567
|
+
location: definitionLocation(definition),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
if (definition.type === 'land' && terrain?.water === true) {
|
|
571
|
+
addDiagnostic(diagnostics, options, {
|
|
572
|
+
code: 'MAP_LAND_TERRAIN_WATER',
|
|
573
|
+
severity: 'error',
|
|
574
|
+
category: 'map',
|
|
575
|
+
message: `Land province ${definition.id} uses water terrain ${definition.terrain}`,
|
|
576
|
+
location: definitionLocation(definition),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
549
579
|
if (definition.type === 'land' && definition.continent === 0) {
|
|
550
580
|
addDiagnostic(diagnostics, options, {
|
|
551
581
|
code: 'MAP_LAND_CONTINENT_MISSING',
|
|
@@ -555,21 +585,30 @@ function* validateDefinitions(index, diagnostics, options) {
|
|
|
555
585
|
location: definitionLocation(definition),
|
|
556
586
|
});
|
|
557
587
|
}
|
|
558
|
-
|
|
588
|
+
// With terrain categories available, any is_water category suits seas and lakes; without
|
|
589
|
+
// them, the base game's ocean and lakes categories are the only known water terrains.
|
|
590
|
+
const water = (fallback) => index.terrainCategories === undefined
|
|
591
|
+
? definition.terrain === fallback
|
|
592
|
+
: terrain === undefined || terrain.water;
|
|
593
|
+
if (definition.type === 'sea' && !water('ocean')) {
|
|
559
594
|
addDiagnostic(diagnostics, options, {
|
|
560
595
|
code: 'MAP_SEA_TERRAIN_INVALID',
|
|
561
596
|
severity: 'error',
|
|
562
597
|
category: 'map',
|
|
563
|
-
message:
|
|
598
|
+
message: index.terrainCategories === undefined
|
|
599
|
+
? `Sea province ${definition.id} must use ocean terrain`
|
|
600
|
+
: `Sea province ${definition.id} must use a water terrain (is_water = yes)`,
|
|
564
601
|
location: definitionLocation(definition),
|
|
565
602
|
});
|
|
566
603
|
}
|
|
567
|
-
if (definition.type === 'lake' &&
|
|
604
|
+
if (definition.type === 'lake' && !water('lakes')) {
|
|
568
605
|
addDiagnostic(diagnostics, options, {
|
|
569
606
|
code: 'MAP_LAKE_TERRAIN_INVALID',
|
|
570
607
|
severity: 'error',
|
|
571
608
|
category: 'map',
|
|
572
|
-
message:
|
|
609
|
+
message: index.terrainCategories === undefined
|
|
610
|
+
? `Lake province ${definition.id} must use lakes terrain`
|
|
611
|
+
: `Lake province ${definition.id} must use a water terrain (is_water = yes)`,
|
|
573
612
|
location: definitionLocation(definition),
|
|
574
613
|
});
|
|
575
614
|
}
|
|
@@ -711,6 +750,23 @@ function* validateStatesAndRegions(index, diagnostics, options) {
|
|
|
711
750
|
});
|
|
712
751
|
}
|
|
713
752
|
}
|
|
753
|
+
if (index.terrainCategories !== undefined)
|
|
754
|
+
for (const region of index.regions) {
|
|
755
|
+
if (region.navalTerrain === undefined)
|
|
756
|
+
continue;
|
|
757
|
+
const category = index.terrainCategories.get(region.navalTerrain);
|
|
758
|
+
if (category?.naval === true)
|
|
759
|
+
continue;
|
|
760
|
+
addDiagnostic(diagnostics, options, {
|
|
761
|
+
code: 'MAP_REGION_NAVAL_TERRAIN_INVALID',
|
|
762
|
+
severity: 'error',
|
|
763
|
+
category: 'map',
|
|
764
|
+
message: category === undefined
|
|
765
|
+
? `Strategic region ${region.id} names naval terrain ${region.navalTerrain}, which no common/terrain category defines`
|
|
766
|
+
: `Strategic region ${region.id} names ${region.navalTerrain}, which is not a naval terrain (naval_terrain = yes)`,
|
|
767
|
+
location: regionLocation(region),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
714
770
|
for (const [id, regions] of duplicateValues(index.regions, (region) => String(region.id))) {
|
|
715
771
|
const firstRegion = regions[0];
|
|
716
772
|
if (firstRegion === undefined)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { errorResult, strictOperationResultSchema } from '../../core/operation-result.js';
|
|
2
2
|
import { ReferenceToolService } from '../../reference/operations.js';
|
|
3
3
|
import { referenceContextRequestSchema, referenceReadRequestSchema, referenceSearchRequestSchema, sourceLookupRequestSchema, referenceContextDataSchema, referenceReadDataSchema, referenceSearchDataSchema, sourceLookupDataSchema, } from '../../schemas/reference.js';
|
|
4
|
+
import { scriptValidateDataSchema, scriptValidateRequestSchema, } from '../../schemas/script-validation.js';
|
|
4
5
|
const readOnly = {
|
|
5
6
|
readOnlyHint: true,
|
|
6
7
|
destructiveHint: false,
|
|
@@ -35,11 +36,19 @@ export const referenceTools = [
|
|
|
35
36
|
{
|
|
36
37
|
name: 'hoi4.source_lookup',
|
|
37
38
|
title: 'Look up HOI4 source symbol',
|
|
38
|
-
description: 'Find exact Clausewitz definitions
|
|
39
|
+
description: 'Find exact Clausewitz definitions and usages; navigate nested keyPath blocks or list bounded child structure with view=structure. Or pass path and line to get the definitions and keyPath containing that line.',
|
|
39
40
|
inputSchema: sourceLookupRequestSchema,
|
|
40
41
|
outputSchema: strictOperationResultSchema(sourceLookupDataSchema),
|
|
41
42
|
annotations: readOnly,
|
|
42
43
|
},
|
|
44
|
+
{
|
|
45
|
+
name: 'hoi4.script_validate',
|
|
46
|
+
title: 'Check HOI4 script commands and scopes',
|
|
47
|
+
description: 'Check effect or trigger snippet syntax, native command kinds and declared scopes against cited installed documentation. Unknown helpers and native argument blocks remain explicit.',
|
|
48
|
+
inputSchema: scriptValidateRequestSchema,
|
|
49
|
+
outputSchema: strictOperationResultSchema(scriptValidateDataSchema),
|
|
50
|
+
annotations: readOnly,
|
|
51
|
+
},
|
|
43
52
|
];
|
|
44
53
|
export function registerReferenceTools(server, engine, context) {
|
|
45
54
|
const service = new ReferenceToolService(engine);
|
|
@@ -100,7 +100,7 @@ export class PackageAnalyzer {
|
|
|
100
100
|
});
|
|
101
101
|
const results = indexedChecks(snapshot, input.manifest);
|
|
102
102
|
const read = async (relativePath) => {
|
|
103
|
-
const resolved = await this.engine.resolver.resolvePath(input.workspaceId, relativePath, 'read', ['mod', 'game', 'dependency', 'fixture'], input.principal);
|
|
103
|
+
const resolved = await this.engine.resolver.resolvePath(input.workspaceId, relativePath, 'read', ['mod', 'game', 'dlc', 'dependency', 'fixture'], input.principal);
|
|
104
104
|
const metadata = await stat(resolved.path);
|
|
105
105
|
if (!metadata.isFile() || metadata.size > MAX_FILE_BYTES)
|
|
106
106
|
throw new ServiceError('PACKAGE_FILE_LIMIT', 'Package file exceeds the bounded reader');
|
|
@@ -50,3 +50,15 @@ export function readBoundedLines(lines, firstLine, startLine, startColumn, maxLi
|
|
|
50
50
|
nextColumn: lineIndex < lines.length ? columnIndex + 1 : null,
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
|
+
/** Preserve continuation while bounding JSON escaping as well as raw UTF-8 text. */
|
|
54
|
+
export function readBoundedJsonLines(lines, firstLine, startLine, startColumn, maxLines, maxBytes, maxJsonBytes) {
|
|
55
|
+
let budget = maxBytes;
|
|
56
|
+
for (;;) {
|
|
57
|
+
const result = readBoundedLines(lines, firstLine, startLine, startColumn, maxLines, budget);
|
|
58
|
+
if (Buffer.byteLength(JSON.stringify(result.text), 'utf8') <= maxJsonBytes)
|
|
59
|
+
return result;
|
|
60
|
+
budget = Math.max(4, Math.floor(budget / 2));
|
|
61
|
+
if (budget === 4 && maxJsonBytes < 26)
|
|
62
|
+
throw new RangeError('JSON text budget cannot retain a source character');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -4,11 +4,14 @@ import { toolResult } from '../core/operation-result.js';
|
|
|
4
4
|
import { referenceContextRequestSchema, referenceReadRequestSchema, referenceSearchRequestSchema, sourceLookupRequestSchema, } from '../schemas/reference.js';
|
|
5
5
|
import { ReferenceService } from './service.js';
|
|
6
6
|
import { sourceLookup } from './source-lookup.js';
|
|
7
|
+
import { scriptValidateDataSchema, scriptValidateRequestSchema, } from '../schemas/script-validation.js';
|
|
8
|
+
import { validateScript } from './script-validation.js';
|
|
7
9
|
const schemas = {
|
|
8
10
|
'hoi4.reference_search': referenceSearchRequestSchema,
|
|
9
11
|
'hoi4.reference_read': referenceReadRequestSchema,
|
|
10
12
|
'hoi4.reference_context': referenceContextRequestSchema,
|
|
11
13
|
'hoi4.source_lookup': sourceLookupRequestSchema,
|
|
14
|
+
'hoi4.script_validate': scriptValidateRequestSchema,
|
|
12
15
|
};
|
|
13
16
|
export function isReferenceTool(name) {
|
|
14
17
|
return Object.hasOwn(schemas, name);
|
|
@@ -35,7 +38,47 @@ export class ReferenceToolService {
|
|
|
35
38
|
? await this.references.read(workspace, referenceReadRequestSchema.parse(parsed), signal)
|
|
36
39
|
: name === 'hoi4.reference_context'
|
|
37
40
|
? await this.references.context(workspace, referenceContextRequestSchema.parse(parsed), signal)
|
|
38
|
-
:
|
|
39
|
-
|
|
41
|
+
: name === 'hoi4.script_validate'
|
|
42
|
+
? await validateScript(this.references, workspace, scriptValidateRequestSchema.parse(parsed), signal)
|
|
43
|
+
: await sourceLookup(this.engine, workspaceId, sourceLookupRequestSchema.parse(parsed), context.principal, signal);
|
|
44
|
+
const result = emptyServiceResult(workspaceId, data);
|
|
45
|
+
if (name === 'hoi4.script_validate') {
|
|
46
|
+
const checked = scriptValidateDataSchema.parse(data);
|
|
47
|
+
result.code = checked.valid === null ? 'SCRIPT_CHECK_PARTIAL' : 'SCRIPT_CHECKED';
|
|
48
|
+
result.validation = {
|
|
49
|
+
passed: checked.valid === true,
|
|
50
|
+
checks: [
|
|
51
|
+
{
|
|
52
|
+
id: 'script-command-checks',
|
|
53
|
+
passed: checked.valid === true,
|
|
54
|
+
message: checked.valid === true
|
|
55
|
+
? 'Syntax, native command kinds and declared scopes match the selected documentation'
|
|
56
|
+
: checked.valid === false
|
|
57
|
+
? 'Script checks found an error'
|
|
58
|
+
: 'Script checks have unresolved coverage',
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
result.diagnostics = checked.findings
|
|
63
|
+
.filter(({ status }) => status !== 'supported')
|
|
64
|
+
.slice(0, 20)
|
|
65
|
+
.map((finding) => ({
|
|
66
|
+
code: finding.code,
|
|
67
|
+
severity: finding.status === 'error' ? 'error' : 'warning',
|
|
68
|
+
category: 'validation',
|
|
69
|
+
message: finding.message,
|
|
70
|
+
details: {
|
|
71
|
+
command: finding.command,
|
|
72
|
+
kind: finding.kind,
|
|
73
|
+
scope: finding.scope,
|
|
74
|
+
line: finding.line,
|
|
75
|
+
column: finding.column,
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
while (Buffer.byteLength(JSON.stringify(result.diagnostics), 'utf8') > 4096 &&
|
|
79
|
+
result.diagnostics.length > 1)
|
|
80
|
+
result.diagnostics.pop();
|
|
81
|
+
}
|
|
82
|
+
return toolResult(result);
|
|
40
83
|
}
|
|
41
84
|
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Term statistics and ranking for local documentation sections.
|
|
3
|
+
*
|
|
4
|
+
* Sections are scored with a field-weighted BM25 model: headings weigh more than body text,
|
|
5
|
+
* rare terms more than common ones, and long sections are normalized so that a passing
|
|
6
|
+
* mention in a thousand-line table does not outrank a focused entry. Clausewitz identifiers
|
|
7
|
+
* are indexed whole and by their underscore-separated words, so `is_controlled_by` answers
|
|
8
|
+
* both the exact identifier and a question about what is "controlled by" a country.
|
|
9
|
+
*/
|
|
10
|
+
const stopWords = new Set([
|
|
11
|
+
'a',
|
|
12
|
+
'about',
|
|
13
|
+
'after',
|
|
14
|
+
'an',
|
|
15
|
+
'and',
|
|
16
|
+
'are',
|
|
17
|
+
'as',
|
|
18
|
+
'at',
|
|
19
|
+
'be',
|
|
20
|
+
'by',
|
|
21
|
+
'can',
|
|
22
|
+
'come',
|
|
23
|
+
'do',
|
|
24
|
+
'does',
|
|
25
|
+
'for',
|
|
26
|
+
'from',
|
|
27
|
+
'get',
|
|
28
|
+
'has',
|
|
29
|
+
'have',
|
|
30
|
+
'how',
|
|
31
|
+
'i',
|
|
32
|
+
'if',
|
|
33
|
+
'in',
|
|
34
|
+
'into',
|
|
35
|
+
'is',
|
|
36
|
+
'it',
|
|
37
|
+
'its',
|
|
38
|
+
'me',
|
|
39
|
+
'my',
|
|
40
|
+
'of',
|
|
41
|
+
'on',
|
|
42
|
+
'one',
|
|
43
|
+
'or',
|
|
44
|
+
'should',
|
|
45
|
+
'so',
|
|
46
|
+
'that',
|
|
47
|
+
'the',
|
|
48
|
+
'their',
|
|
49
|
+
'them',
|
|
50
|
+
'then',
|
|
51
|
+
'there',
|
|
52
|
+
'this',
|
|
53
|
+
'to',
|
|
54
|
+
'use',
|
|
55
|
+
'used',
|
|
56
|
+
'using',
|
|
57
|
+
'want',
|
|
58
|
+
'way',
|
|
59
|
+
'what',
|
|
60
|
+
'when',
|
|
61
|
+
'where',
|
|
62
|
+
'which',
|
|
63
|
+
'while',
|
|
64
|
+
'who',
|
|
65
|
+
'why',
|
|
66
|
+
'will',
|
|
67
|
+
'with',
|
|
68
|
+
'work',
|
|
69
|
+
'you',
|
|
70
|
+
]);
|
|
71
|
+
const vowels = /[aeiouy]/u;
|
|
72
|
+
const MAX_CACHED_STEMS = 100_000;
|
|
73
|
+
const stems = new Map();
|
|
74
|
+
const irregular = new Map([
|
|
75
|
+
['hidden', 'hide'],
|
|
76
|
+
['chosen', 'choose'],
|
|
77
|
+
['given', 'give'],
|
|
78
|
+
['written', 'write'],
|
|
79
|
+
['children', 'child'],
|
|
80
|
+
]);
|
|
81
|
+
/** Conservative suffix folding: `controls`, `controlled`, and `controlling` share `control`. */
|
|
82
|
+
export function stemTerm(input) {
|
|
83
|
+
const cached = stems.get(input);
|
|
84
|
+
if (cached !== undefined)
|
|
85
|
+
return cached;
|
|
86
|
+
const stem = foldTerm(input);
|
|
87
|
+
// Documentation vocabulary repeats heavily; a bounded memo avoids re-folding it.
|
|
88
|
+
if (stems.size >= MAX_CACHED_STEMS)
|
|
89
|
+
stems.clear();
|
|
90
|
+
stems.set(input, stem);
|
|
91
|
+
return stem;
|
|
92
|
+
}
|
|
93
|
+
function foldTerm(input) {
|
|
94
|
+
const word = irregular.get(input) ?? input;
|
|
95
|
+
if (word.length <= 3 || /\d/u.test(word))
|
|
96
|
+
return word;
|
|
97
|
+
let stem = word;
|
|
98
|
+
if (/i(?:es|ed)$/u.test(stem) && stem.length > 4)
|
|
99
|
+
stem = `${stem.slice(0, -3)}y`;
|
|
100
|
+
else if (stem.endsWith('sses'))
|
|
101
|
+
stem = stem.slice(0, -2);
|
|
102
|
+
else if (/(?:x|z|ch|sh)es$/u.test(stem))
|
|
103
|
+
stem = stem.slice(0, -2);
|
|
104
|
+
else if (stem.endsWith('s') && !/(?:ss|us|is)$/u.test(stem))
|
|
105
|
+
stem = stem.slice(0, -1);
|
|
106
|
+
else if (stem.endsWith('ing') && stem.length > 5 && vowels.test(stem.slice(0, -3)))
|
|
107
|
+
stem = stem.slice(0, -3);
|
|
108
|
+
else if (stem.endsWith('ed') && stem.length > 4 && vowels.test(stem.slice(0, -2)))
|
|
109
|
+
stem = stem.slice(0, -2);
|
|
110
|
+
// Fold a doubled final consonant (`stopp`, `controll`) but keep `ss` and `zz` words.
|
|
111
|
+
if (/([b-df-hj-np-rtv-y])\1$/u.test(stem) && stem.length > 4)
|
|
112
|
+
stem = stem.slice(0, -1);
|
|
113
|
+
// Derivational endings common in documentation prose: `capitulation`, `capitulates`,
|
|
114
|
+
// `capitulated`, and `capitulating` share `capitul`; `government` shares `govern`.
|
|
115
|
+
if (/(?:ated|ating)$/u.test(word) && stem.length > 6)
|
|
116
|
+
stem = stem.replace(/at$/u, '');
|
|
117
|
+
else if (stem.endsWith('ation') && stem.length > 8)
|
|
118
|
+
stem = stem.slice(0, -5);
|
|
119
|
+
else if (stem.endsWith('ate') && stem.length > 6)
|
|
120
|
+
stem = stem.slice(0, -3);
|
|
121
|
+
else if (stem.endsWith('ment') && stem.length > 7)
|
|
122
|
+
stem = stem.slice(0, -4);
|
|
123
|
+
if (stem.endsWith('e') && stem.length > 4)
|
|
124
|
+
stem = stem.slice(0, -1);
|
|
125
|
+
return stem;
|
|
126
|
+
}
|
|
127
|
+
const tokenPattern = /[a-z0-9]+(?:_[a-z0-9]+)*/gu;
|
|
128
|
+
/** Content terms of free text. Identifiers yield themselves and their stemmed words. */
|
|
129
|
+
export function textTerms(text) {
|
|
130
|
+
const output = [];
|
|
131
|
+
for (const token of text.toLowerCase().replace(/'s\b/gu, '').match(tokenPattern) ?? []) {
|
|
132
|
+
if (token.includes('_')) {
|
|
133
|
+
output.push(token);
|
|
134
|
+
for (const part of token.split('_'))
|
|
135
|
+
if (part.length > 0 && !stopWords.has(part))
|
|
136
|
+
output.push(stemTerm(part));
|
|
137
|
+
}
|
|
138
|
+
else if (!stopWords.has(token))
|
|
139
|
+
output.push(stemTerm(token));
|
|
140
|
+
}
|
|
141
|
+
return output;
|
|
142
|
+
}
|
|
143
|
+
function counts(terms) {
|
|
144
|
+
const result = new Map();
|
|
145
|
+
for (const term of terms)
|
|
146
|
+
result.set(term, (result.get(term) ?? 0) + 1);
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
const navigationLine = /^\s*(?:[*-]|\d+\.)\s+\[[^\]]*\]\((?:#|<)[^)]*\)\s*$/u;
|
|
150
|
+
export function sectionTerms(title, heading, lines) {
|
|
151
|
+
const bodyTerms = lines.flatMap((line) => textTerms(line));
|
|
152
|
+
const content = lines.filter((line) => line.trim().length > 0);
|
|
153
|
+
const links = content.filter((line) => navigationLine.test(line)).length;
|
|
154
|
+
return {
|
|
155
|
+
heading: counts(textTerms(heading)),
|
|
156
|
+
title: counts(textTerms(title)),
|
|
157
|
+
body: counts(bodyTerms),
|
|
158
|
+
bodyLength: bodyTerms.length,
|
|
159
|
+
headingWords: [
|
|
160
|
+
...new Set(textTerms(heading.replaceAll('_', ' ')).filter((term) => !term.includes('_'))),
|
|
161
|
+
],
|
|
162
|
+
navigation: heading.trim().toLowerCase() === 'table of contents' ||
|
|
163
|
+
(content.length >= 4 && links / content.length >= 0.6),
|
|
164
|
+
specialty: /console commands/iu.test(title)
|
|
165
|
+
? 'console'
|
|
166
|
+
: /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/u.test(heading.trim())
|
|
167
|
+
? 'define'
|
|
168
|
+
: undefined,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** Script names abbreviate counts as `num_`, as in `num_divisions` or `num_of_factories`. */
|
|
172
|
+
const countWords = new Set(['count', 'number', 'amount', 'many', 'much']);
|
|
173
|
+
const headingWeight = 4;
|
|
174
|
+
const titleWeight = 0.5;
|
|
175
|
+
const bodyWeight = 1;
|
|
176
|
+
const saturation = 1.2;
|
|
177
|
+
const lengthNormalization = 0.75;
|
|
178
|
+
/** Corpus statistics for one immutable inventory of sections. */
|
|
179
|
+
export class RankingIndex {
|
|
180
|
+
entries;
|
|
181
|
+
#documentFrequency = new Map();
|
|
182
|
+
#averageBodyLength;
|
|
183
|
+
constructor(entries) {
|
|
184
|
+
this.entries = entries;
|
|
185
|
+
let total = 0;
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
total += entry.bodyLength;
|
|
188
|
+
const seen = new Set([...entry.heading.keys(), ...entry.title.keys(), ...entry.body.keys()]);
|
|
189
|
+
for (const term of seen)
|
|
190
|
+
this.#documentFrequency.set(term, (this.#documentFrequency.get(term) ?? 0) + 1);
|
|
191
|
+
}
|
|
192
|
+
this.#averageBodyLength = entries.length === 0 ? 1 : Math.max(1, total / entries.length);
|
|
193
|
+
}
|
|
194
|
+
idf(term) {
|
|
195
|
+
const frequency = this.#documentFrequency.get(term) ?? 0;
|
|
196
|
+
const count = this.entries.length;
|
|
197
|
+
return Math.log(1 + (count - frequency + 0.5) / (frequency + 0.5));
|
|
198
|
+
}
|
|
199
|
+
plan(query) {
|
|
200
|
+
const normalized = query.trim().toLowerCase();
|
|
201
|
+
const sequence = textTerms(normalized);
|
|
202
|
+
const weights = new Map(sequence.map((term) => [term, 1]));
|
|
203
|
+
const words = (normalized.replace(/'s\b/gu, '').match(tokenPattern) ?? []).filter((word) => !stopWords.has(word) && !word.includes('_'));
|
|
204
|
+
// Clausewitz names often join two English words: `wargoal`, `manpower`, `warscore`.
|
|
205
|
+
for (let index = 1; index < words.length; index += 1) {
|
|
206
|
+
const joined = stemTerm(`${words[index - 1]}${words[index]}`);
|
|
207
|
+
if (this.#documentFrequency.has(joined) && !weights.has(joined))
|
|
208
|
+
weights.set(joined, 1);
|
|
209
|
+
}
|
|
210
|
+
if (words.some((word) => countWords.has(word)) && !weights.has('num'))
|
|
211
|
+
weights.set('num', 0.6);
|
|
212
|
+
const terms = [...weights].map(([term, weight]) => ({ term, idf: this.idf(term), weight }));
|
|
213
|
+
const plain = sequence.filter((term) => !term.includes('_'));
|
|
214
|
+
const pairs = [...new Set(plain.slice(1).map((term, index) => `${plain[index]} ${term}`))].map((pair) => {
|
|
215
|
+
const [left, right] = pair.split(' ');
|
|
216
|
+
return { pair, idf: (this.idf(left) + this.idf(right)) / 2 };
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
terms,
|
|
220
|
+
pairs,
|
|
221
|
+
normalized,
|
|
222
|
+
identifiers: new Set(terms.map(({ term }) => term).filter((term) => term.includes('_'))),
|
|
223
|
+
wants: {
|
|
224
|
+
console: words.some((word) => word === 'console' || word.startsWith('command')),
|
|
225
|
+
define: words.some((word) => word.startsWith('define')) || /\b[A-Z]{2,}_[A-Z]/u.test(query),
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** Score one section; undefined when it matches too little of the query to cite. */
|
|
230
|
+
score(plan, entry, heading) {
|
|
231
|
+
if (plan.terms.length === 0)
|
|
232
|
+
return undefined;
|
|
233
|
+
const lengthFactor = 1 - lengthNormalization + (lengthNormalization * entry.bodyLength) / this.#averageBodyLength;
|
|
234
|
+
let score = 0;
|
|
235
|
+
let matched = 0;
|
|
236
|
+
for (const { term, idf, weight } of plan.terms) {
|
|
237
|
+
const weighted = headingWeight * (entry.heading.get(term) ?? 0) +
|
|
238
|
+
titleWeight * (entry.title.get(term) ?? 0) +
|
|
239
|
+
(bodyWeight * (entry.body.get(term) ?? 0)) / lengthFactor;
|
|
240
|
+
if (weighted === 0)
|
|
241
|
+
continue;
|
|
242
|
+
matched++;
|
|
243
|
+
score += (weight * idf * weighted * (saturation + 1)) / (weighted + saturation);
|
|
244
|
+
}
|
|
245
|
+
// Meaningful terms must agree; a single shared word rarely answers a longer question.
|
|
246
|
+
const words = plan.terms.filter(({ term, weight }) => !term.includes('_') && weight === 1).length;
|
|
247
|
+
const required = words >= 3 ? Math.ceil(words * 0.4) : 1;
|
|
248
|
+
if (matched === 0 || (matched < required && plan.identifiers.size === 0))
|
|
249
|
+
return undefined;
|
|
250
|
+
// A named identifier must itself appear; its words alone describe something else.
|
|
251
|
+
if (plan.identifiers.size > 0 &&
|
|
252
|
+
![...plan.identifiers].some((term) => entry.heading.has(term) || entry.title.has(term) || entry.body.has(term)))
|
|
253
|
+
return undefined;
|
|
254
|
+
const headingText = heading.trim().toLowerCase();
|
|
255
|
+
// An exact heading is the answer; authority, not incidental wording, orders duplicates.
|
|
256
|
+
if (headingText === plan.normalized)
|
|
257
|
+
return 1000;
|
|
258
|
+
else if (plan.identifiers.has(headingText))
|
|
259
|
+
score += 40;
|
|
260
|
+
// A short heading whose every word the question names is usually the topic itself.
|
|
261
|
+
if (entry.headingWords.length > 0) {
|
|
262
|
+
const queryTerms = new Set(plan.terms.map(({ term }) => term));
|
|
263
|
+
const covered = entry.headingWords.filter((word) => queryTerms.has(word));
|
|
264
|
+
const fraction = covered.length / entry.headingWords.length;
|
|
265
|
+
score += fraction * fraction * covered.reduce((sum, word) => sum + this.idf(word), 0) * 0.6;
|
|
266
|
+
}
|
|
267
|
+
if (entry.navigation)
|
|
268
|
+
score *= 0.25;
|
|
269
|
+
if (entry.specialty === 'console' && !plan.wants.console)
|
|
270
|
+
score *= 0.7;
|
|
271
|
+
if (entry.specialty === 'define' && !plan.wants.define)
|
|
272
|
+
score *= 0.6;
|
|
273
|
+
return score;
|
|
274
|
+
}
|
|
275
|
+
/** Extra credit for query words that appear next to each other in the section. */
|
|
276
|
+
phraseBonus(plan, lines) {
|
|
277
|
+
if (plan.pairs.length === 0)
|
|
278
|
+
return 0;
|
|
279
|
+
const wanted = new Map(plan.pairs.map(({ pair, idf }) => [pair, idf]));
|
|
280
|
+
const found = new Set();
|
|
281
|
+
for (const line of lines) {
|
|
282
|
+
const terms = textTerms(line).filter((term) => !term.includes('_'));
|
|
283
|
+
for (let index = 1; index < terms.length; index += 1) {
|
|
284
|
+
const pair = `${terms[index - 1]} ${terms[index]}`;
|
|
285
|
+
if (wanted.has(pair))
|
|
286
|
+
found.add(pair);
|
|
287
|
+
}
|
|
288
|
+
if (found.size === wanted.size)
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
return [...found].reduce((sum, pair) => sum + wanted.get(pair), 0) * 0.5;
|
|
292
|
+
}
|
|
293
|
+
/** The line offset carrying the most query information, for landing a bounded read. */
|
|
294
|
+
bestLine(plan, lines) {
|
|
295
|
+
let best = 0;
|
|
296
|
+
let bestScore = 0;
|
|
297
|
+
const weights = new Map(plan.terms.map(({ term, idf, weight }) => [term, idf * weight]));
|
|
298
|
+
for (const [index, line] of lines.entries()) {
|
|
299
|
+
let lineScore = 0;
|
|
300
|
+
for (const term of new Set(textTerms(line)))
|
|
301
|
+
lineScore += weights.get(term) ?? 0;
|
|
302
|
+
if (lineScore > bestScore) {
|
|
303
|
+
best = index;
|
|
304
|
+
bestScore = lineScore;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return best;
|
|
308
|
+
}
|
|
309
|
+
}
|