import type { ActiveRun, KdFact, KdQuestion } from "./types.ts"; import { allFactLabels, canonicalFactLabel } from "./prompt-policy.ts"; export function formatQuestionMemory(run: ActiveRun): string { const questions = Array.isArray(run.questions) ? run.questions : []; const facts = currentFactsForRun(run); const rejected = rejectedFactsForRun(run); if (questions.length === 0 && facts.length === 0 && rejected.length === 0) return "无。"; const answered = questions.filter((question) => question.status === "answered"); const open = questions.filter((question) => question.status === "open"); const sections: string[] = []; if (facts.length > 0) { sections.push("## 当前结构化事实", formatCurrentFacts(run)); } if (rejected.length > 0) { sections.push("## 已否定候选事实", rejected.map((fact) => `- ${fact.label}:${fact.value}`).join("\n")); } if (answered.length > 0) { sections.push("## 已回答问题", answered.map(formatAnsweredQuestion).join("\n")); } if (open.length > 0) { sections.push("## 未回答问题", open.map(formatOpenQuestion).join("\n")); } return sections.join("\n\n") || "无。"; } export function formatAnsweredQuestionFacts(run: ActiveRun): string { return formatCurrentFacts(run); } export function formatCurrentFacts(run: ActiveRun): string { const lines = currentFactsForRun(run).map((fact) => `- ${fact.label}:${fact.value}`); return lines.length > 0 ? dedupe(lines).join("\n") : "无。"; } export function currentFactsForRun(run: ActiveRun): KdFact[] { return latestFactsByKey(persistedFactsForRun(run).filter((fact) => fact.status === "current" && fact.value.trim())); } export function rejectedFactsForRun(run: ActiveRun): KdFact[] { return latestFactsByKey(persistedFactsForRun(run).filter((fact) => fact.status === "rejected" && fact.value.trim())); } export function currentFactForLabel(run: ActiveRun, label: string): KdFact | undefined { const canonical = canonicalFactLabel(label) ?? label; const key = factKey(canonical); return currentFactsForRun(run).find((fact) => fact.key === key); } export function hasCurrentFactForLabel(run: ActiveRun, label: string): boolean { return Boolean(currentFactForLabel(run, label)); } export function factFromAnsweredQuestion(question: KdQuestion, now = new Date().toISOString()): KdFact | undefined { if (question.status !== "answered" || !question.factLabel) return undefined; const answer = question.answer?.trim(); if (!answer) return undefined; if (isPlaceholderAnswer(answer)) return undefined; const label = canonicalFactLabel(question.factLabel); if (!label) return undefined; if (question.proposedFactValue) { const value = question.proposedFactValue.trim(); if (!value) return undefined; const negativeCorrection = correctionAfterNegativeAnswer(answer); if (negativeCorrection) { return { key: factKey(label), label, value: negativeCorrection, status: "current", source: "question", sourceQuestionId: question.id, reason: question.reason, createdAt: now, updatedAt: now, }; } if (!isAffirmativeOnlyAnswer(answer) && !isNegativeAnswer(answer)) { return { key: factKey(label), label, value: answerMatchesProposedFact(answer, value) ? value : answer, status: "current", source: "question", sourceQuestionId: question.id, reason: question.reason, createdAt: now, updatedAt: now, }; } return { key: factKey(label), label, value, status: isAffirmativeOnlyAnswer(answer) ? "current" : "rejected", source: "question", sourceQuestionId: question.id, reason: question.reason, createdAt: now, updatedAt: now, }; } if (isConcreteAnswer(answer)) { return { key: factKey(label), label, value: answer, status: "current", source: "question", sourceQuestionId: question.id, reason: question.reason, createdAt: now, updatedAt: now, }; } return undefined; } export function inferFactLabelFromQuestion(question: string): string | undefined { const normalized = question.trim(); return allFactLabels().find((label) => labelAppearsInQuestion(normalized, label)); } export function questionFactLabelMismatchReason(input: { question: string; factLabel?: string }): string | undefined { const question = input.question.trim(); const label = input.factLabel?.trim() ? canonicalFactLabel(input.factLabel) : undefined; if (!label) return undefined; const asksProduct = /产品类型|产品画像|金蝶产品|技术栈|苍穹|星瀚|星空旗舰版|旗舰版|企业版|Cloud\s*BOS|Cosmic|BOS/i.test(question); const asksOtherFact = allFactLabels().some((candidate) => candidate !== label && labelAppearsInQuestion(question, candidate)); const conjunction = /以及|和|并|同时|还有|及|\/|、|,|,/.test(question); if (asksProduct && (asksOtherFact || conjunction)) { return [ `问题文本同时要求确认产品类型和 ${label},但 factLabel 只能记录一个结构化事实。`, "产品类型不是 factLabel;先使用 /kd product <产品>,或登记一个无 factLabel 的产品确认问题。", `确认产品后,再单独登记 ${label}。`, ].join("\n"); } if (asksOtherFact && conjunction) { return `问题文本包含多个结构化事实,但 factLabel 只能是一个:${label}。必须拆成独立问题,并通过 kd_ask_user questions 数组提交同一事实组。`; } return undefined; } export function invalidFactValueReason(value: string): string | undefined { return isInvalidFactValue(value) ? "事实值不是可核验内容,不能使用待确认、未知、按实际环境、TODO/TBD、单独确认/否定或口头确认语解除门禁。" : undefined; } export function invalidConfirmationAnswerReason(value: string): string | undefined { if (isPlaceholderAnswer(value) || isAcknowledgementOnlyAnswer(value)) return "确认题答案不能使用占位内容或口头确认语。"; return undefined; } export function factKey(label: string): string { return label.trim().toLowerCase().replace(/\s+/g, ""); } export function upsertFact(facts: KdFact[] | undefined, next: KdFact): KdFact[] { const existing = Array.isArray(facts) ? facts : []; const now = next.updatedAt || new Date().toISOString(); const normalizedNext = { ...next, key: factKey(next.label), updatedAt: now, createdAt: next.createdAt || now }; const updated = existing.map((fact) => ({ ...fact })); if (normalizedNext.status === "current") { for (const fact of updated) { if (fact.key === normalizedNext.key && (fact.status === "rejected" || (fact.status === "current" && fact.value !== normalizedNext.value))) { fact.status = "superseded"; fact.updatedAt = now; fact.supersededBy = normalizedNext.value; } } } const duplicate = updated.find( (fact) => fact.key === normalizedNext.key && fact.value === normalizedNext.value && fact.status === normalizedNext.status && fact.sourceQuestionId === normalizedNext.sourceQuestionId, ); if (duplicate) { duplicate.updatedAt = now; duplicate.reason = normalizedNext.reason ?? duplicate.reason; return updated; } return [...updated, normalizedNext]; } function isConcreteAnswer(answer: string): boolean { return !isInvalidFactValue(answer); } function isAffirmativeOnlyAnswer(answer: string): boolean { return /^(是|是的|对|正确|确认|yes|true)(\s|。|,|,|;|;|$)/i.test(answer.trim()); } function isNegativeAnswer(answer: string): boolean { return /^(否|不是|不对|错误|no|false)(\s|。|,|,|;|;|$)/i.test(answer.trim()); } function isPlaceholderAnswer(answer: string): boolean { return /^(待确认|未知|不清楚|未提供|无|没有|none|unknown|todo|tbd|n\/a|按实际|按实际环境|以后再说)(\s|。|,|,|;|;|$)/i.test(answer.trim()); } function isAcknowledgementOnlyAnswer(answer: string): boolean { return /^(可以|好的|好|行|嗯|嗯嗯|收到|明白|ok|okay|行的)(\s|。|,|,|;|;|$)/i.test(answer.trim()); } function isInvalidFactValue(answer: string): boolean { return isPlaceholderAnswer(answer) || isAffirmativeOnlyAnswer(answer) || isNegativeAnswer(answer) || isAcknowledgementOnlyAnswer(answer); } function answerMatchesProposedFact(answer: string, proposedFactValue: string): boolean { const normalizedAnswer = normalizeFactText(answer); const normalizedProposed = normalizeFactText(proposedFactValue); return Boolean(normalizedAnswer) && (normalizedProposed.includes(normalizedAnswer) || normalizedAnswer.includes(normalizedProposed)); } function normalizeFactText(value: string): string { return value.trim().replace(/\s+/g, "").replace(/[。;;,,、::|]/g, "").toLowerCase(); } function correctionAfterNegativeAnswer(answer: string): string | undefined { const match = /^(?:否|不是|不对|错误|no|false)\s*[。。,,,::;;]?\s*(.+)$/i.exec(answer.trim()); const correction = match?.[1]?.trim(); if (!correction || isInvalidFactValue(correction)) return undefined; return correction; } function formatAnsweredQuestion(question: KdQuestion): string { return [ `- ${question.id}:${question.question} => ${question.answer?.trim() ?? ""}`, question.contextSummary ? ` - 提问前上下文:${question.contextSummary}` : undefined, question.sourceRefs?.length ? ` - 来源:${question.sourceRefs.join(";")}` : undefined, ].filter(Boolean).join("\n"); } function formatOpenQuestion(question: KdQuestion): string { return [ `- ${question.id}${question.blocking ? " [blocking]" : ""}:${question.question}`, question.reason ? ` - 原因:${question.reason}` : undefined, question.contextSummary ? ` - 提问前上下文:${question.contextSummary}` : undefined, question.sourceRefs?.length ? ` - 来源:${question.sourceRefs.join(";")}` : undefined, question.choices?.length ? ` - 选项:${question.choices.join(" | ")}` : undefined, ].filter(Boolean).join("\n"); } function dedupe(values: string[]): string[] { return [...new Set(values)]; } function latestFactsByKey(facts: KdFact[]): KdFact[] { const byKey = new Map(); for (const fact of facts) { const current = byKey.get(fact.key); if (!current || (fact.updatedAt || fact.createdAt).localeCompare(current.updatedAt || current.createdAt) >= 0) { byKey.set(fact.key, fact); } } return [...byKey.values()]; } function persistedFactsForRun(run: ActiveRun): KdFact[] { return (Array.isArray(run.facts) ? run.facts : []).flatMap(normalizePersistedFact); } function normalizePersistedFact(fact: KdFact): KdFact[] { const label = canonicalFactLabel(fact.label); const value = typeof fact.value === "string" ? fact.value.trim() : ""; if (!label || !value || invalidFactValueReason(value)) return []; return [{ ...fact, key: factKey(label), label, value }]; } function labelAppearsInQuestion(question: string, label: string): boolean { const normalizedQuestion = question.toLowerCase().replace(/\s+/g, ""); const normalizedLabel = label.toLowerCase().replace(/\s+/g, ""); if (normalizedQuestion.includes(normalizedLabel)) return true; if (label === "目标 FormId/单据或表单标识") return /formid|formid|单据标识|表单标识|目标单据|目标表单/i.test(question); if (label === "插件类型和触发事件") return /插件类型|触发事件|生命周期|挂载点|审核事件|保存事件/i.test(question); if (label === "字段/实体/分录标识") return /字段标识|实体标识|分录标识|字段|实体|单据体/i.test(question); if (label === "数据读取写入方式") return /读取方式|写入方式|读写|取数|数据访问|SQL|KSQL/i.test(question); if (label === "SQL/KSQL 表名和数据库字段名") return /表名|数据库字段名|SQL字段|KSQL字段/i.test(question); return false; }