443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
// @ts-nocheck
|
||
|
||
|
||
import { render as renderMarkdown } from './markdown.js';
|
||
import { mountExportBar } from '../ui/export-report.js';
|
||
import {
|
||
parseModules,
|
||
assembleReportMarkdown,
|
||
buildAnalysisPrompt,
|
||
buildAnalysisSystemPrompt,
|
||
buildCrossModulePrompt,
|
||
inferReportKind,
|
||
replacePlaceholdersText,
|
||
} from './md-splitter.js';
|
||
import { log as auditLog } from '../utils/audit-log.js';
|
||
import { loadThinkingEnabled, loadCrossModuleEnabled } from '../utils/config.js';
|
||
|
||
const ANALYSIS_MAX_TOKENS = 4096;
|
||
const ANALYSIS_MAX_TOKENS_THINK = 8192;
|
||
|
||
const THINK_SECTION_SKIP_RE = /^(输入信息|检查约束|排版约束|排版条件|排版要求|格式要求|结构要求|输出要求|写作要求|写作注意|约束条件|特殊约束|个人报告约束|排名提及约束|检查清单|要求清单|硬性禁止|硬性要求|本模块特别约束|口径要求|系统规则|任务要求)\s*[::]?$/;
|
||
const THINK_LEAK_RE = /硬性禁止|硬性要求|特别约束|模块分析任务|分析提纲|禁止输出|不要输出\s*[#|]|系统规则|提示词|IDENTITY|【模块|【分析提纲】|【任务要求】|【口径要求】|本模块特别约束|写作注意|写作要求|排版\(必须|排版约束|排版条件|排版要求|格式要求|结构要求|输出要求|检查约束|检查清单|约束条件|输入信息|特殊约束|个人报告约束|排名提及|合计约\s*\d+|不设字数|不要复述|不要罗列|无emoji|无英文|简体中文\s*[::]|三个小标题|四个小标题|用户要求为|调整文字|符合专业|有温度的要求|严禁表格|禁止班组人事|跨模块关联.*必含|根因分析.*必含|优先级矩阵.*必须|风险预警/;
|
||
|
||
function sanitizeThinkingForDisplay(raw) {
|
||
const text = String(raw || '');
|
||
if (!text.trim()) return '';
|
||
const lines = text.split('\n');
|
||
const kept = [];
|
||
let skippingSection = false;
|
||
|
||
for (const line of lines) {
|
||
const t = line.trim();
|
||
if (!t) {
|
||
if (!skippingSection && kept.length && kept[kept.length - 1] !== '') kept.push('');
|
||
continue;
|
||
}
|
||
|
||
|
||
if (THINK_SECTION_SKIP_RE.test(t) || /^(输入信息|检查约束|排版约束)\s*[::]/.test(t)) {
|
||
skippingSection = true;
|
||
continue;
|
||
}
|
||
|
||
if (skippingSection) {
|
||
if (/^(因此|所以|可见|说明|表明|对比|综合|从数据|就排名|优势在于|风险在于|需要关注)/.test(t)
|
||
&& !THINK_LEAK_RE.test(t)) {
|
||
skippingSection = false;
|
||
} else if (/^[-*•]\s/.test(t) || /[::]\s*(是|否|有|无)\s*$/.test(t) || THINK_LEAK_RE.test(t)) {
|
||
continue;
|
||
} else if (/^\*\*(总体表现|主要问题|改进建议)\*\*$/.test(t)) {
|
||
continue;
|
||
} else {
|
||
|
||
if (/约束|禁止|严禁|必须|不要|标题|表格符号|emoji|英文|排版|格式|检查|条件|要求|口径|小标题/.test(t)) continue;
|
||
skippingSection = false;
|
||
}
|
||
}
|
||
|
||
if (THINK_LEAK_RE.test(t)) continue;
|
||
if (/^[-*•]\s*.{0,40}约束/.test(t)) continue;
|
||
if (/[::]\s*(是|否)\s*[。.]?$/.test(t)) continue;
|
||
if (/^\*\*(总体表现|主要问题|改进建议)\*\*$/.test(t)) continue;
|
||
if ((t.match(/不要|禁止|严禁/g) || []).length >= 2 && t.length < 100) continue;
|
||
|
||
if (/^[-*•]\s/.test(t) && !/(因为|所以|说明|偏高|偏低|需要|风险|优势在|弱在|相对|对比)/.test(t)) {
|
||
continue;
|
||
}
|
||
|
||
kept.push(line);
|
||
}
|
||
|
||
let out = kept.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||
|
||
if (out.length < 24) return '';
|
||
if (/^用户要求/.test(out) && out.length < 80) return '';
|
||
return out;
|
||
}
|
||
|
||
export async function composeDirectRenderReport(opts) {
|
||
const {
|
||
md,
|
||
answerPanel,
|
||
textWrap,
|
||
messages,
|
||
chatFn,
|
||
signal,
|
||
traceId,
|
||
hooks,
|
||
onAssistantTextDelta,
|
||
isAborted,
|
||
pidMap = {},
|
||
mainPid = null,
|
||
} = opts;
|
||
|
||
|
||
|
||
|
||
const mdForUi = (s) => {
|
||
const isMultiCandidate = String(s || '').includes('# 多候选');
|
||
return replacePlaceholdersText(s, pidMap, { replaceBare: isMultiCandidate });
|
||
};
|
||
|
||
const { title, modules } = parseModules(md);
|
||
const reportKind = inferReportKind(title);
|
||
const thinkOn = loadThinkingEnabled();
|
||
if (!modules.length) {
|
||
answerPanel.innerHTML = renderMarkdown(mdForUi(String(md || '').replace(/<!--\s*HINT\s*:[\s\S]*?-->/gi, '')));
|
||
mountExportBar(textWrap || answerPanel.parentElement, answerPanel.innerText, { messages });
|
||
return '';
|
||
}
|
||
|
||
answerPanel.innerHTML = '';
|
||
answerPanel.classList.remove('cursor');
|
||
|
||
if (title) {
|
||
const h1 = document.createElement('h1');
|
||
h1.className = 'report-doc-title';
|
||
h1.textContent = mdForUi(title);
|
||
answerPanel.appendChild(h1);
|
||
}
|
||
|
||
const composed = [];
|
||
|
||
const scrollFollow = () => {
|
||
|
||
onAssistantTextDelta?.();
|
||
};
|
||
|
||
for (let i = 0; i < modules.length; i++) {
|
||
if (signal?.aborted || isAborted?.()) break;
|
||
const mod = modules[i];
|
||
|
||
const section = document.createElement('section');
|
||
section.className = 'report-module';
|
||
section.dataset.module = mod.name;
|
||
|
||
const h2 = document.createElement('h2');
|
||
h2.textContent = mod.name;
|
||
section.appendChild(h2);
|
||
|
||
for (const table of mod.tables) {
|
||
if (table.title) {
|
||
const h3 = document.createElement('h3');
|
||
h3.textContent = table.title;
|
||
section.appendChild(h3);
|
||
}
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'report-table-direct';
|
||
wrap.innerHTML = renderMarkdown(mdForUi(table.md));
|
||
section.appendChild(wrap);
|
||
}
|
||
|
||
const analysisEl = document.createElement('div');
|
||
analysisEl.className = 'report-analysis';
|
||
analysisEl.innerHTML = `
|
||
<div class="report-analysis-head">分析</div>
|
||
<div class="think-panel report-module-think" hidden>
|
||
<div class="think-panel-header" title="点击折叠/展开">
|
||
<span><span class="think-panel-status">思考中</span></span>
|
||
<span class="think-panel-chevron">▼</span>
|
||
</div>
|
||
<pre class="think-panel-body"></pre>
|
||
</div>
|
||
<div class="report-analysis-body"></div>`;
|
||
const thinkPanel = analysisEl.querySelector('.think-panel');
|
||
const thinkBody = analysisEl.querySelector('.think-panel-body');
|
||
const thinkHeader = analysisEl.querySelector('.think-panel-header');
|
||
thinkHeader?.addEventListener('click', () => {
|
||
setThinkPanelCollapsed(thinkPanel, !thinkPanel.classList.contains('collapsed'));
|
||
});
|
||
const analysisBody = analysisEl.querySelector('.report-analysis-body');
|
||
const waiting = document.createElement('div');
|
||
waiting.className = 'thinking-indicator';
|
||
waiting.innerHTML = `<div class="dot-pulse"><span></span><span></span><span></span></div><span>${thinkOn ? '思考并撰写分析...' : '撰写分析...'}</span>`;
|
||
analysisBody.appendChild(waiting);
|
||
section.appendChild(analysisEl);
|
||
answerPanel.appendChild(section);
|
||
scrollFollow();
|
||
|
||
let analysis = '';
|
||
let thinkBuf = '';
|
||
try {
|
||
analysis = await writeModuleAnalysis({
|
||
chatFn,
|
||
moduleName: mod.name,
|
||
hints: mod.hints,
|
||
tables: mod.tables,
|
||
reportKind,
|
||
thinkOn,
|
||
crossModule: loadCrossModuleEnabled(),
|
||
prevModules: composed.map(c => ({
|
||
name: c.mod.name,
|
||
hints: c.mod.hints || [],
|
||
tables: c.mod.tables || [],
|
||
})),
|
||
mainPid: opts.mainPid,
|
||
signal,
|
||
onThinkingDelta: (delta) => {
|
||
if (!thinkOn || !delta) return;
|
||
waiting.remove();
|
||
thinkBuf += delta;
|
||
const shown = sanitizeThinkingForDisplay(thinkBuf);
|
||
if (!shown) {
|
||
|
||
return;
|
||
}
|
||
thinkPanel.hidden = false;
|
||
setThinkPanelCollapsed(thinkPanel, false);
|
||
const st = thinkPanel.querySelector('.think-panel-status');
|
||
if (st) st.textContent = '思考中';
|
||
thinkBody.textContent = shown;
|
||
thinkBody.scrollTop = thinkBody.scrollHeight;
|
||
scrollFollow();
|
||
},
|
||
onDelta: (delta) => {
|
||
if (!analysis) {
|
||
waiting.remove();
|
||
const shown = sanitizeThinkingForDisplay(thinkBuf);
|
||
if (shown) {
|
||
thinkBody.textContent = shown;
|
||
collapseThinkPanelDone(thinkPanel);
|
||
} else {
|
||
thinkPanel.hidden = true;
|
||
}
|
||
}
|
||
analysis += delta;
|
||
|
||
analysisBody.innerHTML = renderMarkdown(mdForUi(analysis));
|
||
scrollFollow();
|
||
},
|
||
});
|
||
} catch (e) {
|
||
if (e?.name === 'AbortError') break;
|
||
waiting.remove();
|
||
analysisBody.innerHTML = `<span style="color:var(--danger-color);">本模块分析失败: ${escapeHtml(e.message || e)}</span>`;
|
||
auditLog('error', `模块分析失败: ${mod.name}`, { error: e.message, traceId });
|
||
scrollFollow();
|
||
}
|
||
|
||
waiting.remove();
|
||
const shownThink = sanitizeThinkingForDisplay(thinkBuf);
|
||
if (shownThink) {
|
||
thinkBody.textContent = shownThink;
|
||
collapseThinkPanelDone(thinkPanel);
|
||
} else {
|
||
thinkPanel.hidden = true;
|
||
thinkBody.textContent = '';
|
||
}
|
||
if (analysis) {
|
||
|
||
const isSummary = /综合评价|班组评价|总体评价|整体评价/.test(mod.name);
|
||
const useCrossModuleHere = isSummary && loadCrossModuleEnabled() && composed.length >= 1;
|
||
analysis = normalizeAnalysisStructure(analysis, useCrossModuleHere);
|
||
analysisBody.innerHTML = mdForUi(formatAnalysisHtml(analysis, useCrossModuleHere));
|
||
} else if (!analysisBody.querySelector('[style*="danger"]')) {
|
||
analysisBody.textContent = '';
|
||
}
|
||
scrollFollow();
|
||
|
||
composed.push({ mod, analysis, thinking: shownThink || '' });
|
||
auditLog('system', `报告模块直渲完成: ${mod.name} (${i + 1}/${modules.length})`, {
|
||
traceId,
|
||
tables: mod.tables.length,
|
||
hints: mod.hints.length,
|
||
analysisLen: analysis.length,
|
||
thinkingLen: thinkBuf.length,
|
||
});
|
||
}
|
||
|
||
const fullMd = assembleReportMarkdown(title, composed);
|
||
const fullMdForUi = mdForUi(fullMd);
|
||
|
||
|
||
const rawMdTokenized = assembleReportMarkdown(title, composed.map(c => ({ ...c, analysis: '' })));
|
||
|
||
const thinkingMd = composed
|
||
.filter(c => c.thinking)
|
||
.map(c => `## ${c.mod.name}\n\n${c.thinking}`)
|
||
.join('\n\n---\n\n');
|
||
const last = messages[messages.length - 1];
|
||
if (!(last && last.role === 'assistant' && last.content === fullMd && !last.toolCalls)) {
|
||
messages.push({ role: 'assistant', content: fullMd, thinking: thinkingMd || undefined });
|
||
}
|
||
mountExportBar(textWrap || answerPanel.closest('.assistant-text') || answerPanel.parentElement, fullMdForUi, {
|
||
messages,
|
||
rawMarkdown: rawMdTokenized,
|
||
thinkingMd,
|
||
});
|
||
scrollFollow();
|
||
hooks?.onComplete?.(messages);
|
||
return fullMd;
|
||
}
|
||
|
||
async function writeModuleAnalysis({
|
||
chatFn, moduleName, hints, tables, reportKind, thinkOn, signal, onDelta, onThinkingDelta,
|
||
crossModule, prevModules,
|
||
mainPid = null,
|
||
}) {
|
||
|
||
const isSummaryModule = /综合评价|班组评价|总体评价|整体评价/.test(moduleName);
|
||
const useCrossModule = isSummaryModule && crossModule && (prevModules?.length || 0) >= 1;
|
||
const prompt = useCrossModule
|
||
? buildCrossModulePrompt(prevModules, reportKind)
|
||
: buildAnalysisPrompt(moduleName, hints, { reportKind, tables, mainPid });
|
||
|
||
|
||
const msgs = [];
|
||
if (!useCrossModule) {
|
||
msgs.push({ role: 'system', content: buildAnalysisSystemPrompt({ reportKind, mainPid }) });
|
||
}
|
||
msgs.push({ role: 'user', content: prompt });
|
||
const r = await chatFn({
|
||
messages: msgs,
|
||
tools: [],
|
||
signal,
|
||
|
||
|
||
temperature: 0,
|
||
maxTokens: useCrossModule
|
||
? (thinkOn ? 8000 : 6000)
|
||
: (thinkOn ? ANALYSIS_MAX_TOKENS_THINK : ANALYSIS_MAX_TOKENS),
|
||
onThinkingDelta: (delta) => {
|
||
if (delta) onThinkingDelta?.(delta);
|
||
},
|
||
onTextDelta: (delta) => {
|
||
if (delta) onDelta?.(delta);
|
||
},
|
||
});
|
||
let text = String(r?.text || '').trim();
|
||
|
||
if (useCrossModule) {
|
||
|
||
text = text
|
||
.replace(/^#{1,3}\s+.+$/gm, '')
|
||
.replace(/^.*(排名|前五|后五|前3|前三).*$/gm, (line) => (
|
||
/^\|/.test(line) ? line : line
|
||
))
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
} else {
|
||
text = text
|
||
.replace(/^#{1,3}\s+.+$/gm, '')
|
||
.replace(/^\|.+$/gm, '')
|
||
.replace(/^\|[-:\s|]+\|$/gm, '')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
}
|
||
|
||
if (moduleName === '综合评价' && !useCrossModule) {
|
||
text = text
|
||
.replace(/^.*(排名|前五|后五|前3|前三).*$/gm, (line) => (
|
||
/\|/.test(line) ? '' : line
|
||
))
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
}
|
||
|
||
if (reportKind === 'person') {
|
||
text = text
|
||
.replace(/^.*(兼职与借调人员清单|排班策略|人员调配机制|人员结构的均衡|班组内人员结构|定期梳理兼职).*$/gm, '')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
}
|
||
return normalizeAnalysisStructure(text, useCrossModule);
|
||
}
|
||
|
||
function setThinkPanelCollapsed(panel, collapsed) {
|
||
if (!panel) return;
|
||
panel.classList.toggle('collapsed', !!collapsed);
|
||
const chev = panel.querySelector('.think-panel-chevron');
|
||
if (chev) chev.textContent = collapsed ? '▶' : '▼';
|
||
}
|
||
|
||
function collapseThinkPanelDone(panel) {
|
||
if (!panel || panel.hidden) return;
|
||
const st = panel.querySelector('.think-panel-status');
|
||
if (st) st.textContent = '思考过程';
|
||
setThinkPanelCollapsed(panel, true);
|
||
}
|
||
|
||
function normalizeAnalysisStructure(text, useCrossModule = false) {
|
||
let t = String(text || '').trim();
|
||
if (!t) return t;
|
||
|
||
if (useCrossModule) {
|
||
|
||
return t
|
||
.replace(/([^\n])\n(\*\*[^*\n]+\*\*)/g, '$1\n\n$2')
|
||
.replace(/(\*\*[^*\n]+\*\*)\n([^\n])/g, '$1\n\n$2')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
}
|
||
|
||
t = t
|
||
.replace(/\*\*(总体表现|主要问题|改进建议)\s*[::]?\*\*\s*[::]?\s*/g, '\n\n**$1**\n')
|
||
.replace(/^(?:###?\s*)?(总体表现|主要问题|改进建议)\s*[::]?\s*$/gm, '**$1**')
|
||
.replace(/^(?:###?\s*)?(总体表现|主要问题|改进建议)\s*[::]\s*/gm, '**$1**\n')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
|
||
if (/\*\*总体表现\*\*|\*\*主要问题\*\*|\*\*改进建议\*\*/.test(t)) {
|
||
return t
|
||
.replace(/\*\*(总体表现|主要问题|改进建议)\*\*\s*/g, '\n\n**$1**\n')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
}
|
||
|
||
const parts = t.split(/(?<=[。!?])\s*/).map((s) => s.trim()).filter(Boolean);
|
||
if (parts.length < 2) return t;
|
||
const n = parts.length;
|
||
const a = parts.slice(0, Math.max(1, Math.ceil(n / 3))).join('');
|
||
const b = parts.slice(Math.ceil(n / 3), Math.ceil((2 * n) / 3)).join('') || '本模块未见额外突出短板。';
|
||
const c = parts.slice(Math.ceil((2 * n) / 3)).join('') || '建议对照表格薄弱项持续跟进。';
|
||
return `**总体表现**\n${a}\n\n**主要问题**\n${b}\n\n**改进建议**\n- ${c}`;
|
||
}
|
||
|
||
function formatAnalysisHtml(text, useCrossModule = false) {
|
||
const structured = normalizeAnalysisStructure(text, useCrossModule);
|
||
|
||
const kickerPattern = useCrossModule
|
||
? /\*\*(跨模块关联|根因分析|优先级矩阵|风险预警)\*\*\s*/g
|
||
: /\*\*(总体表现|主要问题|改进建议)\*\*\s*/g;
|
||
const withKickers = structured.replace(
|
||
kickerPattern,
|
||
'\n\n<p class="analysis-kicker">$1</p>\n\n',
|
||
);
|
||
|
||
const parts = withKickers.split(/(<p class="analysis-kicker">[^<]+<\/p>)/);
|
||
return parts.map((part) => {
|
||
if (/^<p class="analysis-kicker">/.test(part)) return part;
|
||
const chunk = String(part || '').trim();
|
||
return chunk ? renderMarkdown(chunk) : '';
|
||
}).join('');
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|