import type { ActiveRun, KdQuestion, KdQuestionOption } from "./types.ts"; import { currentFactForLabel, factKey, inferFactLabelFromQuestion, questionFactLabelMismatchReason } from "./question-memory.ts"; import { canonicalFactLabel, formatFactLabelCatalog } from "./prompt-policy.ts"; export interface AskUserPromptInput { header: string; question: string; options?: KdQuestionOption[]; multiple?: boolean; custom?: boolean; factLabel?: string; proposedFactValue?: string; reason?: string; contextSummary?: string; sourceRefs?: string[]; blocking?: boolean; } export interface NormalizedAskUserPrompt { header: string; question: string; options: KdQuestionOption[]; multiple: boolean; custom: boolean; factLabel?: string; proposedFactValue?: string; reason?: string; contextSummary?: string; sourceRefs?: string[]; blocking: boolean; } export interface AskUserValidationResult { questions?: NormalizedAskUserPrompt[]; error?: string; code?: string; } export interface ParsedQuestionAnswer { id: string; answer: string; } const MAX_QUESTIONS_PER_BATCH = 6; const MAX_OPTIONS_PER_QUESTION = 6; export function validateAskUserQuestions(run: ActiveRun, input: { questions?: AskUserPromptInput[] }): AskUserValidationResult { const prompts = Array.isArray(input.questions) ? input.questions : []; if (prompts.length === 0) return { error: "kd_ask_user 必须提供 questions 数组。", code: "missing-questions" }; if (prompts.length > MAX_QUESTIONS_PER_BATCH) { return { error: `kd_ask_user 单次最多提问 ${MAX_QUESTIONS_PER_BATCH} 个同一事实组问题;请先缩小到当前阶段阻塞事实组。`, code: "too-many-questions", }; } const openBlocking = (run.questions ?? []).filter((question) => question.status === "open" && question.blocking); if (openBlocking.length > 0) { return { error: `已有未回答阻断问题:${openBlocking.map((question) => `${question.id} ${question.question}`).join(";")}。先记录这些问题的答案,再发起新的 kd_ask_user。`, code: "open-question-exists", }; } const seenQuestions = new Set(); const seenLabels = new Set(); const normalized: NormalizedAskUserPrompt[] = []; for (let index = 0; index < prompts.length; index++) { const prompt = prompts[index]; const header = prompt.header?.trim(); const question = prompt.question?.trim(); if (!header || !question) return { error: `questions[${index}] 必须提供 header 和 question。`, code: "invalid-question" }; if (header.length > 30) return { error: `questions[${index}].header 不能超过 30 个字符。`, code: "header-too-long" }; if (question.length > 260) return { error: `questions[${index}].question 不能超过 260 个字符。`, code: "question-too-long" }; const key = normalizeQuestionKey(question); if (seenQuestions.has(key)) return { error: `重复问题:${question}`, code: "duplicate-question" }; seenQuestions.add(key); const rawLabel = prompt.factLabel?.trim(); const inferredLabel = rawLabel ? undefined : inferFactLabelFromQuestion(question); const label = rawLabel ? canonicalFactLabel(rawLabel) : inferredLabel; if (rawLabel && !label) { return { error: [`未知 factLabel:${rawLabel}。必须使用 KCode 集中定义的实现契约、数据源上下文或第三方对接事实标签。`, "", formatFactLabelCatalog()].join("\n"), code: "unknown-fact-label", }; } const mismatchReason = questionFactLabelMismatchReason({ question, factLabel: label }); if (mismatchReason) return { error: mismatchReason, code: "fact-label-question-mismatch" }; if (label) { const labelKey = factKey(label); if (seenLabels.has(labelKey)) return { error: `批量问题中重复 factLabel:${label}。同一事实只能问一次。`, code: "duplicate-fact-label" }; seenLabels.add(labelKey); const currentFact = currentFactForLabel(run, label); if (currentFact) { return { error: `事实标签 ${label} 已确认为 ${currentFact.value},禁止重复提问。用户明确更正时使用 kd_answer_user action=revise factLabel="${label}" answer="<新值>" reason="<更正原因>"。`, code: "fact-already-known", }; } } const blocking = prompt.blocking !== false; if (blocking && !prompt.contextSummary?.trim()) { return { error: [ `questions[${index}] 登记阻断问题时必须提供 contextSummary。`, "contextSummary 必须记录已读取文件逻辑、当前结论和用户回答后的恢复继续点。", ].join("\n"), code: "missing-context-summary", }; } const options = normalizeOptions(prompt.options); const optionProblem = validateOptions(options, prompt.custom !== false); if (optionProblem) return { error: `questions[${index}] ${optionProblem}`, code: "invalid-options" }; if (prompt.proposedFactValue?.trim() && prompt.multiple === true) { return { error: `questions[${index}] proposedFactValue 确认题不能设置 multiple=true。`, code: "invalid-confirmation-question" }; } normalized.push({ header, question, options, multiple: prompt.multiple === true, custom: prompt.custom !== false, factLabel: label, proposedFactValue: prompt.proposedFactValue?.trim() || undefined, reason: prompt.reason?.trim() || undefined, contextSummary: prompt.contextSummary?.trim() || undefined, sourceRefs: prompt.sourceRefs?.map((source) => source.trim()).filter(Boolean), blocking, }); } return { questions: normalized }; } export function formatAskUserModelOutput(questions: readonly NormalizedAskUserPrompt[], answers: readonly string[]): string { const formatted = questions .map((question, index) => `"${question.question}"="${answers[index]?.trim() || "Unanswered"}"`) .join(", "); return `User has answered your questions: ${formatted}. Continue using these answers and the persisted run.facts; do not ask the same facts again.`; } export function parseStructuredQuestionAnswers(openQuestions: readonly Pick[], text: string): ParsedQuestionAnswer[] { const trimmed = text.trim(); if (!trimmed || trimmed.startsWith("/") || openQuestions.length === 0) return []; const questionIds = openQuestions.map((question) => question.id).filter(Boolean); const found: ParsedQuestionAnswer[] = []; for (const id of questionIds) { const answer = answerForQuestionId(trimmed, id); if (answer) found.push({ id, answer }); } return found; } export function formatStructuredAnswerInstruction(openQuestions: readonly Pick[]): string { const parts = openQuestions.map((question) => `${question.id}: <${question.question}>`); if (parts.length <= 1) return `回答方式:使用 /kd answer ${openQuestions[0]?.id ?? "Q-001"} <答案>,或 kd_answer_user action=answer id=<问题编号> answer=<答案>。`; return [`批量回答方式:每行一个答案,例如:`, ...parts.map((part) => ` ${part}`), `也可以逐项使用 /kd answer <问题编号> <答案>。`].join("\n"); } function normalizeOptions(options: KdQuestionOption[] | undefined): KdQuestionOption[] { const seen = new Set(); return (options ?? []) .map((option) => ({ label: option.label?.trim() ?? "", description: option.description?.trim() || undefined, })) .filter((option) => { if (!option.label) return false; const key = option.label.toLowerCase(); if (seen.has(key)) return false; seen.add(key); return true; }); } function answerForQuestionId(text: string, id: string): string | undefined { const escapedId = escapeRegExp(id); const marker = String.raw`(?:^|[\r\n;;])\s*[-*]?\s*${escapedId}\s*(?:[::=]|答案\s*[::])\s*`; const nextMarker = String.raw`(?=[\r\n;;]\s*[-*]?\s*Q-\d{3}\s*(?:[::=]|答案\s*[::])|$)`; const match = new RegExp(`${marker}([\\s\\S]*?)${nextMarker}`, "i").exec(text); const answer = match?.[1]?.trim(); return answer || undefined; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function validateOptions(options: KdQuestionOption[], custom: boolean): string | undefined { if (options.length === 0 && !custom) return "custom=false 时必须提供 options。"; if (options.length > MAX_OPTIONS_PER_QUESTION) return `最多 ${MAX_OPTIONS_PER_QUESTION} 个选项。`; for (const option of options) { if (option.label.length > 30) return `选项标签过长:${option.label}`; if (option.description && option.description.length > 120) return `选项说明过长:${option.label}`; if (custom && /^(其他|其它|自行输入|自定义|other)$/i.test(option.label)) { return "custom=true 时禁止手工添加“其他/自行输入”选项;工具会自动提供自定义输入。"; } } return undefined; } function normalizeQuestionKey(question: string): string { return question.trim().replace(/\s+/g, "").replace(/[??。;;,,]/g, "").toLowerCase(); }