// KnowledgeDetector(veridrop detectors/knowledge.py):critical 题一题一个请求;coverage 题并成一个请求,按行号拆答案。 // 命中 expected 关键词、且不含 anti 关键词算对。 import { KNOWLEDGE_QUESTIONS } from "../data/knowledge_questions.js"; import type { DetectorResult } from "../models.js"; import { cpSlice, joinText, splitlines } from "../py.js"; import { ActiveDetector, errText, type DetectClient } from "./base.js"; const MAX_TOKENS_CRITICAL = 60; const MAX_TOKENS_COVERAGE = 400; export interface Question { id: string; type: string; prompt: string; expected_keywords?: readonly string[]; expected_keyword_match?: string; anti_keywords?: readonly string[]; applicable_models?: readonly string[]; expected_by_model?: Readonly>; } function applies(q: Question, model: string): boolean { const allow = q.applicable_models; if (!allow || allow.length === 0) return true; return allow.some((m) => model.startsWith(m) || m.startsWith(model)); } export function buildCombinedPrompt(coverage: readonly Question[]): string { const lines = [ `Please answer these ${coverage.length} questions briefly. ` + "Reply with one short answer per line, prefixed by question number " + "(e.g. '1. '). If you don't know an answer, reply 'unknown' " + "for that line — do not guess.", ]; coverage.forEach((q, i) => lines.push(`${i + 1}. ${q.prompt}`)); return lines.join("\n\n"); } // "1. answer"、"2) answer"、"3: answer"、" 4 - answer" 都认。 const LINE_RE = /^\s*(\d+)\s*[.):\-]\s*(.+?)\s*$/u; export function parseNumberedAnswers(text: string, n: number): Map { const out = new Map(); for (const line of splitlines(text)) { const m = LINE_RE.exec(line); if (!m) continue; const idx = Number.parseInt(m[1]!, 10); if (idx >= 1 && idx <= n) out.set(idx, m[2]!); } return out; } export function grade(answer: string, q: Question, model: string): boolean { if (!answer) return false; const a = answer.toLowerCase(); const anti = (q.anti_keywords ?? []).map((k) => k.toLowerCase()); if (anti.some((k) => a.includes(k))) return false; let expected: string[] | null = null; for (const [key, kws] of Object.entries(q.expected_by_model ?? {})) { if (model.startsWith(key) || key.startsWith(model)) { expected = kws.map((k) => k.toLowerCase()); break; } } if (expected === null) expected = (q.expected_keywords ?? []).map((k) => k.toLowerCase()); if (expected.length === 0) return true; if ((q.expected_keyword_match ?? "all") === "any") return expected.some((k) => a.includes(k)); return expected.every((k) => a.includes(k)); } export class KnowledgeDetector extends ActiveDetector { readonly name = "knowledge"; readonly displayName = "知识准确度"; readonly weight = 10; async run(client: DetectClient, model: string): Promise { const questions = KNOWLEDGE_QUESTIONS.questions as readonly Question[]; const applicable = questions.filter((q) => applies(q, model)); if (applicable.length === 0) return this.result("skip", 0, { reason: "no applicable questions for model" }); const critical = applicable.filter((q) => q.type === "critical"); const coverage = applicable.filter((q) => q.type === "coverage"); const perQuestion: Record[] = []; for (const q of critical) { let resp: Record; try { ({ resp } = await client.messagesCreate({ model, max_tokens: MAX_TOKENS_CRITICAL, temperature: 0, messages: [{ role: "user", content: q.prompt }], })); } catch (e) { perQuestion.push({ id: q.id, passed: false, error: errText(e), answer: "" }); continue; } const answer = joinText(resp["content"]); perQuestion.push({ id: q.id, passed: grade(answer, q, model), answer: cpSlice(answer, 200) }); } if (coverage.length > 0) { let answerText: string | null = null; try { const { resp } = await client.messagesCreate({ model, max_tokens: MAX_TOKENS_COVERAGE, temperature: 0, messages: [{ role: "user", content: buildCombinedPrompt(coverage) }], }); answerText = joinText(resp["content"]); } catch (e) { for (const q of coverage) perQuestion.push({ id: q.id, passed: false, error: errText(e), answer: "" }); } if (answerText !== null) { const parsed = parseNumberedAnswers(answerText, coverage.length); coverage.forEach((q, i) => { const a = parsed.get(i + 1) ?? ""; perQuestion.push({ id: q.id, passed: grade(a, q, model), answer: cpSlice(a, 200) }); }); } } const passes = perQuestion.filter((r) => r["passed"]).length; const total = perQuestion.length; const score = total ? (passes / total) * 100 : 0; return this.result(score >= 70 ? "pass" : "fail", score, { passes, total, per_question: perQuestion, request_count: critical.length + (coverage.length > 0 ? 1 : 0), }); } }