Version: 0.9.45.dev.260427
后端: 1. execute 主链路重构为“上下文工具域 + 主动优化候选闭环”——移除 order_guard,粗排后默认进入主动微调,先诊断再从后端候选中选择 move/swap,避免 LLM 自由全局乱搜 2. 工具体系升级为动态注入协议——新增 context_tools_add / remove、工具域与二级包映射、主动优化白名单;schedule / taskclass / web 工具按域按包暴露,msg0 规则包与 execute 上下文同步重写 3. analyze_health 升级为主动优化唯一裁判入口——补齐 rhythm / tightness / profile / feasibility 指标、候选扫描与复诊打分、停滞信号、forced imperfection 判定,并把连续优化状态写回运行态 4. 任务类能力并入新 Agent 执行链——新增 upsert_task_class 写工具与启动注入事务写入;任务类模型补充学科画像与整天屏蔽配置,粗排支持 excluded_days_of_week,steady 策略改为基于目标位置/单日负载/分散度/缓冲的候选打分 5. 运行态与路由补齐优化模式语义——新增 active tool domain/packs、pending context hook、active optimize only、taskclass 写入回盘快照;区分 first_full / global_reopt / local_adjust,并完善首次粗排后默认 refine 的判定 前端: 6. 助手时间线渲染细化——推理内容改为独立 reasoning block,支持与工具/状态/正文按时序交错展示,自动收口折叠,修正 confirm reject 恢复动作 仓库: 7. newAgent 文档整体迁入 docs/backend,补充主动优化执行规划与顺序约束拆解文档,删除旧调试日志文件 PS:这次科研了2天,总算是有些进展了——LLM永远只适合做选择题、判断题,不适合做开放创新题。
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
@@ -146,7 +147,15 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt
|
||||
|
||||
// 2. 把工具 schema 注入上下文,供 LLM 看到真实工具边界。
|
||||
if st.Deps.ToolRegistry != nil {
|
||||
schemas := st.Deps.ToolRegistry.Schemas()
|
||||
activeDomain := ""
|
||||
var activePacks []string
|
||||
if flowState := st.EnsureFlowState(); flowState != nil {
|
||||
activeDomain, activePacks = resolveEffectiveExecuteToolDomain(flowState)
|
||||
}
|
||||
schemas := st.Deps.ToolRegistry.SchemasForActiveDomain(activeDomain, activePacks)
|
||||
if flowState := st.EnsureFlowState(); flowState != nil && flowState.ActiveOptimizeOnly {
|
||||
schemas = newagenttools.FilterSchemasForActiveOptimize(schemas)
|
||||
}
|
||||
toolSchemas := make([]newagentmodel.ToolSchemaContext, len(schemas))
|
||||
for i, s := range schemas {
|
||||
toolSchemas[i] = newagentmodel.ToolSchemaContext{
|
||||
@@ -184,20 +193,6 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// OrderGuard 负责把 graph 的 order_guard 节点请求转给 RunOrderGuardNode。
|
||||
func (n *AgentNodes) OrderGuard(ctx context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("order_guard node: state is nil")
|
||||
}
|
||||
|
||||
if err := RunOrderGuardNode(ctx, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveAgentState(ctx, st)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// QuickTask 负责把 graph 的 quick_task 节点请求转给 RunQuickTaskNode。
|
||||
func (n *AgentNodes) QuickTask(ctx context.Context, st *newagentmodel.AgentGraphState) (*newagentmodel.AgentGraphState, error) {
|
||||
if st == nil {
|
||||
@@ -337,3 +332,31 @@ func deleteAgentState(ctx context.Context, st *newagentmodel.AgentGraphState) {
|
||||
|
||||
_ = store.Delete(ctx, flowState.ConversationID)
|
||||
}
|
||||
|
||||
// resolveEffectiveExecuteToolDomain 计算“本轮 execute 真正应看到”的工具域快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先读取 PendingContextHook,让首轮 execute 的 schema 注入与即将生效的规则包保持一致;
|
||||
// 2. 只做只读推导,不消费 PendingContextHook,真正的状态更新仍由 RunExecuteNode 统一处理;
|
||||
// 3. hook 非法或为空时,回退到已持久化的 ActiveToolDomain/ActiveToolPacks,保持历史链路兼容。
|
||||
func resolveEffectiveExecuteToolDomain(flowState *newagentmodel.CommonState) (string, []string) {
|
||||
if flowState == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 1. 若 plan / rough_build 已写入待生效 hook,则首轮 execute 必须优先按它推导工具域,
|
||||
// 否则 prompt 里的规则包和注入的工具 schema 会错位,模型第一轮看不到该用的工具。
|
||||
if hook := flowState.PendingContextHook; hook != nil {
|
||||
domain := newagenttools.NormalizeToolDomain(hook.Domain)
|
||||
if domain != "" {
|
||||
return domain, newagenttools.ResolveEffectiveToolPacks(domain, hook.Packs)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. hook 不可用时回退到当前已激活域,保持老链路与恢复链路的行为不变。
|
||||
domain := newagenttools.NormalizeToolDomain(flowState.ActiveToolDomain)
|
||||
if domain == "" {
|
||||
return "", nil
|
||||
}
|
||||
return domain, newagenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks)
|
||||
}
|
||||
|
||||
@@ -214,6 +214,10 @@ func streamAndDispatch(
|
||||
decision.NeedsRoughBuild = false
|
||||
decision.NeedsRefineAfterRoughBuild = false
|
||||
}
|
||||
// 首次粗排兜底:若用户未明确要求"只要初稿不优化",则粗排后默认进入主动微调。
|
||||
if shouldForceRefineAfterFirstRoughBuild(conversationContext, input.UserInput, decision) {
|
||||
decision.NeedsRefineAfterRoughBuild = true
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"[DEBUG] chat routing chat=%s route=%s needs_rough_build=%v needs_refine_after_rough_build=%v allow_reorder=%v thinking=%v has_rough_build_done=%v task_class_count=%d raw=%s",
|
||||
@@ -445,6 +449,7 @@ func handleRouteExecuteStream(
|
||||
}
|
||||
|
||||
flowState.ExecuteThinking = effectiveThinking
|
||||
flowState.OptimizationMode = resolveOptimizationMode(userInput, decision, flowState)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -510,6 +515,45 @@ func detectReorderPreference(userInput string) reorderPreference {
|
||||
return reorderUnknown
|
||||
}
|
||||
|
||||
// resolveOptimizationMode 统一确定当前 execute 的优化模式。
|
||||
func resolveOptimizationMode(
|
||||
userInput string,
|
||||
decision *newagentmodel.ChatRoutingDecision,
|
||||
flowState *newagentmodel.CommonState,
|
||||
) string {
|
||||
if decision != nil && decision.NeedsRoughBuild && flowState != nil && len(flowState.TaskClassIDs) > 0 {
|
||||
return "first_full"
|
||||
}
|
||||
if isExplicitGlobalReoptRequest(userInput) {
|
||||
return "global_reopt"
|
||||
}
|
||||
return "local_adjust"
|
||||
}
|
||||
|
||||
// isExplicitGlobalReoptRequest 识别用户是否明确要求全局重优化。
|
||||
func isExplicitGlobalReoptRequest(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
keywords := []string{
|
||||
"全局优化",
|
||||
"整体优化",
|
||||
"全局重排",
|
||||
"整体重排",
|
||||
"重新优化全部",
|
||||
"重新优化整体",
|
||||
"全面优化",
|
||||
"整体体检",
|
||||
"全局体检",
|
||||
"重新体检",
|
||||
"global optimize",
|
||||
"global reopt",
|
||||
"overall optimize",
|
||||
}
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
func containsAnyPhrase(text string, phrases []string) bool {
|
||||
for _, phrase := range phrases {
|
||||
if strings.Contains(text, phrase) {
|
||||
@@ -539,6 +583,27 @@ func shouldDisableRoughBuildForRefine(
|
||||
return !isExplicitRoughBuildRequest(userInput)
|
||||
}
|
||||
|
||||
// shouldForceRefineAfterFirstRoughBuild 判断是否应在"首次粗排"场景下强制开启 refine。
|
||||
//
|
||||
// 判定规则:
|
||||
// 1. 仅在当前决策仍然请求粗排时生效;
|
||||
// 2. 仅在首次粗排(上下文不存在 rough_build_done)时生效;
|
||||
// 3. 若用户明确表达"只要初稿/先不优化",则不强制开启;
|
||||
// 4. 其余首次粗排场景一律开启,确保符合 PRD 的默认主动优化策略。
|
||||
func shouldForceRefineAfterFirstRoughBuild(
|
||||
conversationContext *newagentmodel.ConversationContext,
|
||||
userInput string,
|
||||
decision *newagentmodel.ChatRoutingDecision,
|
||||
) bool {
|
||||
if decision == nil || !decision.NeedsRoughBuild {
|
||||
return false
|
||||
}
|
||||
if hasRoughBuildDoneMarker(conversationContext) {
|
||||
return false
|
||||
}
|
||||
return !isExplicitNoRefineAfterRoughBuildRequest(userInput)
|
||||
}
|
||||
|
||||
func hasRoughBuildDoneMarker(conversationContext *newagentmodel.ConversationContext) bool {
|
||||
if conversationContext == nil {
|
||||
return false
|
||||
@@ -575,6 +640,31 @@ func isExplicitRoughBuildRequest(userInput string) bool {
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
// isExplicitNoRefineAfterRoughBuildRequest 识别用户是否明确要求"粗排后先不要自动微调"。
|
||||
func isExplicitNoRefineAfterRoughBuildRequest(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
keywords := []string{
|
||||
"只要初稿",
|
||||
"先给初稿",
|
||||
"先排进去就行",
|
||||
"先排进去",
|
||||
"先不优化",
|
||||
"先别优化",
|
||||
"先不微调",
|
||||
"先别微调",
|
||||
"排完就收口",
|
||||
"粗排就行",
|
||||
"草稿就行",
|
||||
"draft only",
|
||||
"no refine",
|
||||
"no optimization",
|
||||
}
|
||||
return containsAnyPhrase(text, keywords)
|
||||
}
|
||||
|
||||
// handleDeepAnswerStream 处理复杂问答:关闭路由流 → 第二次流式调用。
|
||||
//
|
||||
// 步骤说明:
|
||||
|
||||
@@ -42,15 +42,10 @@ func AppendLLMCorrection(
|
||||
}
|
||||
|
||||
// 1. 构造 assistant 消息,让 LLM 知道自己刚才输出了什么。
|
||||
// 如果 llmOutput 为空,则生成一个占位描述。
|
||||
// 2. 空输出不回灌,避免把占位文本写进历史造成噪音。
|
||||
// 3. 与最近一条 assistant 完全相同则跳过,避免重复回灌放大复读。
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
if assistantContent == "" {
|
||||
assistantContent = "[LLM 输出为空或无法解析]"
|
||||
}
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: assistantContent,
|
||||
})
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
// 2. 构造纠正提示,明确告知 LLM 哪里错了、合法选项有哪些。
|
||||
// 不做硬编码的错误类型,由调用方通过 validOptionsDesc 传入。
|
||||
@@ -88,13 +83,7 @@ func AppendLLMCorrectionWithHint(
|
||||
}
|
||||
|
||||
assistantContent := strings.TrimSpace(llmOutput)
|
||||
if assistantContent == "" {
|
||||
assistantContent = "[LLM 输出为空或无法解析]"
|
||||
}
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: assistantContent,
|
||||
})
|
||||
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||
|
||||
correctionContent := fmt.Sprintf(
|
||||
"%s %s 请重新分析当前状态,输出正确的内容。",
|
||||
@@ -109,3 +98,39 @@ func AppendLLMCorrectionWithHint(
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// appendCorrectionAssistantIfNeeded 在纠错回灌前做最小降噪。
|
||||
//
|
||||
// 1. 空文本直接跳过,避免写入“占位噪音”;
|
||||
// 2. 若与“最近一条 assistant 文本”完全一致则跳过,避免同句反复回灌;
|
||||
// 3. 仅负责“是否回灌”判定,不负责生成纠错 user 提示。
|
||||
func appendCorrectionAssistantIfNeeded(
|
||||
conversationContext *newagentmodel.ConversationContext,
|
||||
assistantContent string,
|
||||
) {
|
||||
if conversationContext == nil {
|
||||
return
|
||||
}
|
||||
assistantContent = strings.TrimSpace(assistantContent)
|
||||
if assistantContent == "" {
|
||||
return
|
||||
}
|
||||
|
||||
history := conversationContext.HistorySnapshot()
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msg := history[i]
|
||||
if msg == nil || msg.Role != schema.Assistant {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(msg.Content) == assistantContent {
|
||||
return
|
||||
}
|
||||
// 只看最近一条 assistant,避免误去重很久以前的正常重复表达。
|
||||
break
|
||||
}
|
||||
|
||||
conversationContext.AppendHistory(&schema.Message{
|
||||
Role: schema.Assistant,
|
||||
Content: assistantContent,
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,18 +82,27 @@ func handleInterruptAskUser(
|
||||
text = "请补充更多信息。"
|
||||
}
|
||||
|
||||
// 伪流式输出,和 chatReply 一样的体感。
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx, interruptSpeakBlockID, interruptStageName,
|
||||
text,
|
||||
newagentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("追问消息推送失败: %w", err)
|
||||
speakStreamed := readPendingMetadataBool(pending, newagentmodel.PendingMetaAskUserSpeakStreamed)
|
||||
historyAppended := readPendingMetadataBool(pending, newagentmodel.PendingMetaAskUserHistoryAppended)
|
||||
|
||||
// 1. 若上游节点已流式推送过 ask_user 文本,则这里跳过二次正文推送;
|
||||
// 2. 这样既保留 interrupt 的统一收口状态,又避免前端出现重复气泡。
|
||||
if !speakStreamed {
|
||||
// 伪流式输出,和 chatReply 一样的体感。
|
||||
if err := emitter.EmitPseudoAssistantText(
|
||||
ctx, interruptSpeakBlockID, interruptStageName,
|
||||
text,
|
||||
newagentstream.DefaultPseudoStreamOptions(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("追问消息推送失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入对话历史,下一轮 resume 时 LLM 能看到这个上下文。
|
||||
msg := schema.AssistantMessage(text, nil)
|
||||
conversationContext.AppendHistory(msg)
|
||||
if !historyAppended {
|
||||
conversationContext.AppendHistory(msg)
|
||||
}
|
||||
persistVisibleAssistantMessage(ctx, persist, runtimeState.EnsureCommonState(), msg)
|
||||
|
||||
// 状态持久化已由 agent_nodes 层统一处理,此处不再需要自行存快照。
|
||||
@@ -105,6 +114,21 @@ func handleInterruptAskUser(
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPendingMetadataBool(pending *newagentmodel.PendingInteraction, key string) bool {
|
||||
if pending == nil || pending.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
raw, exists := pending.Metadata[key]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
value, ok := raw.(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// handleInterruptConfirm 处理确认型中断。
|
||||
//
|
||||
// 确认卡片已由 confirm 节点推送,这里只需推送状态通知并持久化。
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
package newagentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
const (
|
||||
orderGuardStageName = "order_guard"
|
||||
orderGuardStatusBlock = "order_guard.status"
|
||||
)
|
||||
|
||||
type suggestedOrderItem struct {
|
||||
StateID int
|
||||
Day int
|
||||
SlotStart int
|
||||
SlotEnd int
|
||||
Slots []schedule.TaskSlot
|
||||
}
|
||||
|
||||
type orderRestoreResult struct {
|
||||
Restored bool
|
||||
Changed int
|
||||
Detail string
|
||||
}
|
||||
|
||||
// RunOrderGuardNode 负责在收口前校验 suggested 任务相对顺序是否被打乱。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做“相对顺序守卫”这一件事,不负责执行调度工具,也不负责写库;
|
||||
// 2. 仅当 AllowReorder=false 时生效,用户明确授权可打乱顺序时直接放行;
|
||||
// 3. 校验失败时优先“自动复原相对顺序”,由 Deliver 节点继续交付,不再直接终止。
|
||||
func RunOrderGuardNode(ctx context.Context, st *newagentmodel.AgentGraphState) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("order_guard node: state is nil")
|
||||
}
|
||||
|
||||
flowState := st.EnsureFlowState()
|
||||
if flowState == nil {
|
||||
return fmt.Errorf("order_guard node: flow state is nil")
|
||||
}
|
||||
// 1. 用户明确授权可打乱顺序时,顺序守卫节点直接放行。
|
||||
if flowState.AllowReorder {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. 读取当前 ScheduleState,提取 suggested 任务的“时间顺序快照”。
|
||||
scheduleState, err := st.EnsureScheduleState(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("order_guard node: load schedule state failed: %w", err)
|
||||
}
|
||||
if scheduleState == nil {
|
||||
return nil
|
||||
}
|
||||
currentOrder := buildSuggestedOrderSnapshot(scheduleState)
|
||||
|
||||
// 3. 基线为空时,仅初始化基线并放行,避免第一次进入守卫就误判。
|
||||
if len(flowState.SuggestedOrderBaseline) == 0 {
|
||||
flowState.SuggestedOrderBaseline = append([]int(nil), currentOrder...)
|
||||
_ = st.EnsureChunkEmitter().EmitStatus(
|
||||
orderGuardStatusBlock,
|
||||
orderGuardStageName,
|
||||
"order_guard_initialized",
|
||||
"已记录本轮建议任务顺序基线,继续交付当前结果。",
|
||||
false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 基线存在时做逆序检测;发现逆序后优先自动复原,而不是直接中止。
|
||||
violated, detail := detectRelativeOrderViolation(flowState.SuggestedOrderBaseline, currentOrder)
|
||||
if !violated {
|
||||
_ = st.EnsureChunkEmitter().EmitStatus(
|
||||
orderGuardStatusBlock,
|
||||
orderGuardStageName,
|
||||
"order_guard_passed",
|
||||
"顺序守卫校验通过,保持原有相对顺序。",
|
||||
false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4.1 违序后进入自动复原:
|
||||
// 1) 复用“当前坑位集合”,按 baseline 相对顺序回填任务;
|
||||
// 2) 成功则继续 completed 路径,保证预览可写入;
|
||||
// 3) 若复原条件不满足,保守放行并输出诊断,避免再次把整轮流程打成 aborted。
|
||||
restore := restoreSuggestedOrderByBaseline(scheduleState, flowState.SuggestedOrderBaseline)
|
||||
if restore.Restored {
|
||||
_ = st.EnsureChunkEmitter().EmitStatus(
|
||||
orderGuardStatusBlock,
|
||||
orderGuardStageName,
|
||||
"order_guard_restored",
|
||||
fmt.Sprintf("检测到建议任务顺序被打乱,已自动复原(调整 %d 个任务)。", restore.Changed),
|
||||
false,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = st.EnsureChunkEmitter().EmitStatus(
|
||||
orderGuardStatusBlock,
|
||||
orderGuardStageName,
|
||||
"order_guard_restore_skipped",
|
||||
"检测到顺序异常,但本次未执行自动复原,已继续交付当前结果。详情见日志。",
|
||||
false,
|
||||
)
|
||||
log.Printf(
|
||||
"[WARN] order_guard restore skipped chat=%s baseline=%v current=%v detail=%s restore_detail=%s",
|
||||
flowState.ConversationID,
|
||||
flowState.SuggestedOrderBaseline,
|
||||
currentOrder,
|
||||
detail,
|
||||
restore.Detail,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildSuggestedOrderSnapshot 生成 suggested 任务的相对顺序快照(按时间坐标排序)。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里只关心 suggested 任务,因为顺序守卫目标是约束“本轮建议层”的相对次序;
|
||||
// 2. 多 slot 任务取“最早 slot”作为排序锚点,保证排序键稳定;
|
||||
// 3. 返回值是 state_id 列表,便于写入 CommonState 做跨节点持久化。
|
||||
func buildSuggestedOrderSnapshot(state *schedule.ScheduleState) []int {
|
||||
items := buildSuggestedOrderItems(state)
|
||||
order := make([]int, 0, len(items))
|
||||
for _, item := range items {
|
||||
order = append(order, item.StateID)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// buildSuggestedOrderItems 生成 suggested 任务的排序明细。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 统一封装顺序守卫和自动复原都需要的排序素材,避免两处逻辑口径漂移;
|
||||
// 2. 排序键保持与历史实现一致:day -> slot_start -> slot_end -> state_id;
|
||||
// 3. 每项附带完整 slots 快照,供“坑位复用式复原”直接使用。
|
||||
func buildSuggestedOrderItems(state *schedule.ScheduleState) []suggestedOrderItem {
|
||||
if state == nil || len(state.Tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
items := make([]suggestedOrderItem, 0, len(state.Tasks))
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if !schedule.IsSuggestedTask(task) || len(task.Slots) == 0 {
|
||||
continue
|
||||
}
|
||||
day, slotStart, slotEnd := earliestTaskSlot(task.Slots)
|
||||
items = append(items, suggestedOrderItem{
|
||||
StateID: task.StateID,
|
||||
Day: day,
|
||||
SlotStart: slotStart,
|
||||
SlotEnd: slotEnd,
|
||||
Slots: cloneTaskSlots(task.Slots),
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Day != items[j].Day {
|
||||
return items[i].Day < items[j].Day
|
||||
}
|
||||
if items[i].SlotStart != items[j].SlotStart {
|
||||
return items[i].SlotStart < items[j].SlotStart
|
||||
}
|
||||
if items[i].SlotEnd != items[j].SlotEnd {
|
||||
return items[i].SlotEnd < items[j].SlotEnd
|
||||
}
|
||||
return items[i].StateID < items[j].StateID
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func earliestTaskSlot(slots []schedule.TaskSlot) (day int, slotStart int, slotEnd int) {
|
||||
if len(slots) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
best := slots[0]
|
||||
for i := 1; i < len(slots); i++ {
|
||||
current := slots[i]
|
||||
if current.Day < best.Day {
|
||||
best = current
|
||||
continue
|
||||
}
|
||||
if current.Day == best.Day && current.SlotStart < best.SlotStart {
|
||||
best = current
|
||||
continue
|
||||
}
|
||||
if current.Day == best.Day && current.SlotStart == best.SlotStart && current.SlotEnd < best.SlotEnd {
|
||||
best = current
|
||||
}
|
||||
}
|
||||
return best.Day, best.SlotStart, best.SlotEnd
|
||||
}
|
||||
|
||||
// detectRelativeOrderViolation 检查 current 是否破坏 baseline 的相对顺序。
|
||||
//
|
||||
// 规则:
|
||||
// 1. 仅比较 baseline 与 current 的交集任务,避免新增/删除任务引发误报;
|
||||
// 2. 一旦出现 rank 逆序即判定为 violation;
|
||||
// 3. detail 只用于内部排查,不直接给用户。
|
||||
func detectRelativeOrderViolation(baseline []int, current []int) (bool, string) {
|
||||
if len(baseline) == 0 || len(current) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
rankByID := make(map[int]int, len(baseline))
|
||||
for idx, id := range baseline {
|
||||
rankByID[id] = idx
|
||||
}
|
||||
|
||||
filtered := make([]int, 0, len(current))
|
||||
for _, id := range current {
|
||||
if _, ok := rankByID[id]; ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
if len(filtered) < 2 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
prevID := filtered[0]
|
||||
prevRank := rankByID[prevID]
|
||||
for i := 1; i < len(filtered); i++ {
|
||||
id := filtered[i]
|
||||
rank := rankByID[id]
|
||||
if rank < prevRank {
|
||||
return true, strings.TrimSpace(fmt.Sprintf(
|
||||
"reverse pair detected: prev_id=%d prev_rank=%d current_id=%d current_rank=%d",
|
||||
prevID, prevRank, id, rank,
|
||||
))
|
||||
}
|
||||
prevID = id
|
||||
prevRank = rank
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// restoreSuggestedOrderByBaseline 在“默认不允许打乱顺序”场景下自动复原 suggested 相对顺序。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先提取 baseline 与 current 的交集任务,确保只修复本轮可比对对象;
|
||||
// 2. 复用 current 的“坑位序列”(时段集合),按 baseline 顺序重新回填任务;
|
||||
// 3. 回填前校验时长兼容,避免把长任务塞进短坑位;
|
||||
// 4. 回填后再次校验顺序;若失败则回滚,保证状态不会半成功。
|
||||
func restoreSuggestedOrderByBaseline(state *schedule.ScheduleState, baseline []int) orderRestoreResult {
|
||||
if state == nil {
|
||||
return orderRestoreResult{Restored: false, Detail: "schedule_state=nil"}
|
||||
}
|
||||
if len(baseline) == 0 {
|
||||
return orderRestoreResult{Restored: true}
|
||||
}
|
||||
|
||||
items := buildSuggestedOrderItems(state)
|
||||
if len(items) < 2 {
|
||||
return orderRestoreResult{Restored: true}
|
||||
}
|
||||
|
||||
itemByID := make(map[int]suggestedOrderItem, len(items))
|
||||
currentInScope := make([]int, 0, len(items))
|
||||
for _, item := range items {
|
||||
itemByID[item.StateID] = item
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, ok := itemByID[item.StateID]; ok {
|
||||
currentInScope = append(currentInScope, item.StateID)
|
||||
}
|
||||
}
|
||||
|
||||
baselineInScope := make([]int, 0, len(baseline))
|
||||
for _, id := range baseline {
|
||||
if _, ok := itemByID[id]; ok {
|
||||
baselineInScope = append(baselineInScope, id)
|
||||
}
|
||||
}
|
||||
if len(baselineInScope) < 2 {
|
||||
return orderRestoreResult{Restored: true}
|
||||
}
|
||||
|
||||
// currentInScope 只保留 baseline 交集,保证两边长度一致且语义可比。
|
||||
baselineSet := make(map[int]struct{}, len(baselineInScope))
|
||||
for _, id := range baselineInScope {
|
||||
baselineSet[id] = struct{}{}
|
||||
}
|
||||
filteredCurrent := make([]int, 0, len(currentInScope))
|
||||
for _, id := range currentInScope {
|
||||
if _, ok := baselineSet[id]; ok {
|
||||
filteredCurrent = append(filteredCurrent, id)
|
||||
}
|
||||
}
|
||||
if sameIDOrder(filteredCurrent, baselineInScope) {
|
||||
return orderRestoreResult{Restored: true}
|
||||
}
|
||||
if len(filteredCurrent) != len(baselineInScope) {
|
||||
return orderRestoreResult{
|
||||
Restored: false,
|
||||
Detail: fmt.Sprintf("size_mismatch baseline=%d current=%d", len(baselineInScope), len(filteredCurrent)),
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 先构建“当前坑位序列”。
|
||||
slotPool := make([][]schedule.TaskSlot, 0, len(filteredCurrent))
|
||||
for _, currentID := range filteredCurrent {
|
||||
item, ok := itemByID[currentID]
|
||||
if !ok {
|
||||
return orderRestoreResult{
|
||||
Restored: false,
|
||||
Detail: fmt.Sprintf("current_id_missing id=%d", currentID),
|
||||
}
|
||||
}
|
||||
slotPool = append(slotPool, cloneTaskSlots(item.Slots))
|
||||
}
|
||||
|
||||
// 2. 回填前做兼容性校验:默认要求“目标任务时长 == 坑位时长”。
|
||||
for i, targetID := range baselineInScope {
|
||||
targetTask := state.TaskByStateID(targetID)
|
||||
if targetTask == nil {
|
||||
return orderRestoreResult{
|
||||
Restored: false,
|
||||
Detail: fmt.Sprintf("target_task_missing id=%d", targetID),
|
||||
}
|
||||
}
|
||||
if !isSlotsCompatibleWithTask(*targetTask, slotPool[i]) {
|
||||
return orderRestoreResult{
|
||||
Restored: false,
|
||||
Detail: fmt.Sprintf(
|
||||
"slot_incompatible target=%d expected_duration=%d slot_duration=%d expected_segments=%d slot_segments=%d",
|
||||
targetID,
|
||||
expectedTaskDuration(*targetTask),
|
||||
totalSlotDuration(slotPool[i]),
|
||||
len(targetTask.Slots),
|
||||
len(slotPool[i]),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 执行回填,并在失败时支持回滚。
|
||||
beforeSlots := make(map[int][]schedule.TaskSlot, len(baselineInScope))
|
||||
changed := 0
|
||||
for i, targetID := range baselineInScope {
|
||||
task := state.TaskByStateID(targetID)
|
||||
if task == nil {
|
||||
continue
|
||||
}
|
||||
beforeSlots[targetID] = cloneTaskSlots(task.Slots)
|
||||
targetSlots := cloneTaskSlots(slotPool[i])
|
||||
if !equalTaskSlots(task.Slots, targetSlots) {
|
||||
task.Slots = targetSlots
|
||||
changed++
|
||||
}
|
||||
}
|
||||
|
||||
afterOrder := buildSuggestedOrderSnapshot(state)
|
||||
afterFiltered := make([]int, 0, len(afterOrder))
|
||||
for _, id := range afterOrder {
|
||||
if _, ok := baselineSet[id]; ok {
|
||||
afterFiltered = append(afterFiltered, id)
|
||||
}
|
||||
}
|
||||
if !sameIDOrder(afterFiltered, baselineInScope) {
|
||||
// 回滚,避免保留半成功状态。
|
||||
for _, targetID := range baselineInScope {
|
||||
task := state.TaskByStateID(targetID)
|
||||
if task == nil {
|
||||
continue
|
||||
}
|
||||
task.Slots = cloneTaskSlots(beforeSlots[targetID])
|
||||
}
|
||||
return orderRestoreResult{
|
||||
Restored: false,
|
||||
Detail: fmt.Sprintf(
|
||||
"restore_verify_failed expected=%v actual=%v",
|
||||
baselineInScope, afterFiltered,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return orderRestoreResult{
|
||||
Restored: true,
|
||||
Changed: changed,
|
||||
}
|
||||
}
|
||||
|
||||
func sameIDOrder(left, right []int) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for i := range left {
|
||||
if left[i] != right[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneTaskSlots(slots []schedule.TaskSlot) []schedule.TaskSlot {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
copied := make([]schedule.TaskSlot, len(slots))
|
||||
copy(copied, slots)
|
||||
return copied
|
||||
}
|
||||
|
||||
func equalTaskSlots(left, right []schedule.TaskSlot) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for i := range left {
|
||||
if left[i].Day != right[i].Day {
|
||||
return false
|
||||
}
|
||||
if left[i].SlotStart != right[i].SlotStart {
|
||||
return false
|
||||
}
|
||||
if left[i].SlotEnd != right[i].SlotEnd {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func expectedTaskDuration(task schedule.ScheduleTask) int {
|
||||
if task.Duration > 0 {
|
||||
return task.Duration
|
||||
}
|
||||
if len(task.Slots) > 0 {
|
||||
return totalSlotDuration(task.Slots)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func totalSlotDuration(slots []schedule.TaskSlot) int {
|
||||
total := 0
|
||||
for _, slot := range slots {
|
||||
total += slot.SlotEnd - slot.SlotStart + 1
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func isSlotsCompatibleWithTask(task schedule.ScheduleTask, slots []schedule.TaskSlot) bool {
|
||||
if len(slots) == 0 {
|
||||
return false
|
||||
}
|
||||
expectedDuration := expectedTaskDuration(task)
|
||||
if expectedDuration > 0 && expectedDuration != totalSlotDuration(slots) {
|
||||
return false
|
||||
}
|
||||
// 兼容策略:当前任务已有多段落位时,要求目标坑位段数一致,避免跨段语义被破坏。
|
||||
if len(task.Slots) > 0 && len(task.Slots) != len(slots) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -89,7 +89,10 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
messages,
|
||||
infrallm.GenerateOptions{
|
||||
Temperature: 0.2,
|
||||
Thinking: resolveThinkingMode(input.ThinkingEnabled),
|
||||
// 显式设置上限,避免依赖框架默认值(默认 4096)导致长决策被截断。
|
||||
// 注意:当前模型接口 max_tokens 上限为 131072,超过会 400。
|
||||
MaxTokens: 131072,
|
||||
Thinking: resolveThinkingMode(input.ThinkingEnabled),
|
||||
Metadata: map[string]any{
|
||||
"stage": planStageName,
|
||||
"phase": "planning",
|
||||
@@ -102,6 +105,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
|
||||
parser := newagentrouter.NewStreamDecisionParser()
|
||||
firstChunk := true
|
||||
speakStreamed := false
|
||||
|
||||
// 3.1 阶段一:解析决策标签。
|
||||
for {
|
||||
@@ -151,6 +155,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, visible, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("规划文案推送失败: %w", emitErr)
|
||||
}
|
||||
speakStreamed = true
|
||||
fullText.WriteString(visible)
|
||||
firstChunk = false
|
||||
}
|
||||
@@ -173,6 +178,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
if emitErr := emitter.EmitAssistantText(planSpeakBlockID, planStageName, chunk2.Content, firstChunk); emitErr != nil {
|
||||
return fmt.Errorf("规划文案推送失败: %w", emitErr)
|
||||
}
|
||||
speakStreamed = true
|
||||
fullText.WriteString(chunk2.Content)
|
||||
firstChunk = false
|
||||
}
|
||||
@@ -187,7 +193,7 @@ func RunPlanNode(ctx context.Context, input PlanNodeInput) error {
|
||||
}
|
||||
|
||||
// 5. 按规划动作推进流程状态。
|
||||
return handlePlanAction(ctx, input, runtimeState, conversationContext, emitter, flowState, decision)
|
||||
return handlePlanAction(ctx, input, runtimeState, conversationContext, emitter, flowState, decision, speakStreamed)
|
||||
}
|
||||
|
||||
// 流结束但未找到决策标签。
|
||||
@@ -203,6 +209,7 @@ func handlePlanAction(
|
||||
emitter *newagentstream.ChunkEmitter,
|
||||
flowState *newagentmodel.CommonState,
|
||||
decision *newagentmodel.PlanDecision,
|
||||
askUserSpeakStreamed bool,
|
||||
) error {
|
||||
switch decision.Action {
|
||||
case newagentmodel.PlanActionContinue:
|
||||
@@ -211,9 +218,14 @@ func handlePlanAction(
|
||||
case newagentmodel.PlanActionAskUser:
|
||||
question := resolvePlanAskUserText(decision)
|
||||
runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode))
|
||||
// 1. plan 阶段若已流式推送过 ask_user 文本,interrupt 侧应避免重复正文输出;
|
||||
// 2. plan 阶段 ask_user 不会提前写入 history,这里显式标记为 false。
|
||||
runtimeState.SetPendingInteractionMetadata(newagentmodel.PendingMetaAskUserSpeakStreamed, askUserSpeakStreamed)
|
||||
runtimeState.SetPendingInteractionMetadata(newagentmodel.PendingMetaAskUserHistoryAppended, false)
|
||||
return nil
|
||||
case newagentmodel.PlanActionDone:
|
||||
flowState.FinishPlan(decision.PlanSteps)
|
||||
flowState.PendingContextHook = clonePlanContextHook(decision.ContextHook)
|
||||
writePlanPinnedBlocks(conversationContext, decision.PlanSteps)
|
||||
if decision.NeedsRoughBuild {
|
||||
flowState.NeedsRoughBuild = true
|
||||
@@ -295,6 +307,21 @@ func resolvePlanAskUserText(decision *newagentmodel.PlanDecision) string {
|
||||
return "我还缺一点关键信息,想先向你确认一下。"
|
||||
}
|
||||
|
||||
func clonePlanContextHook(hook *newagentmodel.ContextHook) *newagentmodel.ContextHook {
|
||||
if hook == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *hook
|
||||
if len(hook.Packs) > 0 {
|
||||
cloned.Packs = append([]string(nil), hook.Packs...)
|
||||
}
|
||||
cloned.Normalize()
|
||||
if cloned.Domain == "" {
|
||||
return nil
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func writePlanPinnedBlocks(ctx *newagentmodel.ConversationContext, steps []newagentmodel.PlanStep) {
|
||||
if ctx == nil {
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
@@ -69,30 +70,57 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 加载 ScheduleState(含 DayMapping,用于坐标转换)。
|
||||
// 4. 粗排前强制刷新 ScheduleState,避免复用旧快照窗口。
|
||||
// 4.1 设计意图:当用户做“超前规划”时,窗口必须跟随本轮 task_class_ids,而不是沿用历史“当前周”窗口。
|
||||
// 4.2 做法:主动丢弃内存中的旧 state,让 EnsureScheduleState 走 provider 重新加载。
|
||||
// 4.3 失败策略:若任务类缺少有效起止日期,provider 会返回错误,由上层统一透传并让用户补齐字段。
|
||||
st.ScheduleState = nil
|
||||
st.OriginalScheduleState = nil
|
||||
|
||||
// 5. 加载 ScheduleState(含 DayMapping,用于坐标转换)。
|
||||
scheduleState, err := st.EnsureScheduleState(ctx)
|
||||
if err != nil {
|
||||
// 1. 当任务类时间窗缺失时,按“可恢复失败”收口:提示用户先补齐起止日期,再重试粗排。
|
||||
// 2. 不把这类输入缺失上抛为系统错误,避免整条链路直接 fallback 到普通聊天。
|
||||
if strings.Contains(err.Error(), "任务类缺少有效时间窗") {
|
||||
failureMessage := "开始智能编排前,我需要任务类的起止日期(start_date / end_date)。请先补齐时间窗,再让我继续排课。"
|
||||
_ = emitter.EmitStatus(
|
||||
roughBuildStatusBlock,
|
||||
roughBuildStageName,
|
||||
"rough_build_need_time_window",
|
||||
failureMessage,
|
||||
true,
|
||||
)
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.Abort(
|
||||
roughBuildStageName,
|
||||
"rough_build_window_missing",
|
||||
failureMessage,
|
||||
err.Error(),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("rough build node: 加载日程状态失败: %w", err)
|
||||
}
|
||||
if scheduleState == nil {
|
||||
return fmt.Errorf("rough build node: ScheduleState 为空,无法执行粗排")
|
||||
}
|
||||
|
||||
// 5. 调用粗排算法。
|
||||
// 6. 调用粗排算法。
|
||||
placements, err := st.Deps.RoughBuildFunc(ctx, flowState.UserID, taskClassIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rough build node: 粗排算法失败: %w", err)
|
||||
}
|
||||
|
||||
// 6. 把粗排结果写入 ScheduleState。
|
||||
// 7. 把粗排结果写入 ScheduleState。
|
||||
applyStats := applyRoughBuildPlacements(scheduleState, placements)
|
||||
|
||||
// 6.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送"排程完毕"卡片。
|
||||
// 7.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送“排程完毕”卡片。
|
||||
if applyStats.AppliedCount > 0 {
|
||||
flowState.HasScheduleChanges = true
|
||||
}
|
||||
|
||||
// 7. 先校验粗排后是否仍有真实 pending。
|
||||
// 8. 先校验粗排后是否仍有真实 pending。
|
||||
stillPending := countPendingTasks(scheduleState, taskClassIDs)
|
||||
log.Printf(
|
||||
"[DEBUG] rough_build scope_task_classes=%v placements=%d applied=%d day_mapping_miss=%d task_item_match_miss=%d pending_in_scope=%d total_tasks=%d window_days=%d",
|
||||
@@ -197,9 +225,31 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
|
||||
flowState.NeedsRoughBuild = false
|
||||
flowState.NeedsRefineAfterRoughBuild = false
|
||||
if !shouldRefineAfterRoughBuild {
|
||||
flowState.ActiveOptimizeOnly = false
|
||||
flowState.Done()
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(flowState.OptimizationMode) == "" {
|
||||
flowState.OptimizationMode = "first_full"
|
||||
}
|
||||
// 1. 仅“粗排后自动进入微调”的链路打开主动优化专用模式。
|
||||
// 2. 该模式会把 execute 裁成 analyze_health + move + swap 的最小工具面,
|
||||
// 迫使 LLM 基于候选做选择,而不是重新全窗乱搜。
|
||||
// 3. 用户后续重开新请求时,会在 CommonState 的重置入口统一清掉这个标记。
|
||||
flowState.ActiveOptimizeOnly = true
|
||||
// 12. 粗排后进入 execute 微调时,补一条一次性 context hook。
|
||||
//
|
||||
// 1. 目的:即使这条链路不回 plan,也能在 execute 首轮拿到建议工具面(analyze + mutation)。
|
||||
// 2. 边界:这里只写“建议激活域/包”,不直接执行 context_tools_add,仍由 execute 按统一入口消费。
|
||||
// 3. 回退:hook 无效时 execute 会自动忽略并清空,不影响主流程。
|
||||
flowState.PendingContextHook = &newagentmodel.ContextHook{
|
||||
Domain: newagenttools.ToolDomainSchedule,
|
||||
Packs: []string{
|
||||
newagenttools.ToolPackAnalyze,
|
||||
newagenttools.ToolPackMutation,
|
||||
},
|
||||
Reason: "rough_build_post_refine",
|
||||
}
|
||||
flowState.Phase = newagentmodel.PhaseExecuting
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user