/** * 「AI 写作助手」入参解析:把用户自然语言诉求抽取成上游 Dify 真正读取的字段。 * * 本文件是 `tuanwei-admin/src/views/oa/chatgpt/writing-params.ts` 的**逐行等价移植** * (去掉 TS 类型标注,风格对齐小程序仓库:Tab 缩进、单引号、无分号)。 * 两端共用同一套解析规则,避免行为漂移;改动其一请同步另一侧。 * * 背景(实测记录见 .scratch/ai-assistant/probe/): * 上游应用「AI 内容创作与宣传助手」是按**结构化表单**设计的,它的 user prompt 模板是 * * 本次需要生成的文稿类型为:{doc_type} * 篇幅要求(字数限制):{Number} * 以下是本次活动的核心要素,请严格据此生成稿件: * - 活动主题:{theme} * - 活动类型:{event_type} * - 举办时间:{time} / 举办地点:{location} / 参与人员:{participants} * - 核心亮点与具体要求:{highlights} * * 其中 doc_type 与 Number 是**主控指令**,模型会显式服从、甚至忽略用户原话里的冲突要求。 * 实测思维链原文: * I will ignore the conflicting "300字推文" instruction inside the bullet points * because the main task says "文稿类型为:新闻稿" and "篇幅要求(字数限制):800" * * 而小程序问答助手页过去的「稿件」入口没有表单,只按「键:值」解析,解析不到就一律回落 * doc_type=新闻稿 / Number=800,用户自然语言里的真实诉求(推文、300 字)因此被覆盖, * 表现为「写作模块听不懂要求」(issue 13 / 15)。 * * 本模块做三件事: * 1. 保留既有的「键:值」入参(向后兼容,老用法不受影响); * 2. 从自然语言里抽取文体、字数、主题、时间、地点、人员、活动类型; * 3. 允许用户在界面上显式指定文体 / 字数,显式选择优先于自动识别。 * * 纯函数、零依赖。 */ /** 上游 Dify 提示词里真正定义了 Markdown 结构与文风的 4 种文体 */ export const WRITING_DOC_TYPE_OPTIONS = [ { label: '自动识别', value: 'auto' }, { label: '新闻稿', value: '新闻稿' }, { label: '活动总结', value: '活动总结' }, { label: '微信推文', value: '微信推文' }, { label: '通知公告', value: '通知公告' } ] export const WRITING_WORD_COUNT_OPTIONS = [ { label: '自动识别', value: 'auto' }, { label: '300字', value: '300' }, { label: '500字', value: '500' }, { label: '800字', value: '800' }, { label: '1000字', value: '1000' }, { label: '1500字', value: '1500' } ] const DEFAULT_DOC_TYPE = '新闻稿' const DEFAULT_EVENT_TYPE = '校园内容' /** * 用户没提字数时的兜底:按文体给一个常规篇幅。 * * 之所以不干脆不下发 Number —— 上游提示词对空字数的处理是「按文体常规篇幅撰写, * 并在文末用一行注明(未设定有效字数限制)」,那行尾注会直接出现在用户看到的正文里。 */ const DEFAULT_WORD_COUNT_BY_DOC_TYPE = { 新闻稿: 800, 活动总结: 1000, 微信推文: 600, 通知公告: 500 } const FIELD_ALIASES = { docType: ['稿件类型', '文稿类型', '文体', '类型'], eventType: ['活动类型', '场景'], theme: ['稿件标题', '标题', '主题', '活动主题'], time: ['活动时间', '举办时间', '时间'], location: ['活动地点', '举办地点', '地点'], participants: ['参与人员', '参加人员', '受众', '人员'], highlights: ['亮点内容', '要点', '亮点', '具体要求', '要求', '内容'], number: ['建议篇幅', '字数', '篇幅'] } /** 活动类型关键词(与 gxmu/manuscriptGen 表单页的选项保持一致的措辞) */ const EVENT_TYPE_KEYWORDS = [ '志愿服务', '社会实践', '文体活动', '会议论坛', '品牌项目', '校园活动', '团日活动', '迎新', '讲座', '培训', '比赛', '表彰', '招聘', '宣讲' ] /** 既有的「键:值」解析:按行取 `键:值`,普通自然语言(无冒号)解析为空对象 */ export const parseKeyValueInput = (value) => { const result = {} String(value || '') .split(/\n+/) .map((item) => item.trim()) .filter(Boolean) .forEach((line) => { const match = line.match(/^([^::]+)[::]\s*(.*)$/) if (match) { result[match[1].trim()] = match[2].trim() } }) return result } const pickField = (fields, aliases) => { for (let index = 0; index < aliases.length; index += 1) { const value = fields[aliases[index]] if (value) { return value } } return '' } /** 上游没有内置模板、但用户会说的其它文体;命中就原样交给模型发挥 */ const EXTRA_DOC_TYPE_WORDS = [ '发言稿', '演讲稿', '致辞', '主持词', '短视频脚本', '视频脚本', '脚本', '策划书', '活动方案', '简报', '心得体会' ] /** * 从一段文本里识别文体关键词,认不出来返回空串。 * * 只认「明确的文体词」,不做任何兜底 —— 它会被喂进用户的**整句话**, * 若像 `normalizeDocType` 那样原样返回,整句话就会变成一个非法的文稿类型。 */ export const inferDocType = (text) => { if (/推文|公众号|微信|朋友圈|新媒体/.test(text)) { return '微信推文' } if (/总结|复盘|综述/.test(text)) { return '活动总结' } if (/通知|公告|告示|通告|启事/.test(text)) { return '通知公告' } if (/新闻|通讯|通稿|报道|简讯|宣传稿|消息稿/.test(text)) { return '新闻稿' } for (let index = 0; index < EXTRA_DOC_TYPE_WORDS.length; index += 1) { if (text.includes(EXTRA_DOC_TYPE_WORDS[index])) { return EXTRA_DOC_TYPE_WORDS[index] } } return '' } /** * 把用户给出的文体归一化到上游定义过的 4 种。 * * 认不出来的类型(如「发言稿」「短视频脚本」)**原样返回**,交给模型自行发挥 —— * 强行映射成「新闻稿」会生成一篇完全不是发言稿的文章,比不映射更糟。 * 注意:入参应当是「文体名」(键值或界面选择),不要传用户整句话。 */ export const normalizeDocType = (raw) => { const value = String(raw || '').trim() if (!value) { return '' } return inferDocType(value) || value } /** * 从文本里抽取目标字数。 * * 覆盖「300字」「300字左右」「不少于500字」「800-1000字」等写法; * 区间取上界(用户给区间时通常不希望写少)。50~10000 之外视为无效。 */ export const parseWordCount = (text) => { const valid = (raw) => { if (!raw) { return undefined } const value = Number(raw) return value >= 50 && value <= 10000 ? value : undefined } const source = String(text || '') // 「键:值」入参里的字数常常就是一个裸数字(字数:500) const bare = source.trim().match(/^(\d{2,5})\s*(?:字|字符)?$/) if (bare) { return valid(bare[1]) } const range = source.match(/(\d{2,5})\s*[-~~—-至到]\s*(\d{2,5})\s*(?:字|字符)/) if (range) { return valid(range[2]) } const single = source.match(/(\d{2,5})\s*(?:字|字符)/) if (single) { return valid(single[1]) } const target = source.match( /(?:不少于|不低于|至少|不超过|大约|约|控制在|限制在|需要|要)\s*(\d{2,5})\s*(?:字|字符)?/ ) return valid(target ? target[1] : undefined) } /** * 主题清洗:把抽到的短语里混进来的时间、地点、指令动词和助词去掉。 * 例如「2026年3月5日在学校礼堂举办的学习雷锋志愿服务活动」→「学习雷锋志愿服务活动」。 */ const tidyTheme = (value) => { const cleaned = value .replace(/^\s*(?:\d{4}\s*年)?\s*\d{1,2}\s*月\s*\d{1,2}\s*[日号]\s*/, '') .replace(/^\s*(?:在|于)[^,。;!?\n]{2,20}?(?:举办|举行|开展|召开|进行)的?/, '') .replace(/^\s*(?:开展|举办|举行|组织|进行|召开)/, '') .replace(/(?:上的|中的|里的|的)$/, '') .replace(/[,。;!?、\s]+$/, '') .trim() return cleaned || value.trim() } /** 从「关于X的」「以X为主题」「写一篇X总结」等说法里抽取活动主题 */ export const parseTheme = (query) => { const source = String(query || '') const patterns = [ /关于[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?的/, /以[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?(?:为主题|为题|为标题)/, /(?:写|撰写|生成|创作|拟写|草拟|做)(?:一篇|一份|一段|一个|一则|条)?[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?(?:上的|中的|里的|的|之)?(?:新闻稿|通讯稿|通稿|报道|推文|总结|通知|公告|文案|稿件|宣传稿|发言稿|脚本|简讯)/, /([^,。;!?\n]{2,30}?)(?:活动)?(?:总结|新闻稿|推文|通知|公告|报道|简讯)/ ] for (let index = 0; index < patterns.length; index += 1) { const match = source.match(patterns[index]) const value = match && match[1] ? match[1].trim() : '' if (value) { return tidyTheme(value) } } return '' } /** 兜底主题:清洗掉指令性词汇与字数要求后截断 */ const fallbackTheme = (query) => { const source = String(query || '') const cleaned = source .replace(/[\r\n]+/g, ' ') .replace( /(?:请|帮我|麻烦|辛苦|需要)?\s*(?:写|撰写|生成|创作|拟写|草拟|出一?)(?:一篇|一份|一段|一个|条)?/g, '' ) .replace(/(?:关于|围绕|以)/g, '') .replace(/\d{2,5}\s*(?:[-~~—-至到]\s*\d{2,5})?\s*(?:字|字符)(?:左右|以内|以下|以上)?/g, '') .replace(/[,。;!?、,.;!?]/g, ' ') .replace(/\s+/g, ' ') .trim() return (cleaned || source.trim()).slice(0, 30) } const parseTime = (query) => { const match = String(query || '').match(/(\d{4}\s*年)?\s*\d{1,2}\s*月\s*\d{1,2}\s*[日号]/) return match ? match[0].replace(/\s+/g, '') : '' } const parseLocation = (query) => { const match = String(query || '').match( /(?:在|于)\s*([^,。;!?\n]{2,20}?)\s*(?:举办|举行|开展|召开|进行)/ ) return match && match[1] ? match[1].trim() : '' } const parseParticipants = (query) => { const match = String(query || '').match(/面向\s*([^,。;!?\n]{2,20})/) return match && match[1] ? match[1].trim() : '' } const parseEventType = (query) => { const source = String(query || '') for (let index = 0; index < EVENT_TYPE_KEYWORDS.length; index += 1) { if (source.includes(EVENT_TYPE_KEYWORDS[index])) { return EVENT_TYPE_KEYWORDS[index] } } return '' } /** * 把用户输入解析成上游工作流的入参。 * * @param {string} query 用户在写作框里输入的内容(自然语言或「键:值」) * @param {{ docType?: string, number?: number }} overrides 界面上的显式选择,优先于自动识别 */ export const parseWritingRequest = (query, overrides) => { const options = overrides || {} const fields = parseKeyValueInput(query) const explicitDocType = options.docType && options.docType !== 'auto' ? options.docType : '' const keyedDocType = normalizeDocType(pickField(fields, FIELD_ALIASES.docType)) const inferredDocType = inferDocType(query) || DEFAULT_DOC_TYPE const docType = explicitDocType || keyedDocType || inferredDocType const explicitNumber = typeof options.number === 'number' && options.number > 0 ? options.number : undefined const keyedNumber = parseWordCount(pickField(fields, FIELD_ALIASES.number)) const inferredNumber = parseWordCount(query) // 不用 ?? / ?: 链:0 是合法字数但被判为「无」,这里显式按 undefined 判定 let number = explicitNumber if (number === undefined) { number = keyedNumber } if (number === undefined) { number = inferredNumber } if (number === undefined) { number = DEFAULT_WORD_COUNT_BY_DOC_TYPE[docType] } if (number === undefined) { number = 800 } const theme = pickField(fields, FIELD_ALIASES.theme) || parseTheme(query) || fallbackTheme(query) return { docType, eventType: pickField(fields, FIELD_ALIASES.eventType) || parseEventType(query) || DEFAULT_EVENT_TYPE, theme, time: pickField(fields, FIELD_ALIASES.time) || parseTime(query), location: pickField(fields, FIELD_ALIASES.location) || parseLocation(query), participants: pickField(fields, FIELD_ALIASES.participants) || parseParticipants(query), // 「核心亮点与具体要求」保留用户原话:这里放原话比放抽取结果更贴合上游用途 highlights: pickField(fields, FIELD_ALIASES.highlights) || query, number } }