All files / server/services/course-content tutor-quality-directory-validator.ts

80.95% Statements 51/63
67.64% Branches 23/34
86.66% Functions 13/15
92.15% Lines 47/51

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128                              1x 1x     5x   5x 5x 27x   5x       5x 1x   4x 4x 3x 3x     3x 3x 3x       8x 8x       1x         27x 27x 27x 27x 27x 27x 27x 27x 27x             3x 3x 3x 9x 6x 6x               6x                   3x         3x 8x 8x                     20x 20x 20x 20x 20x     20x 20x             2x    
import { readFile } from "node:fs/promises";
import path from "node:path";
import { parse as parseYaml } from "yaml";
import { CourseContentLoader, type LoadedCourseContentSnapshot } from "./course-content-loader";
import { validateInlineFocus } from "./inline-focus-validator";
import { tutorQualityCasesSchema } from "./tutor-quality-schema";
import { BUILT_IN_TUTOR_STRATEGY, type EffectiveTutorStrategy } from "../tutor/strategy/effective-tutor-strategy";
import {
  validateExampleTopicActivations,
  validateTutorContentQuality,
  type CatalogExample,
  type ResolvedTutorQualityCase,
  type TutorContentQualityIssue,
} from "./tutor-quality-validator";
 
const LOCAL_REVISION = "0".repeat(40);
const LOCAL_REPOSITORY = "local/course-content";
 
export async function validateTutorCourseContentDirectory(directory: string): Promise<TutorContentQualityIssue[]> {
  const root = path.resolve(directory);
  let loaded: Awaited<ReturnType<CourseContentLoader["load"]>>;
  try {
    const loader = new CourseContentLoader({
      fetchText: async (url, maxBytes) => readBoundedLocalFile(root, url, maxBytes),
    });
    loaded = await loader.load(LOCAL_REPOSITORY, LOCAL_REVISION);
  } catch {
    return [directoryIssue("invalid-course-content-bundle", "Course Content bundle could not be loaded")];
  }
  if (loaded.tutor.status !== "valid") {
    return [directoryIssue("invalid-course-content-bundle", "Course Content Tutor bundle is absent or invalid")];
  }
  try {
    const qualitySource = await readFile(path.join(root, "tutor/quality-cases.yaml"), "utf8");
    const parsed = tutorQualityCasesSchema.safeParse(parseYaml(qualitySource));
    Iif (!parsed.success) {
      return [directoryIssue("invalid-quality-cases", "Tutor quality case manifest is invalid")];
    }
    const resolution = resolveCases(parsed.data.cases, loaded);
    Iif (resolution.issues.length > 0) return resolution.issues;
    return [
      ...validateTutorContentQuality(loaded.tutor.topics, resolution.cases),
      ...validateExampleTopicActivations(loaded.tutor.topics, catalogExamples(loaded)),
      ...validateInlineFocus(loaded.examples.flatMap((example) => {
        const main = example.files.find(({ name }) => name === example.main);
        return main ? [{ id: example.id, code: main.content, annotation: example.tutorAnnotation }] : [];
      })),
    ];
  } catch {
    return [directoryIssue("invalid-quality-cases", "Tutor quality case manifest could not be loaded")];
  }
}
 
async function readBoundedLocalFile(root: string, url: URL, maxBytes: number): Promise<string> {
  const revisionMarker = `/${LOCAL_REVISION}/`;
  const markerIndex = url.pathname.indexOf(revisionMarker);
  Iif (markerIndex < 0) throw new Error("Invalid local Course Content URL");
  const relativePath = decodeURIComponent(url.pathname.slice(markerIndex + revisionMarker.length));
  const filePath = path.resolve(root, relativePath);
  Iif (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) throw new Error("Path escapes Course Content root");
  const source = await readFile(filePath, "utf8");
  Iif (Buffer.byteLength(source, "utf8") > maxBytes) throw new Error("Course Content file exceeds size limit");
  return source;
}
 
function resolveCases(
  cases: readonly { id: string; example: string; expectedTopics: readonly string[]; forbiddenTopics: readonly string[] }[],
  loaded: LoadedCourseContentSnapshot,
): { cases: ResolvedTutorQualityCase[]; issues: TutorContentQualityIssue[] } {
  const resolved: ResolvedTutorQualityCase[] = [];
  const issues: TutorContentQualityIssue[] = [];
  for (const qualityCase of cases) {
    const example = loaded.examples.find(({ id }) => id === qualityCase.example);
    const main = example?.files.find(({ name }) => name === example.main);
    Iif (!example || !main) {
      issues.push({
        code: "quality-case-example-not-found",
        message: `Quality case ${qualityCase.id} references missing Example ${qualityCase.example}`,
        caseId: qualityCase.id,
      });
      continue;
    }
    resolved.push({
      id: qualityCase.id,
      exampleId: qualityCase.example,
      code: main.content,
      expectedTopics: qualityCase.expectedTopics,
      forbiddenTopics: qualityCase.forbiddenTopics,
      learnStrategy: resolveCaseStrategy(loaded, example.tutorAnnotation?.strategy, "LEARN"),
      deepenStrategy: resolveCaseStrategy(loaded, example.tutorAnnotation?.strategy, "DEEPEN"),
    });
  }
  return { cases: resolved, issues };
}
 
// Every Example's main sketch, as the Tutor receives it, with its effective LEARN strategy.
function catalogExamples(loaded: LoadedCourseContentSnapshot): CatalogExample[] {
  return loaded.examples.flatMap((example) => {
    const main = example.files.find(({ name }) => name === example.main);
    return main
      ? [{ id: example.id, code: main.content, learnStrategy: resolveCaseStrategy(loaded, example.tutorAnnotation?.strategy, "LEARN") }]
      : [];
  });
}
 
function resolveCaseStrategy(
  loaded: LoadedCourseContentSnapshot,
  exampleStrategyId: string | undefined,
  phase: "LEARN" | "DEEPEN",
): EffectiveTutorStrategy {
  Iif (loaded.tutor.status !== "valid") return BUILT_IN_TUTOR_STRATEGY;
  const perExample = loaded.tutor.strategies.find(({ id }) => id === exampleStrategyId);
  Iif (perExample) return perExample;
  const manifest = loaded.tutor.manifest;
  const phaseStrategyId = phase === "DEEPEN" && manifest.schemaVersion === 2
    ? manifest.phaseStrategies?.deepen
    : undefined;
  const strategyId = phaseStrategyId ?? manifest.defaultStrategy;
  return loaded.tutor.strategies.find(({ id }) => id === strategyId) ?? BUILT_IN_TUTOR_STRATEGY;
}
 
function directoryIssue(
  code: "invalid-course-content-bundle" | "invalid-quality-cases",
  message: string,
): TutorContentQualityIssue {
  return { code, message };
}