diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index efda8b9a..f38c6625 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -13,6 +13,7 @@ import ( "chat-agent-system/internal/docgov" "chat-agent-system/internal/docquery" "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/workflowscope" ) func (s *Server) docflowQuery() *docflow.Query { @@ -102,6 +103,7 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht writeJSONResponse(w, http.StatusOK, map[string]any{ "documents": docs, "active_workflow_key": activeKey, + "workflow_scope_mode": string(workflowscope.CurrentMode()), }) } diff --git a/web/src/App.tsx b/web/src/App.tsx index 8d2a2f2d..534fb5bc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -369,6 +369,7 @@ export default function App() { const [projectWorkflowsState, setProjectWorkflowsState] = useState< Record >({}); + const [workflowScopeMode, setWorkflowScopeMode] = useState<'strict' | 'weighted'>('strict'); const [projectLineages, setProjectLineages] = useState([]); const currentActiveSession = @@ -872,6 +873,7 @@ export default function App() { activeDocIdRef, editorHydrationGateRef, applyControlledInvalidationPlan, + onWorkflowScopeMode: setWorkflowScopeMode, }); // 模态弹窗与侧栏 @@ -1186,6 +1188,7 @@ export default function App() { visibleDeliverables, currentProjectDeliverables, activeWorkflowKey: currentWfState.activeKey || undefined, + workflowScopeMode, deliverablesRef, activeDocIdRef, editorHydrationGateRef, @@ -1385,6 +1388,7 @@ export default function App() { visibleDeliverables, currentProjectDeliverables, activeWorkflowKey: currentWfState.activeKey || undefined, + workflowScopeMode, sessions, setDeliverables, setDocCache, diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts index 32f61d8a..1b5e0514 100644 --- a/web/src/api/projectDocuments.ts +++ b/web/src/api/projectDocuments.ts @@ -30,6 +30,7 @@ export type WorkflowDocumentSummary = { export type ProjectWorkflowDocumentsResponse = { documents: WorkflowDocumentSummary[]; active_workflow_key?: string; + workflow_scope_mode?: 'strict' | 'weighted'; }; export type ControlledDocConsistency = @@ -84,6 +85,7 @@ export async function listProjectWorkflowDocumentsDetailed( return { documents: Array.isArray(data?.documents) ? data.documents : [], active_workflow_key: typeof data?.active_workflow_key === 'string' ? data.active_workflow_key : undefined, + workflow_scope_mode: data?.workflow_scope_mode === 'weighted' ? 'weighted' : 'strict', }; } diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index 6aff9962..3f340ba2 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -309,3 +309,36 @@ test('resolveTaskTemplateForMessage keeps in-scope main docs when active is main assert.equal(resolved?.wrongWorkflow, undefined); assert.equal(resolved?.taskKey, 'research'); }); + +test('weighted mode does not let a cross-workflow title hijack an in-scope write', () => { + const research: DeliverableItem = { + ...base, + taskKey: 'bd_research', + title: '调研报告', + workflowKey: 'main', + isTemplateOnly: true, + }; + const proposal: DeliverableItem = { + ...base, + taskKey: 'rd_proposal', + title: '项目建议书', + workflowKey: 'rdev', + isTemplateOnly: true, + }; + const pool = [research, proposal]; + const kept = resolveTaskTemplateForMessage( + '生成调研报告,不要写成项目建议书', + pool, + null, + 'main', + 'weighted', + ); + assert.equal(kept?.wrongWorkflow, undefined); + assert.equal(kept?.taskKey, 'bd_research'); + + const blocked = resolveTaskTemplateForMessage('编写项目建议书', pool, null, 'main', 'weighted'); + assert.equal(blocked?.wrongWorkflow?.ownerWorkflow, 'rdev'); + + const fuzzy = resolveTaskTemplateForMessage('编写建议', pool, null, 'main', 'weighted'); + assert.equal(fuzzy?.wrongWorkflow, undefined); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index 67ed3d67..797c863b 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -42,7 +42,47 @@ const WORKFLOW_LABELS: Record = { software_delivery: '软件交付迭代流程', }; -/** 交付物池外的他流文档别名(激活 main 却点名研发/产品经理文档时用于拦截)。 */ +/** 与 internal/workflowscope 保持同一组常数,避免前后端对「他流能否压过激活流」判断分叉。 */ +const CATALOG_ORDER: Record = { + main: 10, + product_manager: 20, + test_manager: 30, + rdev: 40, + software_bootstrap: 50, + software_delivery: 60, +}; +const MEDICAL_WORKFLOWS = new Set(['main', 'product_manager', 'test_manager', 'rdev']); +const SOFTWARE_WORKFLOWS = new Set(['software_bootstrap', 'software_delivery']); +const WEIGHT_SAME = 1; +const WEIGHT_UNKNOWN = 0.8; +const WEIGHT_ADJACENT = 0.6; +const WEIGHT_SAME_DOMAIN = 0.5; +const WEIGHT_CROSS_DOMAIN = 0.35; +const CROSS_MARGIN = 0.15; +const MIN_CROSS_QUALITY = 0.85; + +export type WorkflowScopeMode = 'strict' | 'weighted'; + +export function relationWeight(active: string, owner: string): number { + if (!active) return WEIGHT_SAME; + if (!owner) return WEIGHT_UNKNOWN; + if (active === owner) return WEIGHT_SAME; + const activeMedical = MEDICAL_WORKFLOWS.has(active); + const ownerMedical = MEDICAL_WORKFLOWS.has(owner); + const sameDomain = activeMedical || ownerMedical + ? activeMedical && ownerMedical + : SOFTWARE_WORKFLOWS.has(active) && SOFTWARE_WORKFLOWS.has(owner); + if (!sameDomain) return WEIGHT_CROSS_DOMAIN; + const delta = Math.abs((CATALOG_ORDER[active] ?? 9999) - (CATALOG_ORDER[owner] ?? 9999)); + if (CATALOG_ORDER[active] != null && CATALOG_ORDER[owner] != null && delta === 10) { + return WEIGHT_ADJACENT; + } + return WEIGHT_SAME_DOMAIN; +} + +function mentionQuality(phrase: string): number { + return Array.from(phrase).length >= 4 ? 1 : 0.5; +} const CROSS_WORKFLOW_ALIASES: { workflow: string; patterns: RegExp[] }[] = [ { workflow: 'rdev', patterns: [/项目建议书/, /设计开发计划任务书/, /系统设计方案/, /用户需求规格书/] }, { workflow: 'product_manager', patterns: [/系统需求规格书/] }, @@ -98,6 +138,53 @@ function detectWrongWorkflowMention( return undefined; } +function weightedWrongWorkflow( + text: string, + deliverables: DeliverableItem[], + activeWorkflowKey: string, +): ResolvedDocumentTask['wrongWorkflow'] | undefined { + const mentions: { label: string; owner: string; quality: number }[] = []; + for (const item of deliverables) { + const title = item.title?.trim(); + if (!title || !text.includes(title)) continue; + const owner = !item.workflowKey || item.workflowKey === activeWorkflowKey + ? activeWorkflowKey + : item.workflowKey; + mentions.push({ label: title, owner, quality: mentionQuality(title) }); + } + for (const alias of CROSS_WORKFLOW_ALIASES) { + for (const pattern of alias.patterns) { + const hit = text.match(pattern)?.[0]; + if (!hit || mentions.some((m) => m.label === hit)) continue; + const owner = alias.workflow === activeWorkflowKey ? activeWorkflowKey : alias.workflow; + mentions.push({ label: hit, owner, quality: mentionQuality(hit) }); + } + } + let bestInScope = -1; + let bestCross = -1; + let cross: { label: string; owner: string; quality: number } | undefined; + for (const mention of mentions) { + const score = mention.quality * relationWeight(activeWorkflowKey, mention.owner); + if (!mention.owner || mention.owner === activeWorkflowKey) { + bestInScope = Math.max(bestInScope, score); + continue; + } + if (score > bestCross) { + bestCross = score; + cross = mention; + } + } + const baseline = bestInScope < 0 ? 0 : bestInScope; + if (!cross || cross.quality < MIN_CROSS_QUALITY || bestCross < baseline + CROSS_MARGIN) { + return undefined; + } + return { + docTitle: cross.label, + ownerWorkflow: cross.owner, + activeWorkflow: activeWorkflowKey, + }; +} + export function wrongWorkflowGuidance(info: NonNullable): string { return `您提到的文档「${info.docTitle}」属于【${workflowDisplayLabel(info.ownerWorkflow)}】,当前激活工作流为【${workflowDisplayLabel(info.activeWorkflow)}】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。`; } @@ -305,11 +392,14 @@ export function resolveTaskTemplateForMessage( deliverables: DeliverableItem[], activeDoc?: DeliverableItem | null, activeWorkflowKey?: string, + scopeMode?: WorkflowScopeMode, ): ResolvedDocumentTask | undefined { if (!hasDocumentWriteIntent(text)) return undefined; if (activeWorkflowKey) { - const wrong = detectWrongWorkflowMention(text, deliverables, activeWorkflowKey); + const wrong = scopeMode === 'weighted' + ? weightedWrongWorkflow(text, deliverables, activeWorkflowKey) + : detectWrongWorkflowMention(text, deliverables, activeWorkflowKey); if (wrong) { return { taskKey: '', wrongWorkflow: wrong }; } diff --git a/web/src/hooks/useSendMessage.ts b/web/src/hooks/useSendMessage.ts index fa339f64..f97b666b 100644 --- a/web/src/hooks/useSendMessage.ts +++ b/web/src/hooks/useSendMessage.ts @@ -97,6 +97,8 @@ export interface SendMessageInput { currentProjectDeliverables: DeliverableItem[]; /** 当前项目激活工作流键;用于文档任务匹配池收窄。 */ activeWorkflowKey?: string; + /** 来自文档列表的 WORKFLOW_SCOPE_MODE;缺省 strict。 */ + workflowScopeMode?: 'strict' | 'weighted'; sessions: ProjectSession[]; setDeliverables: Dispatch>; setDocCache: Dispatch>>; @@ -134,6 +136,7 @@ export function useSendMessage(input: SendMessageInput) { visibleDeliverables, currentProjectDeliverables, activeWorkflowKey, + workflowScopeMode, sessions, setDeliverables, setDocCache, @@ -313,6 +316,7 @@ export function useSendMessage(input: SendMessageInput) { currentProjectDeliverables, sendActiveDoc, activeWorkflowKey, + workflowScopeMode, ); if (resolvedTask?.wrongWorkflow) { setMessages((prev) => [ diff --git a/web/src/hooks/useServerDocumentSync.ts b/web/src/hooks/useServerDocumentSync.ts index 5de4103e..3cca7a55 100644 --- a/web/src/hooks/useServerDocumentSync.ts +++ b/web/src/hooks/useServerDocumentSync.ts @@ -1,6 +1,6 @@ import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; import { listFreeformDocs } from '../api/freeformDoc'; -import { listProjectWorkflowDocuments } from '../api/projectDocuments'; +import { listProjectWorkflowDocumentsDetailed } from '../api/projectDocuments'; import { applyLeaseUpdates, sameLease } from '../lib/deliverableLeaseSync'; import { mergeWorkflowDocumentsFromServer } from '../lib/deliverableSync'; import { @@ -30,6 +30,8 @@ export interface ServerDocumentSyncInput { activeDocIdRef: MutableRefObject; editorHydrationGateRef: MutableRefObject; applyControlledInvalidationPlan: (plan: ControlledInvalidationPlan) => void; + onActiveWorkflowKey?: (key: string) => void; + onWorkflowScopeMode?: (mode: 'strict' | 'weighted') => void; } export function useServerDocumentSync(input: ServerDocumentSyncInput): void { @@ -45,6 +47,8 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void { activeDocIdRef, editorHydrationGateRef, applyControlledInvalidationPlan, + onActiveWorkflowKey, + onWorkflowScopeMode, } = input; useEffect(() => { @@ -106,8 +110,11 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void { const syncWorkflowDocs = async () => { try { // 成功返回(含空数组)才对账;失败不得调用 merge,以免误清本地真实项。 - const workflowDocs = await listProjectWorkflowDocuments(currentProjectId); + const listed = await listProjectWorkflowDocumentsDetailed(currentProjectId); if (!active) return; + if (listed.active_workflow_key) onActiveWorkflowKey?.(listed.active_workflow_key); + if (listed.workflow_scope_mode) onWorkflowScopeMode?.(listed.workflow_scope_mode); + const workflowDocs = listed.documents; setDeliverables((prev) => mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser), ); @@ -138,13 +145,18 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void { currentUser.name, workflowDocsEpoch, applyControlledInvalidationPlan, + onActiveWorkflowKey, + onWorkflowScopeMode, ]); useEffect(() => { if (!session || sessionValidated !== true || currentProjectId <= 0) return; let cancelled = false; const syncLeases = async () => { - const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []); + const listed = await listProjectWorkflowDocumentsDetailed(currentProjectId).catch(() => null); + const docs = listed?.documents ?? []; + if (listed?.active_workflow_key) onActiveWorkflowKey?.(listed.active_workflow_key); + if (listed?.workflow_scope_mode) onWorkflowScopeMode?.(listed.workflow_scope_mode); if (cancelled || docs.length === 0) return; const leases = new Map( docs.map((doc) => [ @@ -177,5 +189,5 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void { cancelled = true; window.clearInterval(timer); }; - }, [session, sessionValidated, currentProjectId]); + }, [session, sessionValidated, currentProjectId, onActiveWorkflowKey, onWorkflowScopeMode]); } diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index c09ec1a6..ffae04b2 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -70,6 +70,7 @@ export interface TaskRunAttemptsInput { visibleDeliverables: DeliverableItem[]; currentProjectDeliverables: DeliverableItem[]; activeWorkflowKey?: string; + workflowScopeMode?: 'strict' | 'weighted'; deliverablesRef: MutableRefObject; activeDocIdRef: MutableRefObject; editorHydrationGateRef: MutableRefObject; @@ -102,6 +103,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { visibleDeliverables, currentProjectDeliverables, activeWorkflowKey, + workflowScopeMode, deliverablesRef, activeDocIdRef, editorHydrationGateRef, @@ -843,6 +845,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { currentProjectDeliverables, retryActive, activeWorkflowKey, + workflowScopeMode, ); if (reResolved?.wrongWorkflow) { chatWindow.updateLiveMessage(messageId, {