santismm-knowledge-mcp 0.2.1
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/LICENSE +21 -0
- package/README.md +62 -0
- package/content/CONVENTIONS.md +77 -0
- package/content/LICENSE +55 -0
- package/content/architectures/ai-workforce.json +280 -0
- package/content/architectures/customer-service-agent.json +292 -0
- package/content/architectures/enterprise-knowledge-assistant.json +292 -0
- package/content/architectures/operations-center.json +280 -0
- package/content/architectures/sales-copilot.json +280 -0
- package/content/governance/agentic-ai-governance-checklist.json +323 -0
- package/content/governance/audit-framework-for-agentic-systems.json +280 -0
- package/content/governance/enterprise-ai-governance-framework.json +277 -0
- package/content/governance/eu-ai-act.json +162 -0
- package/content/governance/human-oversight-and-accountability-policy.json +275 -0
- package/content/governance/iso-42001.json +161 -0
- package/content/governance/mitre-atlas.json +280 -0
- package/content/governance/nist-ai-rmf.json +161 -0
- package/content/governance/owasp-llm-top10.json +301 -0
- package/content/harness/HRN-001-definition-and-overview.es.md +76 -0
- package/content/harness/HRN-001-definition-and-overview.md +125 -0
- package/content/harness/HRN-001-definition-and-overview.pt.md +76 -0
- package/content/harness/HRN-002-a-brief-history-of-harness-engineering.es.md +83 -0
- package/content/harness/HRN-002-a-brief-history-of-harness-engineering.md +113 -0
- package/content/harness/HRN-002-a-brief-history-of-harness-engineering.pt.md +83 -0
- package/content/harness/HRN-003-the-harness-taxonomy.es.md +105 -0
- package/content/harness/HRN-003-the-harness-taxonomy.md +158 -0
- package/content/harness/HRN-003-the-harness-taxonomy.pt.md +105 -0
- package/content/harness/HRN-004-harness-engineering-principles.es.md +91 -0
- package/content/harness/HRN-004-harness-engineering-principles.md +135 -0
- package/content/harness/HRN-004-harness-engineering-principles.pt.md +91 -0
- package/content/harness/HRN-005-memory-in-agentic-systems.es.md +98 -0
- package/content/harness/HRN-005-memory-in-agentic-systems.md +145 -0
- package/content/harness/HRN-005-memory-in-agentic-systems.pt.md +98 -0
- package/content/harness/HRN-006-observability-for-agentic-systems.es.md +97 -0
- package/content/harness/HRN-006-observability-for-agentic-systems.md +139 -0
- package/content/harness/HRN-006-observability-for-agentic-systems.pt.md +97 -0
- package/content/harness/HRN-007-evaluation-of-agentic-systems.es.md +96 -0
- package/content/harness/HRN-007-evaluation-of-agentic-systems.md +145 -0
- package/content/harness/HRN-007-evaluation-of-agentic-systems.pt.md +96 -0
- package/content/harness/HRN-008-governance-within-the-harness.es.md +105 -0
- package/content/harness/HRN-008-governance-within-the-harness.md +146 -0
- package/content/harness/HRN-008-governance-within-the-harness.pt.md +105 -0
- package/content/harness/HRN-009-planning-and-goal-management.es.md +102 -0
- package/content/harness/HRN-009-planning-and-goal-management.md +142 -0
- package/content/harness/HRN-009-planning-and-goal-management.pt.md +102 -0
- package/content/harness/HRN-010-orchestration.es.md +107 -0
- package/content/harness/HRN-010-orchestration.md +149 -0
- package/content/harness/HRN-010-orchestration.pt.md +107 -0
- package/content/harness/HRN-011-security-for-agentic-systems.es.md +107 -0
- package/content/harness/HRN-011-security-for-agentic-systems.md +147 -0
- package/content/harness/HRN-011-security-for-agentic-systems.pt.md +107 -0
- package/content/harness/HRN-012-case-studies-in-harness-engineering.es.md +120 -0
- package/content/harness/HRN-012-case-studies-in-harness-engineering.md +157 -0
- package/content/harness/HRN-012-case-studies-in-harness-engineering.pt.md +120 -0
- package/content/harness/HRN-013-glossary.es.md +109 -0
- package/content/harness/HRN-013-glossary.md +124 -0
- package/content/harness/HRN-013-glossary.pt.md +109 -0
- package/content/harness/HRN-014-bibliography.es.md +89 -0
- package/content/harness/HRN-014-bibliography.md +109 -0
- package/content/harness/HRN-014-bibliography.pt.md +89 -0
- package/content/homeric/episodes/achilles-and-hector.json +139 -0
- package/content/homeric/episodes/aeolus-and-the-winds.json +131 -0
- package/content/homeric/episodes/agamemnons-murder.json +162 -0
- package/content/homeric/episodes/calypso-ogygia.json +157 -0
- package/content/homeric/episodes/catalogue-of-ships.json +177 -0
- package/content/homeric/episodes/cattle-of-the-sun.json +131 -0
- package/content/homeric/episodes/chryse-and-the-plague.json +131 -0
- package/content/homeric/episodes/cicones-at-ismarus.json +131 -0
- package/content/homeric/episodes/circe-on-aeaea.json +131 -0
- package/content/homeric/episodes/cyclops-polyphemus.json +162 -0
- package/content/homeric/episodes/laestrygonians.json +153 -0
- package/content/homeric/episodes/lotus-eaters.json +138 -0
- package/content/homeric/episodes/menelaus-and-proteus.json +130 -0
- package/content/homeric/episodes/nekyia.json +160 -0
- package/content/homeric/episodes/phaeacians-on-scheria.json +129 -0
- package/content/homeric/episodes/priams-ransom.json +131 -0
- package/content/homeric/episodes/return-to-ithaca.json +167 -0
- package/content/homeric/episodes/scylla-and-charybdis.json +131 -0
- package/content/homeric/episodes/suitors-ambush-at-asteris.json +131 -0
- package/content/homeric/episodes/telemachus-at-pylos.json +130 -0
- package/content/homeric/episodes/telemachus-in-sparta.json +130 -0
- package/content/homeric/episodes/the-achaean-camp.json +138 -0
- package/content/homeric/episodes/the-sirens.json +129 -0
- package/content/homeric/episodes/wooden-horse.json +168 -0
- package/content/homeric/places/aeaea.json +129 -0
- package/content/homeric/places/aeolia.json +161 -0
- package/content/homeric/places/asteris.json +120 -0
- package/content/homeric/places/aulis.json +122 -0
- package/content/homeric/places/cape-malea.json +126 -0
- package/content/homeric/places/chryse.json +120 -0
- package/content/homeric/places/dodona.json +129 -0
- package/content/homeric/places/dulichium.json +177 -0
- package/content/homeric/places/egypt.json +125 -0
- package/content/homeric/places/ephyra-acheron.json +127 -0
- package/content/homeric/places/hellespont.json +125 -0
- package/content/homeric/places/house-of-hades.json +91 -0
- package/content/homeric/places/ismarus.json +120 -0
- package/content/homeric/places/ithaca.json +240 -0
- package/content/homeric/places/knossos.json +132 -0
- package/content/homeric/places/laestrygonia.json +168 -0
- package/content/homeric/places/land-of-the-cyclopes.json +169 -0
- package/content/homeric/places/land-of-the-lotus-eaters.json +122 -0
- package/content/homeric/places/mount-ida.json +126 -0
- package/content/homeric/places/mycenae.json +152 -0
- package/content/homeric/places/ogygia.json +125 -0
- package/content/homeric/places/pharos.json +120 -0
- package/content/homeric/places/planctae.json +77 -0
- package/content/homeric/places/pylos.json +188 -0
- package/content/homeric/places/same.json +177 -0
- package/content/homeric/places/scheria.json +129 -0
- package/content/homeric/places/scylla-and-charybdis.json +135 -0
- package/content/homeric/places/sirens.json +127 -0
- package/content/homeric/places/sparta.json +179 -0
- package/content/homeric/places/tenedos.json +129 -0
- package/content/homeric/places/thrinacia.json +116 -0
- package/content/homeric/places/tiryns.json +123 -0
- package/content/homeric/places/troy.json +224 -0
- package/content/homeric/places/zacynthus.json +126 -0
- package/content/homeric/routes/achaean-expedition.json +132 -0
- package/content/homeric/routes/nostoi-of-the-others.json +205 -0
- package/content/homeric/routes/odysseus-nostos.json +307 -0
- package/content/homeric/routes/telemachy.json +134 -0
- package/content/knowledge/agent-memory.json +153 -0
- package/content/knowledge/agentic-ai.json +158 -0
- package/content/knowledge/agentic-evaluation.json +156 -0
- package/content/knowledge/agentic-threat-model.json +287 -0
- package/content/knowledge/ai-agent.json +153 -0
- package/content/knowledge/ai-cyberdefense.json +274 -0
- package/content/knowledge/ai-governance.json +155 -0
- package/content/knowledge/ai-observability.json +156 -0
- package/content/knowledge/context-engineering.json +153 -0
- package/content/knowledge/embeddings.json +153 -0
- package/content/knowledge/enterprise-rag.json +154 -0
- package/content/knowledge/fine-tuning.json +153 -0
- package/content/knowledge/foundation-models.json +154 -0
- package/content/knowledge/guardrails.json +153 -0
- package/content/knowledge/harness-engineering.json +158 -0
- package/content/knowledge/human-in-the-loop.json +153 -0
- package/content/knowledge/mcp-security.json +284 -0
- package/content/knowledge/model-context-protocol.json +154 -0
- package/content/knowledge/multi-agent-architecture.json +153 -0
- package/content/knowledge/prompt-engineering.json +153 -0
- package/content/knowledge/prompt-injection.json +138 -0
- package/content/knowledge/reasoning-models.json +153 -0
- package/content/knowledge/tool-use.json +156 -0
- package/content/library/cognitive-architecture-emergent-ai.md +15 -0
- package/content/library/devready-ep108-ai-customer-experiences.md +14 -0
- package/content/library/how-genai-impact-business.md +12 -0
- package/content/library/lmm-reshaping-industries-2024.md +12 -0
- package/content/library/rethinking-ai-pause.md +12 -0
- package/content/library/rise-of-agentic-ai.md +16 -0
- package/content/library/self-improving-autonomous-ai.md +16 -0
- package/content/library/the-stopwatch-and-the-exam.md +12 -0
- package/content/library/unlock-gpt4-secrets.md +12 -0
- package/content/library/vibe-coding-enterprise.md +15 -0
- package/content/library/video-transformando-negocios-genai.md +14 -0
- package/content/library/video-volando-alto-sky-airlines.md +13 -0
- package/content/matrix/agentic-control-matrix.json +967 -0
- package/content/patterns/attributed-memory.json +237 -0
- package/content/patterns/context-compression.json +304 -0
- package/content/patterns/egress-allowlist.json +310 -0
- package/content/patterns/evaluator-optimizer.json +180 -0
- package/content/patterns/goal-decomposition.json +290 -0
- package/content/patterns/human-approval-gate.json +311 -0
- package/content/patterns/human-escalation.json +288 -0
- package/content/patterns/least-privilege-tooling.json +333 -0
- package/content/patterns/long-term-memory.json +305 -0
- package/content/patterns/orchestrator-workers.json +202 -0
- package/content/patterns/parallelization.json +180 -0
- package/content/patterns/prompt-chaining.json +181 -0
- package/content/patterns/recovery-strategy.json +305 -0
- package/content/patterns/reflection.json +298 -0
- package/content/patterns/routing.json +294 -0
- package/content/patterns/sandboxed-execution.json +311 -0
- package/content/patterns/semantic-caching.json +201 -0
- package/content/patterns/supervisor-agent.json +290 -0
- package/content/patterns/task-prioritization.json +307 -0
- package/dist/content.js +181 -0
- package/dist/index.js +27 -0
- package/dist/shape.js +649 -0
- package/dist/tools.js +652 -0
- package/package.json +47 -0
package/dist/shape.js
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic shaping layer for the MCP corpus.
|
|
3
|
+
*
|
|
4
|
+
* It contains the SINGLE definition of how raw content entries are turned into
|
|
5
|
+
* the cards, bodies, search hits and graph neighbours the MCP tools return.
|
|
6
|
+
* It does not read anything itself: callers inject a `loadAll(domain)` function,
|
|
7
|
+
* so the same shaping is reused by the stdio CLI (which loads from the
|
|
8
|
+
* filesystem) and the HTTP endpoint (which loads via the website's `lib/`
|
|
9
|
+
* loaders). One shaping, one behaviour — the CLI and the web cannot drift.
|
|
10
|
+
*/
|
|
11
|
+
export const DOMAINS = ["knowledge", "patterns", "architectures", "governance"];
|
|
12
|
+
/** Canonical origin for citable URLs returned with every card. */
|
|
13
|
+
export const SITE_URL = "https://santismm.com";
|
|
14
|
+
export const ALL_LOCALES = ["en", "es", "pt"];
|
|
15
|
+
/** The handbook is authored in English; a chapter without a translation falls back to it. */
|
|
16
|
+
export const HANDBOOK_BODY_LOCALE = "en";
|
|
17
|
+
/**
|
|
18
|
+
* The single definition of the content licence, for every surface that has to
|
|
19
|
+
* state it — the JSON APIs, `llms-full.txt`, the MCP tools, the skill.
|
|
20
|
+
*
|
|
21
|
+
* "Attribution required" said what was wanted but granted nothing: it is a
|
|
22
|
+
* request, not a licence, so a company evaluating reuse had no permission to
|
|
23
|
+
* point at and the safe reading was "no rights granted". CC BY 4.0 grants the
|
|
24
|
+
* reuse — including commercial, including derivatives — on the one condition
|
|
25
|
+
* that was already being asked for.
|
|
26
|
+
*
|
|
27
|
+
* The SPDX id is the field that matters for machines: it is what an automated
|
|
28
|
+
* reuse check reads, where prose is unparseable.
|
|
29
|
+
*
|
|
30
|
+
* Scope: this covers the CONTENT — the corpus and everything generated from it
|
|
31
|
+
* (pages, JSON APIs, `llms-full.txt`, MCP payloads). The repository's SOURCE
|
|
32
|
+
* CODE is MIT, in `LICENSE`. Two licences because CC BY is not a software
|
|
33
|
+
* licence and MIT is not a content licence; applying either to both would
|
|
34
|
+
* create ambiguity exactly where an adopter looks first.
|
|
35
|
+
*/
|
|
36
|
+
export const LICENSE_INFO = {
|
|
37
|
+
name: "CC BY 4.0",
|
|
38
|
+
spdx: "CC-BY-4.0",
|
|
39
|
+
url: "https://creativecommons.org/licenses/by/4.0/",
|
|
40
|
+
holder: "Santiago Santa María Morales",
|
|
41
|
+
attribution: "Santiago Santa María Morales — https://santismm.com",
|
|
42
|
+
notice: "Content © Santiago Santa María Morales, licensed CC BY 4.0. Attribution required: credit the author and link the canonical URL.",
|
|
43
|
+
};
|
|
44
|
+
/** Human-readable notice. Kept as a plain string: several surfaces embed it in prose. */
|
|
45
|
+
export const LICENSE = LICENSE_INFO.notice;
|
|
46
|
+
/**
|
|
47
|
+
* What each domain actually holds, and how to reach into it.
|
|
48
|
+
*
|
|
49
|
+
* `get_overview` is the most-called tool by a wide margin — it is the first
|
|
50
|
+
* thing an agent does — and it used to answer with five bare counts. Knowing
|
|
51
|
+
* that `patterns` has 15 units says nothing about what a pattern *is* or which
|
|
52
|
+
* tool retrieves one, so agents either fanned out across every `list_*` (heavy)
|
|
53
|
+
* or guessed at `search`. The orientation an agent needs belongs in the answer
|
|
54
|
+
* to the orienting question.
|
|
55
|
+
*
|
|
56
|
+
* Descriptions are the same sentences `llms.txt` publishes, so the prose and
|
|
57
|
+
* machine surfaces describe the corpus identically.
|
|
58
|
+
*/
|
|
59
|
+
const DOMAIN_INFO = {
|
|
60
|
+
knowledge: {
|
|
61
|
+
description: "Self-contained, citable units on agentic and enterprise AI.",
|
|
62
|
+
lookup: "slug",
|
|
63
|
+
},
|
|
64
|
+
patterns: {
|
|
65
|
+
description: "Reusable design patterns for AI and agentic systems.",
|
|
66
|
+
lookup: "slug",
|
|
67
|
+
},
|
|
68
|
+
architectures: {
|
|
69
|
+
description: "End-to-end blueprints composing patterns and knowledge into working enterprise systems.",
|
|
70
|
+
lookup: "slug",
|
|
71
|
+
},
|
|
72
|
+
governance: {
|
|
73
|
+
description: "Practical reference units for governing enterprise AI — major regimes (EU AI Act, ISO/IEC 42001, NIST AI RMF) and an agentic-AI governance checklist.",
|
|
74
|
+
lookup: "slug",
|
|
75
|
+
},
|
|
76
|
+
handbook: {
|
|
77
|
+
description: "The Harness Engineering Handbook: the canonical long-form corpus (HRN-001…014) — definition, history, taxonomy, principles, memory, observability, evaluation, governance, planning, orchestration, security, case studies, glossary and bibliography.",
|
|
78
|
+
lookup: "HRN id (e.g. HRN-001) or slug",
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
/** Distinct, sorted category values — read off the content, never hand-listed. */
|
|
82
|
+
function categoriesOf(items) {
|
|
83
|
+
return [...new Set(items.map((e) => e.category).filter((c) => Boolean(c)))].sort();
|
|
84
|
+
}
|
|
85
|
+
function domainOverview(domain, items) {
|
|
86
|
+
const info = DOMAIN_INFO[domain] ?? { description: "", lookup: "slug" };
|
|
87
|
+
// The tool names are derived, not written out, so a domain can never be
|
|
88
|
+
// advertised with a tool that isn't registered.
|
|
89
|
+
// `Domain` does not include the handbook (it has its own loader), so compare
|
|
90
|
+
// as a string rather than narrowing a union it was never part of.
|
|
91
|
+
const singular = String(domain) === "handbook" ? "handbook" : domain.replace(/s$/, "");
|
|
92
|
+
return {
|
|
93
|
+
domain,
|
|
94
|
+
count: items.length,
|
|
95
|
+
description: info.description,
|
|
96
|
+
categories: categoriesOf(items),
|
|
97
|
+
tools: { list: `list_${domain}`, get: `get_${singular}` },
|
|
98
|
+
lookup: info.lookup,
|
|
99
|
+
url: `${SITE_URL}/en/${domain}`,
|
|
100
|
+
api_url: `${SITE_URL}/api/${domain}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** The display name + short text from a locale (knowledge uses title; others name). */
|
|
104
|
+
function localeText(entry, locale = "en") {
|
|
105
|
+
const L = (entry.locales?.[locale] ?? entry.locales?.en ?? {});
|
|
106
|
+
const name = (L.name ?? L.title ?? entry.slug);
|
|
107
|
+
const summary = (L.summary ?? L.definition ?? "");
|
|
108
|
+
return { name, summary };
|
|
109
|
+
}
|
|
110
|
+
/** Compact card used in list/search/graph results. */
|
|
111
|
+
export function summarize(domain, entry, locale = "en") {
|
|
112
|
+
const { name, summary } = localeText(entry, locale);
|
|
113
|
+
return {
|
|
114
|
+
domain,
|
|
115
|
+
id: entry.id,
|
|
116
|
+
slug: entry.slug,
|
|
117
|
+
category: entry.category,
|
|
118
|
+
name,
|
|
119
|
+
summary,
|
|
120
|
+
evidence: entry.evidence,
|
|
121
|
+
updated: entry.updated,
|
|
122
|
+
locale,
|
|
123
|
+
// Canonical, citable URL for this unit in the requested language.
|
|
124
|
+
canonical_url: `${SITE_URL}/${locale}/${domain}/${entry.slug}`,
|
|
125
|
+
api_url: `${SITE_URL}/api/${domain}/${entry.slug}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Diacritic- and case-insensitive normalisation. "Aprobación" → "aprobacion",
|
|
130
|
+
* so Spanish and Portuguese queries match regardless of how the user types the
|
|
131
|
+
* accents.
|
|
132
|
+
*/
|
|
133
|
+
export function norm(s) {
|
|
134
|
+
return s
|
|
135
|
+
.normalize("NFD")
|
|
136
|
+
.replace(/[̀-ͯ]/g, "")
|
|
137
|
+
.toLowerCase();
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Function words in the three corpus languages. They carry no topical signal
|
|
141
|
+
* but do appear inside titles ("Framework FOR…", "Política DE…"), so leaving
|
|
142
|
+
* them in lets an unrelated unit win on a stopword `name` match.
|
|
143
|
+
*/
|
|
144
|
+
const STOPWORDS = new Set([
|
|
145
|
+
// en
|
|
146
|
+
"the", "a", "an", "of", "for", "and", "or", "to", "in", "on", "with", "is",
|
|
147
|
+
"are", "what", "how", "why", "when", "that", "this", "it", "as", "by", "from",
|
|
148
|
+
"at", "be", "can", "do", "does",
|
|
149
|
+
// es
|
|
150
|
+
"el", "la", "los", "las", "un", "una", "unos", "unas", "de", "del", "y", "o",
|
|
151
|
+
"para", "por", "con", "en", "que", "es", "son", "como", "cual", "cuando",
|
|
152
|
+
"sobre", "al", "se", "su", "sus", "antes", "despues", "mas",
|
|
153
|
+
// pt
|
|
154
|
+
"o", "os", "as", "um", "uma", "e", "ou", "para", "por", "com", "em", "que",
|
|
155
|
+
"sao", "como", "quando", "sobre", "ao", "dos", "das", "no", "na", "se", "seu",
|
|
156
|
+
]);
|
|
157
|
+
/**
|
|
158
|
+
* Very light stemming: trims a common plural ending so a query term matches the
|
|
159
|
+
* singular in the text (and across languages — "agents"/"agentes" → "agent").
|
|
160
|
+
* Fields are matched with `includes`, so shortening a term only ever broadens
|
|
161
|
+
* the match; it can never make a real hit disappear.
|
|
162
|
+
*/
|
|
163
|
+
function stem(token) {
|
|
164
|
+
if (token.length >= 6 && token.endsWith("es"))
|
|
165
|
+
return token.slice(0, -2);
|
|
166
|
+
if (token.length >= 5 && token.endsWith("s"))
|
|
167
|
+
return token.slice(0, -1);
|
|
168
|
+
return token;
|
|
169
|
+
}
|
|
170
|
+
/** Relative importance of each field when scoring a search hit. */
|
|
171
|
+
const FIELD_WEIGHTS = {
|
|
172
|
+
name: 6,
|
|
173
|
+
slug: 5,
|
|
174
|
+
id: 5,
|
|
175
|
+
summary: 4,
|
|
176
|
+
category: 3,
|
|
177
|
+
keyConcepts: 3,
|
|
178
|
+
tags: 2,
|
|
179
|
+
frameworks: 2,
|
|
180
|
+
body: 1,
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Build the searchable field map for an entry, across EVERY locale (not just
|
|
184
|
+
* English — searching only `locales.en` was why Spanish/Portuguese queries
|
|
185
|
+
* returned nothing). Values are normalised once, at index time.
|
|
186
|
+
*/
|
|
187
|
+
const fieldCache = new WeakMap();
|
|
188
|
+
function searchFields(entry) {
|
|
189
|
+
const cached = fieldCache.get(entry);
|
|
190
|
+
if (cached)
|
|
191
|
+
return cached;
|
|
192
|
+
const built = buildSearchFields(entry);
|
|
193
|
+
fieldCache.set(entry, built);
|
|
194
|
+
return built;
|
|
195
|
+
}
|
|
196
|
+
function buildSearchFields(entry) {
|
|
197
|
+
const raw = entry;
|
|
198
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
199
|
+
const arr = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string").join(" ") : "");
|
|
200
|
+
const names = [];
|
|
201
|
+
const summaries = [];
|
|
202
|
+
const concepts = [];
|
|
203
|
+
const bodies = [];
|
|
204
|
+
for (const loc of ALL_LOCALES) {
|
|
205
|
+
const L = entry.locales?.[loc];
|
|
206
|
+
if (!L)
|
|
207
|
+
continue;
|
|
208
|
+
names.push(str(L.name), str(L.title));
|
|
209
|
+
summaries.push(str(L.summary), str(L.definition), str(L.problem), str(L.scope));
|
|
210
|
+
concepts.push(arr(L.keyConcepts), arr(L.takeaways));
|
|
211
|
+
// Whole localized body last: lowest weight, but makes deep terms findable.
|
|
212
|
+
bodies.push(JSON.stringify(L));
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
name: norm(names.join(" ")),
|
|
216
|
+
slug: norm(entry.slug ?? ""),
|
|
217
|
+
id: norm(str(raw.id)),
|
|
218
|
+
summary: norm(summaries.join(" ")),
|
|
219
|
+
category: norm(entry.category ?? ""),
|
|
220
|
+
keyConcepts: norm(concepts.join(" ")),
|
|
221
|
+
tags: norm(arr(raw.tags)),
|
|
222
|
+
frameworks: norm(arr(raw.frameworks)),
|
|
223
|
+
body: norm(bodies.join(" ")),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Score an entry against the query tokens. Each token contributes the weight of
|
|
228
|
+
* the strongest field it appears in; matching MORE of the query's tokens is
|
|
229
|
+
* rewarded quadratically (coverage²), so a unit hitting every term outranks one
|
|
230
|
+
* that happens to repeat a single common word like "agent".
|
|
231
|
+
*/
|
|
232
|
+
function scoreEntry(fields, tokens, idf) {
|
|
233
|
+
let raw = 0;
|
|
234
|
+
let hits = 0;
|
|
235
|
+
const matchedFields = new Set();
|
|
236
|
+
const matchedTerms = [];
|
|
237
|
+
for (const token of tokens) {
|
|
238
|
+
let best = 0;
|
|
239
|
+
let bestField = "";
|
|
240
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
241
|
+
if (!value || !value.includes(token))
|
|
242
|
+
continue;
|
|
243
|
+
const w = FIELD_WEIGHTS[field] ?? 1;
|
|
244
|
+
if (w > best) {
|
|
245
|
+
best = w;
|
|
246
|
+
bestField = field;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (best > 0) {
|
|
250
|
+
// Weight by term specificity: "agent" appears in almost every unit of
|
|
251
|
+
// this corpus, "guardrails" in a handful — so a title hit on the rare
|
|
252
|
+
// term must outrank a title hit on the ubiquitous one.
|
|
253
|
+
raw += best * idf(token);
|
|
254
|
+
hits++;
|
|
255
|
+
matchedFields.add(bestField);
|
|
256
|
+
matchedTerms.push(token);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (hits === 0)
|
|
260
|
+
return null;
|
|
261
|
+
// Adjacency bonus: query terms occurring as a literal phrase are a far
|
|
262
|
+
// stronger signal than the same terms scattered around. "aprobacion humana"
|
|
263
|
+
// inside the title «Puerta de Aprobación Humana» should beat a unit that
|
|
264
|
+
// merely mentions all three words somewhere in its body.
|
|
265
|
+
let phraseBonus = 0;
|
|
266
|
+
for (let n = tokens.length; n >= 2; n--) {
|
|
267
|
+
for (let i = 0; i + n <= tokens.length; i++) {
|
|
268
|
+
const phrase = tokens.slice(i, i + n).join(" ");
|
|
269
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
270
|
+
if (value && value.includes(phrase)) {
|
|
271
|
+
const w = FIELD_WEIGHTS[field] ?? 1;
|
|
272
|
+
phraseBonus = Math.max(phraseBonus, w * n);
|
|
273
|
+
matchedFields.add(field);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (phraseBonus > 0)
|
|
278
|
+
break; // longest matching phrase wins
|
|
279
|
+
}
|
|
280
|
+
const coverage = hits / tokens.length;
|
|
281
|
+
return {
|
|
282
|
+
score: Math.round((raw * coverage * coverage + phraseBonus) * 100) / 100,
|
|
283
|
+
matchedFields: [...matchedFields],
|
|
284
|
+
matchedTerms,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const REF_FIELDS = {
|
|
288
|
+
knowledge: [{ field: "related", target: "knowledge", type: "related" }],
|
|
289
|
+
patterns: [{ field: "related", target: "patterns", type: "related" }],
|
|
290
|
+
architectures: [
|
|
291
|
+
{ field: "patterns", target: "patterns", type: "composes" },
|
|
292
|
+
{ field: "knowledge", target: "knowledge", type: "builds_on" },
|
|
293
|
+
{ field: "related", target: "architectures", type: "related" },
|
|
294
|
+
],
|
|
295
|
+
governance: [
|
|
296
|
+
{ field: "patterns", target: "patterns", type: "operationalized_by" },
|
|
297
|
+
{ field: "knowledge", target: "knowledge", type: "relates_to" },
|
|
298
|
+
{ field: "related", target: "governance", type: "related" },
|
|
299
|
+
],
|
|
300
|
+
};
|
|
301
|
+
/**
|
|
302
|
+
* Resolve a chapter into one language and say which one came back.
|
|
303
|
+
*
|
|
304
|
+
* Chapters translate independently, so this is per chapter — the old constant
|
|
305
|
+
* declared the WHOLE handbook English, which stopped being true the moment one
|
|
306
|
+
* chapter was translated and would have made every card lie in the other
|
|
307
|
+
* direction.
|
|
308
|
+
*/
|
|
309
|
+
export function resolveHandbookLocale(e, locale) {
|
|
310
|
+
const wanted = e.locales?.[locale];
|
|
311
|
+
const source = e.locales?.[HANDBOOK_BODY_LOCALE];
|
|
312
|
+
// Translation is partial by design: a short title and summary can be
|
|
313
|
+
// translated long before 1,300 words of prose are. Those fall back field by
|
|
314
|
+
// field; the BODY decides `fallback`, because that is what a reader would
|
|
315
|
+
// notice being in the wrong language.
|
|
316
|
+
const bodyTranslated = Boolean(wanted?.body);
|
|
317
|
+
return {
|
|
318
|
+
title: wanted?.title || source?.title || e.title,
|
|
319
|
+
summary: wanted?.summary || source?.summary || e.summary || "",
|
|
320
|
+
body: (bodyTranslated ? wanted?.body : undefined) || e.body,
|
|
321
|
+
resolved: bodyTranslated ? locale : HANDBOOK_BODY_LOCALE,
|
|
322
|
+
fallback: !bodyTranslated,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Compact card for a handbook chapter, mirroring `summarize`.
|
|
327
|
+
*
|
|
328
|
+
* The handbook is authored in English and translated chapter by chapter.
|
|
329
|
+
* Stamping `locale: "es"` on a card whose title and summary are English claims
|
|
330
|
+
* a translation that does not exist — `get_handbook` was fixed to say so, but
|
|
331
|
+
* the cards from `list_handbook` and `get_related` kept making the claim. They
|
|
332
|
+
* now report the resolution, so no surface of the corpus overstates its
|
|
333
|
+
* coverage — nor, now that every chapter is translated, understates it.
|
|
334
|
+
*/
|
|
335
|
+
export function summarizeHandbook(e, locale = "en") {
|
|
336
|
+
const r = resolveHandbookLocale(e, locale);
|
|
337
|
+
return {
|
|
338
|
+
domain: "handbook",
|
|
339
|
+
id: e.id,
|
|
340
|
+
slug: e.slug,
|
|
341
|
+
category: e.category,
|
|
342
|
+
name: r.title,
|
|
343
|
+
summary: r.summary,
|
|
344
|
+
status: e.status,
|
|
345
|
+
evidence: {
|
|
346
|
+
evidenceLevel: e.evidenceLevel,
|
|
347
|
+
confidenceLevel: e.confidenceLevel,
|
|
348
|
+
sourceType: e.sourceType,
|
|
349
|
+
},
|
|
350
|
+
updated: e.updated,
|
|
351
|
+
locale,
|
|
352
|
+
requested_locale: locale,
|
|
353
|
+
resolved_locale: r.resolved,
|
|
354
|
+
fallback: r.fallback,
|
|
355
|
+
// Localized handbook routes exist and render, but the prose is English.
|
|
356
|
+
canonical_url: `${SITE_URL}/${locale}/handbook/${e.slug}`,
|
|
357
|
+
api_url: `${SITE_URL}/api/handbook/${e.id}`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/** Searchable fields for a handbook chapter (same weighting vocabulary). */
|
|
361
|
+
/**
|
|
362
|
+
* Searchable fields for a chapter, across EVERY locale it has been translated
|
|
363
|
+
* into — the same rule the structured domains already follow.
|
|
364
|
+
*
|
|
365
|
+
* Indexing only English is why a Spanish query for handbook terms returned
|
|
366
|
+
* zero while the English equivalent returned ten: the text simply was not in
|
|
367
|
+
* the index. A translated chapter becomes findable in its own language the
|
|
368
|
+
* moment its file exists, with no other change.
|
|
369
|
+
*/
|
|
370
|
+
function handbookFields(e) {
|
|
371
|
+
const titles = [e.title ?? ""];
|
|
372
|
+
const summaries = [e.summary ?? ""];
|
|
373
|
+
const bodies = [e.body ?? ""];
|
|
374
|
+
for (const loc of ALL_LOCALES) {
|
|
375
|
+
const L = e.locales?.[loc];
|
|
376
|
+
if (!L)
|
|
377
|
+
continue;
|
|
378
|
+
titles.push(L.title ?? "");
|
|
379
|
+
summaries.push(L.summary ?? "");
|
|
380
|
+
bodies.push(L.body ?? "");
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
name: norm(titles.join(" ")),
|
|
384
|
+
slug: norm(e.slug ?? ""),
|
|
385
|
+
id: norm(e.id ?? ""),
|
|
386
|
+
summary: norm(summaries.join(" ")),
|
|
387
|
+
category: norm(e.category ?? ""),
|
|
388
|
+
tags: norm((e.tags ?? []).join(" ")),
|
|
389
|
+
body: norm(bodies.join(" ")),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const HOMERIC_CLASS_ORDER = ["accepted", "plausible", "speculative", "mythical"];
|
|
393
|
+
function homericBody(e, locale) {
|
|
394
|
+
return e.locales?.[locale] ?? e.locales?.en;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* The confidence a place leads with: the strongest identification class it
|
|
398
|
+
* carries, then the highest score inside that class — the same rule the site
|
|
399
|
+
* uses to choose which pin a place is represented by.
|
|
400
|
+
*/
|
|
401
|
+
function primaryHomericConfidence(e) {
|
|
402
|
+
if (e.confidence)
|
|
403
|
+
return e.confidence;
|
|
404
|
+
const ranked = [...(e.hypotheses ?? [])].sort((a, b) => HOMERIC_CLASS_ORDER.indexOf(String(a.identification)) -
|
|
405
|
+
HOMERIC_CLASS_ORDER.indexOf(String(b.identification)) ||
|
|
406
|
+
Number(b.confidence?.score ?? 0) - Number(a.confidence?.score ?? 0));
|
|
407
|
+
return ranked[0]?.confidence ?? null;
|
|
408
|
+
}
|
|
409
|
+
/** A list card for one atlas artifact. */
|
|
410
|
+
function summarizeHomeric(kind, e, locale) {
|
|
411
|
+
const body = homericBody(e, locale) ?? {};
|
|
412
|
+
const name = String(body.name ?? body.title ?? e.slug);
|
|
413
|
+
const citation = e.work && e.book !== undefined && e.lines
|
|
414
|
+
? `${e.work === "iliad" ? "Iliad" : "Odyssey"} ${e.book}.${String(e.lines).replace("-", "–")}`
|
|
415
|
+
: undefined;
|
|
416
|
+
return {
|
|
417
|
+
kind,
|
|
418
|
+
id: e.id,
|
|
419
|
+
slug: e.slug,
|
|
420
|
+
name,
|
|
421
|
+
summary: typeof body.summary === "string" ? body.summary : undefined,
|
|
422
|
+
identification: e.identification,
|
|
423
|
+
region: e.region,
|
|
424
|
+
unlocated: e.unlocated,
|
|
425
|
+
work: e.work,
|
|
426
|
+
citation,
|
|
427
|
+
hypotheses: e.hypotheses?.length,
|
|
428
|
+
variants: e.variants?.length,
|
|
429
|
+
confidence: primaryHomericConfidence(e),
|
|
430
|
+
locale,
|
|
431
|
+
canonical_url: `${SITE_URL}/en/labs/homeric-atlas/${kind}/${e.slug}`,
|
|
432
|
+
api_url: `${SITE_URL}/api/homeric/${kind}/${e.slug}`,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
export function makeContent(loadAll, loadHandbook, loadHomeric) {
|
|
436
|
+
const getOne = (domain, slug) => loadAll(domain).find((e) => e.slug === slug);
|
|
437
|
+
const card = (d, e, locale, type) => ({
|
|
438
|
+
type,
|
|
439
|
+
...summarize(d, e, locale),
|
|
440
|
+
});
|
|
441
|
+
return {
|
|
442
|
+
/**
|
|
443
|
+
* The corpus map. This is the orienting call, so it answers the whole
|
|
444
|
+
* orienting question: what each domain holds, which categories are in it,
|
|
445
|
+
* which tool retrieves a unit and what identifier that tool wants — enough
|
|
446
|
+
* for an agent to go straight to the right call instead of fanning out
|
|
447
|
+
* across every `list_*` or guessing at `search`.
|
|
448
|
+
*/
|
|
449
|
+
overview() {
|
|
450
|
+
const domains = DOMAINS.map((domain) => domainOverview(domain, loadAll(domain)));
|
|
451
|
+
// The handbook is part of the corpus, so it belongs in the map an agent
|
|
452
|
+
// uses to decide where to look — omitting it hid 14 chapters.
|
|
453
|
+
if (loadHandbook)
|
|
454
|
+
domains.push(domainOverview("handbook", loadHandbook()));
|
|
455
|
+
return {
|
|
456
|
+
source: "santismm.com",
|
|
457
|
+
site: SITE_URL,
|
|
458
|
+
locales: ALL_LOCALES,
|
|
459
|
+
license: LICENSE,
|
|
460
|
+
// The machine-actionable half: an SPDX id and the deed, so an agent can
|
|
461
|
+
// decide whether it may reuse this without parsing the prose above.
|
|
462
|
+
license_spdx: LICENSE_INFO.spdx,
|
|
463
|
+
license_url: LICENSE_INFO.url,
|
|
464
|
+
total: domains.reduce((n, d) => n + d.count, 0),
|
|
465
|
+
domains,
|
|
466
|
+
next: "search(query, locale) to answer a question across the whole corpus; " +
|
|
467
|
+
"list_<domain> to browse one; get_<domain>(slug) for a full unit with its " +
|
|
468
|
+
"Evidence-First provenance; get_related(domain, slug) to traverse the graph. " +
|
|
469
|
+
"Every result carries canonical_url and api_url, so cite the canonical_url.",
|
|
470
|
+
// For agents that would rather ingest the corpus than walk it.
|
|
471
|
+
bulk: {
|
|
472
|
+
llms_full_txt: `${SITE_URL}/llms-full.txt`,
|
|
473
|
+
graph: `${SITE_URL}/api/graph.json`,
|
|
474
|
+
homeric_atlas: `${SITE_URL}/api/homeric-atlas.json`,
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
},
|
|
478
|
+
listDomain(domain, locale = "en") {
|
|
479
|
+
return loadAll(domain).map((e) => summarize(domain, e, locale));
|
|
480
|
+
},
|
|
481
|
+
getEntry(domain, slug, locale) {
|
|
482
|
+
const entry = getOne(domain, slug);
|
|
483
|
+
if (!entry)
|
|
484
|
+
return undefined;
|
|
485
|
+
// Full units are citable too: carry the same canonical/API URLs the
|
|
486
|
+
// cards do, so an agent never has to reconstruct them.
|
|
487
|
+
const links = {
|
|
488
|
+
domain,
|
|
489
|
+
canonical_url: `${SITE_URL}/${locale ?? "en"}/${domain}/${entry.slug}`,
|
|
490
|
+
api_url: `${SITE_URL}/api/${domain}/${entry.slug}`,
|
|
491
|
+
};
|
|
492
|
+
if (!locale)
|
|
493
|
+
return { ...entry, ...links };
|
|
494
|
+
const { locales, ...meta } = entry;
|
|
495
|
+
return { ...meta, ...links, locale, body: locales?.[locale] ?? locales?.en };
|
|
496
|
+
},
|
|
497
|
+
/**
|
|
498
|
+
* Ranked, language-agnostic keyword search. Tokenises the query, matches
|
|
499
|
+
* each token against every locale's fields (diacritic-insensitive) and
|
|
500
|
+
* ranks by weighted score, so results are ordered by relevance and carry
|
|
501
|
+
* the evidence of WHY they matched.
|
|
502
|
+
*/
|
|
503
|
+
search(query, domains = [...DOMAINS, "handbook"], limit = 20, locale = "en") {
|
|
504
|
+
// norm() has already stripped diacritics, so a plain alphanumeric split
|
|
505
|
+
// is enough (and avoids needing Unicode property escapes).
|
|
506
|
+
const all = norm(query).split(/[^a-z0-9]+/).filter((t) => t.length >= 2);
|
|
507
|
+
// Drop function words — unless that would leave nothing to search for.
|
|
508
|
+
const meaningful = all.filter((t) => !STOPWORDS.has(t));
|
|
509
|
+
const tokens = [...new Set((meaningful.length > 0 ? meaningful : all).map(stem))];
|
|
510
|
+
if (tokens.length === 0)
|
|
511
|
+
return [];
|
|
512
|
+
// Collect the candidate documents first, so term specificity (IDF) can be
|
|
513
|
+
// measured across the corpus actually being searched.
|
|
514
|
+
const wantHandbook = domains.includes("handbook");
|
|
515
|
+
const structured = domains.filter((d) => d !== "handbook");
|
|
516
|
+
const docs = [];
|
|
517
|
+
for (const domain of structured) {
|
|
518
|
+
for (const entry of loadAll(domain)) {
|
|
519
|
+
docs.push({
|
|
520
|
+
fields: searchFields(entry),
|
|
521
|
+
card: () => summarize(domain, entry, locale),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// The handbook is a first-class domain: searched by default, and
|
|
526
|
+
// selectable (or excludable) like any other via `domains`.
|
|
527
|
+
if (loadHandbook && wantHandbook) {
|
|
528
|
+
for (const e of loadHandbook()) {
|
|
529
|
+
docs.push({
|
|
530
|
+
fields: handbookFields(e),
|
|
531
|
+
card: () => summarizeHandbook(e, locale),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const docFreq = new Map();
|
|
536
|
+
for (const token of tokens) {
|
|
537
|
+
let n = 0;
|
|
538
|
+
for (const d of docs) {
|
|
539
|
+
if (Object.values(d.fields).some((v) => v && v.includes(token)))
|
|
540
|
+
n++;
|
|
541
|
+
}
|
|
542
|
+
docFreq.set(token, n);
|
|
543
|
+
}
|
|
544
|
+
const total = docs.length || 1;
|
|
545
|
+
const idf = (token) => Math.log(1 + total / ((docFreq.get(token) ?? 0) + 1));
|
|
546
|
+
const scored = [];
|
|
547
|
+
for (const d of docs) {
|
|
548
|
+
const hit = scoreEntry(d.fields, tokens, idf);
|
|
549
|
+
if (hit)
|
|
550
|
+
scored.push({ ...d.card(), ...hit });
|
|
551
|
+
}
|
|
552
|
+
scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
553
|
+
return scored.slice(0, limit);
|
|
554
|
+
},
|
|
555
|
+
listHomeric(kind, locale = "en") {
|
|
556
|
+
if (!loadHomeric)
|
|
557
|
+
return [];
|
|
558
|
+
return loadHomeric(kind).map((e) => summarizeHomeric(kind, e, locale));
|
|
559
|
+
},
|
|
560
|
+
getHomeric(kind, slug, locale = "en") {
|
|
561
|
+
if (!loadHomeric)
|
|
562
|
+
return undefined;
|
|
563
|
+
const entry = loadHomeric(kind).find((e) => e.slug === slug);
|
|
564
|
+
if (!entry)
|
|
565
|
+
return undefined;
|
|
566
|
+
// The full artifact, plus the requested locale hoisted to `body`. Both:
|
|
567
|
+
// an agent asking in Spanish should not have to know the shape of
|
|
568
|
+
// `locales`, and one auditing the atlas should still see all three.
|
|
569
|
+
return {
|
|
570
|
+
...summarizeHomeric(kind, entry, locale),
|
|
571
|
+
...entry,
|
|
572
|
+
body: homericBody(entry, locale),
|
|
573
|
+
};
|
|
574
|
+
},
|
|
575
|
+
listHandbook(locale = "en") {
|
|
576
|
+
if (!loadHandbook)
|
|
577
|
+
return [];
|
|
578
|
+
return loadHandbook().map((e) => summarizeHandbook(e, locale));
|
|
579
|
+
},
|
|
580
|
+
getHandbookChapter(idOrSlug, locale = "en") {
|
|
581
|
+
if (!loadHandbook)
|
|
582
|
+
return undefined;
|
|
583
|
+
const needle = norm(idOrSlug);
|
|
584
|
+
const e = loadHandbook().find((x) => norm(x.id) === needle || norm(x.slug) === needle);
|
|
585
|
+
if (!e)
|
|
586
|
+
return undefined;
|
|
587
|
+
// The locale resolution now travels with the card itself, so every
|
|
588
|
+
// handbook surface states it identically.
|
|
589
|
+
return {
|
|
590
|
+
...summarizeHandbook(e, locale),
|
|
591
|
+
body: resolveHandbookLocale(e, locale).body,
|
|
592
|
+
related: e.related,
|
|
593
|
+
tags: e.tags,
|
|
594
|
+
};
|
|
595
|
+
},
|
|
596
|
+
related(domain, slug, locale = "en") {
|
|
597
|
+
// Handbook chapters cross-reference each other by HRN id; resolve those
|
|
598
|
+
// in both directions so the graph is traversable from the handbook too.
|
|
599
|
+
if (domain === "handbook") {
|
|
600
|
+
if (!loadHandbook)
|
|
601
|
+
return undefined;
|
|
602
|
+
const chapters = loadHandbook();
|
|
603
|
+
const needle = norm(slug);
|
|
604
|
+
const self = chapters.find((c) => norm(c.id) === needle || norm(c.slug) === needle);
|
|
605
|
+
if (!self)
|
|
606
|
+
return undefined;
|
|
607
|
+
const byId = new Map(chapters.map((c) => [c.id, c]));
|
|
608
|
+
const outgoing = (self.related ?? [])
|
|
609
|
+
.map((ref) => byId.get(ref))
|
|
610
|
+
.filter((c) => Boolean(c))
|
|
611
|
+
.map((c) => ({ type: "related", ...summarizeHandbook(c, locale) }));
|
|
612
|
+
const incoming = chapters
|
|
613
|
+
.filter((c) => c.id !== self.id && (c.related ?? []).includes(self.id))
|
|
614
|
+
.map((c) => ({ type: "related", ...summarizeHandbook(c, locale) }));
|
|
615
|
+
return { unit: summarizeHandbook(self, locale), outgoing, incoming };
|
|
616
|
+
}
|
|
617
|
+
const entry = getOne(domain, slug);
|
|
618
|
+
if (!entry)
|
|
619
|
+
return undefined;
|
|
620
|
+
const outgoing = [];
|
|
621
|
+
for (const { field, target, type } of REF_FIELDS[domain]) {
|
|
622
|
+
const refs = entry[field];
|
|
623
|
+
if (!Array.isArray(refs))
|
|
624
|
+
continue;
|
|
625
|
+
for (const ref of refs) {
|
|
626
|
+
const t = getOne(target, ref);
|
|
627
|
+
if (t)
|
|
628
|
+
outgoing.push(card(target, t, locale, type));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const incoming = [];
|
|
632
|
+
for (const d of DOMAINS) {
|
|
633
|
+
for (const other of loadAll(d)) {
|
|
634
|
+
if (d === domain && other.slug === slug)
|
|
635
|
+
continue;
|
|
636
|
+
for (const { field, target, type } of REF_FIELDS[d]) {
|
|
637
|
+
if (target !== domain)
|
|
638
|
+
continue;
|
|
639
|
+
const refs = other[field];
|
|
640
|
+
if (Array.isArray(refs) && refs.includes(slug)) {
|
|
641
|
+
incoming.push(card(d, other, locale, type));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return { unit: summarize(domain, entry, locale), outgoing, incoming };
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|