Files
pi-skills/.pi/extensions/goal-driven-loop/trigger-detector.ts
T
张宗平 0869060284 fix(ext): correct anti-trigger logic for code/quote blocks + blocker parsing
- 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
2026-06-10 20:31:56 +08:00

215 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}