katman-mcp 0.1.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/LICENSE +13 -0
- package/README.md +160 -0
- package/dist/cli.js +71 -0
- package/dist/context.js +43 -0
- package/dist/crawlers.js +86 -0
- package/dist/index.js +25 -0
- package/dist/license.js +448 -0
- package/dist/meta.js +28 -0
- package/dist/net.js +128 -0
- package/dist/server.js +17 -0
- package/dist/state.js +81 -0
- package/dist/tools/account/account.js +64 -0
- package/dist/tools/account/cli.js +21 -0
- package/dist/tools/account/index.js +48 -0
- package/dist/tools/account/sync.js +142 -0
- package/dist/tools/account/upsell.js +290 -0
- package/dist/tools/crawl/audit/analyze.js +719 -0
- package/dist/tools/crawl/audit/bots.js +114 -0
- package/dist/tools/crawl/audit/checks.js +126 -0
- package/dist/tools/crawl/audit/html.js +382 -0
- package/dist/tools/crawl/audit/index.js +364 -0
- package/dist/tools/crawl/audit/model.js +1 -0
- package/dist/tools/crawl/audit/report.js +199 -0
- package/dist/tools/crawl/audit/robots.js +199 -0
- package/dist/tools/crawl/audit/sitemap.js +147 -0
- package/dist/tools/crawl/cli.js +106 -0
- package/dist/tools/crawl/common/advice.js +61 -0
- package/dist/tools/crawl/common/findings.js +57 -0
- package/dist/tools/crawl/common/routes.js +80 -0
- package/dist/tools/crawl/common/text.js +89 -0
- package/dist/tools/crawl/common/urls.js +64 -0
- package/dist/tools/crawl/index.js +226 -0
- package/dist/tools/crawl/indexnow.js +271 -0
- package/dist/tools/crawl/keywords.js +178 -0
- package/dist/tools/crawl/scan/framework.js +645 -0
- package/dist/tools/crawl/scan/index.js +175 -0
- package/dist/tools/crawl/scan/project.js +180 -0
- package/dist/tools/crawl/scan/routes.js +354 -0
- package/dist/tools/crawl/scan/signals.js +414 -0
- package/dist/tools/method/cli.js +42 -0
- package/dist/tools/method/core/brief.js +302 -0
- package/dist/tools/method/core/extract.js +435 -0
- package/dist/tools/method/core/generate.js +504 -0
- package/dist/tools/method/core/lint.js +556 -0
- package/dist/tools/method/core/plan.js +405 -0
- package/dist/tools/method/core/prompt.js +150 -0
- package/dist/tools/method/core/questions.js +196 -0
- package/dist/tools/method/core/report.js +182 -0
- package/dist/tools/method/core/research-run.js +148 -0
- package/dist/tools/method/core/research.js +448 -0
- package/dist/tools/method/core/snippets.js +292 -0
- package/dist/tools/method/core/start.js +277 -0
- package/dist/tools/method/data/anatomy.js +141 -0
- package/dist/tools/method/data/appendices.js +202 -0
- package/dist/tools/method/data/cloudflare.js +192 -0
- package/dist/tools/method/data/evidence.js +137 -0
- package/dist/tools/method/data/geo-jobs.js +128 -0
- package/dist/tools/method/data/index.js +19 -0
- package/dist/tools/method/data/landing.js +67 -0
- package/dist/tools/method/data/lessons.js +127 -0
- package/dist/tools/method/data/measurement.js +142 -0
- package/dist/tools/method/data/method.js +141 -0
- package/dist/tools/method/data/offsite.js +148 -0
- package/dist/tools/method/data/pages.js +167 -0
- package/dist/tools/method/data/prompts.js +633 -0
- package/dist/tools/method/data/publish.js +113 -0
- package/dist/tools/method/data/render.js +117 -0
- package/dist/tools/method/data/research.js +131 -0
- package/dist/tools/method/data/roadmap.js +105 -0
- package/dist/tools/method/data/traps.js +74 -0
- package/dist/tools/method/data/verify.js +88 -0
- package/dist/tools/method/data/viral-list.js +39 -0
- package/dist/tools/method/index.js +209 -0
- package/dist/tools/method/lib/calls.js +53 -0
- package/dist/tools/method/lib/framework.js +525 -0
- package/dist/tools/method/lib/l10n.js +17 -0
- package/dist/tools/method/lib/routes.js +118 -0
- package/dist/tools/method/lib/state.js +454 -0
- package/dist/tools/method/lib/text.js +165 -0
- package/dist/tools/method/mcp/prompts.js +79 -0
- package/dist/tools/method/mcp/resources.js +50 -0
- package/dist/tools/visibility/analyze.js +463 -0
- package/dist/tools/visibility/cli.js +86 -0
- package/dist/tools/visibility/estimate.js +105 -0
- package/dist/tools/visibility/index.js +108 -0
- package/dist/tools/visibility/judge.js +156 -0
- package/dist/tools/visibility/manual.js +275 -0
- package/dist/tools/visibility/models.js +277 -0
- package/dist/tools/visibility/monitoring.js +165 -0
- package/dist/tools/visibility/openrouter.js +449 -0
- package/dist/tools/visibility/render.js +295 -0
- package/dist/tools/visibility/run.js +684 -0
- package/dist/tools/visibility/summary.js +164 -0
- package/dist/types.js +3 -0
- package/dist/util.js +92 -0
- package/package.json +54 -0
- package/server.json +34 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { isFilled } from "../lib/text.js";
|
|
2
|
+
function langName(code, inLang) {
|
|
3
|
+
try {
|
|
4
|
+
const n = new Intl.DisplayNames([inLang], { type: "language" }).of(code);
|
|
5
|
+
if (n && n.toLowerCase() !== code.toLowerCase())
|
|
6
|
+
return n;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
// fall through
|
|
10
|
+
}
|
|
11
|
+
return code;
|
|
12
|
+
}
|
|
13
|
+
const THE_COUNTRIES = /^(United |Netherlands|Philippines|Czech Republic|Dominican Republic|Bahamas|Gambia|Maldives)/;
|
|
14
|
+
function countryName(c, inLang) {
|
|
15
|
+
const s = c.trim();
|
|
16
|
+
let name = s;
|
|
17
|
+
if (/^[A-Za-z]{2}$/.test(s)) {
|
|
18
|
+
try {
|
|
19
|
+
name = new Intl.DisplayNames([inLang], { type: "region" }).of(s.toUpperCase()) ?? s;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
name = s;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return inLang === "en" && THE_COUNTRIES.test(name) ? `the ${name}` : name;
|
|
26
|
+
}
|
|
27
|
+
/** "/import-pdf-invoices" → "import pdf invoices" (a verb phrase in most situation slugs). */
|
|
28
|
+
export function jobFromUrl(url) {
|
|
29
|
+
if (!url)
|
|
30
|
+
return undefined;
|
|
31
|
+
const last = url.replace(/\/+$/, "").split("/").pop() ?? "";
|
|
32
|
+
const words = last.split("-").filter((w) => /[a-z]/i.test(w));
|
|
33
|
+
return words.length >= 2 ? words.join(" ") : undefined;
|
|
34
|
+
}
|
|
35
|
+
/** Lowercase the first letter of a normal word ("Invoice app" → "invoice app") but keep acronyms ("AI tool"). */
|
|
36
|
+
export function lowerFirst(s) {
|
|
37
|
+
return /^\p{Lu}\p{Ll}/u.test(s) ? s.charAt(0).toLowerCase() + s.slice(1) : s;
|
|
38
|
+
}
|
|
39
|
+
function asQuestion(sentence, T) {
|
|
40
|
+
const s = sentence.trim();
|
|
41
|
+
if (/\?\s*$/.test(s))
|
|
42
|
+
return s;
|
|
43
|
+
const withStop = /[.!…]$/.test(s) ? s : s + ".";
|
|
44
|
+
return T === "tr" ? `${withStop} Bu konuda yardımcı olan bir site var mı?` : `${withStop} Is there a website that can help me with this?`;
|
|
45
|
+
}
|
|
46
|
+
const LANG_HINTS = {
|
|
47
|
+
tr: /[ğşıİ]|\b(ve|bir|ama|için|nasıl|istiyorum|yapmak|yok|mı|mi|mu|benim|değil)\b/i,
|
|
48
|
+
en: /\b(i|i'm|my|have|but|want|can't|cannot|how|the|and|is there)\b/i,
|
|
49
|
+
de: /ß|\b(ich|und|nicht|mein|meine|ein|eine|kann|wie|habe)\b/i,
|
|
50
|
+
es: /ñ|¿|\b(tengo|pero|quiero|cómo|una|para|puedo|mi)\b/i,
|
|
51
|
+
fr: /\b(je|j'ai|mais|pas|mon|une|pour|comment|peux)\b/i,
|
|
52
|
+
it: /\b(ho|ma|voglio|come|una|per|posso|il mio)\b/i,
|
|
53
|
+
pt: /ã|õ|\b(tenho|mas|quero|como|uma|para|posso|meu)\b/i,
|
|
54
|
+
};
|
|
55
|
+
/** Which of the site's languages a situation sentence is written in (defaults to the primary language). */
|
|
56
|
+
export function detectLang(sentence, langs) {
|
|
57
|
+
const primary = langs[0] ?? "en";
|
|
58
|
+
let best = primary;
|
|
59
|
+
let bestScore = 0;
|
|
60
|
+
for (const l of langs) {
|
|
61
|
+
const re = LANG_HINTS[l.split("-")[0]];
|
|
62
|
+
if (!re)
|
|
63
|
+
continue;
|
|
64
|
+
const score = (sentence.match(new RegExp(re.source, "gi")) ?? []).length;
|
|
65
|
+
if (score > bestScore) {
|
|
66
|
+
best = l;
|
|
67
|
+
bestScore = score;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return best;
|
|
71
|
+
}
|
|
72
|
+
function diagnostics(r, T, lang, idPrefix) {
|
|
73
|
+
const out = [];
|
|
74
|
+
if (!isFilled(r.brand.name))
|
|
75
|
+
return out;
|
|
76
|
+
const b = r.brand.name.trim();
|
|
77
|
+
out.push({ id: `${idPrefix}t1`, type: "diagnostic", text: T === "tr" ? `${b} nedir?` : `What is ${b}?`, lang });
|
|
78
|
+
if (isFilled(r.site.domain))
|
|
79
|
+
out.push({ id: `${idPrefix}t2`, type: "diagnostic", text: T === "tr" ? `${b} (${r.site.domain}) nedir?` : `What is ${b} (${r.site.domain})?`, lang });
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
function primarySet(r, sits, lang, now, notes) {
|
|
83
|
+
const base = lang.split("-")[0];
|
|
84
|
+
const T = base === "tr" ? "tr" : "en";
|
|
85
|
+
const year = now.getUTCFullYear();
|
|
86
|
+
const category = isFilled(r.brand.category) ? r.brand.category.trim() : T === "tr" ? "araç" : "tool";
|
|
87
|
+
const cat = lowerFirst(category);
|
|
88
|
+
const comps = r.competitors.filter((c) => isFilled(c.name)).map((c) => c.name.trim());
|
|
89
|
+
const job = T === "en" ? sits.map((s) => jobFromUrl(s.url)).find(Boolean) : undefined;
|
|
90
|
+
const qs = [];
|
|
91
|
+
// 1–3 situation
|
|
92
|
+
const top = sits.slice(0, 3);
|
|
93
|
+
for (const s of top)
|
|
94
|
+
qs.push({ type: "situation", text: asQuestion(s.sentence, T) });
|
|
95
|
+
if (top.length < 3)
|
|
96
|
+
notes.push(`Only ${top.length} situation(s) in ${lang}: add more and regenerate (the set needs 3 situation questions).`);
|
|
97
|
+
// 4–5 tool
|
|
98
|
+
if (T === "tr") {
|
|
99
|
+
qs.push({ type: "tool", text: `Hangi ${cat} kullanmalıyım?` });
|
|
100
|
+
const s2 = sits[3] ?? sits[0];
|
|
101
|
+
qs.push({ type: "tool", text: s2 ? `"${s2.sentence.replace(/[.?!]+$/, "")}" için hangi aracı kullanabilirim?` : `Hangi ${cat} öneriliyor?` });
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
qs.push({ type: "tool", text: `Which ${cat} should I use?` });
|
|
105
|
+
qs.push({ type: "tool", text: job ? `Which tool can ${job}?` : `What ${cat} do people recommend?` });
|
|
106
|
+
}
|
|
107
|
+
// 6–7 best
|
|
108
|
+
if (T === "tr")
|
|
109
|
+
qs.push({ type: "best", text: `en iyi ${cat} ${year}` }, { type: "best", text: `En iyi ${cat} hangisi?` });
|
|
110
|
+
else
|
|
111
|
+
qs.push({ type: "best", text: `best ${cat} ${year}` }, { type: "best", text: `What is the best ${cat}?` });
|
|
112
|
+
// 8–9 alternative
|
|
113
|
+
const [c1, c2] = comps;
|
|
114
|
+
if (!c1)
|
|
115
|
+
notes.push("No competitors in research: the alternative questions are generic. Add the names assistants recommend and regenerate.");
|
|
116
|
+
if (T === "tr") {
|
|
117
|
+
qs.push({ type: "alternative", text: c1 ? `${c1} alternatifleri` : `Büyük ${cat} markalarına alternatifler neler?` });
|
|
118
|
+
qs.push({ type: "alternative", text: c2 ? `${c2} alternatifleri` : c1 ? `${year} için ${c1} alternatifleri` : `${category} alternatifleri` });
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
qs.push({ type: "alternative", text: c1 ? (job ? `${c1} alternatives that can ${job}` : `${c1} alternatives`) : `alternatives to the big ${cat} brands` });
|
|
122
|
+
qs.push({ type: "alternative", text: c2 ? `${c2} alternatives` : c1 ? `${c1} alternatives in ${year}` : `${cat} alternatives` });
|
|
123
|
+
}
|
|
124
|
+
// 10–11 market / language
|
|
125
|
+
const langs = r.site.languages.length ? r.site.languages : [lang];
|
|
126
|
+
const l0 = langName(langs[0], T);
|
|
127
|
+
const l1 = langs[1] ? langName(langs[1], T) : undefined;
|
|
128
|
+
const country = isFilled(r.brand.country) ? countryName(r.brand.country, T) : undefined;
|
|
129
|
+
if (T === "tr") {
|
|
130
|
+
qs.push({ type: "market", text: `${l0} için en iyi ${cat} hangisi?` });
|
|
131
|
+
qs.push({ type: "market", text: l1 ? `${l1} için en iyi ${cat} hangisi?` : country ? `${country} için en iyi ${cat}` : `${l0} destekleyen ${cat}` });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
qs.push({ type: "market", text: `Which ${cat} works best in ${l0}?` });
|
|
135
|
+
qs.push({ type: "market", text: l1 ? `Which ${cat} works best in ${l1}?` : country ? `best ${cat} in ${country}` : `${cat} that supports ${l0}` });
|
|
136
|
+
}
|
|
137
|
+
// 12 problem
|
|
138
|
+
const ps = sits[3] ?? sits[0];
|
|
139
|
+
if (T === "tr")
|
|
140
|
+
qs.push({ type: "problem", text: ps ? `Şunu nasıl çözerim: "${ps.sentence.replace(/[.?!]+$/, "")}"?` : `Doğru ${cat} nasıl seçilir?` });
|
|
141
|
+
else
|
|
142
|
+
qs.push({ type: "problem", text: job ? `How do I ${job}?` : ps ? `How do I solve this: "${ps.sentence.replace(/[.?!]+$/, "")}"?` : `How do I choose a ${cat}?` });
|
|
143
|
+
const questions = qs.map((q, i) => ({ id: `q${i + 1}`, type: q.type, text: q.text, lang }));
|
|
144
|
+
const diag = diagnostics(r, T, lang, "");
|
|
145
|
+
if (!isFilled(r.brand.name))
|
|
146
|
+
notes.push("No brand name yet: the two diagnostic questions were skipped.");
|
|
147
|
+
else if (diag.length < 2)
|
|
148
|
+
notes.push("No site domain yet: diagnostic T2 (\"What is <brand> (<domain>)?\") was skipped.");
|
|
149
|
+
return [...questions, ...diag];
|
|
150
|
+
}
|
|
151
|
+
export function generateQuestions(r, now = new Date()) {
|
|
152
|
+
const langs = [...new Set((r.site.languages.length ? r.site.languages : ["en"]).map((l) => l.trim().toLowerCase()).filter(Boolean))];
|
|
153
|
+
const primary = langs[0];
|
|
154
|
+
const notes = [];
|
|
155
|
+
const sits = r.situations.filter((s) => isFilled(s.sentence));
|
|
156
|
+
const byLang = new Map();
|
|
157
|
+
for (const s of sits) {
|
|
158
|
+
const l = langs.length > 1 ? detectLang(s.sentence, langs) : primary;
|
|
159
|
+
byLang.set(l, [...(byLang.get(l) ?? []), s]);
|
|
160
|
+
}
|
|
161
|
+
const primarySits = byLang.get(primary)?.length ? byLang.get(primary) : sits;
|
|
162
|
+
const pBase = primary.split("-")[0];
|
|
163
|
+
if (pBase !== "en" && pBase !== "tr") {
|
|
164
|
+
notes.push(`The primary language is "${primary}"; Katman has English and Turkish templates only, so the ${primary} questions use English wording. Translate them into ${langName(primary, "en")} (katman_research update with the same ids) before the first run.`);
|
|
165
|
+
}
|
|
166
|
+
const questions = primarySet(r, primarySits, primary, now, notes);
|
|
167
|
+
for (const l of langs.slice(1)) {
|
|
168
|
+
const base = l.split("-")[0];
|
|
169
|
+
const T = base === "tr" ? "tr" : "en";
|
|
170
|
+
const own = byLang.get(l) ?? [];
|
|
171
|
+
const use = own.length ? own : primarySits;
|
|
172
|
+
const set = use.slice(0, 3).map((s, i) => ({ id: `${l}-q${i + 1}`, type: "situation", text: asQuestion(s.sentence, T), lang: l }));
|
|
173
|
+
set.push(...diagnostics(r, T, l, `${l}-`));
|
|
174
|
+
questions.push(...set);
|
|
175
|
+
if (!own.length) {
|
|
176
|
+
notes.push(`${langName(l, "en")} (${l}): no situations written in ${l} yet, so its ${set.length} questions reuse the ${primary} situations. Translate them — or add the customers' own ${l} sentences as situations — with katman_research update (same ids) before the first run.`);
|
|
177
|
+
}
|
|
178
|
+
else if (base !== "en" && base !== "tr") {
|
|
179
|
+
notes.push(`${langName(l, "en")} (${l}): the wrapper sentence and T1/T2 are in English; translate them (same ids) before the first run.`);
|
|
180
|
+
}
|
|
181
|
+
else
|
|
182
|
+
notes.push(`${langName(l, "en")} (${l}): ${set.length} questions (situations + T1/T2) — a smaller set than the primary language's 12 + 2.`);
|
|
183
|
+
}
|
|
184
|
+
notes.push("Review the wording once before the first run; after that keep the set fixed — runs are only comparable with the same questions, models and temperature.");
|
|
185
|
+
return { questions, lang: primary, languages: langs, notes };
|
|
186
|
+
}
|
|
187
|
+
/** The primary language's situation question ids (the "3 most important situation questions" to sample 3×). */
|
|
188
|
+
export function keySituationQuestionIds(r, max = 3) {
|
|
189
|
+
if (!r)
|
|
190
|
+
return [];
|
|
191
|
+
const primary = (r.site.languages[0] ?? "en").toLowerCase();
|
|
192
|
+
return r.questions
|
|
193
|
+
.filter((q) => q.type === "situation" && (!q.lang || q.lang.toLowerCase() === primary))
|
|
194
|
+
.slice(0, max)
|
|
195
|
+
.map((q) => q.id);
|
|
196
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { logEvent, writeText } from "../../../state.js";
|
|
2
|
+
import { mdTable, sortFindings } from "../../../util.js";
|
|
3
|
+
import { calls, hasOpenRouterKey } from "../lib/calls.js";
|
|
4
|
+
import { t } from "../lib/l10n.js";
|
|
5
|
+
import { loadConfigSafe, loadLatestAudit, loadResearchSafe, loadRuns, loadScan, runDate } from "../lib/state.js";
|
|
6
|
+
import { addDays, isFilled, isoDay } from "../lib/text.js";
|
|
7
|
+
import { API_PROXY_NOTE, MEASURE_FREQUENCY, GSC_AI_NOTE } from "../data/measurement.js";
|
|
8
|
+
import { OFFICIAL_REPORTS } from "../data/evidence.js";
|
|
9
|
+
import { FOOTER } from "../data/render.js";
|
|
10
|
+
import { researchCompleteness } from "./research.js";
|
|
11
|
+
import { keySituationQuestionIds } from "./questions.js";
|
|
12
|
+
import { loadPlanState, openTasks, fmtCallShort, taskCall } from "./plan.js";
|
|
13
|
+
import { siteUrlOf } from "./prompt.js";
|
|
14
|
+
function rate(x) {
|
|
15
|
+
return `${Math.round(x * 100)}%`;
|
|
16
|
+
}
|
|
17
|
+
function delta(a, b) {
|
|
18
|
+
if (b === undefined)
|
|
19
|
+
return "–";
|
|
20
|
+
const d = Math.round((a - b) * 100);
|
|
21
|
+
return d === 0 ? "±0" : d > 0 ? `+${d} pts` : `${d} pts`;
|
|
22
|
+
}
|
|
23
|
+
/** Runs are only comparable with the same question set and model panel. */
|
|
24
|
+
export function comparable(a, b) {
|
|
25
|
+
const qa = new Set(a.answers.map((x) => x.question));
|
|
26
|
+
const qb = new Set(b.answers.map((x) => x.question));
|
|
27
|
+
const sameQ = qa.size === qb.size && [...qa].every((q) => qb.has(q));
|
|
28
|
+
const ma = new Set(a.models.map((m) => m.id));
|
|
29
|
+
const mb = new Set(b.models.map((m) => m.id));
|
|
30
|
+
const sameM = ma.size === mb.size && [...ma].every((m) => mb.has(m));
|
|
31
|
+
if (!sameQ)
|
|
32
|
+
return { ok: false, why: "the question set changed" };
|
|
33
|
+
if (!sameM)
|
|
34
|
+
return { ok: false, why: "the model panel changed" };
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
37
|
+
export async function buildReport(root, args = {}) {
|
|
38
|
+
const now = args.now ?? new Date();
|
|
39
|
+
const [research, config, scan, loaded, runs, planState] = await Promise.all([loadResearchSafe(root), loadConfigSafe(root), loadScan(root), loadLatestAudit(root), loadRuns(root), loadPlanState(root)]);
|
|
40
|
+
const lang = args.lang ?? config.lang ?? "en";
|
|
41
|
+
const L = (en, tr) => (lang === "tr" ? tr : en);
|
|
42
|
+
const audit = loaded?.audit;
|
|
43
|
+
const siteUrl = siteUrlOf(research, config);
|
|
44
|
+
const brand = isFilled(research?.brand.name) ? research.brand.name : undefined;
|
|
45
|
+
const comp = researchCompleteness(research);
|
|
46
|
+
const last = runs[runs.length - 1];
|
|
47
|
+
const prev = runs.length >= 2 ? runs[runs.length - 2] : undefined;
|
|
48
|
+
const cmp = last && prev ? comparable(last, prev) : undefined;
|
|
49
|
+
const plan = planState.plan;
|
|
50
|
+
const open = plan ? openTasks(plan) : [];
|
|
51
|
+
const out = [];
|
|
52
|
+
out.push(`# ${L("Katman report", "Katman raporu")} — ${brand ?? L("your site", "siteniz")}${siteUrl ? ` (${siteUrl})` : ""}`);
|
|
53
|
+
out.push(`${isoDay(now)} · ${L("AI visibility: read, quote, fit — and whether assistants name, recommend and cite you.", "Yapay zekâ görünürlüğü: okunmak, alıntılanmak, soruya uymak — ve asistanların sizi anıp, önerip, kaynak gösterip göstermediği.")}`);
|
|
54
|
+
// Summary
|
|
55
|
+
const summary = [];
|
|
56
|
+
summary.push(`- ${L("Research", "Araştırma")}: ${research ? `${comp.percent}%` : L("not started", "başlamadı")}${comp.gaps.length && research ? ` (${L("missing", "eksik")}: ${comp.gaps.slice(0, 3).join("; ")})` : ""}`);
|
|
57
|
+
if (audit)
|
|
58
|
+
summary.push(`- ${L("Site audit", "Site denetimi")} (${isoDay(audit.ts)}): Read ${audit.gates.read.pass}/${audit.gates.read.total} · Quote ${audit.gates.quote.pass}/${audit.gates.quote.total} · Fit ${audit.gates.fit.pass}/${audit.gates.fit.total} ${L("pages pass", "sayfa geçiyor")}`);
|
|
59
|
+
else
|
|
60
|
+
summary.push(`- ${L("Site audit", "Site denetimi")}: ${L("none yet", "henüz yok")}`);
|
|
61
|
+
if (last)
|
|
62
|
+
summary.push(`- ${L("AI visibility", "Yapay zekâ görünürlüğü")} (${isoDay(runDate(last) ?? last.ts)}, ${last.summary.answered} ${L("answers", "cevap")}): ${L("mentioned", "anılma")} ${rate(last.summary.mentionRate)} · ${L("recommended first", "ilk öneri")} ${rate(last.summary.recommendedFirstRate)} · ${L("cited", "kaynak")} ${rate(last.summary.citationRate)}${prev && cmp?.ok ? ` (${delta(last.summary.mentionRate, prev.summary.mentionRate)} ${L("mentions vs", "anılma, önceki:")} ${isoDay(runDate(prev) ?? prev.ts)})` : ""}`);
|
|
63
|
+
else
|
|
64
|
+
summary.push(`- ${L("AI visibility", "Yapay zekâ görünürlüğü")}: ${L("no measurement yet", "henüz ölçüm yok")}`);
|
|
65
|
+
if (plan)
|
|
66
|
+
summary.push(`- ${L("Plan", "Plan")}: ${plan.tasks.length - open.length}/${plan.tasks.length} ${L("tasks done", "görev tamam")} (${plan.tasks.length ? Math.round(((plan.tasks.length - open.length) / plan.tasks.length) * 100) : 0}%)`);
|
|
67
|
+
out.push(`## ${L("Summary", "Özet")}\n${summary.join("\n")}`);
|
|
68
|
+
// Research
|
|
69
|
+
if (research) {
|
|
70
|
+
out.push(`## ${L("Research", "Araştırma")} (${comp.percent}%)\n${mdTable([L("Item", "Madde"), L("Status", "Durum")], comp.items.map((i) => [i.label, `${i.score >= 1 ? L("done", "tamam") : L("open", "açık")} — ${i.detail}`]))}`);
|
|
71
|
+
}
|
|
72
|
+
// Audit
|
|
73
|
+
if (audit) {
|
|
74
|
+
const rows = ["read", "quote", "fit"].map((g) => [g === "read" ? L("Read", "Okunmak") : g === "quote" ? L("Quote", "Alıntılanmak") : L("Fit", "Soruya uymak"), `${audit.gates[g].pass}/${audit.gates[g].total}`, audit.gates[g].notes.slice(0, 2).join("; ")]);
|
|
75
|
+
const site = [];
|
|
76
|
+
const blocked = audit.site.bots.filter((b) => b.blocked);
|
|
77
|
+
site.push(`- ${L("AI bots through the CDN", "CDN’den geçen yapay zekâ botları")}: ${audit.site.bots.length ? (blocked.length ? `${L("blocked", "engelli")}: ${blocked.map((b) => `${b.token} (${b.status})`).join(", ")}` : L("all tested bots get 200", "test edilen bütün botlar 200 alıyor")) : L("not tested", "test edilmedi")}`);
|
|
78
|
+
site.push(`- robots.txt: ${audit.site.robots.exists ? (audit.site.robots.blocksAll ? L("blocks the whole site", "bütün siteyi kapatıyor") : audit.site.robots.blockedAiTokens.length ? `${L("blocks", "engelliyor")} ${audit.site.robots.blockedAiTokens.join(", ")}` : "ok") : L("missing", "yok")} · sitemap: ${audit.site.sitemapUrls} URL · llms.txt: ${audit.site.llmsTxt.exists ? L("yes", "var") : L("no", "yok")}${audit.site.soft404 ? ` · ${L("soft 404s", "sahte 404")}` : ""}`);
|
|
79
|
+
out.push(`## ${L("Site audit", "Site denetimi")} (${isoDay(audit.ts)}, ${audit.pages.length} ${L("pages", "sayfa")})\n${mdTable([L("Gate", "Kapı"), L("Pages passing", "Geçen sayfa"), L("Notes", "Notlar")], rows)}\n\n${site.join("\n")}`);
|
|
80
|
+
}
|
|
81
|
+
// Visibility
|
|
82
|
+
if (last) {
|
|
83
|
+
const lines = [];
|
|
84
|
+
const rows = [
|
|
85
|
+
[L("Mentioned", "Anılma"), rate(last.summary.mentionRate), prev ? rate(prev.summary.mentionRate) : "–", prev ? delta(last.summary.mentionRate, prev.summary.mentionRate) : "–"],
|
|
86
|
+
[L("Recommended first", "İlk öneri"), rate(last.summary.recommendedFirstRate), prev ? rate(prev.summary.recommendedFirstRate) : "–", prev ? delta(last.summary.recommendedFirstRate, prev.summary.recommendedFirstRate) : "–"],
|
|
87
|
+
[L("Cited", "Kaynak gösterilme"), rate(last.summary.citationRate), prev ? rate(prev.summary.citationRate) : "–", prev ? delta(last.summary.citationRate, prev.summary.citationRate) : "–"],
|
|
88
|
+
];
|
|
89
|
+
lines.push(mdTable([L("Rate", "Oran"), `${L("Latest", "Son")} (${isoDay(runDate(last) ?? last.ts)})`, prev ? `${L("Previous", "Önceki")} (${isoDay(runDate(prev) ?? prev.ts)})` : L("Previous", "Önceki"), L("Change", "Değişim")], rows));
|
|
90
|
+
if (cmp && !cmp.ok)
|
|
91
|
+
lines.push(`> ${L(`Not comparable: ${cmp.why}. Keep the same questions and models between runs.`, `Karşılaştırılamaz: ${cmp.why}. Ölçümler arasında aynı soruları ve modelleri kullanın.`)}`);
|
|
92
|
+
if (last.status === "partial")
|
|
93
|
+
lines.push(`> ${L("This run stopped early (credits, cost cap or cancel): skipped answers are recorded, rates cover the answered ones only.", "Bu ölçüm erken durdu (kredi, maliyet sınırı ya da iptal): atlanan cevaplar kayıtlı, oranlar yalnızca cevaplananları kapsıyor.")}`);
|
|
94
|
+
const fam = Object.entries(last.summary.byFamily);
|
|
95
|
+
if (fam.length)
|
|
96
|
+
lines.push(mdTable([L("Assistant", "Asistan"), L("Answers", "Cevap"), L("Mentioned", "Anıldı"), L("First", "İlk"), L("Cited", "Kaynak")], fam.map(([f, v]) => [f, v.answered, v.mentioned, v.recommendedFirst, v.cited])));
|
|
97
|
+
const byLang = Object.entries(last.summary.byLanguage ?? {});
|
|
98
|
+
if (byLang.length > 1)
|
|
99
|
+
lines.push(mdTable([L("Language", "Dil"), L("Answers", "Cevap"), L("Mentioned", "Anıldı"), L("First", "İlk"), L("Cited", "Kaynak")], byLang.map(([l, v]) => [l, v.answered, v.mentioned, v.recommendedFirst, v.cited])));
|
|
100
|
+
const samples = Math.max(0, ...last.answers.map((a) => a.sample ?? 1));
|
|
101
|
+
if (samples > 1)
|
|
102
|
+
lines.push(`- ${L(`Questions were asked up to ${samples} times each: read the rates as frequency (how often you're named), not rank.`, `Sorular en fazla ${samples}’er kez soruldu: oranları sıra değil sıklık olarak okuyun (ne sıklıkla anıldığınız).`)}`);
|
|
103
|
+
const noSearch = last.answers.filter((a) => a.searched === false).length;
|
|
104
|
+
if (noSearch)
|
|
105
|
+
lines.push(`- ${L(`${noSearch} answer(s) came without a web search (answered from memory: big brands dominate those).`, `${noSearch} cevap web araması yapılmadan geldi (hafızadan cevap: bunlarda büyük markalar baskın).`)}`);
|
|
106
|
+
const diag = last.answers.filter((a) => a.questionType === "diagnostic" && a.identityCorrect === false);
|
|
107
|
+
if (diag.length)
|
|
108
|
+
lines.push(`- ${L("Identity problems", "Kimlik sorunları")}: ${diag.map((a) => `${a.family} “${a.answerExcerpt.slice(0, 80)}…”`).join("; ")} → ${L("strengthen the not-X note (P5, P8)", "“X değildir” notunu güçlendirin (P5, P8)")}`);
|
|
109
|
+
lines.push(`- ${t(MEASURE_FREQUENCY, lang)}`);
|
|
110
|
+
lines.push(`- ${t(API_PROXY_NOTE, lang)}`);
|
|
111
|
+
out.push(`## ${L("AI visibility", "Yapay zekâ görünürlüğü")}\n${lines.join("\n\n")}`);
|
|
112
|
+
const comps = last.summary.topCompetitors.slice(0, 10);
|
|
113
|
+
const doms = last.summary.topCitedDomains.filter((d) => !research?.site.domain || !d.domain.endsWith(research.site.domain)).slice(0, 10);
|
|
114
|
+
if (comps.length)
|
|
115
|
+
out.push(`### ${L("Who the assistants name instead", "Asistanların yerinize andıkları")}\n${mdTable([L("Name", "Ad"), L("Times named", "Anılma")], comps.map((c) => [c.name, c.count]))}\n${L("Put these in research.competitors (with url + date) and in the comparison guide (P7).", "Bunları research.competitors’a (url + tarihle) ve karşılaştırma rehberine (P7) ekleyin.")}`);
|
|
116
|
+
if (doms.length)
|
|
117
|
+
out.push(`### ${L("Which sites the assistants cite (off-site targets)", "Asistanların alıntıladığı siteler (site dışı hedefler)")}\n${mdTable([L("Domain", "Alan adı"), L("Citations", "Alıntı")], doms.map((d) => [d.domain, d.count]))}\n${L("These are where to get mentioned (book ch. 14): list authors to contact, directories to join, video and community topics. No bought links, fake reviews or self-ranked lists.", "Bahsedilmeniz gereken yerler bunlar (14. bölüm): ulaşılacak liste yazarları, katılınacak dizinler, video ve topluluk konuları. Satın alınmış link, sahte yorum ya da kendini birinci yazan liste yok.")}`);
|
|
118
|
+
}
|
|
119
|
+
// Plan progress
|
|
120
|
+
if (plan) {
|
|
121
|
+
const rows = [1, 2, 3, 4].map((w) => {
|
|
122
|
+
const wt = plan.tasks.filter((x) => x.week === w);
|
|
123
|
+
return [`${L("Week", "Hafta")} ${w}`, `${wt.filter((x) => x.done).length}/${wt.length}`];
|
|
124
|
+
});
|
|
125
|
+
out.push(`## ${L("Plan progress", "Plan ilerlemesi")}\n${mdTable([L("Week", "Hafta"), L("Done", "Tamam")], rows)}`);
|
|
126
|
+
}
|
|
127
|
+
// Top 10 open issues
|
|
128
|
+
const findings = new Map();
|
|
129
|
+
for (const f of scan?.findings ?? [])
|
|
130
|
+
findings.set(f.id, f);
|
|
131
|
+
for (const f of audit?.findings ?? [])
|
|
132
|
+
findings.set(f.id, f);
|
|
133
|
+
const top = sortFindings([...findings.values()].filter((f) => f.severity !== "info")).slice(0, 10);
|
|
134
|
+
if (top.length) {
|
|
135
|
+
out.push(`## ${L("Top open issues", "Açık sorunlar")}\n${top.map((f, i) => `${i + 1}. **[${f.severity.toUpperCase()}] ${f.title}** (${f.gate}${f.prompt ? `, ${L("fix with", "çözüm")} ${f.prompt}` : ""})${f.fix ? ` — ${f.fix}` : ""}`).join("\n")}`);
|
|
136
|
+
}
|
|
137
|
+
// Next 7 days
|
|
138
|
+
const next = [];
|
|
139
|
+
const lastDate = last ? runDate(last) : undefined;
|
|
140
|
+
const due = lastDate ? addDays(lastDate, 14) : undefined;
|
|
141
|
+
if (!research)
|
|
142
|
+
next.push(`${L("Fill research", "Araştırmayı doldurun")}: ${fmtCallShort(calls.researchTemplate(root))}`);
|
|
143
|
+
else if (comp.blocking.length)
|
|
144
|
+
next.push(`${L("Finish research", "Araştırmayı tamamlayın")}: ${comp.blocking.join(", ")}`);
|
|
145
|
+
const crit = top.filter((f) => f.severity === "critical");
|
|
146
|
+
for (const f of crit.slice(0, 2))
|
|
147
|
+
next.push(`${L("Fix the critical issue", "Kritik sorunu düzeltin")}: ${f.title}${f.prompt ? ` (${fmtCallShort(calls.prompt(root, f.prompt))})` : ""}`);
|
|
148
|
+
for (const task of open.filter((x) => !(x.id === "research" && (!research || comp.blocking.length))).slice(0, 4)) {
|
|
149
|
+
const call = taskCall(root, task, siteUrl);
|
|
150
|
+
next.push(`${task.title}${call ? ` — ${fmtCallShort(call)}` : ""}`);
|
|
151
|
+
}
|
|
152
|
+
if (!last && !open.slice(0, 4).some((x) => x.id === "measure-1"))
|
|
153
|
+
next.push(`${L("Baseline measurement", "İlk ölçüm")}: ${fmtCallShort(hasOpenRouterKey() ? calls.visibilityDryRun(root) : calls.manualCheck(root))}`);
|
|
154
|
+
else if (due && due.getTime() <= addDays(now, 7).getTime())
|
|
155
|
+
next.push(`${L("Re-measure on", "Yeniden ölçüm")} ${isoDay(due)}: ${L("same questions, models and temperature; 3 samples for the 3 key situation questions", "aynı sorular, modeller ve sıcaklık; 3 kilit durum sorusu için 3 örnek")} (${fmtCallShort(calls.visibilityDryRun(root, { question_ids: keySituationQuestionIds(research).length ? keySituationQuestionIds(research) : ["q1", "q2", "q3"], samples: 3 }))}), ${L("and confirm them in the real apps", "ve gerçek uygulamalarda doğrulayın")} (${fmtCallShort(calls.manualCheck(root))}).`);
|
|
156
|
+
else if (due)
|
|
157
|
+
next.push(`${L("Next re-measurement", "Sonraki ölçüm")}: ${isoDay(due)}.`);
|
|
158
|
+
if (!next.length)
|
|
159
|
+
next.push(L("Verify the live pages (katman_audit_site), submit changed URLs (katman_indexnow), keep publishing 5–10 situation pages a week.", "Canlı sayfaları doğrulayın (katman_audit_site), değişen adresleri gönderin (katman_indexnow), haftada 5–10 durum sayfası yayınlamaya devam edin."));
|
|
160
|
+
out.push(`## ${L("Next 7 days", "Önümüzdeki 7 gün")}\n${next.map((n) => `- ${n}`).join("\n")}`);
|
|
161
|
+
// Where to look
|
|
162
|
+
out.push(`## ${L("Where to look outside Katman", "Katman dışında nereye bakılır")}\n${OFFICIAL_REPORTS.map((r) => `- **${r.name}** (${t(r.since, lang)}): ${t(r.shows, lang)}`).join("\n")}\n- ${t(GSC_AI_NOTE, lang)}\n- ${L("Your analytics: the AI channel (utm_source=chatgpt.com counts even without a referrer), human sessions only; server-side bot counts.", "Analitiğiniz: yapay zekâ kanalı (utm_source=chatgpt.com referrer olmadan da sayılır), yalnızca insan oturumları; sunucu tarafı bot sayıları.")}`);
|
|
163
|
+
out.push(`---\n${FOOTER}`);
|
|
164
|
+
const markdown = out.join("\n\n") + "\n";
|
|
165
|
+
return {
|
|
166
|
+
markdown,
|
|
167
|
+
data: {
|
|
168
|
+
researchPct: comp.percent,
|
|
169
|
+
audit: audit ? { ts: audit.ts, gates: audit.gates } : null,
|
|
170
|
+
visibility: last ? { ts: last.ts, ...last.summary, comparableWithPrevious: cmp?.ok ?? null } : null,
|
|
171
|
+
plan: plan ? { tasks: plan.tasks.length, done: plan.tasks.length - open.length } : null,
|
|
172
|
+
topIssues: top.map((f) => ({ id: f.id, title: f.title, severity: f.severity, prompt: f.prompt ?? null })),
|
|
173
|
+
nextSevenDays: next,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export async function runReport(root, args = {}) {
|
|
178
|
+
const { markdown, data } = await buildReport(root, args);
|
|
179
|
+
const p = await writeText(root, "report.md", markdown);
|
|
180
|
+
await logEvent(root, "katman_report: wrote report.md");
|
|
181
|
+
return { text: `Wrote ${p}\n\n${markdown}`, structured: { path: p, ...data } };
|
|
182
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { logEvent, saveConfig, saveResearch, writeText } from "../../../state.js";
|
|
2
|
+
import { loadConfigSafe, loadResearchSafe, loadRuns, loadScan } from "../lib/state.js";
|
|
3
|
+
import { calls, fmtCall } from "../lib/calls.js";
|
|
4
|
+
import { t } from "../lib/l10n.js";
|
|
5
|
+
import { codeBlock, isFilled } from "../lib/text.js";
|
|
6
|
+
import { RESEARCH_FILL_GUIDE, RESEARCH_MD_TEMPLATE, RESEARCH_STEPS } from "../data/research.js";
|
|
7
|
+
import { emptyResearch, mergeResearch, researchCompleteness, renderResearchMd, templateResearch, validateResearch, } from "./research.js";
|
|
8
|
+
import { generateQuestions } from "./questions.js";
|
|
9
|
+
const UPDATE_EXAMPLE = {
|
|
10
|
+
mode: "update",
|
|
11
|
+
data: {
|
|
12
|
+
site: { url: "https://example.com", languages: ["en"] },
|
|
13
|
+
brand: {
|
|
14
|
+
name: "<one distinctive name>",
|
|
15
|
+
category: "<category, e.g. AI invoice scanner>",
|
|
16
|
+
notX: "<what it gets confused with, or none>",
|
|
17
|
+
oneLiner: { brings: "<what the customer brings>", weDo: "<what we do>", gets: "<what they get>", fear: "<which fear is unnecessary>" },
|
|
18
|
+
pricing: "<exactly as on the pricing page>",
|
|
19
|
+
},
|
|
20
|
+
situations: [{ sentence: "<the customer's own sentence, no brand name>", url: "/<words-from-the-sentence>" }],
|
|
21
|
+
competitors: [{ name: "<name>", url: "https://…", what: "…", price: "…", forWhom: "…", checkedAt: "YYYY-MM-DD" }],
|
|
22
|
+
proof: [{ claim: "…", value: "…", source: "https://…", date: "YYYY-MM-DD" }],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
async function persist(root, r, lang) {
|
|
26
|
+
await saveResearch(root, r);
|
|
27
|
+
const runs = await loadRuns(root);
|
|
28
|
+
await writeText(root, "research.md", renderResearchMd(r, runs, lang));
|
|
29
|
+
}
|
|
30
|
+
function statusBlock(r) {
|
|
31
|
+
const completeness = researchCompleteness(r);
|
|
32
|
+
const warnings = validateResearch(r);
|
|
33
|
+
const lines = [
|
|
34
|
+
`**Research: ${completeness.percent}% complete.**`,
|
|
35
|
+
completeness.items.map((i) => `- ${i.score >= 1 ? "[x]" : "[ ]"} ${i.label} — ${i.detail}`).join("\n"),
|
|
36
|
+
];
|
|
37
|
+
if (warnings.length)
|
|
38
|
+
lines.push(`**Fix these (${warnings.length}):**\n${warnings.map((w) => `- ${w}`).join("\n")}`);
|
|
39
|
+
return { md: lines.join("\n\n"), completeness, warnings };
|
|
40
|
+
}
|
|
41
|
+
function nextAfterResearch(root, r) {
|
|
42
|
+
const c = researchCompleteness(r);
|
|
43
|
+
if (c.blocking.includes("question set") && r.situations.length >= 3 && c.blocking.length === 1) {
|
|
44
|
+
return `**Next:** build the question set: ${fmtCall(calls.generateQuestions(root))}`;
|
|
45
|
+
}
|
|
46
|
+
if (c.blocking.length) {
|
|
47
|
+
return `**Next:** fill ${c.blocking.join(", ")} with ${fmtCall(calls.researchUpdate(root, {}))} — from the codebase where possible, asking the user for customer sentences. Never invent facts: unknown stays [FILL].`;
|
|
48
|
+
}
|
|
49
|
+
return `**Next:** ${fmtCall(calls.start(root))} — it picks the next step (scan, audit, first measurement, plan).`;
|
|
50
|
+
}
|
|
51
|
+
export async function runResearch(root, args) {
|
|
52
|
+
const config = await loadConfigSafe(root);
|
|
53
|
+
const lang = args.lang ?? config.lang ?? "en";
|
|
54
|
+
const existing = await loadResearchSafe(root);
|
|
55
|
+
const mode = args.mode ?? (args.data || args.generateQuestions ? "update" : existing ? "show" : "template");
|
|
56
|
+
if (mode === "template") {
|
|
57
|
+
const header = `# Research template (step 1)\n\n${RESEARCH_STEPS.map((s) => `${s.n}. **${t(s.title, lang)}** — ${t(s.how, lang)}`).join("\n")}`;
|
|
58
|
+
if (existing) {
|
|
59
|
+
const st = statusBlock(existing);
|
|
60
|
+
return {
|
|
61
|
+
text: [
|
|
62
|
+
`.katman/research.json already exists — nothing was overwritten. Use mode "update" to change it.`,
|
|
63
|
+
st.md,
|
|
64
|
+
header,
|
|
65
|
+
`## How to fill it\n${t(RESEARCH_FILL_GUIDE, lang)}`,
|
|
66
|
+
nextAfterResearch(root, existing),
|
|
67
|
+
].join("\n\n"),
|
|
68
|
+
structured: { mode, created: false, completeness: st.completeness.percent, gaps: st.completeness.gaps, warnings: st.warnings },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const scan = await loadScan(root);
|
|
72
|
+
const langs = scan?.i18n.locales.length ? scan.i18n.locales.map((l) => l.toLowerCase()) : scan?.i18n.htmlLang ? [scan.i18n.htmlLang.toLowerCase()] : [];
|
|
73
|
+
const r = templateResearch(config.siteUrl, langs);
|
|
74
|
+
await persist(root, r, lang);
|
|
75
|
+
await logEvent(root, "katman_research: created research.json from the template");
|
|
76
|
+
return {
|
|
77
|
+
text: [
|
|
78
|
+
`Created \`.katman/research.json\` and \`.katman/research.md\` from the book's template (values are [FILL]).`,
|
|
79
|
+
header,
|
|
80
|
+
`## The template\n${codeBlock(t(RESEARCH_MD_TEMPLATE, lang), "md")}`,
|
|
81
|
+
`## How to fill it (do this now)\n${t(RESEARCH_FILL_GUIDE, lang)}`,
|
|
82
|
+
`## Update call shape\n${codeBlock(JSON.stringify(UPDATE_EXAMPLE, null, 2), "json")}`,
|
|
83
|
+
`Once there are ≥3 situations, add \`"generate_questions": true\` to build the 12 + 2 question set.`,
|
|
84
|
+
].join("\n\n"),
|
|
85
|
+
structured: { mode, created: true, path: ".katman/research.json", completeness: researchCompleteness(r).percent },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (mode === "show") {
|
|
89
|
+
if (!existing) {
|
|
90
|
+
return {
|
|
91
|
+
text: `No research yet. Start with ${fmtCall(calls.researchTemplate(root))}.`,
|
|
92
|
+
structured: { mode, exists: false },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
await persist(root, existing, lang);
|
|
96
|
+
const st = statusBlock(existing);
|
|
97
|
+
const runs = await loadRuns(root);
|
|
98
|
+
return {
|
|
99
|
+
text: [st.md, "---", renderResearchMd(existing, runs, lang), nextAfterResearch(root, existing)].join("\n\n"),
|
|
100
|
+
structured: { mode, exists: true, completeness: st.completeness.percent, gaps: st.completeness.gaps, warnings: st.warnings, research: existing },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// update
|
|
104
|
+
const base = existing ?? emptyResearch(config.siteUrl);
|
|
105
|
+
const { research, changes } = mergeResearch(base, args.data ?? {}, !!args.replaceLists);
|
|
106
|
+
const notes = [];
|
|
107
|
+
let generated = false;
|
|
108
|
+
if (args.generateQuestions) {
|
|
109
|
+
if (base.questions.length && !args.replaceLists && !args.data?.questions) {
|
|
110
|
+
notes.push(`A question set already exists (${base.questions.length} questions); it was kept so runs stay comparable. Pass "replace_lists": true with "generate_questions": true to rebuild it (the next run won't be comparable with earlier ones).`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const g = generateQuestions(research);
|
|
114
|
+
research.questions = g.questions;
|
|
115
|
+
notes.push(...g.notes);
|
|
116
|
+
generated = true;
|
|
117
|
+
changes.push(`questions: generated ${g.questions.length} (${g.languages.join(", ")})`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
await persist(root, research, lang);
|
|
121
|
+
if (isFilled(research.site.url) && research.site.url !== config.siteUrl && /^https?:\/\//.test(research.site.url)) {
|
|
122
|
+
await saveConfig(root, { ...config, siteUrl: research.site.url });
|
|
123
|
+
notes.push(`Saved site URL ${research.site.url} to .katman/config.json.`);
|
|
124
|
+
}
|
|
125
|
+
await logEvent(root, `katman_research update: ${changes.join("; ") || "no changes"}`);
|
|
126
|
+
const st = statusBlock(research);
|
|
127
|
+
const parts = [
|
|
128
|
+
`Updated \`.katman/research.json\` and \`.katman/research.md\`${existing ? "" : " (new file)"}.`,
|
|
129
|
+
changes.length ? `**Changes:** ${changes.join(" · ")}` : "**No changes** (data was empty).",
|
|
130
|
+
st.md,
|
|
131
|
+
];
|
|
132
|
+
if (generated)
|
|
133
|
+
parts.push(`**Question set** (${research.questions.length}):\n${research.questions.map((q) => `- \`${q.id}\` [${q.type}${q.lang ? `, ${q.lang}` : ""}] ${q.text}`).join("\n")}`);
|
|
134
|
+
if (notes.length)
|
|
135
|
+
parts.push(notes.map((n) => `- ${n}`).join("\n"));
|
|
136
|
+
parts.push(nextAfterResearch(root, research));
|
|
137
|
+
return {
|
|
138
|
+
text: parts.join("\n\n"),
|
|
139
|
+
structured: {
|
|
140
|
+
mode,
|
|
141
|
+
changes,
|
|
142
|
+
completeness: st.completeness.percent,
|
|
143
|
+
gaps: st.completeness.gaps,
|
|
144
|
+
warnings: st.warnings,
|
|
145
|
+
questions: research.questions,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|