265 lines
7.6 KiB
TypeScript
265 lines
7.6 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)];
|
|
}
|
|
|
|
/**
|
|
* 构建审核驳回流转确认交互卡片
|
|
*/
|
|
export function createRejectConfirmationCard(
|
|
targetDoc: { docId: string; title: string },
|
|
matchingBlock?: DocumentBlock
|
|
): InteractiveCardData {
|
|
const cardId = 'card-reject-' + Date.now();
|
|
const sectionLabel = matchingBlock?.numbering
|
|
? `${matchingBlock.numbering} ${matchingBlock.content.slice(0, 24)}`
|
|
: '第3节「电气安全与合规风险对标」';
|
|
|
|
return {
|
|
cardId,
|
|
cardType: 'CONFIRMATION',
|
|
title: '审核驳回与返工确认',
|
|
targetDocId: targetDoc.docId,
|
|
targetDocTitle: targetDoc.title,
|
|
targetSectionId: matchingBlock?.id || 'sec-3',
|
|
sectionTitle: sectionLabel,
|
|
proposedContent: '【驳回审查意见】关键技术指标与竞品对比不充分,需补充电气安全与EMC合规佐证,退回编制人重新修订。',
|
|
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,
|
|
},
|
|
},
|
|
{
|
|
actionId: 'cancel',
|
|
label: '放弃操作',
|
|
style: 'subtle',
|
|
payload: {
|
|
docId: targetDoc.docId,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 判定用户输入是否属于肯定型双轨回复(好、确认、同意等)
|
|
*/
|
|
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;
|
|
});
|
|
}
|