Forge
csharp4405de34
1#nullable enable
2using System.Text;
3
4namespace CascadeIDE.Services;
5
6internal enum MafRoutingGoal
7{
8 Unknown = 0,
9 Coding,
10 Debug,
11 Review,
12 Planning,
13}
14
15internal enum MafRoutingLevel
16{
17 Unknown = 0,
18 System,
19 Container,
20 Component,
21 Code,
22}
23
24internal sealed record MafPromptRoutingState(
25 MafRoutingGoal Goal,
26 MafRoutingLevel Level,
27 int Uncertainty,
28 int Risk,
29 double Confidence,
30 bool ToolHeavy,
31 bool ErrorHeavy,
32 bool PlanningHeavy,
33 IReadOnlyList<string> Evidence);
34
35internal sealed record MafPromptPackSelection(
36 string Key,
37 string Text,
38 int Score,
39 IReadOnlyList<string> Reasons);
40
41internal sealed record MafPromptPackRouteResult(
42 MafPromptRoutingState State,
43 IReadOnlyList<MafPromptPackSelection> Selections);
44
45internal static class MafPromptPackRouter
46{
47 private const int BaseSelectionThreshold = 48;
48 private const double LowConfidenceFallbackThreshold = 0.28;
49 private const double SessionHistoryDecay = 0.85;
50 private static readonly object SessionHistoryGate = new();
51 private static readonly Dictionary<string, double> SessionSuccessScores = new(StringComparer.OrdinalIgnoreCase);
52
53 private static readonly string[] CodingWords = ["implement", "feature", "refactor", "добав", "сделай", "реализ", "напис"];
54 private static readonly string[] DebugWords = ["bug", "debug", "error", "stack", "fix", "падает", "ошибка", "почини", "не работает"];
55 private static readonly string[] ReviewWords = ["review", "audit", "risk", "проверь", "ревью", "регресс"];
56 private static readonly string[] PlanWords = ["plan", "strategy", "design", "архитект", "подход", "спланируй"];
57
58 private static readonly string[] SystemWords = ["system", "архитектур", "end-to-end", "domain", "границ", "c4"];
59 private static readonly string[] ContainerWords = ["service", "module", "subsystem", "слой", "контейнер"];
60 private static readonly string[] ComponentWords = ["class", "component", "viewmodel", "handler", "метод", "класс"];
61 private static readonly string[] CodeWords = [".cs", ".axaml", "line", "method", "property", "файл", "код"];
62
63 private static readonly string[] UncertaintyWords = ["?", "maybe", "думаю", "наверное", "не увер", "кажется"];
64 private static readonly string[] RiskWords = ["prod", "production", "migration", "security", "data loss", "critical", "боев", "безопас"];
65
66 private sealed record PackProfile(
67 string Key,
68 MafRoutingGoal Goal,
69 MafRoutingLevel MinLevel,
70 string[] EvidenceWords);
71
72 private static readonly IReadOnlyList<PackProfile> Profiles =
73 [
74 new("pack_mode_coding", MafRoutingGoal.Coding, MafRoutingLevel.Component, ["change", "implement", "реализ", "добав"]),
75 new("pack_mode_debug", MafRoutingGoal.Debug, MafRoutingLevel.Component, ["error", "debug", "ошибка", "падает"]),
76 new("pack_mode_review", MafRoutingGoal.Review, MafRoutingLevel.Component, ["review", "risk", "ревью", "регресс"]),
77 new("pack_domain_secret_full", MafRoutingGoal.Planning, MafRoutingLevel.System, ["secret mode", "гипотеза", "доказ"]),
78 new("pack_domain_csharp_roslyn", MafRoutingGoal.Coding, MafRoutingLevel.Code, [".cs", "roslyn", "diagnostic", "analyzer", "dotnet"]),
79 new("pack_domain_git", MafRoutingGoal.Coding, MafRoutingLevel.Component, ["git", "commit", "push", "merge", "rebase"]),
80 ];
81
82 internal static MafPromptPackRouteResult Route(
83 MafIdeAgentPrompts.PromptPack prompts,
84 IReadOnlyList<ChatMessage> cascadeConversation,
85 string? minimizedContextBlock,
86 int budgetChars)
87 {
88 var query = (GetLastUserMessage(cascadeConversation) ?? "").Trim();
89 var normalized = query.ToLowerInvariant();
90 var context = (minimizedContextBlock ?? "").ToLowerInvariant();
91
92 var state = InferState(cascadeConversation, normalized, context);
93 var selections = SelectPacks(prompts, state, normalized, context, budgetChars);
94 return new MafPromptPackRouteResult(state, selections);
95 }
96
97 internal static string FormatRoutingStateForPrompt(MafPromptRoutingState state)
98 {
99 var sb = new StringBuilder();
100 sb.Append("Top-Down: goal=").Append(state.Goal)
101 .Append(", level=").Append(state.Level)
102 .Append(", uncertainty=").Append(state.Uncertainty)
103 .Append(", risk=").Append(state.Risk)
104 .Append(", toolHeavy=").Append(state.ToolHeavy ? "yes" : "no")
105 .Append(", errorHeavy=").Append(state.ErrorHeavy ? "yes" : "no")
106 .Append(", planningHeavy=").Append(state.PlanningHeavy ? "yes" : "no")
107 .Append(", confidence=").Append(state.Confidence.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture))
108 .AppendLine(".");
109 if (state.Evidence.Count > 0)
110 sb.Append("Bottom-Up evidence: ").Append(string.Join("; ", state.Evidence)).Append('.');
111 return sb.ToString().TrimEnd();
112 }
113
114 private static MafPromptRoutingState InferState(IReadOnlyList<ChatMessage> cascadeConversation, string query, string context)
115 {
116 var evidence = new List<string>();
117 var (toolHeavy, errorHeavy, planningHeavy) = AnalyzeRecentConversation(cascadeConversation, evidence);
118
119 var goalScores = new Dictionary<MafRoutingGoal, int>
120 {
121 [MafRoutingGoal.Coding] = ScoreWords(query, CodingWords, evidence, "goal:coding"),
122 [MafRoutingGoal.Debug] = ScoreWords(query, DebugWords, evidence, "goal:debug"),
123 [MafRoutingGoal.Review] = ScoreWords(query, ReviewWords, evidence, "goal:review"),
124 [MafRoutingGoal.Planning] = ScoreWords(query, PlanWords, evidence, "goal:planning"),
125 };
126
127 if (errorHeavy)
128 goalScores[MafRoutingGoal.Debug] += 3;
129 if (planningHeavy)
130 goalScores[MafRoutingGoal.Planning] += 2;
131 if (toolHeavy && !errorHeavy)
132 goalScores[MafRoutingGoal.Coding] += 1;
133
134 var levelScores = new Dictionary<MafRoutingLevel, int>
135 {
136 [MafRoutingLevel.System] = ScoreWords(query, SystemWords, evidence, "level:system"),
137 [MafRoutingLevel.Container] = ScoreWords(query, ContainerWords, evidence, "level:container"),
138 [MafRoutingLevel.Component] = ScoreWords(query, ComponentWords, evidence, "level:component"),
139 [MafRoutingLevel.Code] = ScoreWords(query, CodeWords, evidence, "level:code"),
140 };
141
142 if (context.Contains(".cs", StringComparison.Ordinal) || context.Contains("using ", StringComparison.Ordinal))
143 {
144 levelScores[MafRoutingLevel.Code] += 2;
145 evidence.Add("context:code-snippets");
146 }
147
148 var (goal, goalScore, goalSecond) = PickTop(goalScores);
149 var (level, levelScore, levelSecond) = PickTop(levelScores);
150 var uncertainty = Math.Min(100, ScoreWords(query, UncertaintyWords, evidence, "uncertainty") * 12 + (query.Contains('?') ? 15 : 0));
151 var risk = Math.Min(100, ScoreWords(query, RiskWords, evidence, "risk") * 15);
152
153 var confidenceRaw = ((goalScore - goalSecond) * 0.45) + ((levelScore - levelSecond) * 0.35) + (Math.Max(0, 40 - uncertainty) * 0.2);
154 var confidence = Math.Clamp(confidenceRaw / 40.0, 0.05, 0.99);
155
156 return new MafPromptRoutingState(goal, level, uncertainty, risk, confidence, toolHeavy, errorHeavy, planningHeavy, evidence);
157 }
158
159 private static List<MafPromptPackSelection> SelectPacks(
160 MafIdeAgentPrompts.PromptPack prompts,
161 MafPromptRoutingState state,
162 string query,
163 string context,
164 int budgetChars)
165 {
166 if (state.Confidence < LowConfidenceFallbackThreshold)
167 return [];
168
169 var candidates = new List<MafPromptPackSelection>();
170 foreach (var profile in Profiles)
171 {
172 if (!prompts.TryGetOptionalSection(profile.Key, out var sectionText))
173 continue;
174
175 var reasons = new List<string>();
176 var score = 0;
177
178 if (profile.Goal == state.Goal)
179 {
180 score += 40;
181 reasons.Add("goal-match");
182 }
183
184 if (state.Level >= profile.MinLevel)
185 {
186 score += 22;
187 reasons.Add("level-fit");
188 }
189
190 if (state.ErrorHeavy && profile.Key == "pack_mode_debug")
191 {
192 score += 15;
193 reasons.Add("recent-error-heavy");
194 }
195
196 if (state.ToolHeavy && profile.Key is "pack_mode_coding" or "pack_domain_csharp_roslyn")
197 {
198 score += 8;
199 reasons.Add("recent-tool-heavy");
200 }
201
202 if (state.PlanningHeavy && profile.Key == "pack_domain_secret_full")
203 {
204 score += 10;
205 reasons.Add("recent-planning-heavy");
206 }
207
208 var evHits = CountHits(query, profile.EvidenceWords) + CountHits(context, profile.EvidenceWords);
209 if (evHits > 0)
210 {
211 score += evHits * 9;
212 reasons.Add("evidence-hits=" + evHits);
213 }
214
215 if (profile.Key == "pack_domain_secret_full" && state.Confidence < 0.55 && state.Uncertainty >= 20)
216 {
217 score += 12;
218 reasons.Add("low-confidence-guardrail");
219 }
220
221 if (profile.Key == "pack_domain_csharp_roslyn" &&
222 (query.Contains("c#", StringComparison.Ordinal) || query.Contains(".cs", StringComparison.Ordinal) ||
223 context.Contains(".cs", StringComparison.Ordinal)))
224 {
225 score += 18;
226 reasons.Add("csharp-context");
227 }
228
229 var sessionBoost = GetSessionHistoryBoost(profile.Key);
230 if (sessionBoost > 0)
231 {
232 score += sessionBoost;
233 reasons.Add("session-history+" + sessionBoost);
234 }
235
236 if (profile.Key == "pack_mode_debug" && state.Goal == MafRoutingGoal.Planning)
237 {
238 score -= 8;
239 reasons.Add("goal-mismatch-penalty");
240 }
241
242 if (score < BaseSelectionThreshold)
243 continue;
244
245 candidates.Add(new MafPromptPackSelection(profile.Key, sectionText.Trim(), score, reasons));
246 }
247
248 var ordered = candidates
249 .OrderByDescending(x => x.Score)
250 .ThenBy(x => x.Key, StringComparer.Ordinal)
251 .ToList();
252
253 var selected = new List<MafPromptPackSelection>();
254 var used = 0;
255 foreach (var candidate in ordered)
256 {
257 if (state.Confidence < 0.35 && candidate.Score < 55)
258 continue;
259
260 var projected = used + candidate.Text.Length;
261 if (projected > budgetChars)
262 continue;
263
264 selected.Add(candidate);
265 used = projected;
266 }
267
268 RegisterSelections(selected);
269 return selected;
270 }
271
272 private static (bool ToolHeavy, bool ErrorHeavy, bool PlanningHeavy) AnalyzeRecentConversation(
273 IReadOnlyList<ChatMessage> cascadeConversation,
274 List<string> evidence)
275 {
276 var toolCount = 0;
277 var errorCount = 0;
278 var planningCount = 0;
279 var considered = 0;
280
281 for (var i = cascadeConversation.Count - 1; i >= 0 && considered < 8; i--)
282 {
283 var message = cascadeConversation[i];
284 considered++;
285 var role = (message.Role ?? "").Trim();
286 var content = (message.Content ?? "").ToLowerInvariant();
287
288 if (string.Equals(role, "tool", StringComparison.OrdinalIgnoreCase))
289 toolCount++;
290 if (content.Contains("error", StringComparison.Ordinal) || content.Contains("exception", StringComparison.Ordinal) ||
291 content.Contains("ошибка", StringComparison.Ordinal) || content.Contains("failed", StringComparison.Ordinal))
292 errorCount++;
293 if (content.Contains("plan", StringComparison.Ordinal) || content.Contains("шаг", StringComparison.Ordinal) ||
294 content.Contains("подход", StringComparison.Ordinal) || content.Contains("стратег", StringComparison.Ordinal))
295 planningCount++;
296 }
297
298 var toolHeavy = considered > 0 && toolCount >= Math.Max(2, considered / 3);
299 var errorHeavy = considered > 0 && errorCount >= Math.Max(1, considered / 4);
300 var planningHeavy = considered > 0 && planningCount >= Math.Max(1, considered / 4);
301
302 if (toolHeavy)
303 evidence.Add($"recent:tool-heavy({toolCount}/{considered})");
304 if (errorHeavy)
305 evidence.Add($"recent:error-heavy({errorCount}/{considered})");
306 if (planningHeavy)
307 evidence.Add($"recent:planning-heavy({planningCount}/{considered})");
308
309 return (toolHeavy, errorHeavy, planningHeavy);
310 }
311
312 private static int GetSessionHistoryBoost(string packKey)
313 {
314 lock (SessionHistoryGate)
315 {
316 foreach (var key in SessionSuccessScores.Keys.ToArray())
317 SessionSuccessScores[key] *= SessionHistoryDecay;
318
319 if (!SessionSuccessScores.TryGetValue(packKey, out var value))
320 return 0;
321 return (int)Math.Clamp(Math.Round(value), 0, 12);
322 }
323 }
324
325 private static void RegisterSelections(IReadOnlyList<MafPromptPackSelection> selections)
326 {
327 if (selections.Count == 0)
328 return;
329
330 lock (SessionHistoryGate)
331 {
332 foreach (var selection in selections)
333 {
334 SessionSuccessScores.TryGetValue(selection.Key, out var current);
335 SessionSuccessScores[selection.Key] = Math.Clamp(current + 3.0, 0.0, 24.0);
336 }
337 }
338 }
339
340 private static int ScoreWords(string text, IEnumerable<string> words, List<string> evidence, string tag)
341 {
342 var score = 0;
343 foreach (var word in words)
344 {
345 if (!text.Contains(word, StringComparison.Ordinal))
346 continue;
347 score++;
348 evidence.Add($"{tag}:{word}");
349 }
350
351 return score;
352 }
353
354 private static int CountHits(string text, IEnumerable<string> words)
355 {
356 var hits = 0;
357 foreach (var word in words)
358 {
359 if (text.Contains(word, StringComparison.Ordinal))
360 hits++;
361 }
362
363 return hits;
364 }
365
366 private static (T Key, int Max, int Second) PickTop<T>(IReadOnlyDictionary<T, int> map) where T : notnull
367 {
368 var maxKey = map.Keys.First();
369 var max = int.MinValue;
370 var second = int.MinValue;
371 foreach (var pair in map)
372 {
373 if (pair.Value > max)
374 {
375 second = max;
376 max = pair.Value;
377 maxKey = pair.Key;
378 }
379 else if (pair.Value > second)
380 {
381 second = pair.Value;
382 }
383 }
384
385 if (max <= 0)
386 return (map.Keys.First(), 0, 0);
387 if (second == int.MinValue)
388 second = 0;
389 return (maxKey, max, second);
390 }
391
392 private static string? GetLastUserMessage(IReadOnlyList<ChatMessage> cascadeConversation)
393 {
394 for (var i = cascadeConversation.Count - 1; i >= 0; i--)
395 {
396 var message = cascadeConversation[i];
397 if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase))
398 continue;
399 var text = (message.Content ?? "").Trim();
400 if (text.Length > 0)
401 return text;
402 }
403
404 return null;
405 }
406}
407
View only · write via MCP/CIDE