refactor(linkedin-studio): M0-7 — scorer reads external instance (mirrors MOVE/COPY_FILES), plugin fallback (HIGH #3)
This commit is contained in:
parent
abc19b5641
commit
3ddf9c5ffa
2 changed files with 99 additions and 44 deletions
|
|
@ -5,61 +5,80 @@ import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { calculateScore } from '../personalization-score.mjs';
|
import { calculateScore } from '../personalization-score.mjs';
|
||||||
|
|
||||||
// Step 5 (remediation): the shipped voice profile is a PII-free placeholder
|
// M0-7: the scorer now reads INSTANCE data from the EXTERNAL data root
|
||||||
// carrying the sentinel below. Detection must key on the sentinel — NOT the old
|
// (getDataRoot, stubbed via LINKEDIN_STUDIO_DATA), mirroring the migration's
|
||||||
// `[Your Name]` heuristic — so the placeholder earns 0 of the 25 voice points,
|
// dests, and falls back to the plugin tree only for back-compat. The placeholder
|
||||||
// and a populated profile (sentinel removed) earns them.
|
// sentinel still keys voice detection (Step-5 remediation): the placeholder earns
|
||||||
|
// 0 of the 25 voice points; a populated profile (sentinel removed) earns them.
|
||||||
const SENTINEL = '<!-- VOICE_PLACEHOLDER -->';
|
const SENTINEL = '<!-- VOICE_PLACEHOLDER -->';
|
||||||
|
|
||||||
// A >50-line body that contains NEITHER the sentinel NOR `[Your Name]`, so the
|
// A >50-line body with NEITHER the sentinel NOR `[Your Name]`, so the sentinel is
|
||||||
// only thing distinguishing placeholder from real profile is the sentinel.
|
// the only thing distinguishing placeholder from real profile.
|
||||||
const LONG_BODY = Array.from({ length: 60 }, (_, i) => `- voice characteristic line ${i + 1}`).join('\n');
|
const LONG_BODY = Array.from({ length: 60 }, (_, i) => `- voice characteristic line ${i + 1}`).join('\n');
|
||||||
|
|
||||||
function makePluginRoot() {
|
function makeRoots() {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'lis-personalization-'));
|
const dataDir = mkdtempSync(join(tmpdir(), 'lis-score-data-'));
|
||||||
mkdirSync(join(root, 'assets', 'voice-samples'), { recursive: true });
|
const pluginRoot = mkdtempSync(join(tmpdir(), 'lis-score-plugin-'));
|
||||||
return root;
|
mkdirSync(join(dataDir, 'voice-samples'), { recursive: true });
|
||||||
|
return { dataDir, pluginRoot };
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeVoice(root, content) {
|
function writeExternalVoice(dataDir, content) {
|
||||||
writeFileSync(join(root, 'assets', 'voice-samples', 'authentic-voice-samples.md'), content, 'utf-8');
|
writeFileSync(join(dataDir, 'voice-samples', 'authentic-voice-samples.md'), content, 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('calculateScore — voice samples (sentinel-based placeholder detection)', () => {
|
describe('calculateScore — reads external instance data (M0-7)', () => {
|
||||||
let root;
|
let dataDir, pluginRoot;
|
||||||
|
const saved = { LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA };
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
if (root && existsSync(root)) {
|
for (const d of [dataDir, pluginRoot]) {
|
||||||
rmSync(root, { recursive: true, force: true });
|
if (d && existsSync(d)) rmSync(d, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
root = undefined;
|
dataDir = pluginRoot = undefined;
|
||||||
|
if (saved.LINKEDIN_STUDIO_DATA === undefined) delete process.env.LINKEDIN_STUDIO_DATA;
|
||||||
|
else process.env.LINKEDIN_STUDIO_DATA = saved.LINKEDIN_STUDIO_DATA;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('placeholder with the sentinel scores 0 voice points (even >50 lines, no [Your Name])', () => {
|
test('external placeholder with the sentinel scores 0 voice points (even >50 lines, no [Your Name])', () => {
|
||||||
root = makePluginRoot();
|
({ dataDir, pluginRoot } = makeRoots());
|
||||||
writeVoice(root, `# Voice Profile (placeholder)\n${SENTINEL}\n\n${LONG_BODY}\n`);
|
process.env.LINKEDIN_STUDIO_DATA = dataDir;
|
||||||
|
writeExternalVoice(dataDir, `# Voice Profile (placeholder)\n${SENTINEL}\n\n${LONG_BODY}\n`);
|
||||||
|
|
||||||
const { score, personalized } = calculateScore(root);
|
const { score, personalized } = calculateScore(pluginRoot);
|
||||||
|
|
||||||
assert.equal(score, 0, 'placeholder must not earn the 25 voice points');
|
assert.equal(score, 0, 'placeholder must not earn the 25 voice points');
|
||||||
assert.equal(personalized, 0);
|
assert.equal(personalized, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a real profile (no sentinel, >50 lines) earns the 25 voice points', () => {
|
test('a real EXTERNAL profile (no sentinel, >50 lines) earns the 25 voice points', () => {
|
||||||
root = makePluginRoot();
|
({ dataDir, pluginRoot } = makeRoots());
|
||||||
writeVoice(root, `# My Voice Profile\n\n${LONG_BODY}\n`);
|
process.env.LINKEDIN_STUDIO_DATA = dataDir;
|
||||||
|
writeExternalVoice(dataDir, `# My Voice Profile\n\n${LONG_BODY}\n`);
|
||||||
|
|
||||||
const { score, personalized } = calculateScore(root);
|
const { score, personalized } = calculateScore(pluginRoot);
|
||||||
|
|
||||||
assert.equal(score, 25, 'a populated profile must earn the 25 voice points');
|
assert.equal(score, 25, 'a populated external profile must earn the 25 voice points');
|
||||||
assert.equal(personalized, 1);
|
assert.equal(personalized, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a short placeholder (<50 lines) also scores 0 voice points', () => {
|
test('profile absent at external profile/user-profile.md → 0, no crash (graceful degradation)', () => {
|
||||||
root = makePluginRoot();
|
({ dataDir, pluginRoot } = makeRoots());
|
||||||
writeVoice(root, `# Voice Profile (placeholder)\n${SENTINEL}\n`);
|
process.env.LINKEDIN_STUDIO_DATA = dataDir;
|
||||||
|
// nothing seeded — no voice, no profile, no scaffolds anywhere
|
||||||
|
|
||||||
const { score } = calculateScore(root);
|
const { score, personalized } = calculateScore(pluginRoot);
|
||||||
|
|
||||||
|
assert.equal(score, 0);
|
||||||
|
assert.equal(personalized, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a short external placeholder (<50 lines) also scores 0 voice points', () => {
|
||||||
|
({ dataDir, pluginRoot } = makeRoots());
|
||||||
|
process.env.LINKEDIN_STUDIO_DATA = dataDir;
|
||||||
|
writeExternalVoice(dataDir, `# Voice Profile (placeholder)\n${SENTINEL}\n`);
|
||||||
|
|
||||||
|
const { score } = calculateScore(pluginRoot);
|
||||||
|
|
||||||
assert.equal(score, 0);
|
assert.equal(score, 0);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,46 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Personalization score calculator for linkedin-studio plugin
|
// Personalization score calculator for linkedin-studio plugin
|
||||||
// Checks 8 asset categories for real user data vs placeholder templates
|
// Checks 8 asset categories for real user data vs placeholder templates.
|
||||||
|
// Instance data is read from the EXTERNAL data root (getDataRoot, M0); the plugin
|
||||||
|
// tree is a back-compat fallback during the migration window (external wins).
|
||||||
// Standalone: outputs SCORE:N|M/8 assets personalized
|
// Standalone: outputs SCORE:N|M/8 assets personalized
|
||||||
// Import: export function calculateScore(pluginRoot) => { score, personalized, categories }
|
// Import: export function calculateScore(pluginRoot) => { score, personalized, categories }
|
||||||
|
|
||||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||||
import { join, basename, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { getDataRoot } from './data-root.mjs';
|
||||||
|
import { MOVE_FILES, COPY_FILES } from './migrate-data.mjs';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Mirror the migration's external dests (single source of truth) so the scorer
|
||||||
|
// reads exactly where migrate-data.mjs writes — never a drifted path
|
||||||
|
// (migrate-data.mjs:13-16, STATE.md M0). Keyed by in-plugin source path.
|
||||||
|
const DEST = Object.fromEntries([...MOVE_FILES, ...COPY_FILES].map(([src, dest]) => [src, dest]));
|
||||||
|
|
||||||
export function calculateScore(pluginRoot) {
|
export function calculateScore(pluginRoot) {
|
||||||
|
const dataRoot = getDataRoot();
|
||||||
let score = 0;
|
let score = 0;
|
||||||
let personalized = 0;
|
let personalized = 0;
|
||||||
const categories = 8;
|
const categories = 8;
|
||||||
|
|
||||||
|
// Instance lives in the external root; fall back to the plugin tree for
|
||||||
|
// back-compat during the migration window. External wins when present.
|
||||||
|
const pick = (dataRel, pluginRel) => {
|
||||||
|
const dataPath = join(dataRoot, dataRel);
|
||||||
|
return existsSync(dataPath) ? dataPath : join(pluginRoot, pluginRel);
|
||||||
|
};
|
||||||
|
|
||||||
// --- 1. Voice samples (25 points) ---
|
// --- 1. Voice samples (25 points) ---
|
||||||
// The shipped file is a PII-free placeholder carrying the VOICE_PLACEHOLDER
|
// The shipped file is a PII-free placeholder carrying the VOICE_PLACEHOLDER
|
||||||
// sentinel. Key detection on the sentinel (deterministic) rather than the old
|
// sentinel. Key detection on the sentinel (deterministic): a populated profile
|
||||||
// `[Your Name]` heuristic: a populated profile removes the sentinel and earns
|
// removes it and earns the points; the placeholder (or any file still carrying
|
||||||
// the points; the placeholder (or any file still carrying it) scores 0.
|
// it) scores 0.
|
||||||
const voiceFile = join(pluginRoot, 'assets', 'voice-samples', 'authentic-voice-samples.md');
|
const voiceFile = pick(
|
||||||
|
DEST['assets/voice-samples/authentic-voice-samples.local.md'],
|
||||||
|
join('assets', 'voice-samples', 'authentic-voice-samples.md'),
|
||||||
|
);
|
||||||
if (existsSync(voiceFile)) {
|
if (existsSync(voiceFile)) {
|
||||||
const content = readFileSync(voiceFile, 'utf-8');
|
const content = readFileSync(voiceFile, 'utf-8');
|
||||||
const lineCount = content.split('\n').length;
|
const lineCount = content.split('\n').length;
|
||||||
|
|
@ -31,7 +51,10 @@ export function calculateScore(pluginRoot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. User profile (20 points) ---
|
// --- 2. User profile (20 points) ---
|
||||||
const profileFile = join(pluginRoot, 'config', 'user-profile.local.md');
|
const profileFile = pick(
|
||||||
|
DEST['config/user-profile.local.md'],
|
||||||
|
join('config', 'user-profile.local.md'),
|
||||||
|
);
|
||||||
if (existsSync(profileFile)) {
|
if (existsSync(profileFile)) {
|
||||||
const content = readFileSync(profileFile, 'utf-8');
|
const content = readFileSync(profileFile, 'utf-8');
|
||||||
const placeholderCount = (content.match(/\[Your /g) || []).length;
|
const placeholderCount = (content.match(/\[Your /g) || []).length;
|
||||||
|
|
@ -41,8 +64,9 @@ export function calculateScore(pluginRoot) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 3. Case studies (15 points) ---
|
// --- 3. Case studies (15 points) --- dir; not in the move tables (migrated
|
||||||
const caseDir = join(pluginRoot, 'assets', 'case-studies');
|
// per-file) → brief-7.1 convention: external dest = in-plugin path minus assets/.
|
||||||
|
const caseDir = pick('case-studies', join('assets', 'case-studies'));
|
||||||
if (existsSync(caseDir)) {
|
if (existsSync(caseDir)) {
|
||||||
let realCases = 0;
|
let realCases = 0;
|
||||||
try {
|
try {
|
||||||
|
|
@ -57,7 +81,7 @@ export function calculateScore(pluginRoot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 4. Frameworks (10 points) ---
|
// --- 4. Frameworks (10 points) ---
|
||||||
const fwDir = join(pluginRoot, 'assets', 'frameworks');
|
const fwDir = pick('frameworks', join('assets', 'frameworks'));
|
||||||
if (existsSync(fwDir)) {
|
if (existsSync(fwDir)) {
|
||||||
let realFw = 0;
|
let realFw = 0;
|
||||||
try {
|
try {
|
||||||
|
|
@ -72,7 +96,10 @@ export function calculateScore(pluginRoot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 5. High-engagement posts (10 points) ---
|
// --- 5. High-engagement posts (10 points) ---
|
||||||
const postsFile = join(pluginRoot, 'assets', 'examples', 'high-engagement-posts.md');
|
const postsFile = pick(
|
||||||
|
DEST['assets/examples/high-engagement-posts.md'],
|
||||||
|
join('assets', 'examples', 'high-engagement-posts.md'),
|
||||||
|
);
|
||||||
if (existsSync(postsFile)) {
|
if (existsSync(postsFile)) {
|
||||||
const content = readFileSync(postsFile, 'utf-8');
|
const content = readFileSync(postsFile, 'utf-8');
|
||||||
const postCount = (content.match(/^## Post [0-9]/gm) || []).length;
|
const postCount = (content.match(/^## Post [0-9]/gm) || []).length;
|
||||||
|
|
@ -81,7 +108,10 @@ export function calculateScore(pluginRoot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 6. Demographics (8 points) ---
|
// --- 6. Demographics (8 points) ---
|
||||||
const demoFile = join(pluginRoot, 'assets', 'audience-insights', 'demographics.md');
|
const demoFile = pick(
|
||||||
|
DEST['assets/audience-insights/demographics.md'],
|
||||||
|
join('assets', 'audience-insights', 'demographics.md'),
|
||||||
|
);
|
||||||
if (existsSync(demoFile)) {
|
if (existsSync(demoFile)) {
|
||||||
const content = readFileSync(demoFile, 'utf-8');
|
const content = readFileSync(demoFile, 'utf-8');
|
||||||
const placeholderCount = (content.match(/\[Industry name\]|\[Function\]|\[Country\]|\[X\]%/g) || []).length;
|
const placeholderCount = (content.match(/\[Industry name\]|\[Function\]|\[Country\]|\[X\]%/g) || []).length;
|
||||||
|
|
@ -92,7 +122,10 @@ export function calculateScore(pluginRoot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 7. Engagement patterns (7 points) ---
|
// --- 7. Engagement patterns (7 points) ---
|
||||||
const patternsFile = join(pluginRoot, 'assets', 'audience-insights', 'engagement-patterns.md');
|
const patternsFile = pick(
|
||||||
|
DEST['assets/audience-insights/engagement-patterns.md'],
|
||||||
|
join('assets', 'audience-insights', 'engagement-patterns.md'),
|
||||||
|
);
|
||||||
if (existsSync(patternsFile)) {
|
if (existsSync(patternsFile)) {
|
||||||
const content = readFileSync(patternsFile, 'utf-8');
|
const content = readFileSync(patternsFile, 'utf-8');
|
||||||
const placeholderCount = (content.match(/\[Day\]|\[Time\]|\[Topic\]|\[Format\]|\[Hook type\]/g) || []).length;
|
const placeholderCount = (content.match(/\[Day\]|\[Time\]|\[Topic\]|\[Format\]|\[Hook type\]/g) || []).length;
|
||||||
|
|
@ -103,7 +136,10 @@ export function calculateScore(pluginRoot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 8. Post templates (5 points) ---
|
// --- 8. Post templates (5 points) ---
|
||||||
const templatesFile = join(pluginRoot, 'assets', 'templates', 'my-post-templates.md');
|
const templatesFile = pick(
|
||||||
|
DEST['assets/templates/my-post-templates.md'],
|
||||||
|
join('assets', 'templates', 'my-post-templates.md'),
|
||||||
|
);
|
||||||
if (existsSync(templatesFile)) {
|
if (existsSync(templatesFile)) {
|
||||||
const content = readFileSync(templatesFile, 'utf-8');
|
const content = readFileSync(templatesFile, 'utf-8');
|
||||||
const unfilled = (content.match(/\[Name - e\.g\./g) || []).length;
|
const unfilled = (content.match(/\[Name - e\.g\./g) || []).length;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue