feat(ms-ai-architect): Sesjon 2 - ai-foundry->foundry registry-remap
Microsoft rebranet Azure AI Foundry -> Microsoft Foundry; doc-URLer flyttet /azure/ai-foundry/ -> /azure/foundry/. Ny lib/registry-migrate.mjs (rene funksjoner remapKey + migrateRegistry, 10 enhetstester) + one-shot migrate-ai-foundry.mjs (backup via lib/backup.mjs + atomisk saveRegistry). Remap: 164 ai-foundry-URLer -> foundry (2 probede DEAD-sider far eksplisitte mal: evaluation-evaluators -> built-in-evaluators; agent-service -> agents/overview). Remappede entries resettes til status=unpolled/lastmod=null sa neste poll re-evaluerer. 1 kollisjon merget (ref_files unionert). Schema-migrering: authority_source+course lagt til alle 1343 entries. Idempotent. Registry er gitignored (regenererbar) - kun kode committes. kb-update 66 tester gronne, validate 239. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REiKFhP4w6xGXXqWKpPCJJ
This commit is contained in:
parent
eb2a002c2c
commit
d3af3f73c8
3 changed files with 238 additions and 0 deletions
80
scripts/kb-update/lib/registry-migrate.mjs
Normal file
80
scripts/kb-update/lib/registry-migrate.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
// registry-migrate.mjs — One-shot ai-foundry -> foundry URL remap + reserved
|
||||||
|
// schema migration. Pure functions (no I/O) so they are unit-testable; the
|
||||||
|
// migrate-ai-foundry.mjs script wires backup + atomic write around them.
|
||||||
|
//
|
||||||
|
// Microsoft rebranded "Azure AI Foundry" -> "Microsoft Foundry"; doc URLs moved
|
||||||
|
// /azure/ai-foundry/ -> /azure/foundry/. Two pages probed in Sesjon 1 are DEAD
|
||||||
|
// on a naive prefix swap and need explicit targets.
|
||||||
|
|
||||||
|
const AI_FOUNDRY_PREFIX = 'https://learn.microsoft.com/azure/ai-foundry/';
|
||||||
|
const FOUNDRY_PREFIX = 'https://learn.microsoft.com/azure/foundry/';
|
||||||
|
|
||||||
|
// Probed DEAD pages (Sesjon 1): naive prefix swap 404s, these are the live targets.
|
||||||
|
const DEAD_REMAPS = {
|
||||||
|
'https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-evaluators':
|
||||||
|
'https://learn.microsoft.com/azure/foundry/concepts/built-in-evaluators',
|
||||||
|
'https://learn.microsoft.com/azure/ai-foundry/agent-service':
|
||||||
|
'https://learn.microsoft.com/azure/foundry/agents/overview',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remap a single registry URL key. DEAD overrides win; otherwise prefix-swap
|
||||||
|
* ai-foundry -> foundry; non-ai-foundry URLs are returned unchanged.
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function remapKey(url) {
|
||||||
|
if (DEAD_REMAPS[url]) return DEAD_REMAPS[url];
|
||||||
|
if (url.startsWith(AI_FOUNDRY_PREFIX)) {
|
||||||
|
return FOUNDRY_PREFIX + url.slice(AI_FOUNDRY_PREFIX.length);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a registry: remap ai-foundry keys (resetting their tracking state so
|
||||||
|
* the next poll re-evaluates them), merge any target collisions, and ensure the
|
||||||
|
* reserved schema fields (authority_source, course) exist on every entry.
|
||||||
|
* Pure — does not mutate the input.
|
||||||
|
* @param {object} registry
|
||||||
|
* @returns {{registry: object, stats: {remapped: number, deadRemapped: number, collisions: number}}}
|
||||||
|
*/
|
||||||
|
export function migrateRegistry(registry) {
|
||||||
|
const out = { ...registry, urls: {} };
|
||||||
|
const stats = { remapped: 0, deadRemapped: 0, collisions: 0 };
|
||||||
|
|
||||||
|
for (const [url, entry] of Object.entries(registry.urls)) {
|
||||||
|
const target = remapKey(url);
|
||||||
|
const isRemap = target !== url;
|
||||||
|
|
||||||
|
// Normalize every entry to the full schema (reserved fields included).
|
||||||
|
const base = {
|
||||||
|
sitemap_lastmod: entry.sitemap_lastmod ?? null,
|
||||||
|
reference_files: entry.reference_files ?? [],
|
||||||
|
status: entry.status ?? 'unpolled',
|
||||||
|
authority_source: entry.authority_source ?? null,
|
||||||
|
course: entry.course ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let next = base;
|
||||||
|
if (isRemap) {
|
||||||
|
stats.remapped++;
|
||||||
|
if (DEAD_REMAPS[url]) stats.deadRemapped++;
|
||||||
|
// The URL changed — old poll state is invalid; let the next poll decide.
|
||||||
|
next = { ...base, sitemap_lastmod: null, status: 'unpolled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out.urls[target]) {
|
||||||
|
// Two source keys collapsed onto one target — union the reference files.
|
||||||
|
stats.collisions++;
|
||||||
|
const merged = [
|
||||||
|
...new Set([...out.urls[target].reference_files, ...next.reference_files]),
|
||||||
|
].sort();
|
||||||
|
out.urls[target] = { ...out.urls[target], ...next, reference_files: merged };
|
||||||
|
} else {
|
||||||
|
out.urls[target] = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { registry: out, stats };
|
||||||
|
}
|
||||||
35
scripts/kb-update/migrate-ai-foundry.mjs
Normal file
35
scripts/kb-update/migrate-ai-foundry.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// migrate-ai-foundry.mjs — One-shot migration: back up data/, remap registry
|
||||||
|
// keys ai-foundry -> foundry (+ 2 probed DEAD targets), migrate reserved schema
|
||||||
|
// fields, write atomically. Idempotent: re-running finds nothing to remap.
|
||||||
|
// Usage: node migrate-ai-foundry.mjs
|
||||||
|
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { loadRegistry, saveRegistry } from './lib/registry-io.mjs';
|
||||||
|
import { backupDir } from './lib/backup.mjs';
|
||||||
|
import { migrateRegistry } from './lib/registry-migrate.mjs';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DATA_DIR = join(__dirname, 'data');
|
||||||
|
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
||||||
|
const BACKUP_ROOT = join(PLUGIN_ROOT, '.kb-backup');
|
||||||
|
|
||||||
|
const registry = loadRegistry(DATA_DIR);
|
||||||
|
const before = Object.keys(registry.urls).length;
|
||||||
|
|
||||||
|
const { backupPath } = backupDir(DATA_DIR, BACKUP_ROOT);
|
||||||
|
console.log(`Backed up data/ -> ${backupPath}`);
|
||||||
|
|
||||||
|
const { registry: migrated, stats } = migrateRegistry(registry);
|
||||||
|
const after = Object.keys(migrated.urls).length;
|
||||||
|
|
||||||
|
saveRegistry(migrated, DATA_DIR);
|
||||||
|
|
||||||
|
const remainingAiFoundry = Object.keys(migrated.urls)
|
||||||
|
.filter((u) => u.includes('/azure/ai-foundry/')).length;
|
||||||
|
|
||||||
|
console.log('=== ai-foundry -> foundry remap ===');
|
||||||
|
console.log(`URLs: ${before} -> ${after} (collisions merged: ${stats.collisions})`);
|
||||||
|
console.log(`Remapped: ${stats.remapped} (DEAD explicit targets: ${stats.deadRemapped})`);
|
||||||
|
console.log(`Remaining ai-foundry keys: ${remainingAiFoundry} (expect 0)`);
|
||||||
123
tests/kb-update/test-registry-migrate.test.mjs
Normal file
123
tests/kb-update/test-registry-migrate.test.mjs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// tests/kb-update/test-registry-migrate.test.mjs
|
||||||
|
// Unit tests for scripts/kb-update/lib/registry-migrate.mjs — the one-shot
|
||||||
|
// ai-foundry -> foundry URL remap + reserved-schema migration.
|
||||||
|
// Microsoft rebranded "Azure AI Foundry" -> "Microsoft Foundry"; URLs moved
|
||||||
|
// /azure/ai-foundry/ -> /azure/foundry/. Two probed pages are DEAD on a naive
|
||||||
|
// prefix swap and need explicit targets (verified in Sesjon 1).
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { remapKey, migrateRegistry } from '../../scripts/kb-update/lib/registry-migrate.mjs';
|
||||||
|
|
||||||
|
const AF = 'https://learn.microsoft.com/azure/ai-foundry/';
|
||||||
|
const FD = 'https://learn.microsoft.com/azure/foundry/';
|
||||||
|
|
||||||
|
test('remapKey — prefix swap ai-foundry -> foundry', () => {
|
||||||
|
assert.equal(remapKey(AF + 'openai/how-to/manage-costs'), FD + 'openai/how-to/manage-costs');
|
||||||
|
assert.equal(remapKey(AF + 'responsible-ai/openai/content-filter'), FD + 'responsible-ai/openai/content-filter');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remapKey — two probed DEAD pages get explicit targets', () => {
|
||||||
|
assert.equal(
|
||||||
|
remapKey('https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-evaluators'),
|
||||||
|
'https://learn.microsoft.com/azure/foundry/concepts/built-in-evaluators'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
remapKey('https://learn.microsoft.com/azure/ai-foundry/agent-service'),
|
||||||
|
'https://learn.microsoft.com/azure/foundry/agents/overview'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remapKey — non-ai-foundry URLs untouched', () => {
|
||||||
|
const other = 'https://learn.microsoft.com/azure/machine-learning/overview';
|
||||||
|
assert.equal(remapKey(other), other);
|
||||||
|
// already-foundry URL is left as-is
|
||||||
|
assert.equal(remapKey(FD + 'what-is-azure-ai-foundry'), FD + 'what-is-azure-ai-foundry');
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixtureRegistry() {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
created_at: '2026-01-01',
|
||||||
|
last_poll: '2026-06-19T11:00:00Z',
|
||||||
|
sitemap_state: { 'azure_en-us_7': { lastmod: '2026-06-01', checked_at: '2026-06-19' } },
|
||||||
|
urls: {
|
||||||
|
[AF + 'openai/how-to/manage-costs']: {
|
||||||
|
sitemap_lastmod: null, reference_files: ['skills/a.md'], status: 'not_in_sitemap',
|
||||||
|
},
|
||||||
|
[AF + 'agent-service']: {
|
||||||
|
sitemap_lastmod: null, reference_files: ['skills/b.md'], status: 'not_in_sitemap',
|
||||||
|
},
|
||||||
|
// already-tracked non-foundry URL must survive untouched
|
||||||
|
'https://learn.microsoft.com/azure/search/rag': {
|
||||||
|
sitemap_lastmod: '2026-05-10', reference_files: ['skills/c.md'], status: 'tracked',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('migrateRegistry — remaps ai-foundry keys and resets their tracking', () => {
|
||||||
|
const { registry } = migrateRegistry(fixtureRegistry());
|
||||||
|
// keys remapped
|
||||||
|
assert.ok(registry.urls[FD + 'openai/how-to/manage-costs'], 'prefix-swap key missing');
|
||||||
|
assert.ok(registry.urls[FD + 'agents/overview'], 'DEAD-remap key missing');
|
||||||
|
assert.ok(!registry.urls[AF + 'openai/how-to/manage-costs'], 'old ai-foundry key still present');
|
||||||
|
// remapped entries reset so the next poll re-evaluates
|
||||||
|
const e = registry.urls[FD + 'openai/how-to/manage-costs'];
|
||||||
|
assert.equal(e.status, 'unpolled');
|
||||||
|
assert.equal(e.sitemap_lastmod, null);
|
||||||
|
assert.deepEqual(e.reference_files, ['skills/a.md']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrateRegistry — non-ai-foundry entry preserved verbatim (plus reserved fields)', () => {
|
||||||
|
const { registry } = migrateRegistry(fixtureRegistry());
|
||||||
|
const e = registry.urls['https://learn.microsoft.com/azure/search/rag'];
|
||||||
|
assert.equal(e.status, 'tracked');
|
||||||
|
assert.equal(e.sitemap_lastmod, '2026-05-10');
|
||||||
|
assert.deepEqual(e.reference_files, ['skills/c.md']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrateRegistry — reserved schema fields present on every entry', () => {
|
||||||
|
const { registry } = migrateRegistry(fixtureRegistry());
|
||||||
|
for (const [url, e] of Object.entries(registry.urls)) {
|
||||||
|
assert.ok('authority_source' in e, `authority_source missing on ${url}`);
|
||||||
|
assert.ok('course' in e, `course missing on ${url}`);
|
||||||
|
assert.equal(e.authority_source, null);
|
||||||
|
assert.equal(e.course, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrateRegistry — top-level registry metadata preserved', () => {
|
||||||
|
const { registry } = migrateRegistry(fixtureRegistry());
|
||||||
|
assert.equal(registry.version, 1);
|
||||||
|
assert.equal(registry.created_at, '2026-01-01');
|
||||||
|
assert.ok(registry.sitemap_state['azure_en-us_7']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrateRegistry — collision on same target merges reference_files (union, sorted)', () => {
|
||||||
|
const reg = {
|
||||||
|
version: 1, created_at: null, last_poll: null, sitemap_state: {},
|
||||||
|
urls: {
|
||||||
|
// both map to FD + agents/overview (one via DEAD remap, one via prefix swap)
|
||||||
|
[AF + 'agent-service']: { sitemap_lastmod: null, reference_files: ['skills/z.md', 'skills/a.md'], status: 'not_in_sitemap' },
|
||||||
|
[AF + 'agents/overview']: { sitemap_lastmod: null, reference_files: ['skills/a.md', 'skills/m.md'], status: 'not_in_sitemap' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { registry, stats } = migrateRegistry(reg);
|
||||||
|
const e = registry.urls[FD + 'agents/overview'];
|
||||||
|
assert.deepEqual(e.reference_files, ['skills/a.md', 'skills/m.md', 'skills/z.md']);
|
||||||
|
assert.equal(Object.keys(registry.urls).length, 1);
|
||||||
|
assert.ok(stats.collisions >= 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrateRegistry — does not mutate the input registry', () => {
|
||||||
|
const input = fixtureRegistry();
|
||||||
|
migrateRegistry(input);
|
||||||
|
assert.ok(input.urls[AF + 'openai/how-to/manage-costs'], 'input was mutated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrateRegistry — stats count remaps and DEAD remaps', () => {
|
||||||
|
const { stats } = migrateRegistry(fixtureRegistry());
|
||||||
|
assert.equal(stats.remapped, 2);
|
||||||
|
assert.equal(stats.deadRemapped, 1); // agent-service is the only DEAD one in the fixture
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue