0869060284
- trigger-detector: strip code blocks before pattern matching (triggers outside code blocks now fire) - trigger-detector: use code-stripped content for quote and reported-speech anti-trigger checks - state-injector: strip markdown list prefix from blocker text Tests: 27/27 trigger-detector, 17/17 state-injector passing
215 lines
5.3 KiB
TypeScript
215 lines
5.3 KiB
TypeScript
/**
|
||
* Trigger detection for goal-driven-loop.
|
||
*
|
||
* Detects goal-setting phrases in user input and validates them
|
||
* against anti-trigger rules.
|
||
*/
|
||
|
||
export interface TriggerMatch {
|
||
goalText: string;
|
||
rawCriteria?: string;
|
||
}
|
||
|
||
/**
|
||
* Trigger phrases — order matters (longer patterns first to avoid
|
||
* "当前目标是" matching "是" alone).
|
||
*/
|
||
const TRIGGER_PATTERNS: RegExp[] = [
|
||
// Chinese
|
||
/设定当前目标为\s*(.+)/,
|
||
/把当前目标设为\s*(.+)/,
|
||
/当前目标是\s*(.+)/,
|
||
/我的目标是\s*(.+)/,
|
||
// English
|
||
/set\s+(?:the\s+)?current\s+goal\s+to\s+(.+)/i,
|
||
/current\s+goal\s+is\s+(.+)/i,
|
||
/my\s+goal\s+is\s+(.+)/i,
|
||
/the\s+goal\s+is\s+(.+)/i,
|
||
// Uncertainty fallback: commitment language + deliverable
|
||
/(?:goal|目标)\s*(?:is|为|是)\s+(?:(?:to|去)\s+)?(.+)/i,
|
||
];
|
||
|
||
const TRIVIAL_KEYWORDS = [
|
||
"hotfix",
|
||
"tweak",
|
||
"fix a bug",
|
||
"fix bug",
|
||
"small change",
|
||
"quick fix",
|
||
"修个bug",
|
||
"小改",
|
||
];
|
||
|
||
const ARCHITECTURAL_KEYWORDS = [
|
||
"architecture",
|
||
"architectural",
|
||
"module",
|
||
"system",
|
||
"refactor",
|
||
"integration",
|
||
"重构",
|
||
"模块",
|
||
"系统",
|
||
"集成",
|
||
];
|
||
|
||
const REPORTED_SPEECH_MARKERS = [
|
||
"he said",
|
||
"she said",
|
||
"they said",
|
||
"he says",
|
||
"she says",
|
||
"they say",
|
||
"他说",
|
||
"她说",
|
||
"他们说",
|
||
];
|
||
|
||
/**
|
||
* Detect if the input is a goal-setting trigger and extract the goal text.
|
||
* Returns null if no trigger or if anti-trigger rules match.
|
||
*/
|
||
export function detectTrigger(input: string): TriggerMatch | null {
|
||
// Anti-trigger: code block — remove code blocks from input before checking
|
||
// for trigger phrases, so triggers outside code blocks still fire.
|
||
const inputWithoutCode = input.replace(/```[\s\S]*?```/g, "");
|
||
|
||
// Anti-trigger: markdown quote block — only block if trigger phrase appears
|
||
// exclusively inside quote lines (no trigger in non-quote content)
|
||
const nonQuoteLines = inputWithoutCode
|
||
.split("\n")
|
||
.filter((line) => !/^\s*>/.test(line));
|
||
const nonQuoteContent = nonQuoteLines.join("\n");
|
||
const hasTriggerOutsideQuote = TRIGGER_PATTERNS.some((p) =>
|
||
p.test(nonQuoteContent)
|
||
);
|
||
if (!hasTriggerOutsideQuote) {
|
||
// All trigger phrases are inside quotes or absent — block
|
||
return null;
|
||
}
|
||
|
||
// Use the code-stripped, non-quote content for further checks
|
||
const effectiveInput = nonQuoteContent;
|
||
|
||
// Anti-trigger: reported speech — check per-sentence
|
||
const lowerInput = effectiveInput.toLowerCase();
|
||
const hasReportedSpeech = REPORTED_SPEECH_MARKERS.some((marker) =>
|
||
lowerInput.includes(marker)
|
||
);
|
||
if (hasReportedSpeech) {
|
||
const sentences = effectiveInput.split(/[.!?。!?]/);
|
||
for (const sentence of sentences) {
|
||
const lowerSentence = sentence.toLowerCase();
|
||
const hasMarker = REPORTED_SPEECH_MARKERS.some((m) =>
|
||
lowerSentence.includes(m)
|
||
);
|
||
const hasTrigger = TRIGGER_PATTERNS.some((p) => p.test(sentence));
|
||
if (hasMarker && hasTrigger) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Anti-trigger: trivial task keywords
|
||
const hasTrivialKeyword = TRIVIAL_KEYWORDS.some((kw) =>
|
||
lowerInput.includes(kw.toLowerCase())
|
||
);
|
||
if (hasTrivialKeyword) {
|
||
return null;
|
||
}
|
||
|
||
// Try to match trigger pattern
|
||
for (const pattern of TRIGGER_PATTERNS) {
|
||
const match = effectiveInput.match(pattern);
|
||
if (match) {
|
||
const fullText = match[1].trim();
|
||
|
||
// Validate: must be non-empty and not a short fragment
|
||
if (fullText.length < 3) {
|
||
return null;
|
||
}
|
||
|
||
// Anti-trigger: short description without architectural keywords
|
||
if (fullText.length <= 10) {
|
||
const hasArchitectural = ARCHITECTURAL_KEYWORDS.some((kw) =>
|
||
fullText.toLowerCase().includes(kw.toLowerCase())
|
||
);
|
||
if (!hasArchitectural) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Try to extract acceptance criteria
|
||
const criteriaMatch = fullText.match(
|
||
/(?:验收条件[是为::]\s*|acceptance\s*(?:criteria)?\s*[::]\s*)(.+)$/i
|
||
);
|
||
|
||
if (criteriaMatch) {
|
||
const goalText = fullText.substring(0, criteriaMatch.index).trim();
|
||
return {
|
||
goalText: cleanGoalText(goalText),
|
||
rawCriteria: criteriaMatch[1].trim(),
|
||
};
|
||
}
|
||
|
||
return {
|
||
goalText: cleanGoalText(fullText),
|
||
};
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function cleanGoalText(text: string): string {
|
||
return text.replace(/[.,;:。;,]+$/, "").trim();
|
||
}
|
||
|
||
/**
|
||
* Determine if a goal is coarse-grained (should delegate to comet).
|
||
*/
|
||
export function isCoarseGrained(goalText: string): boolean {
|
||
const lower = goalText.toLowerCase();
|
||
|
||
const multiModuleSignals = [
|
||
"across",
|
||
"throughout",
|
||
"整个",
|
||
"全",
|
||
"all modules",
|
||
"multiple",
|
||
"multiple files",
|
||
"多模块",
|
||
];
|
||
for (const signal of multiModuleSignals) {
|
||
if (lower.includes(signal)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
const fileCountMatch = goalText.match(/(\d+)\s*(?:个)?\s*(?:file|files|文件)/i);
|
||
if (fileCountMatch) {
|
||
const count = parseInt(fileCountMatch[1], 10);
|
||
if (count > 5) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
const architecturalSignals = [
|
||
"redesign",
|
||
"rewrite",
|
||
"migrate",
|
||
"重构",
|
||
"迁移",
|
||
"重写",
|
||
"重新设计",
|
||
];
|
||
for (const signal of architecturalSignals) {
|
||
if (lower.includes(signal.toLowerCase())) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|