334 lines
9.8 KiB
TypeScript
334 lines
9.8 KiB
TypeScript
import { DocumentAST, DocumentBlock, BlockComment } from '../types/block';
|
||
import { ChatMessage, InteractiveCardData } from '../types/workbench';
|
||
|
||
/**
|
||
* 扫描受控文档 AST,寻找与审核审查最匹配的锚定段落块 (如电气安全、EMC、竞品对比、临床痛点)
|
||
*/
|
||
export function findMatchingBlockForReview(ast: DocumentAST, hint?: string): DocumentBlock {
|
||
const children = ast.rootBlock?.children || [];
|
||
if (children.length === 0) {
|
||
return ast.rootBlock;
|
||
}
|
||
|
||
// 1. 若外部指定了段落 ID 或文本线索,优先精确/包含匹配
|
||
if (hint && hint.trim()) {
|
||
const trimmed = hint.trim().toLowerCase();
|
||
const matched = children.find(
|
||
(b) =>
|
||
b.id === hint ||
|
||
b.content.toLowerCase().includes(trimmed) ||
|
||
(b.properties?.tableData &&
|
||
JSON.stringify(b.properties.tableData).toLowerCase().includes(trimmed))
|
||
);
|
||
if (matched) return matched;
|
||
}
|
||
|
||
// 2. 依次匹配关键合规与审查高频词
|
||
const keywords = ['电气安全', 'emc', '电磁兼容', '竞品', '对标', '临床痛点', '性能矩阵'];
|
||
for (const kw of keywords) {
|
||
const matched = children.find(
|
||
(b) =>
|
||
b.content.toLowerCase().includes(kw) ||
|
||
(b.properties?.tableData &&
|
||
JSON.stringify(b.properties.tableData).toLowerCase().includes(kw))
|
||
);
|
||
if (matched) return matched;
|
||
}
|
||
|
||
// 3. 降级:优先返回第 3 个段落块,或第 1 个 H2 标题块,或第 1 个子块
|
||
const h2Block = children.find((b) => b.type === 'heading' && b.level === 2);
|
||
if (h2Block) return h2Block;
|
||
|
||
return children[Math.min(2, children.length - 1)];
|
||
}
|
||
|
||
/**
|
||
* 构建审核驳回流转确认交互卡片(仅供审核/批准执行路径;编制人查询请用 createRejectReasonInfoCard)
|
||
*/
|
||
export function createRejectConfirmationCard(
|
||
targetDoc: { docId: string; title: string },
|
||
matchingBlock?: DocumentBlock,
|
||
options?: { reason?: string; mode?: 'review' | 'final' }
|
||
): InteractiveCardData {
|
||
const cardId = 'card-reject-' + Date.now();
|
||
const sectionLabel = matchingBlock?.numbering
|
||
? `${matchingBlock.numbering} ${matchingBlock.content.slice(0, 24)}`
|
||
: '第3节「电气安全与合规风险对标」';
|
||
const mode = options?.mode || 'review';
|
||
const reason =
|
||
options?.reason ||
|
||
'【驳回审查意见】关键技术指标与竞品对比不充分,需补充电气安全与EMC合规佐证,退回编制人重新修订。';
|
||
|
||
return {
|
||
cardId,
|
||
cardType: 'CONFIRMATION',
|
||
title: mode === 'final' ? '终审驳回确认' : '审核驳回与返工确认',
|
||
targetDocId: targetDoc.docId,
|
||
targetDocTitle: targetDoc.title,
|
||
targetSectionId: matchingBlock?.id || 'sec-3',
|
||
sectionTitle: sectionLabel,
|
||
proposedContent: reason,
|
||
impactRisk: '高风险:将退回至草稿编制阶段,正文锁定将解除',
|
||
status: 'active',
|
||
ttlTurns: 3,
|
||
createdAt: new Date().toTimeString().substring(0, 5),
|
||
actions: [
|
||
{
|
||
actionId: 'confirm_reject',
|
||
label: '确认驳回退回编制',
|
||
style: 'danger',
|
||
isAffirmativeDefault: true,
|
||
payload: {
|
||
docId: targetDoc.docId,
|
||
reason,
|
||
targetSection: sectionLabel,
|
||
target_action: mode === 'final' ? 'confirm_final_reject' : 'confirm_reject',
|
||
},
|
||
},
|
||
{
|
||
actionId: 'cancel',
|
||
label: '放弃驳回',
|
||
style: 'subtle',
|
||
payload: {
|
||
docId: targetDoc.docId,
|
||
},
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 构建驳回原因说明卡(编制人/查询路径;无危险流转按钮)
|
||
*/
|
||
export function createRejectReasonInfoCard(
|
||
targetDoc: { docId: string; title: string },
|
||
reason: string,
|
||
opts?: { includeStartRevision?: boolean; sectionLabel?: string; sectionId?: string }
|
||
): InteractiveCardData {
|
||
const actions: InteractiveCardData['actions'] = [
|
||
{
|
||
actionId: 'ack_reject_reason',
|
||
label: '知道了',
|
||
style: 'primary',
|
||
isAffirmativeDefault: true,
|
||
payload: { docId: targetDoc.docId },
|
||
},
|
||
];
|
||
if (opts?.includeStartRevision !== false) {
|
||
actions.push({
|
||
actionId: 'start_revision',
|
||
label: '开始按意见修订',
|
||
style: 'subtle',
|
||
payload: { docId: targetDoc.docId },
|
||
});
|
||
}
|
||
return {
|
||
cardId: 'card-reject-reason-' + Date.now(),
|
||
cardType: 'REMINDER_NOTICE',
|
||
title: '驳回原因说明',
|
||
targetDocId: targetDoc.docId,
|
||
targetDocTitle: targetDoc.title,
|
||
targetSectionId: opts?.sectionId,
|
||
sectionTitle: opts?.sectionLabel,
|
||
proposedContent: reason,
|
||
impactRisk: '本卡片为查询说明,不会变更文档生命周期状态',
|
||
status: 'active',
|
||
ttlTurns: 5,
|
||
createdAt: new Date().toTimeString().substring(0, 5),
|
||
actions,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 从文档 AST 中提取最近一条 issue 类驳回批注
|
||
*/
|
||
export function extractLatestRejectReasonFromAst(ast: DocumentAST): string | undefined {
|
||
const collect = (block: DocumentBlock | undefined): string[] => {
|
||
if (!block) return [];
|
||
const out: string[] = [];
|
||
for (const c of block.comments || []) {
|
||
if (c.type === 'issue' || (c.content && /驳回|退回|缺少|需补充/.test(c.content))) {
|
||
out.push(c.content);
|
||
}
|
||
}
|
||
for (const child of block.children || []) {
|
||
out.push(...collect(child));
|
||
}
|
||
return out;
|
||
};
|
||
const reasons = collect(ast.rootBlock);
|
||
return reasons.length > 0 ? reasons[reasons.length - 1] : undefined;
|
||
}
|
||
|
||
/**
|
||
* 判定用户输入是否属于肯定型双轨回复(好、确认、同意等)
|
||
*/
|
||
export function isAffirmativeResponse(text: string): boolean {
|
||
const lower = text.trim().toLowerCase();
|
||
const affirmativeKeywords = ['好', '确认', '同意', '是的', '准许', '批准', '行', '确认驳回', '同意驳回', 'ok', 'yes'];
|
||
return affirmativeKeywords.some(
|
||
(kw) =>
|
||
lower === kw ||
|
||
lower.startsWith(kw) ||
|
||
(lower.length <= 8 && lower.includes(kw))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 查找消息历史中最近一条处于 active 状态的交互卡片消息
|
||
*/
|
||
export function findLastActiveCard(
|
||
messages: ChatMessage[],
|
||
sessionId?: string
|
||
): { message: ChatMessage; card: InteractiveCardData } | null {
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const m = messages[i];
|
||
if (sessionId && m.sessionId !== sessionId) continue;
|
||
if (m.interactiveCard && m.interactiveCard.status === 'active') {
|
||
return { message: m, card: m.interactiveCard };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 递减所有 active 卡片的有效交互轮次 (TTL);达到 0 自动归档为 expired
|
||
*/
|
||
export function decrementActiveCardsTtl(
|
||
messages: ChatMessage[],
|
||
sessionId?: string
|
||
): ChatMessage[] {
|
||
return messages.map((m) => {
|
||
if (sessionId && m.sessionId !== sessionId) return m;
|
||
if (m.interactiveCard && m.interactiveCard.status === 'active') {
|
||
const nextTtl = m.interactiveCard.ttlTurns - 1;
|
||
return {
|
||
...m,
|
||
interactiveCard: {
|
||
...m.interactiveCard,
|
||
ttlTurns: nextTtl,
|
||
status: nextTtl <= 0 ? 'expired' : m.interactiveCard.status,
|
||
},
|
||
};
|
||
}
|
||
return m;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 执行审核驳回对文档 AST 的变更:回退为 drafting 并挂载 issue 批注
|
||
*/
|
||
export function applyConfirmRejectToDoc(
|
||
doc: DocumentAST,
|
||
cardId: string,
|
||
reviewerName: string,
|
||
reason: string,
|
||
hint?: string
|
||
): { updatedDoc: DocumentAST; matchingBlock: DocumentBlock } {
|
||
const matchingBlock = findMatchingBlockForReview(doc, hint);
|
||
const commentId = `cmt-reject-${cardId}`;
|
||
|
||
const issueComment: BlockComment = {
|
||
id: commentId,
|
||
authorName: reviewerName,
|
||
authorRole: 'reviewer',
|
||
createdAt: new Date().toTimeString().substring(0, 5),
|
||
type: 'issue',
|
||
content: reason || '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。',
|
||
resolved: false,
|
||
};
|
||
|
||
// 克隆子块并追加批注
|
||
const updatedChildren = (doc.rootBlock.children || []).map((b) => {
|
||
if (b.id === matchingBlock.id) {
|
||
return {
|
||
...b,
|
||
comments: [...(b.comments || []), issueComment],
|
||
};
|
||
}
|
||
return b;
|
||
});
|
||
|
||
const updatedDoc: DocumentAST = {
|
||
...doc,
|
||
lifecycleState: 'drafting',
|
||
rootBlock: {
|
||
...doc.rootBlock,
|
||
children: updatedChildren,
|
||
},
|
||
};
|
||
|
||
return { updatedDoc, matchingBlock };
|
||
}
|
||
|
||
/**
|
||
* 执行合法窗口期撤销驳回对文档 AST 的变更:恢复为 reviewing 并移除挂载的驳回批注
|
||
*/
|
||
export function applyUndoRejectToDoc(doc: DocumentAST, cardId: string): DocumentAST {
|
||
const commentId = `cmt-reject-${cardId}`;
|
||
|
||
const updatedChildren = (doc.rootBlock.children || []).map((b) => {
|
||
if (b.comments && b.comments.length > 0) {
|
||
return {
|
||
...b,
|
||
comments: b.comments.filter((c) => c.id !== commentId),
|
||
};
|
||
}
|
||
return b;
|
||
});
|
||
|
||
return {
|
||
...doc,
|
||
lifecycleState: 'reviewing',
|
||
rootBlock: {
|
||
...doc.rootBlock,
|
||
children: updatedChildren,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 在消息列表中将指定卡片更新为 resolved
|
||
*/
|
||
export function resolveCardInMessages(
|
||
messages: ChatMessage[],
|
||
cardId: string,
|
||
resolutionSummary: string,
|
||
operatorName: string,
|
||
resolvedAt?: string
|
||
): ChatMessage[] {
|
||
return messages.map((m) => {
|
||
if (m.interactiveCard && m.interactiveCard.cardId === cardId) {
|
||
return {
|
||
...m,
|
||
interactiveCard: {
|
||
...m.interactiveCard,
|
||
status: 'resolved',
|
||
resolutionSummary,
|
||
resolvedAt: resolvedAt || new Date().toISOString(),
|
||
resolvedBy: operatorName,
|
||
},
|
||
};
|
||
}
|
||
return m;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 在消息列表中将指定卡片更新为 cancelled
|
||
*/
|
||
export function cancelCardInMessages(messages: ChatMessage[], cardId: string): ChatMessage[] {
|
||
return messages.map((m) => {
|
||
if (m.interactiveCard && m.interactiveCard.cardId === cardId) {
|
||
return {
|
||
...m,
|
||
interactiveCard: {
|
||
...m.interactiveCard,
|
||
status: 'cancelled',
|
||
},
|
||
};
|
||
}
|
||
return m;
|
||
});
|
||
}
|