Agent/reports/report-direct-render.ts
2026-08-06 22:17:39 +08:00

443 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}