change v1

This commit is contained in:
lky-spec
2026-04-25 16:18:45 +08:00
parent 737f9bca95
commit c244e4cdf1
89 changed files with 17382 additions and 6198 deletions

View File

@@ -0,0 +1,409 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
type skillSemanticGateDecision struct {
Decision string `json:"decision,omitempty"`
Field string `json:"field,omitempty"`
Reason string `json:"reason,omitempty"`
}
func (a *Agent) evaluateHardSkillCandidate(ctx context.Context, storeUserID string, userID int64, lang string, session skillSession, skillName, action, text string) skillSemanticGateDecision {
fallback := fallbackSkillSemanticGate(skillName, action, session, text)
if a == nil || a.aiClient == nil {
return fallback
}
systemPrompt := `You are the second-stage semantic gate for one NOFXi hard skill.
Return JSON only. No markdown.
Decide exactly one:
- "execute": this request is concrete enough for this hard skill to handle now
- "explain": the user is asking about visible UI fields, options, requirements, or how to fill them
- "planner": this request is too open-ended, strategic, subjective, ambiguous, or not yet UI-ready for this hard skill
Rules:
- Use "execute" when the request maps to the current skill's visible fields, existing entity options, or a normal multi-turn form flow.
- Use "explain" when the user asks what fields/options exist, what a field means, or how to fill it.
- Use "planner" when the user asks for outcome-seeking advice like "make money", "don't lose", "best", "optimize", or otherwise asks you to design the solution before UI-level parameters are clear.
- Use "planner" when semantic readiness is not met: the route points to a skill/action, but the request is still missing core required fields and would otherwise mostly result in a mechanical missing-field error.
- Be conservative. If this hard skill would mostly respond with a misleading missing-field error, choose "planner" instead.
Return JSON:
{"decision":"execute|explain|planner","field":"","reason":""}`
userPrompt := fmt.Sprintf(
"Language: %s\nSkill: %s\nAction: %s\nActive skill session JSON: %s\nSemantic readiness summary:\n%s\nVisible field summary:\n%s\nVisible option summary:\n%s\nDomain primer:\n%s\nUser message: %s",
lang,
skillName,
action,
mustMarshalJSON(session),
a.skillSemanticReadinessSummary(lang, session, skillName, action, text),
a.skillVisibleFieldSummary(storeUserID, lang, skillName, action),
a.skillVisibleOptionSummary(storeUserID, lang, skillName, action),
buildSkillDomainPrimer(lang, skillName),
text,
)
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return fallback
}
if parsed, ok := parseSkillSemanticGateDecision(raw); ok {
if parsed.Field == "" {
parsed.Field = detectSkillQuestionField(skillName, text, session)
}
return parsed
}
return fallback
}
func parseSkillSemanticGateDecision(raw string) (skillSemanticGateDecision, bool) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var out skillSemanticGateDecision
if err := json.Unmarshal([]byte(raw), &out); err != nil {
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start || json.Unmarshal([]byte(raw[start:end+1]), &out) != nil {
return skillSemanticGateDecision{}, false
}
}
out.Decision = strings.TrimSpace(strings.ToLower(out.Decision))
out.Field = strings.TrimSpace(out.Field)
out.Reason = strings.TrimSpace(out.Reason)
switch out.Decision {
case "execute", "explain", "planner":
return out, true
default:
return skillSemanticGateDecision{}, false
}
}
func fallbackSkillSemanticGate(skillName, action string, session skillSession, text string) skillSemanticGateDecision {
if looksLikeExplanationQuestion(text) {
return skillSemanticGateDecision{
Decision: "explain",
Field: "",
}
}
if strings.TrimSpace(session.Name) == skillName && strings.TrimSpace(session.Action) != "" {
return skillSemanticGateDecision{Decision: "execute"}
}
if isSimpleEntityMutationAction(action) && !hasExplicitSkillCueForSemanticGate(skillName, text) {
return skillSemanticGateDecision{Decision: "planner", Reason: "requires_llm_intent_resolution"}
}
if missing := semanticReadinessMissingSlots(skillName, action, session, text); len(missing) > 0 {
return skillSemanticGateDecision{Decision: "planner", Reason: "semantic_readiness_missing_core_fields"}
}
return skillSemanticGateDecision{Decision: "execute"}
}
func hasExplicitSkillCueForSemanticGate(skillName, text string) bool {
switch strings.TrimSpace(skillName) {
case "trader_management":
return hasExplicitManagementDomainCue(text, "trader") || hasExplicitCreateIntentForDomain(text, "trader")
case "exchange_management":
return hasExplicitManagementDomainCue(text, "exchange")
case "model_management":
return hasExplicitManagementDomainCue(text, "model")
case "strategy_management":
return hasExplicitManagementDomainCue(text, "strategy")
default:
return false
}
}
func semanticReadinessMissingSlots(skillName, action string, session skillSession, text string) []string {
if strings.TrimSpace(action) == "" {
return nil
}
if strings.TrimSpace(session.Name) == skillName && strings.TrimSpace(session.Action) != "" {
return nil
}
values := map[string]string{}
missing := missingRequiredActionSlots(skillName, action, values)
if action != "create" {
return nil
}
if skillName == "strategy_management" {
return nil
}
if skillName == "model_management" {
coreMissing := missingCoreReadinessSlots([]string{"provider"}, values)
if len(coreMissing) > 0 {
return coreMissing
}
return nil
}
if skillName == "exchange_management" {
coreMissing := missingCoreReadinessSlots([]string{"exchange_type", "account_name", "api_key", "secret_key"}, values)
if len(coreMissing) > 0 {
return coreMissing
}
return nil
}
if len(missing) == 0 || len(missing) == len(missingRequiredActionSlots(skillName, action, map[string]string{})) {
if hasExplicitCreateIntentForDomain(text, "trader") || containsAny(strings.ToLower(text), []string{"创建", "新建", "create", "new"}) {
return nil
}
}
if len(missing) >= 2 {
return missing
}
return nil
}
func missingCoreReadinessSlots(keys []string, values map[string]string) []string {
missing := make([]string, 0, len(keys))
for _, key := range keys {
if strings.TrimSpace(values[key]) == "" {
missing = append(missing, key)
}
}
return missing
}
func (a *Agent) skillSemanticReadinessSummary(lang string, session skillSession, skillName, action, text string) string {
missing := semanticReadinessMissingSlots(skillName, action, session, text)
if len(missing) == 0 {
if lang == "zh" {
return "当前语义已足够进入该 skill。"
}
return "Semantic readiness is sufficient for this skill."
}
display := make([]string, 0, len(missing))
for _, slot := range missing {
display = append(display, slotDisplayName(slot, lang))
}
if lang == "zh" {
return "当前语义还缺核心字段:" + strings.Join(display, "、") + "。如果直接执行只会变成程序式缺字段提示,应优先走 planner/ask_user。"
}
return "Core semantic fields are still missing: " + strings.Join(display, ", ") + ". Prefer planner/ask_user before direct execution."
}
func detectSkillQuestionField(skillName, text string, session skillSession) string {
return ""
}
func (a *Agent) skillVisibleFieldSummary(storeUserID, lang, skillName, action string) string {
fieldNames := make([]string, 0, 20)
add := func(field string) {
field = strings.TrimSpace(field)
if field == "" {
return
}
for _, existing := range fieldNames {
if existing == field {
return
}
}
fieldNames = append(fieldNames, field)
}
switch skillName {
case "model_management":
if lang == "zh" {
add("Provider")
} else {
add("provider")
}
add(displayCatalogFieldName("name", lang))
for _, field := range manualModelEditableFieldKeys() {
add(displayCatalogFieldName(field, lang))
}
case "exchange_management":
add(slotDisplayName("exchange_type", lang))
for _, field := range manualExchangeEditableFieldKeys() {
add(displayCatalogFieldName(field, lang))
}
case "trader_management":
add(slotDisplayName("name", lang))
add(slotDisplayName("exchange", lang))
add(slotDisplayName("model", lang))
add(slotDisplayName("strategy", lang))
for _, field := range manualTraderEditableFieldKeys() {
add(displayCatalogFieldName(field, lang))
}
case "strategy_management":
add(slotDisplayName("name", lang))
for _, field := range manualStrategyEditableFieldKeys() {
add(strategyConfigFieldDisplayName(field, lang))
}
}
if len(fieldNames) == 0 {
return ""
}
prefix := "Visible UI fields"
if lang == "zh" {
prefix = "当前可见字段"
}
return prefix + "" + strings.Join(fieldNames, "、")
}
func (a *Agent) skillVisibleOptionSummary(storeUserID, lang, skillName, action string) string {
switch skillName {
case "model_management":
return a.modelSkillOptionSummary(lang)
case "exchange_management":
return a.exchangeSkillOptionSummary(lang)
case "trader_management":
return a.traderSkillOptionSummary(storeUserID, lang)
case "strategy_management":
return a.strategySkillOptionSummary(storeUserID, lang)
default:
return ""
}
}
func (a *Agent) modelSkillOptionSummary(lang string) string {
if lang == "zh" {
return modelProviderChoicePrompt(lang)
}
return modelProviderChoicePrompt(lang)
}
func (a *Agent) exchangeSkillOptionSummary(lang string) string {
options := enumOptionValues("exchange_management", "exchange_type")
if len(options) == 0 {
options = []string{"Binance", "Bybit", "OKX", "Bitget", "Gate", "KuCoin", "Hyperliquid", "Aster", "Lighter", "Indodax"}
}
if lang == "zh" {
return "交易所类型选项:" + strings.Join(options, "、")
}
return "Exchange type options: " + strings.Join(options, ", ")
}
func enumOptionValues(skillName, field string) []string {
def, ok := getSkillDefinition(skillName)
if !ok {
return nil
}
constraint, ok := def.FieldConstraints[field]
if !ok || len(constraint.Values) == 0 {
return nil
}
values := make([]string, 0, len(constraint.Values))
for _, value := range constraint.Values {
if value == "" {
continue
}
switch value {
case "openai":
values = append(values, "OpenAI")
case "deepseek":
values = append(values, "DeepSeek")
case "claude":
values = append(values, "Claude")
case "gemini":
values = append(values, "Gemini")
case "qwen":
values = append(values, "Qwen")
case "kimi":
values = append(values, "Kimi")
case "grok":
values = append(values, "Grok")
case "minimax":
values = append(values, "Minimax")
case "binance":
values = append(values, "Binance")
case "okx":
values = append(values, "OKX")
case "bybit":
values = append(values, "Bybit")
case "gate":
values = append(values, "Gate")
case "kucoin":
values = append(values, "KuCoin")
case "bitget":
values = append(values, "Bitget")
case "hyperliquid":
values = append(values, "Hyperliquid")
case "aster":
values = append(values, "Aster")
case "lighter":
values = append(values, "Lighter")
case "indodax":
values = append(values, "Indodax")
default:
values = append(values, value)
}
}
return values
}
func (a *Agent) traderSkillOptionSummary(storeUserID, lang string) string {
parts := []string{
formatSkillOptionList(lang, "可选模型", "Available models", a.loadEnabledModelOptions(storeUserID)),
formatSkillOptionList(lang, "可选交易所", "Available exchanges", a.loadExchangeOptions(storeUserID)),
formatSkillOptionList(lang, "可选策略", "Available strategies", a.loadStrategyOptions(storeUserID)),
}
return strings.Join(filterNonEmptyStrings(parts), "\n")
}
func (a *Agent) strategySkillOptionSummary(storeUserID, lang string) string {
parts := []string{
"",
formatSkillOptionList(lang, "现有策略", "Existing strategies", a.loadStrategyOptions(storeUserID)),
}
sourceOptions := []string{"static", "ai500", "oi_top", "oi_low"}
if lang == "zh" {
parts[0] = "选币来源选项static、ai500、oi_top、oi_low"
} else {
parts[0] = "Coin source options: static, ai500, oi_top, oi_low"
}
_ = sourceOptions
return strings.Join(filterNonEmptyStrings(parts), "\n")
}
func formatSkillOptionList(lang, zhPrefix, enPrefix string, options []traderSkillOption) string {
names := make([]string, 0, len(options))
for _, option := range options {
label := strings.TrimSpace(defaultIfEmpty(option.Name, option.ID))
if label == "" {
continue
}
names = append(names, label)
}
if len(names) == 0 {
if lang == "zh" {
return zhPrefix + ":暂无"
}
return enPrefix + ": none"
}
if lang == "zh" {
return zhPrefix + "" + strings.Join(names, "、")
}
return enPrefix + ": " + strings.Join(names, ", ")
}
func filterNonEmptyStrings(items []string) []string {
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
out = append(out, item)
}
return out
}