feat(web): scope template matching to the active workflow
- Document list returns workflow_key per item and active_workflow_key - DeliverableItem keeps workflowKey through server sync - resolveTaskTemplateForMessage filters the match pool and blocks cross-workflow mentions with switch guidance Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,7 @@ type WorkflowDocumentSummary struct {
|
||||
ProjectID int64 `json:"project_id"`
|
||||
TaskKey string `json:"task_key"`
|
||||
Title string `json:"title"`
|
||||
WorkflowKey string `json:"workflow_key,omitempty"`
|
||||
LifecycleState DocumentState `json:"lifecycle_state"`
|
||||
IsTemplateOnly bool `json:"is_template_only"`
|
||||
ContentSHA256 string `json:"content_sha256"`
|
||||
|
||||
@@ -108,6 +108,17 @@ func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int
|
||||
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
|
||||
}
|
||||
|
||||
// workflowKeyForDocument 为列表项标注归属工作流:优先激活流任务集合,其次任务键前缀推断。
|
||||
func workflowKeyForDocument(taskKey, activeKey string, scopeTaskKeys map[string]bool) string {
|
||||
if activeKey != "" && len(scopeTaskKeys) > 0 && projectinstance.TaskKeyInScope(scopeTaskKeys, taskKey) {
|
||||
return activeKey
|
||||
}
|
||||
if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" {
|
||||
return owner
|
||||
}
|
||||
return activeKey
|
||||
}
|
||||
|
||||
// errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。
|
||||
type errDocWrongWorkflow struct {
|
||||
Title string
|
||||
|
||||
@@ -95,7 +95,14 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs)
|
||||
writeJSONResponse(w, http.StatusOK, docs)
|
||||
activeKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(r.Context(), projectID)
|
||||
for i := range docs {
|
||||
docs[i].WorkflowKey = workflowKeyForDocument(docs[i].TaskKey, activeKey, scopeTaskKeys)
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{
|
||||
"documents": docs,
|
||||
"active_workflow_key": activeKey,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDocGovGetDocumentContent 读取受控文档 Markdown 正文。
|
||||
|
||||
@@ -1185,6 +1185,7 @@ export default function App() {
|
||||
deliverables,
|
||||
visibleDeliverables,
|
||||
currentProjectDeliverables,
|
||||
activeWorkflowKey: currentWfState.activeKey || undefined,
|
||||
deliverablesRef,
|
||||
activeDocIdRef,
|
||||
editorHydrationGateRef,
|
||||
@@ -1383,6 +1384,7 @@ export default function App() {
|
||||
deliverables,
|
||||
visibleDeliverables,
|
||||
currentProjectDeliverables,
|
||||
activeWorkflowKey: currentWfState.activeKey || undefined,
|
||||
sessions,
|
||||
setDeliverables,
|
||||
setDocCache,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type WorkflowDocumentSummary = {
|
||||
project_id: number;
|
||||
task_key: string;
|
||||
title: string;
|
||||
workflow_key?: string;
|
||||
lifecycle_state: string;
|
||||
is_template_only: boolean;
|
||||
content_sha256?: string;
|
||||
@@ -26,6 +27,11 @@ export type WorkflowDocumentSummary = {
|
||||
duty_policy?: string;
|
||||
};
|
||||
|
||||
export type ProjectWorkflowDocumentsResponse = {
|
||||
documents: WorkflowDocumentSummary[];
|
||||
active_workflow_key?: string;
|
||||
};
|
||||
|
||||
export type ControlledDocConsistency =
|
||||
| 'aligned'
|
||||
| 'missing'
|
||||
@@ -55,7 +61,30 @@ export async function listProjectWorkflowDocuments(
|
||||
throw new Error(`list project workflow documents failed: ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
// 兼容旧数组响应与新 { documents, active_workflow_key } 包装。
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && Array.isArray(data.documents)) return data.documents;
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function listProjectWorkflowDocumentsDetailed(
|
||||
projectId: number,
|
||||
): Promise<ProjectWorkflowDocumentsResponse> {
|
||||
const resp = await fetch(
|
||||
`/api/v1/projects/${projectId}/documents?category=workflow`,
|
||||
{ headers: getBearerAuthHeaders() },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`list project workflow documents failed: ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (Array.isArray(data)) {
|
||||
return { documents: data };
|
||||
}
|
||||
return {
|
||||
documents: Array.isArray(data?.documents) ? data.documents : [],
|
||||
active_workflow_key: typeof data?.active_workflow_key === 'string' ? data.active_workflow_key : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class ControlledContentRequestError extends Error {
|
||||
|
||||
@@ -266,3 +266,46 @@ test('word-order variants of the intake sheet stay on intake, not the shorter UR
|
||||
}
|
||||
assert.equal(resolveTaskTemplateForMessage('请更新用户需求表 URS', pool)?.taskKey, 'bd_user_requirements');
|
||||
});
|
||||
|
||||
test('resolveTaskTemplateForMessage blocks cross-workflow document mentions', () => {
|
||||
const mainResearch: DeliverableItem = {
|
||||
...base,
|
||||
taskKey: 'bd_research',
|
||||
title: '调研报告',
|
||||
workflowKey: 'main',
|
||||
};
|
||||
const rdevProposal: DeliverableItem = {
|
||||
...base,
|
||||
taskKey: 'rd_proposal',
|
||||
title: '项目建议书',
|
||||
workflowKey: 'rdev',
|
||||
isTemplateOnly: true,
|
||||
};
|
||||
const pool = [mainResearch, rdevProposal];
|
||||
|
||||
const blocked = resolveTaskTemplateForMessage('编写项目建议书', pool, null, 'main');
|
||||
assert.ok(blocked?.wrongWorkflow);
|
||||
assert.equal(blocked?.wrongWorkflow?.ownerWorkflow, 'rdev');
|
||||
assert.equal(blocked?.taskKey, '');
|
||||
|
||||
const allowed = resolveTaskTemplateForMessage('编写项目建议书', pool, null, 'rdev');
|
||||
assert.equal(allowed?.wrongWorkflow, undefined);
|
||||
assert.equal(allowed?.taskKey, 'rd_proposal');
|
||||
|
||||
const legacy = resolveTaskTemplateForMessage('编写项目建议书', pool, null, undefined);
|
||||
assert.equal(legacy?.wrongWorkflow, undefined);
|
||||
assert.equal(legacy?.taskKey, 'rd_proposal');
|
||||
});
|
||||
|
||||
test('resolveTaskTemplateForMessage keeps in-scope main docs when active is main', () => {
|
||||
const research: DeliverableItem = {
|
||||
...base,
|
||||
taskKey: 'research',
|
||||
title: '调研报告',
|
||||
workflowKey: 'main',
|
||||
isTemplateOnly: true,
|
||||
};
|
||||
const resolved = resolveTaskTemplateForMessage('生成调研报告', [research], null, 'main');
|
||||
assert.equal(resolved?.wrongWorkflow, undefined);
|
||||
assert.equal(resolved?.taskKey, 'research');
|
||||
});
|
||||
|
||||
@@ -24,12 +24,83 @@ export type ResolvedDocumentTask = {
|
||||
template?: DeliverableItem;
|
||||
docId?: string;
|
||||
pinnedToActiveDoc?: boolean;
|
||||
/** 显式点名了非激活工作流的文档时填充;此时不应启动任务。 */
|
||||
wrongWorkflow?: {
|
||||
docTitle: string;
|
||||
ownerWorkflow: string;
|
||||
activeWorkflow: string;
|
||||
};
|
||||
};
|
||||
|
||||
const WORKFLOW_LABELS: Record<string, string> = {
|
||||
main: '商务对接流程',
|
||||
product_manager: '智能产品经理流程',
|
||||
test_manager: '智能测试工程师流程',
|
||||
rdev: '研发设计控制流程',
|
||||
software_bootstrap: '软件项目自举流程',
|
||||
software_delivery: '软件交付迭代流程',
|
||||
};
|
||||
|
||||
/** 交付物池外的他流文档别名(激活 main 却点名研发/产品经理文档时用于拦截)。 */
|
||||
const CROSS_WORKFLOW_ALIASES: { workflow: string; patterns: RegExp[] }[] = [
|
||||
{ workflow: 'rdev', patterns: [/项目建议书/, /设计开发计划任务书/, /系统设计方案/, /用户需求规格书/] },
|
||||
{ workflow: 'product_manager', patterns: [/系统需求规格书/] },
|
||||
{ workflow: 'test_manager', patterns: [/系统验证方案及计划/, /系统验证方案/] },
|
||||
];
|
||||
|
||||
export function workflowDisplayLabel(key: string): string {
|
||||
return WORKFLOW_LABELS[key] || key;
|
||||
}
|
||||
|
||||
function templatePool(deliverables: DeliverableItem[]): DeliverableItem[] {
|
||||
return deliverables.filter((d) => d.isTemplateOnly);
|
||||
}
|
||||
|
||||
/** 激活流匹配池:无激活键或文档未标注 workflowKey 时保持全量(存量兼容)。 */
|
||||
function scopedDeliverables(
|
||||
deliverables: DeliverableItem[],
|
||||
activeWorkflowKey?: string,
|
||||
): DeliverableItem[] {
|
||||
if (!activeWorkflowKey) return deliverables;
|
||||
return deliverables.filter((d) => !d.workflowKey || d.workflowKey === activeWorkflowKey);
|
||||
}
|
||||
|
||||
function detectWrongWorkflowMention(
|
||||
text: string,
|
||||
deliverables: DeliverableItem[],
|
||||
activeWorkflowKey: string,
|
||||
): ResolvedDocumentTask['wrongWorkflow'] | undefined {
|
||||
const outside = deliverables
|
||||
.filter((d) => d.workflowKey && d.workflowKey !== activeWorkflowKey && d.title?.trim())
|
||||
.sort((a, b) => (b.title?.length || 0) - (a.title?.length || 0));
|
||||
for (const d of outside) {
|
||||
if (text.includes(d.title)) {
|
||||
return {
|
||||
docTitle: d.title,
|
||||
ownerWorkflow: d.workflowKey!,
|
||||
activeWorkflow: activeWorkflowKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const alias of CROSS_WORKFLOW_ALIASES) {
|
||||
if (alias.workflow === activeWorkflowKey) continue;
|
||||
for (const p of alias.patterns) {
|
||||
if (p.test(text)) {
|
||||
return {
|
||||
docTitle: text.match(p)?.[0] || alias.patterns[0].source,
|
||||
ownerWorkflow: alias.workflow,
|
||||
activeWorkflow: activeWorkflowKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function wrongWorkflowGuidance(info: NonNullable<ResolvedDocumentTask['wrongWorkflow']>): string {
|
||||
return `您提到的文档「${info.docTitle}」属于【${workflowDisplayLabel(info.ownerWorkflow)}】,当前激活工作流为【${workflowDisplayLabel(info.activeWorkflow)}】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。`;
|
||||
}
|
||||
|
||||
/** 只读动词优先;无明确写动作则不启动文档任务。 */
|
||||
export function hasDocumentWriteIntent(text: string): boolean {
|
||||
const normalized = text.trim();
|
||||
@@ -226,24 +297,35 @@ export function matchTemplateDeliverable(
|
||||
* 合并文案解析与模板匹配。
|
||||
* 指示「当前受控文档」且 active 为已注册 doc_proj 时,钉住该文档 taskKey/docId,
|
||||
* 不被正文领域关键词改路由;显式点名另一交付物仍可切换。
|
||||
* activeWorkflowKey 非空时:匹配池收窄到激活流;显式点名他流文档返回 wrongWorkflow。
|
||||
*/
|
||||
export function resolveTaskTemplateForMessage(
|
||||
text: string,
|
||||
deliverables: DeliverableItem[],
|
||||
activeDoc?: DeliverableItem | null,
|
||||
activeWorkflowKey?: string,
|
||||
): ResolvedDocumentTask | undefined {
|
||||
if (!hasDocumentWriteIntent(text)) return undefined;
|
||||
|
||||
if (activeWorkflowKey) {
|
||||
const wrong = detectWrongWorkflowMention(text, deliverables, activeWorkflowKey);
|
||||
if (wrong) {
|
||||
return { taskKey: '', wrongWorkflow: wrong };
|
||||
}
|
||||
}
|
||||
|
||||
const scoped = scopedDeliverables(deliverables, activeWorkflowKey);
|
||||
|
||||
if (refersToCurrentControlledDocument(text) && isServerRegisteredControlledDoc(activeDoc)) {
|
||||
const pinnedKey = consistentTaskKeyForControlledDoc(activeDoc!);
|
||||
const named = explicitNamedDeliverableTaskKey(text, deliverables, pinnedKey);
|
||||
const named = explicitNamedDeliverableTaskKey(text, scoped, pinnedKey);
|
||||
if (named) {
|
||||
const taskKey = normalizeTaskKeyToProject(named, deliverables);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
|
||||
const taskKey = normalizeTaskKeyToProject(named, scoped);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, scoped, text);
|
||||
return { taskKey, template };
|
||||
}
|
||||
if (pinnedKey) {
|
||||
const template = matchTemplateDeliverableByKey(pinnedKey, deliverables, text);
|
||||
const template = matchTemplateDeliverableByKey(pinnedKey, scoped, text);
|
||||
return {
|
||||
taskKey: pinnedKey,
|
||||
template,
|
||||
@@ -254,22 +336,22 @@ export function resolveTaskTemplateForMessage(
|
||||
}
|
||||
|
||||
if (isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) {
|
||||
const taskKey = normalizeTaskKeyToProject('intake', deliverables);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
|
||||
const taskKey = normalizeTaskKeyToProject('intake', scoped);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, scoped, text);
|
||||
return { taskKey, template };
|
||||
}
|
||||
|
||||
const named = explicitNamedDeliverableTaskKey(text, deliverables);
|
||||
const named = explicitNamedDeliverableTaskKey(text, scoped);
|
||||
if (named) {
|
||||
const taskKey = normalizeTaskKeyToProject(named, deliverables);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
|
||||
const taskKey = normalizeTaskKeyToProject(named, scoped);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, scoped, text);
|
||||
return { taskKey, template };
|
||||
}
|
||||
|
||||
const rawKey = resolveTaskKeyFromMessage(text);
|
||||
if (!rawKey) return undefined;
|
||||
const taskKey = normalizeTaskKeyToProject(rawKey, deliverables);
|
||||
const template = matchTemplateDeliverable(text, deliverables);
|
||||
const taskKey = normalizeTaskKeyToProject(rawKey, scoped);
|
||||
const template = matchTemplateDeliverable(text, scoped);
|
||||
return { taskKey, template };
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isAffirmativeResponse,
|
||||
} from '../engine/cardActionEngine';
|
||||
import { markdownToBlockAST } from '../engine/markdownToBlock';
|
||||
import { resolveTaskTemplateForMessage } from '../engine/templateMatch';
|
||||
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
|
||||
import { createClientTurnId } from '../lib/createClientTurnId';
|
||||
import { canonicalDocIdForTask } from '../lib/controlledDocHydrate';
|
||||
import { holdsDocDuty } from '../lib/docGovActionVisibility';
|
||||
@@ -95,6 +95,8 @@ export interface SendMessageInput {
|
||||
deliverables: DeliverableItem[];
|
||||
visibleDeliverables: DeliverableItem[];
|
||||
currentProjectDeliverables: DeliverableItem[];
|
||||
/** 当前项目激活工作流键;用于文档任务匹配池收窄。 */
|
||||
activeWorkflowKey?: string;
|
||||
sessions: ProjectSession[];
|
||||
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
|
||||
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
|
||||
@@ -131,6 +133,7 @@ export function useSendMessage(input: SendMessageInput) {
|
||||
deliverables,
|
||||
visibleDeliverables,
|
||||
currentProjectDeliverables,
|
||||
activeWorkflowKey,
|
||||
sessions,
|
||||
setDeliverables,
|
||||
setDocCache,
|
||||
@@ -309,11 +312,27 @@ export function useSendMessage(input: SendMessageInput) {
|
||||
text,
|
||||
currentProjectDeliverables,
|
||||
sendActiveDoc,
|
||||
activeWorkflowKey,
|
||||
);
|
||||
if (resolvedTask?.wrongWorkflow) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'ast-wrong-wf-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '项目助手',
|
||||
content: wrongWorkflowGuidance(resolvedTask.wrongWorkflow!),
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
status: 'completed',
|
||||
},
|
||||
]);
|
||||
return { accepted: true };
|
||||
}
|
||||
const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut;
|
||||
|
||||
// P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复)
|
||||
if (resolvedTask) {
|
||||
if (resolvedTask?.taskKey) {
|
||||
const asstId = 'ast-' + Date.now();
|
||||
const currentProj = sessions.find((s) => s.sessionId === activeSessionId);
|
||||
// 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续
|
||||
|
||||
@@ -11,7 +11,7 @@ import { listProjectWorkflowDocuments } from '../api/projectDocuments';
|
||||
import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns';
|
||||
import { buildTaskRequest, streamTask } from '../api/taskStream';
|
||||
import { applyPhaseEvent } from '../engine/phaseEventMapper';
|
||||
import { resolveTaskTemplateForMessage } from '../engine/templateMatch';
|
||||
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
|
||||
import { GOVERNANCE_BLOCKED_HINT, isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy';
|
||||
import {
|
||||
controlledHydrateCandidateIds,
|
||||
@@ -68,6 +68,7 @@ export interface TaskRunAttemptsInput {
|
||||
deliverables: DeliverableItem[];
|
||||
visibleDeliverables: DeliverableItem[];
|
||||
currentProjectDeliverables: DeliverableItem[];
|
||||
activeWorkflowKey?: string;
|
||||
deliverablesRef: MutableRefObject<DeliverableItem[]>;
|
||||
activeDocIdRef: MutableRefObject<string>;
|
||||
editorHydrationGateRef: MutableRefObject<Gate>;
|
||||
@@ -99,6 +100,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
deliverables,
|
||||
visibleDeliverables,
|
||||
currentProjectDeliverables,
|
||||
activeWorkflowKey,
|
||||
deliverablesRef,
|
||||
activeDocIdRef,
|
||||
editorHydrationGateRef,
|
||||
@@ -803,8 +805,17 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
userText,
|
||||
currentProjectDeliverables,
|
||||
retryActive,
|
||||
activeWorkflowKey,
|
||||
);
|
||||
const retryTask = reResolved
|
||||
if (reResolved?.wrongWorkflow) {
|
||||
chatWindow.updateLiveMessage(messageId, {
|
||||
content: wrongWorkflowGuidance(reResolved.wrongWorkflow),
|
||||
livePhaseLabel: undefined,
|
||||
status: 'completed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const retryTask = reResolved?.taskKey
|
||||
? {
|
||||
...target.taskRequest,
|
||||
taskKey: reResolved.taskKey,
|
||||
|
||||
@@ -115,6 +115,7 @@ export function mergeWorkflowDocumentsFromServer(
|
||||
docId: s.doc_id,
|
||||
projectId,
|
||||
taskKey: s.task_key,
|
||||
workflowKey: s.workflow_key,
|
||||
title: s.title,
|
||||
stage: 'V0.5',
|
||||
category: 'workflow',
|
||||
@@ -136,6 +137,7 @@ export function mergeWorkflowDocumentsFromServer(
|
||||
|
||||
base.docId = s.doc_id;
|
||||
base.taskKey = s.task_key;
|
||||
base.workflowKey = s.workflow_key || base.workflowKey;
|
||||
base.title = s.title || base.title;
|
||||
base.isTemplateOnly = s.is_template_only;
|
||||
base.lifecycleState = mapLifecycleState(s.lifecycle_state);
|
||||
|
||||
@@ -307,6 +307,8 @@ export interface DeliverableItem {
|
||||
projectId: number;
|
||||
taskKey: string;
|
||||
title: string;
|
||||
/** 文档所属工作流键(main / product_manager / rdev …);缺省表示存量未标注。 */
|
||||
workflowKey?: string;
|
||||
stage: StageKey;
|
||||
category: DeliverableCategory;
|
||||
creatorId: number;
|
||||
|
||||
Reference in New Issue
Block a user