feat: 范式检查和复检功能支持智能粒度过滤和批量处理

## 核心功能
- 范式检查和复检支持自动分批处理(15句/批)
- 智能粒度过滤:根据选中句子数量自动调整检查粒度
  * 1-5句:仅句子级检查(术语、语气、表达)
  * 6-20句:句子级+段落级检查(逻辑结构、递进关系)
  * 21+句:全文级检查(章节完整性、篇幅占比等)

## 新增文件
- src/stores/paradigm.js:自定义范式管理 store
  * inferScope:智能推断检查项粒度
  * ensureGuidelinesHasScope:为旧范式补充 scope 字段
- src/utils/requirementParser.js:需求文档解析工具
  * buildRequirementParserPrompt:生成解析 prompt
  * validateParadigm:范式验证(兼容字符串/对象格式)
- src/components/RequirementParserPanel.vue:需求解析面板
  * 支持粘贴文本或选择文件
  * AI 自动生成范式配置(specializedPrompt + expertGuidelines)
  * 可视化编辑检查项(title/description/scope)

## 主要改进
- ArticleRewritePanel.vue:
  * 范式检查支持粒度过滤和批量处理
  * 复检功能支持粒度过滤和批量处理
  * 集成 paradigm store 自动补充 scope 字段
  * 添加批次进度显示
- RequirementParserPanel.vue:
  * 修复对象格式 expertGuidelines 显示问题
  * 支持编辑 title/description/scope 三个字段
  * 添加粒度下拉选择器(句子级/段落级/全文级)

## 技术细节
- 向后兼容:支持字符串和对象两种 expertGuidelines 格式
- 自动转换:旧范式字符串格式自动转换为对象格式并推断 scope
- 错误处理:JSON 解析失败时提供降级方案
- 控制台日志:[复检] 前缀标识复检相关日志

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-11 13:51:40 +08:00
parent f9f0785106
commit 29bb7e2e87
4 changed files with 1206 additions and 105 deletions

View File

@@ -141,10 +141,15 @@
</div>
</div>
<!-- 检查中状态 -->
<div v-if="isChecking" class="flex items-center gap-2 text-blue-300">
<div v-if="isChecking" class="flex flex-col gap-2 text-blue-300">
<div class="flex items-center gap-2">
<span class="animate-spin"></span>
<span class="text-sm">AI 正在检查中...</span>
</div>
<div v-if="checkProgress" class="text-xs text-slate-400 pl-6">
{{ checkProgress }}
</div>
</div>
<!-- 检查结果列表 -->
<div
v-for="(result, idx) in checkResults"
@@ -213,6 +218,9 @@
<span class="animate-spin"></span>
<span class="text-sm">AI 正在重写中...</span>
</div>
<div v-if="rewriteProgress" class="text-xs text-slate-400 pl-6">
{{ rewriteProgress }}
</div>
<div v-if="rewriteStreamContent" class="bg-slate-800/50 rounded-lg p-3 border border-slate-700">
<p class="text-sm text-slate-200 whitespace-pre-wrap">{{ rewriteStreamContent }}</p>
</div>
@@ -295,6 +303,7 @@
:visible="showParadigmSelector"
@close="showParadigmSelector = false"
@select="handleParadigmSelect"
@create-custom="handleCreateCustomParadigm"
/>
<!-- 文稿选择弹窗 -->
@@ -303,18 +312,28 @@
@close="showDocSelector = false"
@select="handleDocSelect"
/>
<!-- 需求解析面板 -->
<RequirementParserPanel
v-if="showRequirementParser"
@close="showRequirementParser = false"
@paradigm-created="handleParadigmCreated"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useAppStore } from '../stores/app'
import { useParadigmStore } from '../stores/paradigm'
import { splitIntoSentencesWithPosition, computeDiff } from '../utils/textDiff'
import { updateDocument, saveDocumentVersion } from '../db/index.js'
import ParadigmSelectorModal from './ParadigmSelectorModal.vue'
import DocumentSelectorModal from './DocumentSelectorModal.vue'
import RequirementParserPanel from './RequirementParserPanel.vue'
const appStore = useAppStore()
const paradigmStore = useParadigmStore()
// ========== 数据状态 ==========
// 原文相关
@@ -328,16 +347,19 @@ const selectedSentenceIdxs = ref([])
// 范式相关
const selectedParadigm = ref(null)
const showParadigmSelector = ref(false)
const showRequirementParser = ref(false) // 需求解析面板
// 文稿选择
const showDocSelector = ref(false)
// 检查相关
const isChecking = ref(false)
const checkProgress = ref('') // 批次进度信息
const checkResults = ref([]) // {sentenceIdx, status, message}
// 重写相关
const isRewriting = ref(false)
const rewriteProgress = ref('') // 批次进度信息
const rewriteStreamContent = ref('')
const rewrittenSentences = ref([]) // {text, start, end}
const selectedRewriteIdxs = ref([])
@@ -403,8 +425,11 @@ const toggleRewriteSentence = (idx) => {
// 处理范式选择
const handleParadigmSelect = (paradigm) => {
selectedParadigm.value = paradigm
// 确保 expertGuidelines 有 scope 字段
const paradigmWithScope = paradigmStore.ensureGuidelinesHasScope(paradigm)
selectedParadigm.value = paradigmWithScope
showParadigmSelector.value = false
console.log('选择范式后的 expertGuidelines:', paradigmWithScope.expertGuidelines)
}
// 处理文稿选择
@@ -420,70 +445,172 @@ const handleDocSelect = (doc) => {
showDocSelector.value = false
}
// 处理创建自定义范式(打开需求解析面板)
const handleCreateCustomParadigm = () => {
showParadigmSelector.value = false
showRequirementParser.value = true
}
// 处理范式创建完成
const handleParadigmCreated = (paradigm) => {
selectedParadigm.value = paradigm
showRequirementParser.value = false
// 可选:显示成功提示
console.log('自定义范式已创建:', paradigm)
}
// 粒度判断函数:根据句子数量确定允许的检查粒度
const getCheckScope = (sentenceCount) => {
if (sentenceCount <= 5) return ['sentence'] // 1-5 句:仅句子级
if (sentenceCount <= 20) return ['sentence', 'paragraph'] // 6-20 句:句子级+段落级
return ['sentence', 'paragraph', 'document'] // 21+ 句:所有级别
}
// 执行范式检查
const runParadigmCheck = async () => {
if (!canCheck.value) return
isChecking.value = true
checkProgress.value = ''
checkResults.value = []
try {
// 获取选中的句子文本
const selectedSentences = selectedSentenceIdxs.value
.sort((a, b) => a - b)
.map(idx => sentences.value[idx])
// 获取选中的句子索引(排序)
const sortedIdxs = [...selectedSentenceIdxs.value].sort((a, b) => a - b)
const sentenceCount = sortedIdxs.length
// 构建检查 Prompt
// 自动分批处理每批最多15个句子
const BATCH_SIZE = 15
const totalBatches = Math.ceil(sortedIdxs.length / BATCH_SIZE)
// 确定当前检查粒度
const allowedScopes = getCheckScope(sentenceCount)
console.log(`总共选中 ${sentenceCount} 个句子,将分 ${totalBatches} 批处理,允许粒度:`, allowedScopes)
// 构建公共的检查参数
const paradigmPrompt = selectedParadigm.value.specializedPrompt || ''
const guidelinesText = (selectedParadigm.value.expertGuidelines || [])
.map((g, i) => `${i + 1}. 【${g.title}${g.description}`)
// expertGuidelines 可能是字符串数组或对象数组,需要兼容处理并过滤
const guidelines = selectedParadigm.value.expertGuidelines || []
const filteredGuidelines = guidelines.filter(g => {
// 兼容字符串和对象格式
if (typeof g === 'string') {
// 字符串格式:默认为 sentence 级别
return allowedScopes.includes('sentence')
} else {
// 对象格式:检查 scope 字段
const scope = g.scope || 'sentence' // 默认句子级
return allowedScopes.includes(scope)
}
})
console.log(`过滤前 expertGuidelines: ${guidelines.length} 条,过滤后: ${filteredGuidelines.length}`)
const guidelinesText = filteredGuidelines
.map((g, i) => {
if (typeof g === 'string') {
return `${i + 1}. ${g}`
} else {
return `${i + 1}. 【${g.title || '检查项'}${g.description || g}`
}
})
.join('\n')
// 逐批处理
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
const batchStart = batchIndex * BATCH_SIZE
const batchEnd = Math.min(batchStart + BATCH_SIZE, sortedIdxs.length)
const batchIdxs = sortedIdxs.slice(batchStart, batchEnd)
const batchSentences = batchIdxs.map(idx => sentences.value[idx])
checkProgress.value = `正在检查第 ${batchIndex + 1}/${totalBatches} 批(句子 ${batchStart + 1}-${batchEnd}...`
console.log(`检查批次 ${batchIndex + 1}/${totalBatches}(句子 ${batchStart + 1}-${batchEnd}`)
const checkPrompt = `你是一个写作质量检查专家。请基于以下范式规则和专家指令,逐条检查给定的句子。
【检查上下文】
本次检查的是 ${batchSentences.length} 个句子的片段(共选中 ${sentenceCount} 句)。
${sentenceCount <= 5 ? '⚠️ 这是句子级检查,请忽略文档结构要求,专注于句子质量(如术语、语气、表达)。' : ''}${sentenceCount <= 20 ? '⚠️ 这是段落级检查,请检查局部逻辑和结构,忽略全文结构要求(如篇幅占比、章节完整性)。' : ''}
# 范式规则
${paradigmPrompt}
# 专家检查项
# 专家检查项(已根据检查粒度自动过滤)
${guidelinesText || '(无特定检查项)'}
# 待检查的句子
${selectedSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
${batchSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
# 输出要求
请严格按照以下 JSON 格式输出检查结果(仅输出 JSON不要其他内容
请严格按照以下JSON格式输出(只输出JSON不要markdown代码块标记
{
"results": [
{"sentenceIdx": 0, "status": "pass|warning|fail", "message": "评价说明"}
{"sentenceIdx": 0, "status": "pass", "message": "符合要求"},
{"sentenceIdx": 1, "status": "warning", "message": "建议改进"},
{"sentenceIdx": 2, "status": "fail", "message": "不符合要求"}
]
}`
}
注意:
1. status只能是 pass/warning/fail 三个值之一
2. message要简洁明了不超过50字
3. sentenceIdx从0开始对应上面句子的序号
4. 必须检查所有${batchSentences.length}个句子
5. 确保JSON格式完全正确不要有多余的逗号或换行`
let fullResponse = ''
await appStore.callApi(checkPrompt, (chunk) => {
fullResponse += chunk
}, { temperature: 0.3 })
// 解析结果
console.log(`批次 ${batchIndex + 1} - AI完整响应长度:`, fullResponse.length)
// 解析本批次结果
try {
const jsonMatch = fullResponse.match(/\{[\s\S]*\}/)
if (jsonMatch) {
// 1. 尝试提取JSON代码块
let jsonText = fullResponse
const jsonBlockMatch = fullResponse.match(/```json\s*([\s\S]*?)\s*```/)
if (jsonBlockMatch) {
jsonText = jsonBlockMatch[1]
}
// 2. 提取JSON对象
const jsonMatch = jsonText.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
throw new Error('未找到有效的JSON对象')
}
const parsed = JSON.parse(jsonMatch[0])
// 映射回原始句子索引
checkResults.value = (parsed.results || []).map((r, i) => ({
sentenceIdx: selectedSentenceIdxs.value[r.sentenceIdx] ?? selectedSentenceIdxs.value[i],
if (!parsed.results || !Array.isArray(parsed.results)) {
throw new Error('JSON格式错误缺少results数组')
}
// 映射回原始句子索引并添加到总结果
const batchResults = parsed.results.map((r, i) => ({
sentenceIdx: batchIdxs[r.sentenceIdx] ?? batchIdxs[i],
status: r.status || 'warning',
message: r.message || '检查完成'
}))
}
checkResults.value.push(...batchResults)
console.log(`批次 ${batchIndex + 1} 解析成功,本批 ${batchResults.length} 个结果`)
} catch (e) {
console.warn('检查结果解析失败:', e)
// 降级:为每个句子生成通用结果
checkResults.value = selectedSentenceIdxs.value.map((idx) => ({
console.error(`批次 ${batchIndex + 1} 解析失败:`, e)
console.error('完整响应内容:', fullResponse)
// 降级:为本批次的每个句子生成通用结果
const fallbackResults = batchIdxs.map((idx) => ({
sentenceIdx: idx,
status: 'warning',
message: '解析异常,建议人工复核'
}))
checkResults.value.push(...fallbackResults)
}
}
console.log(`所有批次检查完成,共 ${checkResults.value.length} 个结果`)
checkProgress.value = `检查完成!共检查 ${checkResults.value.length} 个句子`
} catch (error) {
console.error('范式检查失败:', error)
alert('检查失败: ' + error.message)
@@ -497,33 +624,53 @@ const runAIRewrite = async () => {
if (!canRewrite.value) return
isRewriting.value = true
rewriteProgress.value = ''
rewriteStreamContent.value = ''
rewrittenSentences.value = []
selectedRewriteIdxs.value = []
try {
// 获取选中的句子和检查结果
const selectedSentences = selectedSentenceIdxs.value
.sort((a, b) => a - b)
.map(idx => sentences.value[idx])
// 获取选中的句子索引(排序)
const sortedIdxs = [...selectedSentenceIdxs.value].sort((a, b) => a - b)
const checkResultsText = checkResults.value
.map(r => `句子${r.sentenceIdx + 1}: ${r.status} - ${r.message}`)
.join('\n')
// 自动分批处理每批最多10个句子重写比检查需要更多tokens所以批次更小
const BATCH_SIZE = 10
const totalBatches = Math.ceil(sortedIdxs.length / BATCH_SIZE)
// 构建重写 Prompt
console.log(`总共需要重写 ${sortedIdxs.length} 个句子,将分 ${totalBatches} 批处理`)
// 构建公共的重写参数
const paradigmPrompt = selectedParadigm.value.specializedPrompt || ''
// 逐批处理
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
const batchStart = batchIndex * BATCH_SIZE
const batchEnd = Math.min(batchStart + BATCH_SIZE, sortedIdxs.length)
const batchIdxs = sortedIdxs.slice(batchStart, batchEnd)
const batchSentences = batchIdxs.map(idx => sentences.value[idx])
rewriteProgress.value = `正在重写第 ${batchIndex + 1}/${totalBatches} 批(句子 ${batchStart + 1}-${batchEnd}...`
console.log(`重写批次 ${batchIndex + 1}/${totalBatches}(句子 ${batchStart + 1}-${batchEnd}`)
// 获取本批次对应的检查结果
const batchCheckResults = checkResults.value
.filter(r => batchIdxs.includes(r.sentenceIdx))
.map(r => {
const localIdx = batchIdxs.indexOf(r.sentenceIdx)
return `句子${localIdx + 1}: ${r.status} - ${r.message}`
})
.join('\n')
const rewritePrompt = `你是一个专业的写作润色专家。请基于以下范式规则和检查结果,重写给定的句子。
# 范式规则
${paradigmPrompt}
# 检查结果
${checkResultsText}
${batchCheckResults || '(无检查结果)'}
# 原句
${selectedSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
${batchSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
# 输出要求
请直接按照顺序输出重写后的句子,每句一行,格式如下(仅输出重写内容,不要其他说明):
@@ -544,6 +691,8 @@ ${selectedSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
}
}, { temperature: 0.7 })
console.log(`批次 ${batchIndex + 1} - AI完整响应长度:`, fullResponse.length)
// 解析重写结果:提取 <draft> 标签内的内容
let contentToParse = fullResponse
const draftMatch = fullResponse.match(/<draft>([\s\S]*?)<\/draft>/)
@@ -553,12 +702,18 @@ ${selectedSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
// 按行拆分并处理
const lines = contentToParse.split('\n').filter(line => line.trim())
rewrittenSentences.value = lines.map((line, i) => {
const batchRewritten = lines.map((line, i) => {
// 移除行首的序号
const text = line.replace(/^\d+\.\s*/, '').trim()
return { text, start: 0, end: text.length }
}).filter(s => s.text) // 过滤空行
rewrittenSentences.value.push(...batchRewritten)
console.log(`批次 ${batchIndex + 1} 重写完成,本批 ${batchRewritten.length} 个句子`)
}
console.log(`所有批次重写完成,共 ${rewrittenSentences.value.length} 个句子`)
rewriteProgress.value = `重写完成!共重写 ${rewrittenSentences.value.length} 个句子`
} catch (error) {
console.error('AI 重写失败:', error)
alert('重写失败: ' + error.message)
@@ -688,27 +843,70 @@ const recheckRewrittenContent = async () => {
checkResults.value = []
try {
// 获取选中的重写句子
const selectedSentences = selectedRewriteIdxs.value
.sort((a, b) => a - b)
.map(idx => rewrittenSentences.value[idx])
// 获取选中的重写句子索引(排序)
const sortedIdxs = [...selectedRewriteIdxs.value].sort((a, b) => a - b)
const sentenceCount = sortedIdxs.length
// 构建检查 Prompt
// 自动分批处理每批最多15个句子
const BATCH_SIZE = 15
const totalBatches = Math.ceil(sortedIdxs.length / BATCH_SIZE)
// 确定当前检查粒度
const allowedScopes = getCheckScope(sentenceCount)
console.log(`[复检] 总共选中 ${sentenceCount} 个句子,将分 ${totalBatches} 批处理,允许粒度:`, allowedScopes)
// 构建公共的检查参数
const paradigmPrompt = selectedParadigm.value.specializedPrompt || ''
const guidelinesText = (selectedParadigm.value.expertGuidelines || [])
.map((g, i) => `${i + 1}. 【${g.title}${g.description}`)
// expertGuidelines 可能是字符串数组或对象数组,需要兼容处理并过滤
const guidelines = selectedParadigm.value.expertGuidelines || []
const filteredGuidelines = guidelines.filter(g => {
// 兼容字符串和对象格式
if (typeof g === 'string') {
// 字符串格式:默认为 sentence 级别
return allowedScopes.includes('sentence')
} else {
// 对象格式:检查 scope 字段
const scope = g.scope || 'sentence' // 默认句子级
return allowedScopes.includes(scope)
}
})
console.log(`[复检] 过滤前 expertGuidelines: ${guidelines.length} 条,过滤后: ${filteredGuidelines.length}`)
const guidelinesText = filteredGuidelines
.map((g, i) => {
if (typeof g === 'string') {
return `${i + 1}. ${g}`
} else {
return `${i + 1}. 【${g.title || '检查项'}${g.description || g}`
}
})
.join('\n')
// 逐批处理
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
const batchStart = batchIndex * BATCH_SIZE
const batchEnd = Math.min(batchStart + BATCH_SIZE, sortedIdxs.length)
const batchIdxs = sortedIdxs.slice(batchStart, batchEnd)
const batchSentences = batchIdxs.map(idx => rewrittenSentences.value[idx])
console.log(`[复检] 批次 ${batchIndex + 1}/${totalBatches}: 处理句子 ${batchStart + 1}-${batchEnd}`)
const checkPrompt = `你是一个写作质量检查专家。请基于以下范式规则和专家指令,逐条检查给定的句子。
【检查上下文】
本次检查的是 ${batchSentences.length} 个句子的片段(共选中 ${sentenceCount} 句,这是重写后的内容复检)。
${sentenceCount <= 5 ? '⚠️ 这是句子级检查,请忽略文档结构要求,专注于句子质量(如术语、语气、表达)。' : ''}${sentenceCount <= 20 ? '⚠️ 这是段落级检查,请检查局部逻辑和结构,忽略全文结构要求(如篇幅占比、章节完整性)。' : ''}
# 范式规则
${paradigmPrompt}
# 专家检查项
# 专家检查项(已根据检查粒度自动过滤)
${guidelinesText || '(无特定检查项)'}
# 待检查的句子(重写后的内容)
${selectedSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
${batchSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
# 输出要求
请严格按照以下 JSON 格式输出检查结果(仅输出 JSON不要其他内容
@@ -729,22 +927,28 @@ ${selectedSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0])
// 映射回重写句子索引,并标记为「复检」
checkResults.value = (parsed.results || []).map((r, i) => ({
sentenceIdx: selectedRewriteIdxs.value[r.sentenceIdx] ?? selectedRewriteIdxs.value[i],
const batchResults = (parsed.results || []).map((r, i) => ({
sentenceIdx: batchIdxs[r.sentenceIdx] ?? batchIdxs[i],
status: r.status || 'warning',
message: `[复检] ${r.message || '检查完成'}`,
isRecheck: true // 标记为复检结果
}))
checkResults.value.push(...batchResults)
}
} catch (e) {
console.warn('检查结果解析失败:', e)
checkResults.value = selectedRewriteIdxs.value.map((idx) => ({
console.warn(`[复检] 批次 ${batchIndex + 1} 结果解析失败:`, e)
// 为当前批次的句子添加警告结果
const fallbackResults = batchIdxs.map((idx) => ({
sentenceIdx: idx,
status: 'warning',
message: '[复检] 解析异常,建议人工复核',
isRecheck: true
}))
checkResults.value.push(...fallbackResults)
}
}
console.log(`[复检] 所有批次处理完成,共 ${checkResults.value.length} 条结果`)
} catch (error) {
console.error('复检失败:', error)
alert('复检失败: ' + error.message)

View File

@@ -0,0 +1,431 @@
<template>
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="w-full max-w-4xl bg-slate-800 rounded-lg shadow-2xl border border-slate-700 max-h-[90vh] flex flex-col">
<!-- 头部 -->
<header class="p-4 border-b border-slate-700 flex items-center justify-between shrink-0">
<h2 class="font-bold text-lg text-white flex items-center gap-2">
<span class="text-2xl">🎯</span> 需求文档解析
</h2>
<button
@click="$emit('close')"
class="text-slate-400 hover:text-white transition"
>
<span class="text-xl">×</span>
</button>
</header>
<!-- 主体内容 -->
<div class="flex-1 overflow-y-auto p-6">
<!-- 步骤1输入需求文档 -->
<section v-if="step === 1" class="space-y-4">
<div class="flex items-center gap-2 text-sm text-slate-400">
<span class="bg-indigo-600 text-white w-6 h-6 rounded-full flex items-center justify-center text-xs">1</span>
<span>输入需求文档</span>
</div>
<div class="space-y-3">
<!-- 文档来源选择 -->
<div class="flex items-center gap-3">
<span class="text-sm text-slate-400">来源</span>
<div class="flex bg-slate-900 rounded p-1 border border-slate-700">
<button
@click="sourceType = 'text'"
:class="['text-xs px-3 py-1 rounded transition',
sourceType === 'text' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white']"
>
粘贴文本
</button>
<button
@click="sourceType = 'file'"
:class="['text-xs px-3 py-1 rounded transition',
sourceType === 'file' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white']"
>
选择文件
</button>
</div>
</div>
<!-- 文本输入区 -->
<div v-if="sourceType === 'text'">
<textarea
v-model="requirementText"
placeholder="请粘贴需求文档内容,例如会议要求、文稿规范等..."
class="w-full h-64 p-4 bg-slate-900 border border-slate-700 rounded text-sm text-slate-200 placeholder-slate-600 focus:outline-none focus:border-indigo-500 resize-none"
></textarea>
<div class="flex items-center justify-between mt-2 text-xs text-slate-500">
<span>{{ requirementText.length }} 字符</span>
<span v-if="requirementText.length > 10000" class="text-amber-500">
文档过长可能影响解析质量建议控制在10000字符以内
</span>
</div>
</div>
<!-- 文件选择区 -->
<div v-if="sourceType === 'file'" class="space-y-3">
<input
type="file"
ref="fileInput"
@change="handleFileSelect"
accept=".txt,.md"
class="hidden"
/>
<button
@click="$refs.fileInput.click()"
class="w-full p-6 border-2 border-dashed border-slate-700 rounded-lg hover:border-indigo-500 transition text-slate-400 hover:text-indigo-400 flex flex-col items-center gap-2"
>
<span class="text-3xl">📄</span>
<span class="text-sm">点击选择文件支持 .txt, .md</span>
<span v-if="selectedFileName" class="text-xs text-indigo-400 mt-2">
已选择{{ selectedFileName }}
</span>
</button>
<div v-if="requirementText" class="bg-slate-900 p-4 rounded border border-slate-700 max-h-48 overflow-y-auto">
<pre class="text-xs text-slate-400 whitespace-pre-wrap">{{ requirementText.slice(0, 500) }}{{ requirementText.length > 500 ? '...' : '' }}</pre>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end gap-2 pt-4">
<button
@click="$emit('close')"
class="px-4 py-2 text-sm rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>
取消
</button>
<button
@click="startParsing"
:disabled="!requirementText || isParsing"
class="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-500 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<span v-if="isParsing" class="animate-spin"></span>
<span>{{ isParsing ? '解析中...' : '开始解析' }}</span>
</button>
</div>
</section>
<!-- 步骤2解析进度 -->
<section v-if="step === 2" class="space-y-4">
<div class="flex items-center gap-2 text-sm text-slate-400 mb-4">
<span class="bg-indigo-600 text-white w-6 h-6 rounded-full flex items-center justify-center text-xs animate-pulse">2</span>
<span>AI 解析中...</span>
</div>
<div class="bg-slate-900 p-6 rounded-lg border border-slate-700 space-y-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
<span class="text-slate-300">{{ parsingProgress || '正在分析需求文档...' }}</span>
</div>
<div class="text-xs text-slate-500 leading-relaxed">
AI 正在提取文档中的核心要求生成范式配置这可能需要 10-30 ...
</div>
</div>
</section>
<!-- 步骤3编辑确认 -->
<section v-if="step === 3" class="space-y-4">
<div class="flex items-center gap-2 text-sm text-slate-400 mb-4">
<span class="bg-green-600 text-white w-6 h-6 rounded-full flex items-center justify-center text-xs">3</span>
<span>编辑确认</span>
</div>
<div v-if="parsedParadigm" class="space-y-4">
<!-- 范式名称 -->
<div>
<label class="text-sm text-slate-400 block mb-2">范式名称</label>
<input
v-model="parsedParadigm.metadata.name"
placeholder="如2025年组织生活会对照检查材料"
class="w-full p-2 bg-slate-900 border border-slate-700 rounded text-sm text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<!-- 范式描述 -->
<div>
<label class="text-sm text-slate-400 block mb-2">描述</label>
<input
v-model="parsedParadigm.metadata.description"
placeholder="简要描述该范式的用途..."
class="w-full p-2 bg-slate-900 border border-slate-700 rounded text-sm text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<!-- System Prompt -->
<div>
<label class="text-sm text-slate-400 block mb-2 flex items-center justify-between">
<span>System Prompt系统提示词</span>
<span class="text-xs text-slate-600">{{ parsedParadigm.specializedPrompt.length }} 字符</span>
</label>
<textarea
v-model="parsedParadigm.specializedPrompt"
class="w-full h-48 p-3 bg-slate-900 border border-slate-700 rounded text-xs text-slate-200 font-mono focus:outline-none focus:border-indigo-500 resize-none"
></textarea>
</div>
<!-- Expert Guidelines -->
<div>
<label class="text-sm text-slate-400 block mb-2 flex items-center justify-between">
<span>专家检查指令</span>
<button
@click="addGuideline"
class="text-xs px-2 py-1 rounded bg-green-600 text-white hover:bg-green-500 transition"
>
+ 添加
</button>
</label>
<div class="space-y-3">
<div
v-for="(guideline, index) in parsedParadigm.expertGuidelines"
:key="index"
class="bg-slate-900 border border-slate-700 rounded p-3 space-y-2"
>
<div class="flex items-start gap-2">
<span class="text-xs text-slate-600 mt-2 shrink-0">{{ index + 1 }}.</span>
<div class="flex-1 space-y-2">
<!-- 标题 -->
<input
v-model="guideline.title"
placeholder="检查项标题"
class="w-full p-2 bg-slate-800 border border-slate-600 rounded text-xs text-white focus:outline-none focus:border-indigo-500"
/>
<!-- 描述 -->
<textarea
v-model="guideline.description"
placeholder="检查项详细描述"
rows="2"
class="w-full p-2 bg-slate-800 border border-slate-600 rounded text-xs text-white focus:outline-none focus:border-indigo-500 resize-none"
></textarea>
<!-- Scope 选择 -->
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">粒度</span>
<select
v-model="guideline.scope"
class="text-xs px-2 py-1 bg-slate-800 border border-slate-600 rounded text-white focus:outline-none focus:border-indigo-500"
>
<option value="sentence">句子级</option>
<option value="paragraph">段落级</option>
<option value="document">全文级</option>
</select>
</div>
</div>
<button
@click="removeGuideline(index)"
class="text-xs px-2 py-2 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30 transition shrink-0"
>
删除
</button>
</div>
</div>
</div>
</div>
<!-- 关键要求只读预览 -->
<div v-if="parsedParadigm.metadata.keyRequirements">
<label class="text-sm text-slate-400 block mb-2">核心要求关键词</label>
<div class="flex flex-wrap gap-2">
<span
v-for="(keyword, index) in parsedParadigm.metadata.keyRequirements"
:key="index"
class="px-2 py-1 bg-indigo-900/50 text-indigo-300 rounded text-xs"
>
{{ keyword }}
</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end gap-2 pt-4">
<button
@click="step = 1"
class="px-4 py-2 text-sm rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>
重新解析
</button>
<button
@click="saveParadigm"
class="px-4 py-2 text-sm rounded bg-green-600 text-white hover:bg-green-500 transition flex items-center gap-2"
>
<span></span>
<span>保存并使用</span>
</button>
</div>
</section>
<!-- 解析失败 -->
<section v-if="step === 'error'" class="space-y-4">
<div class="bg-red-900/20 border border-red-700 rounded-lg p-6 text-center">
<span class="text-4xl block mb-3"></span>
<p class="text-red-400 mb-2">解析失败</p>
<p class="text-xs text-slate-500">{{ errorMessage }}</p>
<button
@click="step = 1"
class="mt-4 px-4 py-2 text-sm rounded bg-slate-700 text-white hover:bg-slate-600 transition"
>
返回重试
</button>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useParadigmStore } from '../stores/paradigm'
import DeepSeekAPI from '../api/deepseek'
import { config } from '../utils/config'
import {
buildRequirementParserPrompt,
parseParadigmConfig,
buildParadigmObject,
validateParadigm
} from '../utils/requirementParser'
const emit = defineEmits(['close', 'paradigm-created'])
const paradigmStore = useParadigmStore()
// 状态
const step = ref(1) // 1: 输入, 2: 解析中, 3: 编辑确认, 'error': 失败
const sourceType = ref('text') // 'text' | 'file'
const requirementText = ref('')
const selectedFileName = ref('')
const isParsing = ref(false)
const parsingProgress = ref('')
const parsedParadigm = ref(null)
const errorMessage = ref('')
// 文件输入引用
const fileInput = ref(null)
/**
* 处理文件选择
*/
async function handleFileSelect(event) {
const file = event.target.files[0]
if (!file) return
selectedFileName.value = file.name
try {
const text = await file.text()
requirementText.value = text
} catch (error) {
console.error('读取文件失败:', error)
alert('读取文件失败,请重试')
}
}
/**
* 开始解析
*/
async function startParsing() {
if (!requirementText.value.trim()) {
alert('请先输入需求文档内容')
return
}
isParsing.value = true
step.value = 2
parsingProgress.value = '正在生成解析 Prompt...'
try {
// 构建 Prompt
const prompt = buildRequirementParserPrompt(requirementText.value)
parsingProgress.value = '正在调用 AI 分析...'
// 调用 DeepSeek API - 传递配置
const api = new DeepSeekAPI({
url: config.apiUrl,
key: config.apiKey
})
let responseText = ''
await api.generateContent(
prompt,
(chunk) => {
responseText += chunk
parsingProgress.value = `已接收 ${responseText.length} 字符...`
},
{ temperature: 0.7 } // 使用较高温度以获得更有创造性的输出
)
parsingProgress.value = '正在解析 AI 返回结果...'
// 调试:输出响应
console.log('收到的完整响应长度:', responseText.length);
console.log('响应内容:', responseText);
// 解析返回的配置
const parsedConfig = parseParadigmConfig(responseText)
if (!parsedConfig) {
throw new Error(`AI 返回的配置格式不正确。响应长度:${responseText.length} 字符。请检查浏览器控制台查看完整输出。`)
}
// 构建完整的范式对象
const paradigm = buildParadigmObject(parsedConfig)
// 验证
const validation = validateParadigm(paradigm)
if (!validation.valid) {
console.warn('范式验证警告:', validation.errors)
// 仍然允许用户编辑,只是给出提示
}
parsedParadigm.value = paradigm
step.value = 3
} catch (error) {
console.error('解析失败:', error)
errorMessage.value = error.message || '未知错误'
step.value = 'error'
} finally {
isParsing.value = false
parsingProgress.value = ''
}
}
/**
* 添加新的检查指令
*/
function addGuideline() {
if (!parsedParadigm.value) return
parsedParadigm.value.expertGuidelines.push({
title: '新的检查项',
description: '请填写检查项的详细描述...',
scope: 'sentence'
})
}
/**
* 删除检查指令
*/
function removeGuideline(index) {
if (!parsedParadigm.value) return
parsedParadigm.value.expertGuidelines.splice(index, 1)
}
/**
* 保存范式
*/
function saveParadigm() {
if (!parsedParadigm.value) return
// 再次验证
const validation = validateParadigm(parsedParadigm.value)
if (!validation.valid) {
if (!confirm(`范式存在以下问题:\n${validation.errors.join('\n')}\n\n是否仍然保存`)) {
return
}
}
// 保存到 store
paradigmStore.addCustomParadigm(parsedParadigm.value)
// 通知父组件
emit('paradigm-created', parsedParadigm.value)
emit('close')
}
</script>

267
src/stores/paradigm.js Normal file
View File

@@ -0,0 +1,267 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
/**
* 自定义范式管理 Store
* 用于管理用户通过需求文档生成的自定义范式
*/
export const useParadigmStore = defineStore('paradigm', () => {
// 自定义范式列表(存储在 localStorage
const customParadigms = ref([])
// 当前正在编辑的范式
const editingParadigm = ref(null)
// 是否正在解析需求文档
const isParsing = ref(false)
// 解析进度信息
const parsingProgress = ref('')
/**
* 从 localStorage 加载自定义范式
*/
function loadCustomParadigms() {
try {
const stored = localStorage.getItem('customParadigms')
if (stored) {
customParadigms.value = JSON.parse(stored)
}
} catch (error) {
console.error('加载自定义范式失败:', error)
customParadigms.value = []
}
}
/**
* 保存自定义范式到 localStorage
*/
function saveCustomParadigms() {
try {
localStorage.setItem('customParadigms', JSON.stringify(customParadigms.value))
} catch (error) {
console.error('保存自定义范式失败:', error)
}
}
/**
* 添加自定义范式
* @param {Object} paradigm - 范式对象
*/
function addCustomParadigm(paradigm) {
// 检查是否已存在相同ID
const index = customParadigms.value.findIndex(p => p.id === paradigm.id)
if (index >= 0) {
// 更新现有范式
customParadigms.value[index] = paradigm
} else {
// 添加新范式
customParadigms.value.push(paradigm)
}
saveCustomParadigms()
}
/**
* 删除自定义范式
* @param {string} paradigmId - 范式ID
*/
function deleteCustomParadigm(paradigmId) {
customParadigms.value = customParadigms.value.filter(p => p.id !== paradigmId)
saveCustomParadigms()
}
/**
* 根据ID获取自定义范式
* @param {string} paradigmId - 范式ID
* @returns {Object|null}
*/
function getCustomParadigmById(paradigmId) {
return customParadigms.value.find(p => p.id === paradigmId) || null
}
/**
* 更新范式的某个字段
* @param {string} paradigmId - 范式ID
* @param {string} field - 字段名
* @param {any} value - 新值
*/
function updateParadigmField(paradigmId, field, value) {
const paradigm = getCustomParadigmById(paradigmId)
if (paradigm) {
paradigm[field] = value
saveCustomParadigms()
}
}
/**
* 获取所有范式(包括内置和自定义)
* @param {Object} builtInParadigms - 内置范式对象
* @returns {Array}
*/
function getAllParadigms(builtInParadigms = {}) {
const builtIn = Object.values(builtInParadigms).map(p => ({
...p,
type: 'builtin'
}))
return [
...builtIn,
...customParadigms.value
]
}
/**
* 计算属性:自定义范式数量
*/
const customParadigmCount = computed(() => customParadigms.value.length)
/**
* 计算属性:最近使用的范式(按创建时间排序)
*/
const recentCustomParadigms = computed(() => {
return [...customParadigms.value]
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 5)
})
/**
* 导出范式为JSON
* @param {string} paradigmId - 范式ID
* @returns {string|null} JSON字符串
*/
function exportParadigm(paradigmId) {
const paradigm = getCustomParadigmById(paradigmId)
if (!paradigm) return null
try {
return JSON.stringify(paradigm, null, 2)
} catch (error) {
console.error('导出范式失败:', error)
return null
}
}
/**
* 从JSON导入范式
* @param {string} jsonString - JSON字符串
* @returns {boolean} 是否成功
*/
function importParadigm(jsonString) {
try {
const paradigm = JSON.parse(jsonString)
// 基本验证
if (!paradigm.id || !paradigm.name || !paradigm.specializedPrompt) {
throw new Error('范式格式不正确')
}
// 生成新ID避免冲突
paradigm.id = `custom-imported-${Date.now()}`
paradigm.createdAt = new Date().toISOString()
addCustomParadigm(paradigm)
return true
} catch (error) {
console.error('导入范式失败:', error)
return false
}
}
/**
* 清空所有自定义范式(慎用)
*/
function clearAllCustomParadigms() {
customParadigms.value = []
saveCustomParadigms()
}
/**
* 智能推断 guideline 的 scope
* @param {string|Object} guideline - 检查指令
* @returns {string} scope ('sentence' | 'paragraph' | 'document')
*/
function inferScope(guideline) {
const text = typeof guideline === 'string' ? guideline : (guideline.description || guideline.title || '')
// 全文级关键词
if (/篇幅|章节|完整性|结构.*完整|会前准备|典型案例|巡视|巡察/.test(text)) {
return 'document'
}
// 段落级关键词
if (/递进|逻辑|段落|承接|前后.*呼应|层次|论述|因果/.test(text)) {
return 'paragraph'
}
// 默认句子级
return 'sentence'
}
/**
* 为现有范式补充 scope 字段(返回新对象,不修改原对象)
* @param {Object} paradigm - 范式对象
* @returns {Object} 更新后的范式(新对象)
*/
function ensureGuidelinesHasScope(paradigm) {
if (!paradigm.expertGuidelines || !Array.isArray(paradigm.expertGuidelines)) {
return paradigm
}
// 创建深拷贝,避免修改原对象
const updatedParadigm = {
...paradigm,
expertGuidelines: paradigm.expertGuidelines.map(g => {
if (typeof g === 'string') {
// 字符串格式:转换为对象格式并推断 scope
// 提取前面的关键词作为标题最多15个字符
const title = g.length > 15 ? g.substring(0, 15) : g
return {
title: title,
description: g,
scope: inferScope(g)
}
} else if (!g.scope) {
// 对象格式但缺少 scope推断并补充
return {
...g,
scope: inferScope(g)
}
}
return g
})
}
return updatedParadigm
}
// 初始化时加载
loadCustomParadigms()
return {
// 状态
customParadigms,
editingParadigm,
isParsing,
parsingProgress,
// 计算属性
customParadigmCount,
recentCustomParadigms,
// 方法
loadCustomParadigms,
saveCustomParadigms,
addCustomParadigm,
deleteCustomParadigm,
getCustomParadigmById,
updateParadigmField,
getAllParadigms,
exportParadigm,
importParadigm,
clearAllCustomParadigms,
// Scope 工具函数
inferScope,
ensureGuidelinesHasScope
}
})

View File

@@ -0,0 +1,199 @@
/**
* 需求文档解析工具
* 将需求文档转换为范式配置
*/
/**
* 构建需求解析的 Prompt
*/
export function buildRequirementParserPrompt(requirementText) {
return `你是一位专业的文档分析专家,擅长提取文档中的核心要求并将其转化为结构化的写作范式配置。
【任务】
分析以下需求文档,提取关键要求并生成"范式配置"用于指导AI检查和润色文稿。
【需求文档】
${requirementText}
【输出要求】
请生成以下三个部分使用JSON格式输出
1. **specializedPrompt** (string): 系统提示词,包含:
- 会议主题/文档目标
- 核心要求(列表形式)
- 论述规范(结构要求、术语规范、语气要求等)
- 维度要求(如五维度溯源等)
长度300-500字
2. **expertGuidelines** (array): 专家检查指令,每条指令应该:
- 针对需求文档中的具体要求
- 可直接用于检查文稿是否符合标准
- 清晰、可执行
- **包含 scope 字段**(指定检查粒度):
* "sentence" - 句子级检查(如术语规范、语气分寸、表达方式)
* "paragraph" - 段落级检查(如逻辑结构、递进关系、论述层次)
* "document" - 全文级检查(如章节完整性、篇幅占比、结构要求)
数量8-12条
3. **metadata** (object): 元数据,包含:
- name (string): 范式名称简短10字以内
- description (string): 范式描述30字以内
- keyRequirements (array): 核心要求关键词3-5个
【输出格式】
\`\`\`json
{
"specializedPrompt": "你是一位资深的...",
"expertGuidelines": [
{"title": "党内术语规范", "description": "检查是否使用...", "scope": "sentence"},
{"title": "递进式结构", "description": "检查段落是否符合...", "scope": "paragraph"},
{"title": "章节完整性", "description": "检查是否包含会前准备...", "scope": "document"},
...
],
"metadata": {
"name": "范式名称",
"description": "范式描述",
"keyRequirements": ["要求1", "要求2", "要求3"]
}
}
\`\`\`
【注意事项】
1. specializedPrompt 应该完整、系统,涵盖所有关键要求
2. expertGuidelines 应该具体、可操作,每条针对一个检查点
3. 保留原文中的专业术语和标准表述
4. 如果需求文档提到篇幅要求、格式要求等,务必在 specializedPrompt 中明确体现
5. **scope 分配原则**
- sentence 级适用于任何句子片段1-5句
- paragraph 级需要段落上下文6-20句
- document 级需要完整文档或大段落21+句)
请开始分析并生成范式配置。`;
}
/**
* 解析AI返回的范式配置
* @param {string} aiResponse - AI返回的文本
* @returns {Object|null} 解析后的范式配置失败返回null
*/
export function parseParadigmConfig(aiResponse) {
try {
// 调试:输出原始响应
console.log('AI 原始响应长度:', aiResponse.length);
console.log('AI 原始响应内容前500字符:', aiResponse.substring(0, 500));
// 检查响应是否为空
if (!aiResponse || aiResponse.trim().length === 0) {
console.error('AI 返回内容为空');
return null;
}
// 尝试提取 JSON 代码块
const jsonMatch = aiResponse.match(/```json\s*([\s\S]*?)\s*```/);
let jsonText = jsonMatch ? jsonMatch[1] : aiResponse;
console.log('提取的 JSON 文本前200字符:', jsonText.substring(0, 200));
// 移除可能的注释
jsonText = jsonText.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*/g, '');
const config = JSON.parse(jsonText);
// 验证必需字段
if (!config.specializedPrompt || !Array.isArray(config.expertGuidelines) || !config.metadata) {
console.error('配置缺少必需字段:', {
hasPrompt: !!config.specializedPrompt,
hasGuidelines: Array.isArray(config.expertGuidelines),
hasMetadata: !!config.metadata
});
throw new Error('缺少必需字段');
}
return config;
} catch (error) {
console.error('解析范式配置失败:', error);
console.error('完整的 AI 响应:', aiResponse);
return null;
}
}
/**
* 将解析的配置转换为完整的范式对象
* @param {Object} parsedConfig - 解析后的配置
* @param {string} sourceDocPath - 源需求文档路径(可选)
* @returns {Object} 完整的范式对象
*/
export function buildParadigmObject(parsedConfig, sourceDocPath = null) {
const timestamp = Date.now();
const id = `custom-${timestamp}`;
return {
id,
name: parsedConfig.metadata.name,
description: parsedConfig.metadata.description,
type: 'custom', // 标记为自定义范式
createdAt: new Date().toISOString(),
sourceDoc: sourceDocPath,
specializedPrompt: parsedConfig.specializedPrompt,
expertGuidelines: parsedConfig.expertGuidelines,
// 可选:继承默认的逻辑范式和维度集
logicParadigms: null, // 由使用者决定是否继承
dimensionSetId: null,
defaultReference: null,
// 元数据
metadata: {
...parsedConfig.metadata,
customGenerated: true
}
};
}
/**
* 验证范式配置的完整性
* @param {Object} paradigm - 范式对象
* @returns {Object} 验证结果 { valid: boolean, errors: string[] }
*/
export function validateParadigm(paradigm) {
const errors = [];
if (!paradigm.name || paradigm.name.trim().length === 0) {
errors.push('范式名称不能为空');
}
if (!paradigm.specializedPrompt || paradigm.specializedPrompt.length < 100) {
errors.push('specializedPrompt 内容过短建议至少300字');
}
if (!Array.isArray(paradigm.expertGuidelines) || paradigm.expertGuidelines.length < 5) {
errors.push('expertGuidelines 至少需要5条指令');
}
if (paradigm.expertGuidelines) {
paradigm.expertGuidelines.forEach((guideline, index) => {
// 兼容字符串和对象格式
if (typeof guideline === 'string') {
if (guideline.trim().length === 0) {
errors.push(`${index + 1}条指令为空`);
}
} else if (typeof guideline === 'object') {
// 对象格式:检查 description 字段
if (!guideline.description || guideline.description.trim().length === 0) {
errors.push(`${index + 1}条指令的描述为空`);
}
if (!guideline.scope || !['sentence', 'paragraph', 'document'].includes(guideline.scope)) {
errors.push(`${index + 1}条指令的 scope 字段无效`);
}
} else {
errors.push(`${index + 1}条指令格式错误`);
}
});
}
return {
valid: errors.length === 0,
errors
};
}