diff --git a/AGENTS.md b/AGENTS.md index 319f1b8..df795a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ 12. 若后续在 `backend/agent` 中新增、下沉、替换任何“通用能力”,必须同步更新 `backend/agent/通用能力接入文档.md`,否则视为重构信息不完整。 13. 写完代码后,如果输入输出格式明确、逻辑可验证(如数据转换函数、解析函数、工具层操作),必须编写单元测试验证正确性。跑完之后删除测试文件(`*_test.go`),禁止把测试文件长期留在项目中。 14. 当 Claude Code 帮助操作 git 提交时,commit message 中禁止出现与 Claude 协同相关的描述(如 Co-Authored-By 等),只保留项目本身的内容。 +15. 实现任何 Eino 新功能之前,必须先阅读 Eino 官方文档并确认对应能力的推荐接入方式与参数语义,禁止在未查文档的情况下直接编码。 ## 注释规范(强制) diff --git a/backend/api/course.go b/backend/api/course.go index 0a8b95c..225f093 100644 --- a/backend/api/course.go +++ b/backend/api/course.go @@ -3,6 +3,8 @@ package api import ( "context" "errors" + "io" + "log" "net/http" "time" @@ -13,11 +15,9 @@ import ( ) type CourseHandler struct { - // 伸出手:准备接住 Service service *service.CourseService } -// NewCourseHandler 创建 CourseHandler 实例 func NewCourseHandler(service *service.CourseService) *CourseHandler { return &CourseHandler{ service: service, @@ -25,45 +25,130 @@ func NewCourseHandler(service *service.CourseService) *CourseHandler { } func (sa *CourseHandler) CheckUserCourse(c *gin.Context) { - //1.从请求中获取课程信息 var req model.UserCheckCourseRequest - err := c.ShouldBindJSON(&req) - if err != nil { + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, respond.WrongParamType) return } - //2.调用 service 层的 CheckSingleCourse 方法进行校验 - result := service.CheckSingleCourse(req) - //3.根据校验结果返回响应 - if result { + + if service.CheckSingleCourse(req) { c.JSON(http.StatusOK, respond.Ok) - } else { - c.JSON(http.StatusBadRequest, respond.WrongCourseInfo) + return } + c.JSON(http.StatusBadRequest, respond.WrongCourseInfo) } func (sa *CourseHandler) AddUserCourses(c *gin.Context) { - //1.从请求中获取课程信息 var req model.UserImportCoursesRequest - err := c.ShouldBindJSON(&req) - if err != nil { + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, respond.WrongParamType) return } - //2.从上下文获取用户ID - userIDInterface := c.GetInt("user_id") - //3.调用 service 层的 AddUserCoursesIntoSchedule 方法添加课程 - // 创建一个带 1 秒超时的上下文 + + userID := c.GetInt("user_id") ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) - defer cancel() // 记得释放资源 - conflicts, err := sa.service.AddUserCourses(ctx, req, userIDInterface) + defer cancel() + + conflicts, err := sa.service.AddUserCourses(ctx, req, userID) if err != nil { if errors.Is(err, respond.ScheduleConflict) { c.JSON(http.StatusConflict, respond.RespWithData(respond.ScheduleConflict, conflicts)) + return } respond.DealWithError(c, err) return } - //4.返回成功响应 + c.JSON(http.StatusOK, respond.Ok) } + +func (sa *CourseHandler) ParseCourseTableImage(c *gin.Context) { + userID := c.GetInt("user_id") + fileHeader, err := c.FormFile("image") + if err != nil { + log.Printf("[COURSE_PARSE][API] missing file user=%d path=%s err=%v", userID, c.FullPath(), err) + c.JSON(http.StatusBadRequest, respond.MissingParam) + return + } + + log.Printf( + "[COURSE_PARSE][API] request start user=%d path=%s filename=%q header_content_type=%q size=%d", + userID, + c.FullPath(), + fileHeader.Filename, + fileHeader.Header.Get("Content-Type"), + fileHeader.Size, + ) + + file, err := fileHeader.Open() + if err != nil { + log.Printf("[COURSE_PARSE][API] open file failed user=%d filename=%q err=%v", userID, fileHeader.Filename, err) + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + defer file.Close() + + imageBytes, err := io.ReadAll(file) + if err != nil { + log.Printf("[COURSE_PARSE][API] read file failed user=%d filename=%q err=%v", userID, fileHeader.Filename, err) + respond.DealWithError(c, err) + return + } + + log.Printf( + "[COURSE_PARSE][API] file loaded user=%d filename=%q bytes=%d", + userID, + fileHeader.Filename, + len(imageBytes), + ) + + // 课表图片识别当前不再额外叠加服务端 45 秒超时,避免长耗时多模态请求被本层提前打断。 + // 这里只跟随客户端请求上下文,便于先观察真实上游耗时与失败位置。 + ctx, cancel := context.WithCancel(c.Request.Context()) + defer cancel() + + draft, err := sa.service.ParseCourseTableImage(ctx, model.CourseImageParseRequest{ + Filename: fileHeader.Filename, + MIMEType: fileHeader.Header.Get("Content-Type"), + ImageBytes: imageBytes, + }) + if err != nil { + switch { + case errors.Is(err, service.ErrCourseImageParserUnavailable): + log.Printf("[COURSE_PARSE][API] parser unavailable user=%d filename=%q", userID, fileHeader.Filename) + c.JSON(http.StatusServiceUnavailable, respond.Response{Status: "50003", Info: "course image parser is not configured"}) + return + case errors.Is(err, service.ErrCourseImageTooLarge): + log.Printf("[COURSE_PARSE][API] file too large user=%d filename=%q bytes=%d", userID, fileHeader.Filename, len(imageBytes)) + c.JSON(http.StatusBadRequest, respond.Response{Status: "40064", Info: "course image too large"}) + return + case errors.Is(err, service.ErrCourseImageUnsupportedMIME): + log.Printf( + "[COURSE_PARSE][API] unsupported mime user=%d filename=%q header_content_type=%q", + userID, + fileHeader.Filename, + fileHeader.Header.Get("Content-Type"), + ) + c.JSON(http.StatusBadRequest, respond.Response{Status: "40065", Info: "unsupported course image format"}) + return + case errors.Is(err, service.ErrCourseImageEmpty): + log.Printf("[COURSE_PARSE][API] empty file user=%d filename=%q", userID, fileHeader.Filename) + c.JSON(http.StatusBadRequest, respond.Response{Status: "40066", Info: "course image is empty"}) + return + default: + log.Printf("[COURSE_PARSE][API] unexpected failure user=%d filename=%q err=%v", userID, fileHeader.Filename, err) + respond.DealWithError(c, err) + return + } + } + + log.Printf( + "[COURSE_PARSE][API] request success user=%d filename=%q draft_status=%s rows=%d warnings=%d", + userID, + fileHeader.Filename, + draft.DraftStatus, + len(draft.Rows), + len(draft.Warnings), + ) + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, draft)) +} diff --git a/backend/cmd/start.go b/backend/cmd/start.go index 32aed24..e3c54c6 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "os" "time" "github.com/LoveLosita/smartflow/backend/api" @@ -148,7 +149,21 @@ func Start() { // Service 层初始化。 userService := service.NewUserService(userRepo, cacheRepo) taskSv := service.NewTaskService(taskRepo, cacheRepo, eventBus) - courseService := service.NewCourseService(courseRepo, scheduleRepo) + courseImageResponsesClient := infrallm.NewArkResponsesClient( + os.Getenv("ARK_API_KEY"), + viper.GetString("agent.baseURL"), + viper.GetString("courseImport.visionModel"), + ) + courseService := service.NewCourseService( + courseRepo, + scheduleRepo, + courseImageResponsesClient, + service.NewCourseImageParseConfig( + viper.GetInt64("courseImport.maxImageBytes"), + viper.GetInt("courseImport.maxTokens"), + ), + viper.GetString("courseImport.visionModel"), + ) taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager) scheduleService := service.NewScheduleService(scheduleRepo, userRepo, taskClassRepo, manager, cacheRepo) agentService := service.NewAgentServiceWithSchedule(aiHub, agentRepo, taskRepo, cacheRepo, agentCacheRepo, eventBus, scheduleService, taskSv) diff --git a/backend/config.example.yaml b/backend/config.example.yaml index c9aba16..cc11c26 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -1,18 +1,12 @@ # SmartFlow 后端配置示例 # -# 使用说明: -# 1. 请复制为 config.yaml 后再按实际环境填写。 -# 2. 示例文件强调“结构清晰”和“字段语义明确”,不是生产推荐值。 -# 3. 若你只想看 memory 相关配置,优先看本文件下半部分的 memory / rag / websearch 段。 +# 说明: +# 1. 请复制为 config.yaml 后按实际环境填写。 +# 2. 本示例只保留当前代码仍会读取的配置项,避免示例与运行配置持续漂移。 -# 服务启动与 HTTP 行为。 +# 服务启动配置。 server: - # HTTP 监听端口。 port: 8080 - # gin 运行模式:debug / release。 - mode: debug - # 单次请求默认超时时间。 - timeout: 30s # MySQL 主库配置。 database: @@ -21,30 +15,19 @@ database: user: smartflow_user password: "put_your_database_password_here" dbname: "put_your_database_name_here" - charset: utf8mb4 - parseTime: true - loc: Local # 登录态与鉴权令牌配置。 jwt: accessSecret: "put_your_jwt_access_secret_here" refreshSecret: "put_your_jwt_refresh_secret_here" - # access token 有效期,面向接口鉴权。 accessTokenExpire: 15min - # refresh token 有效期,面向续签。 refreshTokenExpire: 7d -# 应用日志输出配置。 -log: - level: info - path: logs/ - -# Redis 缓存与轻量状态存储。 +# Redis 配置。 redis: host: localhost port: 6379 password: "" - db: 0 # Kafka outbox 事件总线配置。 kafka: @@ -60,156 +43,91 @@ kafka: # 时间与学期边界配置。 time: zone: "Asia/Shanghai" - # 学期开始日期,一定要设定为周一,以便于计算周数。 semesterStartDate: "2026-03-02" - # 学期结束日期,一定要设定为周日,确保最后一周完整。 semesterEndDate: "2026-07-19" -# 智能体模型与规划参数。 +# 智能体模型配置。 agent: - # 轻量模型:标题生成等低复杂度、低延迟场景。 liteModel: "doubao-seed-2-0-code-preview-260215" - # 标准模型:Chat 路由/闲聊/深度回答/Deliver 总结。 proModel: "doubao-seed-2-0-code-preview-260215" - # 高能力模型:Plan 规划 + Execute ReAct 等深度推理场景。 maxModel: "doubao-seed-2-0-code-preview-260215" - # 模型服务根路径。 baseURL: "https://ark.cn-beijing.volces.com/api/v3" - # 日内并发优化并发度,建议按模型配额调整。 - dailyRefineConcurrency: 7 - # 周级跨天配平额度上限,防止过度调整。 - weeklyAdjustBudget: 5 thinking: - # plan 节点(单轮深度规划),默认开 thinking。 plan: true - # execute 节点(ReAct 深度推理),默认开 thinking。 execute: true - # deliver 节点(交付总结),默认关 thinking。 deliver: false - # 记忆模块(决策比对 + 抽取),默认关 thinking。 memory: false +# 课表图片导入识别配置。 +courseImport: + visionModel: "" + maxImageBytes: 5242880 + maxTokens: 8192 + # 通用 RAG 配置。 rag: - # 总开关;关闭后不再走通用向量检索链路。 enabled: true - # 当前向量存储类型,可选:inmemory / milvus。 store: "milvus" - # 召回候选上限。 topK: 8 - # 召回相似度阈值。 threshold: 0.55 retrieve: - # 单次检索超时时间,避免主链路长时间阻塞。 timeoutMs: 1500 ingest: - # 文档切块大小;过大影响召回精度,过小影响上下文完整度。 chunkSize: 400 - # 相邻 chunk 重叠字符数。 chunkOverlap: 80 embed: - # embedding 供应商实现,可选:mock / eino。 provider: "eino" - # embedding 模型名。 model: "doubao-embedding-vision-251215" - # embedding 服务根路径;API Key 统一从环境变量读取。 baseURL: "https://ark.cn-beijing.volces.com/api/v3" timeoutMs: 1200 - # 向量维度,必须与向量库 collection 配置一致。 dimension: 1024 reranker: - # 是否启用重排。 enabled: false - # 当前默认 noop,后续可扩展。 provider: "noop" + timeoutMs: 1200 milvus: - # Milvus REST 地址,不要填健康检查口。 address: "http://localhost:19530" token: "root:Milvus" dbName: "" - # 通用 RAG chunk collection。 collectionName: "smartflow_rag_chunks" metricType: "COSINE" requestTimeoutMs: 1500 # 记忆模块配置。 memory: - # memory 总开关;关闭后不做抽取、写入、召回、注入。 enabled: true rag: - # 是否允许 memory 读写链路使用向量召回能力。 - # 关闭后,memory 里的“语义候选”会退回 MySQL 路径,不等于整个 memory 模块关闭。 enabled: true read: - # 读取模式: - # 1. legacy:旧读链路,语义上是“RAG 优先,失败再走 legacy”。 - # 2. hybrid:新读链路,先取强约束,再补语义候选,再统一去重/排序/预算裁剪。 - # 3. 如果你想强制纯 MySQL 召回,建议同时设置 read.mode=legacy 且 memory.rag.enabled=false。 mode: legacy - # constraint 类型最大注入条数。 constraintLimit: 5 - # preference 类型最大注入条数。 preferenceLimit: 5 - # fact 类型最大注入条数。 factLimit: 5 inject: - # 注入渲染模式: - # flat 为旧扁平列表;typed_v2 为按类型分段,便于模型区分“硬约束”和“参考事实”。 renderMode: flat prompt: - # 留空表示走代码内默认抽取 prompt。 extract: "" - # 留空表示走代码内默认决策 prompt。 decision: "" - # memory 向量召回阈值。 threshold: 0.55 - # 是否启用重排;当前默认关闭。 enableReranker: false llm: - # 记忆抽取/决策使用的 LLM 随机度,默认尽量保守,提升可复现性。 temperature: 0.1 topP: 0.2 job: - # 异步记忆任务最大重试次数。 maxRetry: 6 worker: - # worker 轮询间隔。 pollEvery: 2s - # 单次认领任务数。 claimBatch: 1 decision: - # 决策层总开关。 - # 开启后,写入链路会从”直接新增”升级成”召回旧记忆 -> 比对 -> 决策动作”。 enabled: true - # 决策层语义候选数上限。 candidateTopK: 5 - # 决策层语义候选最低相似度阈值。 candidateMinScore: 0.6 - # 决策流程整体失败时的降级策略: - # legacy_add:退回旧路径直接新增 - # drop:直接丢弃本次写入 fallbackMode: legacy_add write: - # 写入模式: - # legacy:沿用旧写入路径 - # decision:启用决策式写入 - # 注意:只有 decision.enabled=true 时,这个值才真正生效。 mode: legacy - # 写入最低置信度阈值,抽取结果 confidence 低于此值直接丢弃。 minConfidence: 0.5 # 联网搜索能力配置。 websearch: - # 可选:mock | bocha。 provider: bocha - # 搜索供应商 API Key;bocha 模式必填,否则会降级为 mock。 apiKey: "" - # 单次搜索请求超时。 - timeout: 10s - # 单次 URL 抓取超时。 - fetchTimeout: 15s - # 抓取正文最大字符数。 - fetchMaxChars: 4000 - rag: - # 是否把 websearch 结果继续送入 RAG 处理。 - enabled: false diff --git a/backend/infra/llm/ark_adapter.go b/backend/infra/llm/ark_adapter.go index 2c6ecf9..3234501 100644 --- a/backend/infra/llm/ark_adapter.go +++ b/backend/infra/llm/ark_adapter.go @@ -32,7 +32,19 @@ func WrapArkClient(arkChatModel *ark.ChatModel) *Client { if msg == nil { return nil, errors.New("ark model returned nil message") } - return &TextResult{Text: msg.Content}, nil + + var usage *schema.TokenUsage + finishReason := "" + if msg.ResponseMeta != nil { + usage = CloneUsage(msg.ResponseMeta.Usage) + finishReason = msg.ResponseMeta.FinishReason + } + + return &TextResult{ + Text: msg.Content, + Usage: usage, + FinishReason: finishReason, + }, nil } // 流式文本生成。 diff --git a/backend/infra/llm/ark_responses_client.go b/backend/infra/llm/ark_responses_client.go new file mode 100644 index 0000000..a82aed6 --- /dev/null +++ b/backend/infra/llm/ark_responses_client.go @@ -0,0 +1,337 @@ +package llm + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/volcengine/volcengine-go-sdk/service/arkruntime" + "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model/responses" +) + +// ArkResponsesMessage 描述一次 Responses 输入消息。 +// +// 职责边界: +// 1. 负责表达角色与多模态内容(文本/图片); +// 2. 不负责业务 prompt 生成; +// 3. 不负责输出 JSON 的字段校验。 +type ArkResponsesMessage struct { + Role string + Text string + ImageURL string + ImageDetail string +} + +// ArkResponsesOptions 描述 Responses 生成选项。 +type ArkResponsesOptions struct { + Model string + Temperature float64 + MaxOutputTokens int + Thinking ThinkingMode + TextFormat string +} + +// ArkResponsesUsage 统一透传 token 使用量。 +type ArkResponsesUsage struct { + InputTokens int64 + OutputTokens int64 + TotalTokens int64 +} + +// ArkResponsesResult 是 Ark Responses 的统一输出结构。 +type ArkResponsesResult struct { + Text string + Status string + IncompleteReason string + ErrorCode string + ErrorMessage string + Usage *ArkResponsesUsage +} + +// ArkResponsesClient 是 Ark SDK Responses 的统一模型出口。 +type ArkResponsesClient struct { + model string + client *arkruntime.Client +} + +// NewArkResponsesClient 创建 Ark SDK Responses 客户端。 +// +// 说明: +// 1. model 为空时返回 nil,表示当前能力未启用; +// 2. baseURL 为空时使用 SDK 默认地址; +// 3. 仅负责客户端创建,不做连通性探测。 +func NewArkResponsesClient(apiKey string, baseURL string, model string) *ArkResponsesClient { + model = strings.TrimSpace(model) + if model == "" { + return nil + } + + options := make([]arkruntime.ConfigOption, 0, 1) + if strings.TrimSpace(baseURL) != "" { + options = append(options, arkruntime.WithBaseUrl(strings.TrimSpace(baseURL))) + } + + return &ArkResponsesClient{ + model: model, + client: arkruntime.NewClientWithApiKey(strings.TrimSpace(apiKey), options...), + } +} + +// GenerateText 执行一次非流式 Responses 调用并提取文本。 +func (c *ArkResponsesClient) GenerateText(ctx context.Context, messages []ArkResponsesMessage, options ArkResponsesOptions) (*ArkResponsesResult, error) { + req, err := c.buildRequest(messages, options) + if err != nil { + return nil, err + } + + resp, err := c.client.CreateResponses(ctx, req) + if err != nil { + return nil, err + } + + result := buildArkResponsesResult(resp) + if result.Status == "failed" { + if result.ErrorMessage != "" { + return result, fmt.Errorf("ark responses failed: %s", result.ErrorMessage) + } + return result, errors.New("ark responses failed") + } + + if strings.TrimSpace(result.Text) == "" { + return result, FormatEmptyResponseError("ark_responses") + } + return result, nil +} + +// GenerateArkResponsesJSON 先调用 Responses,再解析为 JSON 结构体。 +func GenerateArkResponsesJSON[T any](ctx context.Context, client *ArkResponsesClient, messages []ArkResponsesMessage, options ArkResponsesOptions) (*T, *ArkResponsesResult, error) { + if client == nil { + return nil, nil, errors.New("ark responses client is not ready") + } + + result, err := client.GenerateText(ctx, messages, options) + if err != nil { + return nil, result, err + } + + parsed, err := ParseJSONObject[T](result.Text) + if err != nil { + return nil, result, err + } + return parsed, result, nil +} + +func (c *ArkResponsesClient) buildRequest(messages []ArkResponsesMessage, options ArkResponsesOptions) (*responses.ResponsesRequest, error) { + if c == nil || c.client == nil { + return nil, errors.New("ark responses client is not ready") + } + if len(messages) == 0 { + return nil, errors.New("ark responses messages is empty") + } + + modelName := strings.TrimSpace(options.Model) + if modelName == "" { + modelName = c.model + } + if modelName == "" { + return nil, errors.New("ark responses model is empty") + } + + inputItems := make([]*responses.InputItem, 0, len(messages)) + for idx := range messages { + item, err := buildInputItem(messages[idx]) + if err != nil { + return nil, fmt.Errorf("build ark responses message[%d] failed: %w", idx, err) + } + inputItems = append(inputItems, item) + } + + request := &responses.ResponsesRequest{ + Model: modelName, + Input: &responses.ResponsesInput{ + Union: &responses.ResponsesInput_ListValue{ + ListValue: &responses.InputItemList{ListValue: inputItems}, + }, + }, + } + + if options.Temperature > 0 { + request.Temperature = float64Ptr(options.Temperature) + } + if options.MaxOutputTokens > 0 { + request.MaxOutputTokens = int64Ptr(int64(options.MaxOutputTokens)) + } + + switch options.Thinking { + case ThinkingModeEnabled: + thinkingType := responses.ThinkingType_enabled + request.Thinking = &responses.ResponsesThinking{Type: &thinkingType} + case ThinkingModeDisabled: + thinkingType := responses.ThinkingType_disabled + request.Thinking = &responses.ResponsesThinking{Type: &thinkingType} + } + + if textType, ok := parseTextType(options.TextFormat); ok { + request.Text = &responses.ResponsesText{ + Format: &responses.TextFormat{ + Type: textType, + }, + } + } + + return request, nil +} + +func buildInputItem(message ArkResponsesMessage) (*responses.InputItem, error) { + role, ok := parseMessageRole(message.Role) + if !ok { + return nil, fmt.Errorf("unsupported message role: %s", strings.TrimSpace(message.Role)) + } + + content := make([]*responses.ContentItem, 0, 2) + if text := strings.TrimSpace(message.Text); text != "" { + content = append(content, &responses.ContentItem{ + Union: &responses.ContentItem_Text{ + Text: &responses.ContentItemText{ + Type: responses.ContentItemType_input_text, + Text: text, + }, + }, + }) + } + + if imageURL := strings.TrimSpace(message.ImageURL); imageURL != "" { + image := &responses.ContentItemImage{ + Type: responses.ContentItemType_input_image, + ImageUrl: stringPtr(imageURL), + } + if detail, ok := parseImageDetail(message.ImageDetail); ok { + image.Detail = &detail + } + + content = append(content, &responses.ContentItem{ + Union: &responses.ContentItem_Image{ + Image: image, + }, + }) + } + + if len(content) == 0 { + return nil, errors.New("message content is empty") + } + + return &responses.InputItem{ + Union: &responses.InputItem_InputMessage{ + InputMessage: &responses.ItemInputMessage{ + Role: role, + Content: content, + }, + }, + }, nil +} + +func buildArkResponsesResult(resp *responses.ResponseObject) *ArkResponsesResult { + if resp == nil { + return &ArkResponsesResult{} + } + + result := &ArkResponsesResult{ + Text: extractArkResponsesText(resp), + Status: strings.TrimSpace(resp.GetStatus().String()), + } + + if details := resp.GetIncompleteDetails(); details != nil { + result.IncompleteReason = strings.TrimSpace(details.GetReason()) + } + + if responseErr := resp.GetError(); responseErr != nil { + result.ErrorCode = strings.TrimSpace(responseErr.GetCode()) + result.ErrorMessage = strings.TrimSpace(responseErr.GetMessage()) + } + + if usage := resp.GetUsage(); usage != nil { + result.Usage = &ArkResponsesUsage{ + InputTokens: usage.GetInputTokens(), + OutputTokens: usage.GetOutputTokens(), + TotalTokens: usage.GetTotalTokens(), + } + } + return result +} + +func extractArkResponsesText(resp *responses.ResponseObject) string { + if resp == nil { + return "" + } + + textParts := make([]string, 0, 2) + for _, outputItem := range resp.GetOutput() { + outputMessage := outputItem.GetOutputMessage() + if outputMessage == nil { + continue + } + + for _, contentItem := range outputMessage.GetContent() { + text := strings.TrimSpace(contentItem.GetText().GetText()) + if text == "" { + continue + } + textParts = append(textParts, text) + } + } + return strings.TrimSpace(strings.Join(textParts, "\n")) +} + +func parseMessageRole(raw string) (responses.MessageRole_Enum, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "user": + return responses.MessageRole_user, true + case "system": + return responses.MessageRole_system, true + case "developer": + return responses.MessageRole_developer, true + case "assistant": + return responses.MessageRole_assistant, true + default: + return responses.MessageRole_unspecified, false + } +} + +func parseImageDetail(raw string) (responses.ContentItemImageDetail_Enum, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "high": + return responses.ContentItemImageDetail_high, true + case "low": + return responses.ContentItemImageDetail_low, true + case "auto": + return responses.ContentItemImageDetail_auto, true + default: + return responses.ContentItemImageDetail_auto, false + } +} + +func parseTextType(raw string) (responses.TextType_Enum, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "": + return responses.TextType_unspecified, false + case "text": + return responses.TextType_text, true + case "json_object": + return responses.TextType_json_object, true + default: + return responses.TextType_unspecified, false + } +} + +func stringPtr(value string) *string { + return &value +} + +func float64Ptr(value float64) *float64 { + return &value +} + +func int64Ptr(value int64) *int64 { + return &value +} diff --git a/backend/infra/llm/client.go b/backend/infra/llm/client.go index c29f329..4536971 100644 --- a/backend/infra/llm/client.go +++ b/backend/infra/llm/client.go @@ -45,6 +45,8 @@ type GenerateOptions struct { type TextResult struct { Text string Usage *schema.TokenUsage + // FinishReason 透传 provider 的停止原因,便于上层判断是否因 length 等原因被截断。 + FinishReason string } // StreamReader 抽象了“可逐块 Recv 的流式返回器”。 diff --git a/backend/inits/eino.go b/backend/inits/eino.go index 3865599..dc62a92 100644 --- a/backend/inits/eino.go +++ b/backend/inits/eino.go @@ -8,16 +8,10 @@ import ( "github.com/spf13/viper" ) -// AIHub 存储三级模型的实例,按能力分级调度。 -// -// 分级策略: -// 1. Lite:轻量模型,用于标题生成等低复杂度、低延迟场景; -// 2. Pro:标准模型,用于 Chat 路由/闲聊/深度回答/Deliver 总结; -// 3. Max:高能力模型,用于 Plan 规划和 Execute ReAct 等需要深度推理的场景。 type AIHub struct { - Lite *ark.ChatModel // 轻量模型:标题生成等低复杂度任务 - Pro *ark.ChatModel // 标准模型:Chat 路由、闲聊、交付总结 - Max *ark.ChatModel // 高能力模型:Plan 规划、Execute ReAct + Lite *ark.ChatModel + Pro *ark.ChatModel + Max *ark.ChatModel } func InitEino() (*AIHub, error) { @@ -25,7 +19,6 @@ func InitEino() (*AIHub, error) { baseURL := viper.GetString("agent.baseURL") apiKey := os.Getenv("ARK_API_KEY") - // 1. Lite 模型:标题生成等低复杂度场景,优先控制成本和延迟。 lite, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{ Model: viper.GetString("agent.liteModel"), BaseURL: baseURL, @@ -34,7 +27,7 @@ func InitEino() (*AIHub, error) { if err != nil { return nil, err } - // 2. Pro 模型:Chat 路由/闲聊/交付总结等标准复杂度场景。 + pro, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{ Model: viper.GetString("agent.proModel"), BaseURL: baseURL, @@ -43,7 +36,7 @@ func InitEino() (*AIHub, error) { if err != nil { return nil, err } - // 3. Max 模型:Plan 规划和 Execute ReAct 等需要深度推理的场景。 + maxModel, err := ark.NewChatModel(ctx, &ark.ChatModelConfig{ Model: viper.GetString("agent.maxModel"), BaseURL: baseURL, @@ -52,6 +45,7 @@ func InitEino() (*AIHub, error) { if err != nil { return nil, err } + return &AIHub{ Lite: lite, Pro: pro, diff --git a/backend/model/course_parse.go b/backend/model/course_parse.go new file mode 100644 index 0000000..0f69adb --- /dev/null +++ b/backend/model/course_parse.go @@ -0,0 +1,38 @@ +package model + +type CourseImageParseDraftStatus string + +const ( + CourseImageParseDraftStatusSuccess CourseImageParseDraftStatus = "success" + CourseImageParseDraftStatusPartial CourseImageParseDraftStatus = "partial" + CourseImageParseDraftStatusReject CourseImageParseDraftStatus = "reject" +) + +type CourseImageParseRow struct { + RowID string `json:"row_id"` + CourseName string `json:"course_name"` + Location string `json:"location"` + IsAllowTasks bool `json:"is_allow_tasks"` + StartWeek *int `json:"start_week"` + EndWeek *int `json:"end_week"` + DayOfWeek *int `json:"day_of_week"` + StartSection *int `json:"start_section"` + EndSection *int `json:"end_section"` + WeekType string `json:"week_type"` + Confidence float64 `json:"confidence"` + RawText string `json:"raw_text"` + RowWarnings []string `json:"row_warnings"` +} + +type CourseImageParseResponse struct { + DraftStatus CourseImageParseDraftStatus `json:"draft_status"` + Message string `json:"message"` + Warnings []string `json:"warnings"` + Rows []CourseImageParseRow `json:"rows"` +} + +type CourseImageParseRequest struct { + Filename string + MIMEType string + ImageBytes []byte +} diff --git a/backend/newAgent/prd文档.md b/backend/newAgent/prd文档.md new file mode 100644 index 0000000..5e36898 --- /dev/null +++ b/backend/newAgent/prd文档.md @@ -0,0 +1,732 @@ +# SmartFlow 主动优化功能 PRD(讨论版) + +## 0. 文档信息 +- 文档状态:讨论中(骨架版) +- 适用范围:主动优化(对话内 execute + 对话内任务类共创) +- 文档目的:先对齐产品方向,再指导后续实现 +- 约束说明:本 PRD 只谈产品,不谈技术实现 + +--- + +## 1. 业务背景与问题定义(已讨论 v0.1) +### 1.1 当前用户问题 +- 用户并不总会明确表达需求,存在两类典型入口: + - 默认入口:用户未明确偏好,只希望“尽快排好任务类”。 +- 偏好入口:用户给出较多约束与倾向(强度、时段、节奏、容错等)。 +- 现状容易把优化做成“单点最佳实践”或“一次性建议”,缺少可持续迭代与偏好对齐。 +- 因此,工具体系必须同时支持: + - 在信息不足时,按科学界公认最佳实践给出稳健中位方案。 + - 在用户偏好明确时,优先按用户需求调参,不盲从默认最佳实践。 + +### 1.2 核心问题陈述 +- 我们要解决的问题是: + `如何让 AI 在“科学最佳实践”和“用户个性化需求”之间做可解释、可调节、可收敛的主动优化。` +- 该问题直接决定工具设计方向: + - 读工具覆盖面必须足够广,能够支撑不同偏好下的判断。 + - 每个核心指标必须是“区间型”而不是“单点型”: + - 默认站在中位(平衡值)。 + - 能向左/向右偏移,对应不同用户诉求。 + +### 1.3 本章已确定结论 +- 首发主用户策略: + - 若用户需求不提或较弱,系统默认采用中位最佳实践快速生成。 + - 若用户需求明确且较多,系统优先满足用户需求,科学原则作为安全边界。 +- “满意方案”判定口径(本章层面): + - 本质不是固定模板,而是“在用户诉求方向上的可接受平衡点”。 + - 默认用户采用中位平衡;偏好用户采用定向偏移平衡。 +- 自动优化容忍边界(当前已定项): + - 轮次上限暂定 60 轮。 + - 时长与是否开启深度思考的权衡暂不在本章冻结,后续章节决策。 + +### 1.4 对后续章节的约束 +- 第 6 章(科学原则)必须给出“中位默认 + 双向偏移”的可解释规则。 +- 第 8 章(工具蓝图)必须体现“覆盖广度 + 区间刻度”的产品能力。 +- 第 11 章(指标验收)必须衡量“默认模式质量”与“偏好对齐质量”两条线。 + +--- + +## 2. 产品目标与非目标(已讨论 v0.1) +### 2.1 产品目标定义与优先级(已定) +- 目标 A(最高优先级):自主迭代收敛 + - 定义:AI 以“观测-调整-复盘”循环持续优化,直到达到可接受方案再收口。 + - 用户价值:减少用户逐步指挥成本,体现“主动出击”。 +- 目标 B(第二优先级):可解释且有改进证据 + - 定义:每轮调整都要给出“为何调整、调整内容、前后差异”。 + - 用户价值:可控、可信,避免“黑箱瞎调”。 +- 目标 C(第三优先级):对话内任务类共创草案 + - 定义:用户在聊天中触发后,AI 通过反问与检索产出完整任务类草案。 + - 用户价值:降低冷启动门槛,减少配置负担,避免新增第二交互区。 +- 优先级结论:`A > B > C`。 + +### 2.2 阶段目标策略(已定) +- 首发必须保证:A 与 B 构成闭环能力。 +- 首发可落可迭代:C 以“可用版”上线,后续逐步提高草案准确率与覆盖深度。 +- 取舍原则:若资源冲突,优先保障 A;若 A 满足基本可用,再保障 B;C 按剩余资源推进。 + +### 2.3 非目标(已定) +- 不追求一次优化即全局最优,目标是“可收敛的高质量可接受方案”。 +- 不追求首发覆盖全部学习风格与全部人群偏好。 +- 不追求在高风险场景下完全替代用户决策。 +- 不以“工具数量”作为目标,避免能力堆叠但无法形成闭环价值。 + +### 2.4 本章已确定结论 +- 我们的核心差异化能力是 A(主动迭代优化),不是一次性建议或单轮算法执行。 +- B 是 A 的信任保障,必须同步建设,不能后补。 +- C 是重要入口能力,但在首发阶段不应挤占 A/B 的闭环建设资源。 + +### 2.5 对后续章节的约束 +- 第 5 章(主动优化流程)必须完整体现 A 的循环收敛机制。 +- 第 9 章(交互要求)必须体现 B 的解释与改进证据结构。 +- 第 12 章(分期路线图)必须以 `A > B > C` 排序规划交付。 + +--- + +## 3. 用户与场景(已讨论 v1.0) +### 3.1 目标用户分层(已形成草案) +| 用户分层 | 典型特征 | 当前痛点 | 价值诉求 | 首发优先级 | +|---|---|---|---|---| +| 极速排程型 | 不想多聊,希望尽快出方案 | 参数配置成本高、上手慢 | 一键可用、少改动 | P1 | +| 偏好驱动型 | 明确表达强度/时段/节奏偏好 | 通用最佳实践不一定贴合个人需求 | 结果沿偏好方向明显偏移、可控可解释 | P0(首发主优先) | +| 反复调优型 | 接受多轮优化,关注持续变好 | 容易遇到来回调整、无效微调 | 稳定收敛、每轮有改进证据 | P1 | + +### 3.2 首发核心场景清单(已形成草案) +| 场景 | 触发方式 | 用户期望 | 成功标准 | +|---|---|---|---| +| 场景 S1:对话内任务类共创草案 | 用户在聊天中提出“帮我设计任务类” | 快速得到完整且可确认的任务类草案 | 用户可直接采纳或仅小幅修改后采纳 | +| 场景 S2:对话内“帮我优化一下” | 用户在对话中发起优化请求 | AI 主动多轮调整并收口 | 至少完成 1-2 轮有效改进且最终可交付 | +| 场景 S3:对话内“按我的偏好重排” | 用户明确给出偏好/约束 | AI 优先满足偏好,不盲从默认最佳实践 | 结果明显朝偏好方向偏移且不破坏硬约束 | + +### 3.3 场景优先策略(已形成草案) +- 首发优先主线:偏好驱动型(P0)。 +- 原因:该人群最能体现本功能差异化价值,即“可调节的主动优化”,而非一次性默认排程。 +- 策略要求:所有首发核心场景都必须支持“默认中位 + 偏好偏移”双模式。 + +### 3.4 暂不支持场景清单(草案) +| 暂不支持场景 | 暂缓原因 | 后续进入条件 | +|---|---|---| +| 跨超长周期(如整学期/跨学期)全局最优规划 | 目标跨度过大,首发优先保证局部收敛质量 | 收敛稳定性和性能目标达标后再纳入 | +| 多主体联合排程(多人协同/冲突协商) | 交互复杂度高,超出首发边界 | 单人场景成熟后评估 | +| 高风险不可逆决策自动执行 | 需要更强确认链路与责任边界 | 风险治理机制完善后评估 | + +### 3.5 本章已确定的判定阈值口径 +- S1(任务类共创草案“小幅修改”阈值): + - 定义:关键字段修改率 <= 30% 视为“小幅修改”。 + - 用途:衡量草案可用性与采纳质量(用于产品验收,不作为用户前台提示)。 +- S2(主动优化“有效改进”最小标准): + - 定义:至少一个核心问题域的严重度下降,视为“有效改进”。 + - 严重度层级:`critical > warning > info`。 + - 用途:判断单轮优化是否有实质收益,避免无效循环。 +- S3(偏好冲突裁决规则): + - 定义:用户偏好优先,科学原则兜底。 + - 用途:在“通用最佳实践 vs 用户个性化需求”冲突时,给出统一裁决路径。 + +### 3.6 新增场景候选:对话内任务类共创(WebSearch 增强) +#### 3.6.1 场景定义(已讨论结论) +- 场景目标:由 AI 在对话中产出“完整任务类草案”,而非仅补全单个参数。 +- 触发方式:仅支持聊天触发,不新增聊天外按钮入口。 +- 原因:该能力需要多轮反问与澄清,若放在聊天外容易形成“第二对话区”,增加认知负担。 + +#### 3.6.2 信息来源优先级(已讨论结论) +- WebSearch 负责:补充通用知识(如课程信息、学习路径共识、考试结构常识)。 +- 用户输入负责:表达个人偏好与约束(强度、时段、节奏、目标侧重)。 +- 冲突处理:用户偏好优先,通用知识仅作参考与兜底。 + +#### 3.6.3 字段确认策略(已讨论结论) +- 关键字段:必须用户确认后落库。 +- 普通字段:允许静默落库,并在结果摘要中可追溯展示。 + +#### 3.6.4 成功标准(草案) +- 草案采纳率(用户直接采纳完整草案的比例)。 +- 草案修改率(用户修改后采纳的比例)。 +- 后续优化收敛效率(基于该草案进入主动优化后的平均有效轮次变化)。 + +--- + +## 4. 核心体验原则(已讨论 v1.0) +### 4.1 体验总纲(草案) +- 原则 1:先看全局,再做局部。 + - 先识别主要矛盾,再执行局部调整,避免“盲调”。 +- 原则 2:单轮单主问题域。 + - 每轮只聚焦一个主问题域,降低震荡与来回改动。 +- 原则 3:每轮必须复盘并判定有效性。 + - 任何调整都要有“是否变好”的结论,不允许无结论进入下一轮。 +- 原则 4:达标即收口。 + - 达到可接受阈值后立即停止,避免过度优化。 +- 原则 5:偏好优先、科学兜底。 + - 用户偏好是目标方向,科学原则提供安全边界。 +- 原则 6:硬约束优先于体验优化。 + - 先保证不违约束,再追求负载/节奏/切换等体验改进。 + +### 4.2 单轮优化行为规范(草案) +- 规范 A:本轮开始前必须声明“主问题域 + 目标变化”。 +- 规范 B:单轮仅允许一个主问题域,允许附带次问题观察但不展开动作。 +- 规范 C:同一主问题域若尚未出现有效改进,不应频繁切换到其他问题域。 +- 规范 D:若用户明确指定优化方向,优先采用用户方向作为本轮主问题域。 + +### 4.3 单轮复盘输出规范(草案) +- 每轮都应给出三段式结果: + - 本轮目标:本轮要改善什么。 + - 本轮改动:改了哪些关键位置。 + - 本轮结果:哪些指标或问题严重度发生了变化。 +- 单轮判定结果仅允许两类: + - `有效改进`:至少一个核心问题域严重度下降。 + - `无效改进`:无严重度下降,需换策略或收口。 + +### 4.4 收口与停机原则(已定) +- 正常收口条件: + - 达到可接受方案阈值; + - 或主要问题已降至可接受等级。 +- 防循环停机条件: + - 连续多轮无有效改进; + - 或达到轮次上限(当前上限 60)。 +- 强制人工确认规则(已定): + - 只要涉及“移动类改动”,默认都需用户确认后执行。 + - 仅当用户显式开启“始终同意”时,允许自动通过确认。 + - 即使自动通过,也需在结果中保留可追溯记录。 + +### 4.5 本章已确定结论 +- Q4-1 结论:支持用户强制覆盖单轮主问题域。 + - 说明:前端已支持用户自由拖动,该能力与产品原则一致。 +- Q4-2 结论:采用“移动必确认,始终同意可自动通过”的统一规则。 + - 说明:确认链路以用户控制权优先,兼顾效率模式。 + +--- + +## 5. 主动优化产品流程(已讨论 v1.0) +### 5.0 模式切换策略(补充,已定) +- 首次主动排课(粗排 + 主动微调)默认启用全流程模式。 +- 后续局部调整请求默认启用局部执行模式(优先旧工具链)。 +- 仅在以下情况升级为全流程模式: + - 用户明确授权“重新全局优化”; + - 用户诉求明确命中指标域(如切换过多、太满、容错不足等)。 + +### 5.1 流程总览(已定) +1. 入场判定:确定本次优化模式(默认中位 / 偏好驱动)、目标窗口、可改动范围。 +2. 首轮体检:强制先体检,再进入改动(避免盲调)。 +3. 迭代优化:按“单轮主问题域”执行改动与复盘。 +4. 收口判定:达标即收口;未达标则继续循环。 +5. 异常处理:冲突、失败、用户改目标时按规则回退或重开。 +6. 结果交付:输出改动摘要、改进证据、剩余风险与下一步建议。 + +### 5.2 轮次定义(已定) +- “1 轮优化”定义为一次完整闭环: + 1. 选定主问题域; + 2. 生成本轮改动方案; + 3. 通过确认门禁; + 4. 执行改动; + 5. 复盘并判定有效/无效。 +- 说明: + - 仅观察不改动,不计入优化轮。 + - “连续无效轮次”仅统计“已执行改动但未出现有效改进”的轮。 + +### 5.3 详细流程规则(已定) +#### 5.3.1 入场判定 +- 输入:用户目标、偏好、限制、当前日程状态。 +- 输出:本次优化上下文(模式、范围、约束、初始问题池)。 +- 规则:若用户目标不明确,默认按中位最佳实践入场。 +- 规则补充: + - 局部执行模式可跳过全流程体检,直接做最小必要校验后执行。 + - 全流程模式必须先体检再改动。 + +#### 5.3.2 首轮体检(强制) +- 必须先完成体检再改动。 +- 体检结果至少包含:问题清单、严重度排序、建议主问题域。 +- 禁止跳过体检直接执行改动。 + +#### 5.3.3 单轮优化执行 +- 每轮必须先声明:本轮主问题域与目标变化。 +- 本轮仅允许一个主问题域,避免并发多目标拉扯。 +- 涉及移动类改动: + - 默认需用户确认; + - 用户开启“始终同意”后可自动通过; + - 自动通过仍需可追溯记录。 + +#### 5.3.4 单轮复盘判定 +- 有效改进标准:至少一个核心问题域严重度下降。 +- 无效改进标准:执行改动后无严重度下降。 +- 无效轮次处置:允许换策略继续,但需计入连续无效轮次计数。 + +### 5.4 收口规则(已定) +- 正常收口阈值: + - `critical = 0`; + - `warning <= 1`。 +- 防循环强制收口: + - 连续无效轮次 >= 3; + - 或达到总轮次上限(当前 60 轮)。 +- 收口后必须输出:已解决问题、未解决问题、建议后续动作。 + +### 5.5 用户中途改目标处理(已定) +- 当用户在优化过程中明确变更目标/偏好时: + - 立即重开“入场判定”; + - 清空当前主问题域上下文; + - 基于新目标重新体检并进入下一轮。 +- 目的:避免沿旧目标继续优化导致结果跑偏。 + +### 5.6 本章已确定结论 +- 首轮体检强制执行。 +- 可接受阈值采用 `critical=0 且 warning<=1`。 +- 连续无效 3 轮即强制收口。 +- 用户中途改目标时,必须重开入场判定。 +- 首次主动排课默认全流程;后续局部调整默认旧工具链。 + +--- + +## 6. 科学安排原则(已讨论 v1.0) +### 6.1 原则优先级(已定) +按“上位约束可否决下位偏好”的顺序执行: +1. 硬约束合法性(不可冲突、不可越界、不可违规改动) +2. 截止与时间压力(先保证不发生明显延期风险) +3. 用户偏好方向(在上位约束允许范围内优先满足) +4. 负载均衡(避免极端堆积与突增) +5. 认知切换(控制高频切换与过长连续块) +6. 容错能力(可用空窗规模,平衡稳定性与利用率) + +### 6.2 冲突裁决规则(已定) +| 冲突场景 | 裁决规则 | 用户可覆盖性 | +|---|---|---| +| 用户偏好 vs 硬约束合法性 | 硬约束优先,拒绝违规方案并给替代建议 | 不可覆盖 | +| 用户偏好 vs 截止/时间压力红线 | 截止压力优先,默认前移高风险任务 | 可显式确认后覆盖部分策略 | +| 用户偏好 vs 下位优化项(负载/切换/容错) | 用户偏好优先,科学原则兜底 | 可覆盖 | +| 无明确用户偏好 | 采用中位最佳实践 | 不适用 | + +### 6.3 原则刻度化口径(中位默认 + 双向偏移) +| 原则维度 | 中位默认 | 左偏 | 右偏 | +|---|---|---|---| +| 负载强度 | 平衡推进 | 低强度(更松) | 冲刺强度(更满) | +| 截止推进 | 均衡前移 | 早缓冲(更早完成) | 临近冲刺(更晚推进) | +| 认知切换 | 适度切换 | 低切换(同类聚合) | 高切换(灵活穿插) | +| 容错能力 | 平衡容错 | 高容错(多留大空窗) | 低容错(任务排得更满) | + +### 6.4 软硬约束分层(已定) +- 硬约束: + - 合法性约束(冲突、越界、禁止改动范围) + - 截止/时间压力红线 +- 软约束: + - 负载均衡 + - 认知切换 + - 容错能力 +- 执行原则: + - 先满足硬约束,再在软约束内做偏好优化。 + +### 6.5 本章已确定结论 +- 科学原则优先级已固定为“硬约束与截止优先,偏好次之,其余体验项随后优化”。 +- 冲突裁决已固定为“分层裁决”:不可覆盖项直接否决,可覆盖项通过显式确认处理。 +- “容错”作为用户可理解维度,已替代“空窗/缓冲”作为统一外显术语。 + +--- + +## 7. 用户需求与偏好模型(已讨论 v1.0) +### 7.1 边界定义(已定) +- 本章只定义“偏好消费与确认规则”,不定义“偏好采集机制”。 +- 偏好采集由 memory 系统负责: + - 持续采集; + - 去重注入; + - 产品层直接消费。 + +### 7.2 偏好消费优先级(已定) +1. 用户显式输入(最高优先级) +2. memory 注入偏好(次优先) +3. WebSearch 通用知识(仅补全,不可覆盖用户偏好) +4. 无信息时采用中位默认值 + +### 7.3 必要点判定与 ask_user 规则(已定) +- 必要点定义:缺失会导致方案不可执行或高风险误判的关键信息。 +- 必要点缺失时:必须 ask_user,不允许静默推断。 +- 当前必要点清单: + - 时间窗(至少明确 end,start 可按策略补齐); + - 强度方向(均匀/冲刺); + - 容错偏好(高容错/平衡/低容错); + - 禁排时段(若用户表达了禁忌但未结构化)。 + +### 7.4 字段分级(已定) +#### 7.4.1 关键字段(必须确认) +- 时间窗(start/end,截止时间统一归入 end,不单列重复字段) +- 强度策略(均匀/冲刺) +- 总预算(total_slots) +- 容错偏好(高容错/平衡/低容错) +- 禁排时段(excluded_slots) +- 任务项清单完整性(是否齐全) +- 任务项优先级/依赖关系(如用户提供) + +#### 7.4.2 普通字段(可静默落) +- 推荐时段偏好权重(上午/下午/晚间) +- 同类任务聚合偏好(聚合/平衡/穿插) +- 阶段里程碑拆分建议 +- 标准化知识标签与学习路径备注(命中统一标准时结构化落地;未命中仅文本备注) + +### 7.5 口径修正(已定) +- 不在偏好层管理“单次学习块长度”: + - 该项属于任务类/任务项结构属性,不作为本章普通偏好字段。 +- 统一命名“时间窗”: + - “截止时间”视为时间窗 end 的口语表达,不单列独立字段。 + +### 7.6 本章已确定结论 +- 偏好由 memory 采集,产品层只做消费与确认。 +- 必要点缺失必须 ask_user,避免静默误判。 +- 字段分级与统一命名口径已固定,可直接指导后续工具设计与交互文案。 + +--- + +## 8. 工具能力产品蓝图(已讨论 v1.0) +### 8.1 工具分层(产品视角) +- 事实读取层:告诉 AI“现在是什么” +- 分析体检层:告诉 AI“问题在哪” +- 评估复盘层:告诉 AI“这轮是否变好” +- 执行动作层:让 AI 进行可控调整(以旧工具链为主) + +### 8.2 混合工具策略(新增) +- 策略 1:旧工具保留为主执行层,不做全线替换。 +- 策略 2:新分析工具作为导航层,主要用于首次主动排课与指标域重优化。 +- 策略 3:局部请求默认旧工具直达执行,避免过度主动出击。 +- 策略 4:仅在用户授权或命中指标域诉求时,升级为分析链路。 + +### 8.3 对话内能力(草案) +| 能力 | 适用模式 | 用户价值 | AI 产出 | 风险控制 | +|---|---|---|---|---| +| analyze_health(总览体检) | 首次编排/明确触发全流程时默认首入口(可跳过) | 快速定位主要问题 | metrics/issues/next_actions | 防盲钻、防误判 | +| analyze_load | 全流程模式/指标域触发 | 识别过载与波动 | 负载证据 + 动作建议 | 防局部最优 | +| analyze_subjects | 全流程模式/指标域触发 | 识别科目节奏与预算压力 | 分布证据 + 动作建议 | 防断档 | +| analyze_context | 全流程模式/指标域触发 | 识别切换过高与碎片化 | 切换证据 + 动作建议 | 防认知疲劳 | +| analyze_tolerance | 全流程模式/指标域触发 | 识别容错不足风险 | 容错证据 + 动作建议 | 防计划脆弱 | +| build_task_class_draft(WebSearch增强) | 共创模式 | 从 0 到 1 生成可用任务类草案 | 完整任务类草案 + 关键字段确认请求 | 防知识幻觉、防越权落库 | + +### 8.4 分析工具输出结构规范(草案) +- 分析工具统一返回三段: + - `metrics`:测量值; + - `issues`:问题及严重度(critical/warning/info); + - `next_actions`:下一步建议(只建议,不自动执行)。 +- 细节级别: + - 默认 `summary`; + - 用户追问或需要取证时使用 `full`。 + +### 8.5 WebSearch 共创能力边界(新增) +- 本能力定位:对话内共创,不替代主动优化主线。 +- 输出形态:完整任务类草案,不是单字段建议。 +- 决策边界:用户偏好优先于通用知识。 +- 安全边界:关键字段需确认,普通字段可静默落并可追溯。 + +### 8.6 本章已确定结论 +- `analyze_health` 仅在“首次编排”或“用户明确触发全流程”时作为默认首入口(可跳过)。 +- 分析工具默认明细级别统一为 `summary`,用户追问或需取证时切换 `full`。 + +--- + +## 9. 关键体验与交互要求(已讨论 v1.0) +### 9.1 本章定位(已对齐) +- 本章只定义“用户看到什么、怎么被解释、何时需要确认”。 +- 不定义算法细节、不定义工具内部实现。 +- 目标是让主动优化“有方向、可理解、不过度”。 + +### 9.2 双模式对话体验(已对齐) +- 首次编排/明确触发全流程时:进入“体检 + 迭代优化”模式,先给全局判断,再给单轮改进。 +- 后续局部请求时:默认走旧工具的局部执行链,不擅自升级为全流程。 +- 仅在两类条件下可升级全流程:用户明确授权;用户诉求明确命中指标域(如“切换太多”“太满了”)。 + +### 9.3 单轮解释三段式(已定) +- 观察段:本轮先说“我看到了什么问题”,并给最小证据(指标或现象)。 +- 动作段:再说“我准备怎么改、为什么这么改”,同时点明遵循了哪条科学原则与用户偏好。 +- 结果段:最后说“改完发生了什么变化”,并给下一步建议(继续微调或收口)。 +- 三段式的意义:让用户始终知道“问题-动作-结果”的闭环,避免 AI 黑箱式挪动。 + +### 9.4 解释字段最小集合(已定) +- 字段1(必显):本轮主问题域(负载/切换/截止/容错/科目分布等)。 +- 字段2(必显):本轮改动摘要(改了哪些任务、从哪到哪、影响了哪几天)。 +- 字段3(必显):改动理由(科学原则 + 用户偏好 + 冲突裁决依据)。 +- 字段4(建议显):前后对比(至少 1 个核心指标变化)。 +- 字段5(建议显):副作用提示(例如“容错下降”“切换略增”)。 +- 字段6(建议显):下一步建议(继续某方向微调,或建议收口)。 +- 默认规则:最少展示前 3 字段;全流程场景建议展示 1-6 字段。 + +### 9.5 用户控制与确认边界(已对齐) +- 涉及“移动类改动”默认都要确认;若用户已开启“始终同意”,可自动通过但需可追溯。 +- 用户可自由手动拖动,系统应尊重手动结果,不反向强改。 +- 用户可随时改目标;改目标后按既定规则重开入场判定。 +- AI 可主动给建议,但不能越权执行超出用户授权范围的改动。 + +### 9.6 对话内任务类共创体验(已对齐) +- 仅聊天触发,不做聊天外按钮触发。 +- 输出形态为“完整任务类草案”,而非零散参数建议。 +- 关键字段必须确认;普通字段可静默落并保留可追溯记录。 +- 用户偏好与 Web 通用知识冲突时,用户偏好优先。 + +### 9.7 本章已确定结论 +- 默认解释风格采用“专业结论 + 通俗补充”双层表达。 +- 最小必显字段固定为 3 项:主问题域、改动摘要、改动理由。 +- 局部模式下不强制固定边界提示,是否提示由上下文按需决定。 + +--- + +## 10. 风险、边界与治理(已讨论 v1.0) +### 10.1 风险分层(产品视角) +- R1 收敛风险:LLM 长时间小步试探但无实质改进,造成轮次浪费。 +- R2 体验风险:指标看起来改善,但用户主观体感变差(例如更累、更碎)。 +- R3 越权风险:AI 在未充分授权下做了超出预期范围的改动。 +- R4 可信风险:解释与真实改动不一致,导致用户不信任系统。 +- R5 数据风险:关键信息缺失/冲突,导致判断前提不成立却仍继续优化。 + +### 10.2 产品边界(已对齐) +- 边界1:全流程优化默认仅用于首次编排或用户明确触发,后续局部请求默认局部执行。 +- 边界2:涉及移动类改动默认确认;用户开启“始终同意”后可自动通过,但需保留追溯。 +- 边界3:用户手动拖动结果优先,AI 不得反向强改。 +- 边界4:用户可随时改目标;改目标后立即重开入场判定。 +- 边界5:用户偏好与通用知识冲突时,用户偏好优先。 + +### 10.3 治理机制(过程治理) +- 入场治理:先判定是“全流程模式”还是“局部模式”;必要信息缺失必须 ask_user,不允许静默猜测。 +- 轮中治理:坚持单轮单主问题域;每轮都输出“观察-动作-结果”,并判断是否有效改进。 +- 收口治理:命中 `critical=0 且 warning<=1` 立即收口;连续无效 3 轮或达到轮次上限强制收口。 +- 出口治理:收口时必须显式说明“当前残留问题 + 可选后续动作”,避免用户误以为已全局最优。 + +### 10.4 强制确认清单(已定) +- A类(必须确认):任何会导致任务/课程位置变化的移动类改动(已拍板规则)。 +- B类(必须确认):会改变用户明确声明偏好的改动(如偏好时段、偏好节奏)。 +- C类(必须确认):一次影响多个日期的大范围联动调整(避免“无感大改”)。 +- 说明:A/B/C 三类均为硬规则;若用户开启“始终同意”,可自动通过但须完整追溯。 + +### 10.5 “禁止 AI 改动清单”能力(已定) +- 能力定义:用户可声明一组“不可被 AI 主动改动”的对象或范围(例如某类固定课程/某些日期)。 +- 产品意义:降低越权风险,提升高控制型用户的信任感。 +- 首发口径:支持“对话内声明即生效”的轻量禁改语义;通过现有上下文注入链路生效,本期不新增 agent 侧治理改动。 +- 后续演进:配置化、持久化禁改清单能力纳入后续阶段评估。 + +### 10.6 可追溯与回退要求(已定) +- 每轮必须可追溯:至少记录主问题域、改动摘要、改动理由、影响范围、确认来源。 +- 对“已执行改动”应支持最小粒度回退能力,避免用户对试错型优化产生风险焦虑。 +- 回退后应触发一次简版复盘,避免回退导致隐性冲突未被感知。 +- 首发最低要求:至少支持“回退最近一轮已执行改动”;多版本日程管理(多轮历史回退)纳入 P2。 + +### 10.7 本章已确定结论 +- 强制确认范围升级为 A/B/C 三类全部硬规则。 +- 首发纳入“禁止 AI 改动清单(对话内轻量版)”。 +- 回退能力首发最低要求为“回退最近一轮”,多版本管理纳入 P2。 + +--- + +## 11. 目标指标与验收标准(已讨论 v1.0) +### 11.1 指标设计原则(已对齐) +- 原则1:指标必须服务于“首次编排全流程”主场景,不用局部请求噪声稀释判断。 +- 原则2:指标必须同时覆盖“结果好不好、过程稳不稳、体验可不可信”三层。 +- 原则3:指标必须可落地采集,避免依赖大量主观人工打分。 + +### 11.2 首发核心指标(已定) +| 指标层级 | 指标名 | 指标定义(产品口径) | 首发目标 | +|---|---|---|---| +| 结果指标 | 首次编排可接受收口率 | 首次编排全流程中,满足 `critical=0 且 warning<=1` 并进入收口的会话占比 | >= 70% | +| 过程指标 | 有效优化轮次占比 | 全流程会话内,“有效轮次”占总轮次比例 | >= 50% | +| 质量指标 | 无效回摆率 | 近两轮内被反向撤回的改动占全部改动比例(衡量“折返跑”) | <= 15% | + +### 11.3 关键口径定义(已定) +- 有效优化轮次:至少满足“一个核心问题域严重度下降”,且不引入新的 `critical` 问题。 +- 可接受收口:达到既定收口阈值(`critical=0 且 warning<=1`)并完成收口说明。 +- 无效回摆:同一任务/课程在短窗口内出现“改过去又改回来”的反向变更。 + +### 11.4 辅助观测指标(不作为首发硬门槛) +- 平均收口轮次:成功收口会话平均用了多少轮(用于评估效率,不单独卡上线)。 +- 强制确认后撤销率:已确认改动后被用户撤销的比例(用于识别解释质量问题)。 +- 对话内追问率:用户对“为什么这么改”继续追问的比例(用于评估解释清晰度)。 + +### 11.5 验收规则(已定) +- 验收窗口:按自然周滚动观测,至少连续 2 个观察窗口达标再判定“阶段通过”。 +- 达标判定:第 11.2 的 3 个核心指标同时达标。 +- 未达标处理:按指标归因回到对应章节优化(流程、工具、解释、确认边界),不允许只调阈值“做数字”。 + +### 11.6 本章已确定结论 +- 首发核心指标冻结为:可接受收口率 + 有效优化轮次占比 + 无效回摆率。 +- “有效优化轮次”口径冻结为:至少一个问题域下降,且不新增 `critical`。 +- 首发目标值冻结为:`>=70% / >=50% / <=15%`。 + +--- + +## 12. 分期路线图(已讨论 v1.0) +### 12.1 分期原则(执行导向) +- 原则1:先闭环再扩面。先把“首次编排可收敛”做扎实,再扩展高级能力。 +- 原则2:每期都有“明确不做”,避免执行期目标漂移。 +- 原则3:每期必须有可量化出场标准,未达标不进入下一期主目标。 + +### 12.2 分期总览(已定) +| 阶段 | 核心目标 | 必做交付范围(产品) | 明确不做(冻结范围) | 出场标准(产品) | +|---|---|---|---|---| +| Phase 1 | 建立首次编排的主动优化闭环 | 首次编排默认全流程;后续局部默认旧工具;6个分析工具口径落地;A/B/C三类确认规则;最近一轮回退;第11章三核心指标可观测 | 不做多版本日程管理;不做配置化禁改清单;不扩展到聊天外触发 | 连续2个观察窗口达到第11章目标值(70%/50%/15%) | +| Phase 1.5 | 建立对话内任务类共创可用版 | 聊天触发的完整任务类草案;关键字段确认+普通字段静默落;用户偏好优先于Web通识 | 不做按钮触发;不做全自动无确认落库;不做课程库平台化治理 | 任务类草案一次可用率达到预设阈值(阈值在阶段启动前冻结) | +| Phase 2 | 强化个性化和治理能力 | 配置化禁改清单;多版本日程管理(含多轮回退);解释与确认策略按用户类型分层 | 不做跨终端复杂编排协同;不做完全自治无人值守优化 | 在保持Phase 1核心指标不退化前提下,撤销率与追问率下降 | +| Phase 3 | 平台化与长期稳定性 | 能力模块化复用;跨场景复用统一口径;长期策略调优与治理看板 | 不新增未经验证的大跨度能力域 | 核心指标长期稳定且新增能力不破坏既有闭环 | + +### 12.3 Phase 1 最小可用闭环(MVP)定义(已定) +- 入口:仅“首次编排”自动进入全流程,或用户明确触发全流程。 +- 执行:按既定单轮机制运行(观察-动作-结果),并遵守A/B/C确认规则。 +- 收口:按既定阈值收口(`critical=0 且 warning<=1`;或触发强制收口)。 +- 保障:支持最近一轮回退、保留可追溯记录、支持对话内轻量禁改。 +- 验收:以第11章三核心指标作为唯一阶段通过标准。 + +### 12.4 跨期依赖关系(已定) +- Phase 1 是所有后续阶段前置,未通过则不进入 Phase 2 的主交付。 +- Phase 1.5 可与 Phase 1 后段并行推进,但不得影响 Phase 1 指标达标。 +- Phase 2 的多版本管理与配置化禁改,依赖 Phase 1 的追溯数据结构稳定。 + +### 12.5 本章已确定结论 +- Phase 1 出场标准固定为:第11章三核心指标连续 2 个窗口达标。 +- Phase 1.5 与 Phase 1 时序固定为:允许后半程并行推进,前提是不影响 Phase 1 指标达标。 +- Phase 2 主目标冻结为:配置化禁改清单 + 多版本日程管理。 + +### 12.6 当前执行优先级(新增) +- 当前版本优先目标为“先跑通 Phase 1 ~ Phase 1.5”。 +- Phase 2 / Phase 3 暂缓,待前两阶段稳定后再回到路线图继续推进。 + +--- + +## 13. 待决策清单(滚动更新) +| 编号 | 议题 | 决策选项 | 当前状态 | 负责人 | +|---|---|---|---|---| +| D-001 | 对话内主动优化目标优先级 | A>B>C / A=C>B / C>A>B | 已确定(A>B>C) | 产品 | +| D-002 | WebSearch 任务类设计触发形态 | 聊天触发 / 聊天外按钮触发 | 已确定(聊天触发) | 产品 | +| D-003 | WebSearch 与用户偏好冲突策略 | 通用知识优先 / 用户偏好优先 | 已确定(用户偏好优先) | 产品 | +| D-004 | 任务类草案落库确认策略 | 全字段确认 / 关键字段确认+普通字段静默落 | 已确定(后者) | 产品 | +| D-005 | 任务类草案“小幅修改”阈值 | 20% / 30% / 40% | 已确定(30%) | 产品 | +| D-006 | 主动优化“有效改进”最小标准 | 严重度下降 / 分数提升 / 二者同时满足 | 已确定(至少一个问题域严重度下降) | 产品 | +| D-007 | 用户是否可强制覆盖单轮主问题域 | 支持 / 不支持 / 有条件支持 | 已确定(支持) | 产品 | +| D-008 | 强制人工确认触发条件 | 精简2类 / 标准3类 / 扩展4类+ | 已确定(涉及移动默认确认;始终同意可自动通过) | 产品 | +| D-009 | 连续无效轮次强制收口阈值 | 2 / 3 / 4 | 已确定(3) | 产品 | +| D-010 | 可接受方案阈值 | critical=0且warning<=0/1/2 | 已确定(critical=0 且 warning<=1) | 产品 | +| D-011 | 用户中途改目标处理策略 | 延续当前轮 / 下轮生效 / 立即重开入场判定 | 已确定(立即重开入场判定) | 产品 | +| D-012 | 科学原则优先级 | 多种排序方案 | 已确定(硬约束 > 截止压力 > 用户偏好 > 负载 > 切换 > 容错) | 产品 | +| D-013 | 原则冲突裁决口径 | 用户优先 / 科学优先 / 分层裁决 | 已确定(分层裁决) | 产品 | +| D-014 | 偏好模型边界 | 产品层负责采集+消费 / 仅消费不采集 | 已确定(仅消费不采集) | 产品 | +| D-015 | 必要点缺失处理 | 静默推断 / ask_user / 混合策略 | 已确定(必要点缺失必须 ask_user) | 产品 | +| D-016 | 后续局部请求默认模式 | 全流程优先 / 局部执行优先 | 已确定(局部执行优先) | 产品 | +| D-017 | 旧工具与新工具关系 | 全替换 / 并行混合 | 已确定(并行混合,旧工具主执行) | 产品 | +| D-018 | `analyze_health` 默认入口触发条件 | 全程默认 / 首次与明确触发默认 | 已确定(首次与明确触发默认) | 产品 | +| D-019 | 分析工具默认明细级别 | summary / full | 已确定(summary) | 产品 | +| D-020 | 第九章默认解释风格 | 纯专业 / 纯通俗 / 专业结论+通俗补充 | 已确定(专业结论+通俗补充) | 产品 | +| D-021 | 第九章最小必显字段 | 2项 / 3项 / 4项+ | 已确定(3项) | 产品 | +| D-022 | 局部模式是否固定边界提示 | 固定提示 / 按需提示 | 已确定(按需提示) | 产品 | +| D-023 | 第十章强制确认范围 | 仅A类(移动类)硬规则 / A+B类硬规则 / A+B+C类硬规则 | 已确定(A+B+C类硬规则) | 产品 | +| D-024 | 首发是否支持禁改清单 | 不支持 / 支持对话内轻量版 / 直接支持配置化 | 已确定(支持对话内轻量版) | 产品 | +| D-025 | 回退能力最低要求 | 不要求 / 回退最近一轮 / 多轮可选回退 | 已确定(回退最近一轮;多版本管理纳入P2) | 产品 | +| D-026 | 第十一章首发核心指标组合 | 多种组合方案 | 已确定(收口率+有效轮次占比+无效回摆率) | 产品 | +| D-027 | “有效优化轮次”口径 | 仅严重度下降 / 严重度下降且不新增critical / 复合打分 | 已确定(严重度下降且不新增critical) | 产品 | +| D-028 | 第十一章首发目标值 | 激进/中性/保守三档 | 已确定(70% / 50% / 15%) | 产品 | +| D-029 | Phase 1 出场标准 | 三核心指标连续1/2/3窗口达标 | 已确定(连续2窗口) | 产品 | +| D-030 | Phase 1.5 与 Phase 1 时序 | 串行 / 后半程并行 / 完全并行 | 已确定(后半程并行) | 产品 | +| D-031 | Phase 2 主目标冻结范围 | 多方案 | 已确定(配置化禁改+多版本管理) | 产品 | +| D-032 | 当前版本执行优先级 | 全路线并推 / 先P1~P1.5后续暂缓 | 已确定(先P1~P1.5后续暂缓) | 产品 | + +--- + +## 14. 章节讨论记录(按“讨论一章、定一章”推进) +### 记录模板 +- 讨论章节: +- 结论: +- 未决问题: +- 下一步动作: +- 更新时间: + +### 已讨论记录 +- 讨论章节:第 1 章 业务背景与问题定义 +- 结论:采用“双模式策略”(默认中位最佳实践 + 偏好优先偏移);读工具按“广覆盖+区间指标”设计;自动优化轮次上限暂定 60。 +- 未决问题:时长目标与是否默认开启深度思考的策略未冻结。 +- 下一步动作:进入第 2 章,冻结“满意方案”与目标优先级定义。 +- 更新时间:2026-04-24 +- 讨论章节:第 2 章 产品目标与非目标 +- 结论:目标优先级确定为 A(自主迭代收敛)> B(可解释与改进证据)> C(对话内任务类共创草案);首发先保 A+B 闭环,C 走可用版。 +- 未决问题:C 可用版的覆盖范围与补全字段边界待在第 8 章细化。 +- 下一步动作:进入第 3 章,明确首发用户分层与高频场景清单。 +- 更新时间:2026-04-24 +- 讨论章节:第 3 章补充议题 WebSearch 任务类共创 +- 结论:定位为“对话内触发、产出完整任务类草案”的增强能力;知识来源为 WebSearch 通用信息 + 用户偏好,冲突时用户优先;字段按关键/普通分级确认。 +- 未决问题:关键字段名单与普通字段名单待在后续章节细化。 +- 下一步动作:在第 8 章与第 12 章细化能力边界与分期。 +- 更新时间:2026-04-24 +- 讨论章节:第 3 章阈值口径补充(S1/S2) +- 结论:S1 采用“关键字段修改率<=30%”作为小幅修改阈值;S2 采用“至少一个核心问题域严重度下降”作为有效改进最小标准。 +- 未决问题:关键字段清单与核心问题域枚举待后续章节细化。 +- 下一步动作:推进第 4 章核心体验原则,固化“单轮单问题域 + 复盘判定”。 +- 更新时间:2026-04-24 +- 讨论章节:第 3 章 用户与场景(v1.0) +- 结论:用户分层、首发场景、场景优先级、暂不支持边界、S1/S2/S3 判定口径均已形成可冻结版本。 +- 未决问题:无(本章内容进入后续引用阶段)。 +- 下一步动作:推进第 4 章,明确“单轮策略、复盘规范、停机确认”的执行口径。 +- 更新时间:2026-04-24 +- 讨论章节:第 4 章 核心体验原则(v0.1 草案) +- 结论:已形成“总纲-单轮规范-复盘规范-停机原则”的完整草案结构。 +- 未决问题:D-007(用户强制覆盖策略)与 D-008(强制确认触发条件)待拍板。 +- 下一步动作:根据 D-007/D-008 决策冻结第 4 章。 +- 更新时间:2026-04-24 +- 讨论章节:第 4 章 核心体验原则(v1.0) +- 结论:支持用户强制覆盖单轮主问题域;涉及移动类改动默认确认,用户开启“始终同意”后可自动通过并保留追溯记录。 +- 未决问题:无(本章已冻结)。 +- 下一步动作:进入第 5 章,细化主动优化流程与收口判定口径。 +- 更新时间:2026-04-24 +- 讨论章节:第 5 章 主动优化产品流程(v1.0) +- 结论:明确了“轮次定义、首轮强制体检、单轮执行闭环、连续无效3轮收口、critical=0且warning<=1收口、用户改目标即重开入场判定”。 +- 未决问题:无(本章已冻结)。 +- 下一步动作:进入第 6 章,细化科学安排原则与冲突优先级口径。 +- 更新时间:2026-04-24 +- 讨论章节:第 6 章 科学安排原则(v1.0) +- 结论:优先级确定为“硬约束 > 截止压力 > 用户偏好 > 负载 > 切换 > 容错”;冲突裁决采用分层规则;“容错”作为统一用户解释术语。 +- 未决问题:无(本章已冻结)。 +- 下一步动作:进入第 7 章,细化偏好模型与关键字段清单。 +- 更新时间:2026-04-24 +- 讨论章节:第 7 章 用户需求与偏好模型(v1.0) +- 结论:偏好采集由 memory 负责,产品层仅消费;必要点缺失必须 ask_user;关键/普通字段分级与“时间窗”统一口径已确定。 +- 未决问题:无(本章已冻结)。 +- 下一步动作:进入第 8 章,细化工具能力蓝图与工具边界。 +- 更新时间:2026-04-24 +- 讨论章节:第 8 章补充议题(首次全流程 vs 后续局部执行) +- 结论:首次主动排课默认全流程;后续局部请求默认旧工具链;仅在授权或命中指标域诉求时升级分析链路。 +- 未决问题:`analyze_health` 是否固定为默认首入口(可跳过)仍待拍板。 +- 下一步动作:继续冻结第 8 章细项后推进第 9 章。 +- 更新时间:2026-04-24 +- 讨论章节:第 8 章 工具能力产品蓝图(v1.0) +- 结论:`analyze_health` 仅在首次编排或明确触发全流程时默认首入口;分析工具默认 `summary`,按需切换 `full`。 +- 未决问题:无(本章已冻结)。 +- 下一步动作:进入第 9 章,细化对话内体验文案与解释字段规范。 +- 更新时间:2026-04-24 +- 讨论章节:第 9 章 关键体验与交互要求(v0.1 草案) +- 结论:已形成“双模式体验 + 单轮三段式解释 + 最小解释字段 + 用户控制边界 + 共创体验”的完整草案。 +- 未决问题:D-020(默认解释风格)、D-021(最小必显字段数量)、D-022(局部模式固定边界提示)待拍板。 +- 下一步动作:完成 D-020~D-022 拍板后冻结第 9 章,进入第 10 章风险与治理。 +- 更新时间:2026-04-24 +- 讨论章节:第 9 章 关键体验与交互要求(v1.0) +- 结论:解释风格定为“专业结论+通俗补充”;最小必显字段固定 3 项;局部模式边界提示改为按需提示;第 9 章冻结。 +- 未决问题:无(本章已冻结)。 +- 下一步动作:进入第 10 章,讨论风险、边界与治理策略。 +- 更新时间:2026-04-24 +- 讨论章节:第 10 章 风险、边界与治理(v0.1 草案) +- 结论:已形成“风险分层 + 过程治理 + 强制确认分级 + 禁改清单 + 回退追溯”的完整草案结构。 +- 未决问题:D-023(强制确认范围)、D-024(禁改清单首发形态)、D-025(回退能力最低要求)待拍板。 +- 下一步动作:完成 D-023~D-025 拍板后冻结第 10 章,进入第 11 章指标与验收。 +- 更新时间:2026-04-24 +- 讨论章节:第 10 章 风险、边界与治理(v1.0) +- 结论:强制确认范围定为 A/B/C 全硬规则;首发支持对话内轻量禁改清单;回退最低要求定为“最近一轮”,多版本管理纳入 P2;第 10 章冻结。 +- 未决问题:无(本章已冻结)。 +- 下一步动作:进入第 11 章,讨论目标指标与验收标准。 +- 更新时间:2026-04-24 +- 讨论章节:第 11 章 目标指标与验收标准(v0.1 草案) +- 结论:已形成“首发三核心指标 + 关键口径定义 + 验收窗口规则”的完整草案结构。 +- 未决问题:D-026(核心指标组合)、D-027(有效轮次口径)、D-028(首发目标值)待拍板。 +- 下一步动作:完成 D-026~D-028 拍板后冻结第 11 章,进入第 12 章分期路线图。 +- 更新时间:2026-04-24 +- 讨论章节:第 11 章 目标指标与验收标准(v1.0) +- 结论:首发核心指标冻结为“收口率+有效轮次占比+无效回摆率”;有效轮次口径冻结为“问题域下降且不新增critical”;目标值冻结为“70% / 50% / 15%”;第 11 章冻结。 +- 未决问题:无(本章已冻结)。 +- 下一步动作:进入第 12 章,讨论分期路线图与每期冻结范围。 +- 更新时间:2026-04-24 +- 讨论章节:第 12 章 分期路线图(v0.1 草案) +- 结论:已形成“分期总览 + 每期明确不做 + 出场标准 + 跨期依赖”的执行导向草案。 +- 未决问题:D-029(Phase 1出场标准窗口数)、D-030(Phase 1.5与Phase 1时序)、D-031(Phase 2主目标冻结范围)待拍板。 +- 下一步动作:完成 D-029~D-031 拍板后冻结第 12 章。 +- 更新时间:2026-04-24 +- 讨论章节:第 12 章 分期路线图(v1.0) +- 结论:Phase 1 出场标准定为连续2窗口达标;Phase 1.5 采用后半程并行;Phase 2 主目标冻结为“配置化禁改+多版本管理”;当前执行优先级定为先跑通 P1~P1.5、后续阶段暂缓;第 12 章冻结。 +- 未决问题:无(本章已冻结)。 +- 下一步动作:进入收尾阶段,统一检查决策表与章节状态一致性。 +- 更新时间:2026-04-24 + +--- + +## 15. 术语表(持续补充) +| 术语 | 业务定义 | +|---|---| +| 主动优化 | AI 连续观测-调整-复盘-收口的优化过程 | +| 收口 | 达到阈值后停止迭代并输出最终方案 | +| 主问题域 | 单轮优化聚焦的首要问题类型 | diff --git a/backend/routers/routers.go b/backend/routers/routers.go index f09ab8b..d091c0b 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -63,6 +63,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d { courseGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) courseGroup.POST("/validate", handlers.CourseHandler.CheckUserCourse) + courseGroup.POST("/parse-image", handlers.CourseHandler.ParseCourseTableImage) courseGroup.POST("/import", middleware.IdempotencyMiddleware(cache), handlers.CourseHandler.AddUserCourses) } taskClassGroup := apiGroup.Group("/task-class") diff --git a/backend/service/course.go b/backend/service/course.go index 5793cc6..d7cd299 100644 --- a/backend/service/course.go +++ b/backend/service/course.go @@ -6,21 +6,34 @@ import ( "github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/dao" + infrallm "github.com/LoveLosita/smartflow/backend/infra/llm" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/respond" ) type CourseService struct { // 伸出手:准备接住 DAO - courseDAO *dao.CourseDAO - scheduleDAO *dao.ScheduleDAO + courseDAO *dao.CourseDAO + scheduleDAO *dao.ScheduleDAO + courseImageResponsesClient *infrallm.ArkResponsesClient + courseImageConfig CourseImageParseConfig + courseImageModel string } // NewCourseService 创建 CourseService 实例 -func NewCourseService(courseDAO *dao.CourseDAO, scheduleDAO *dao.ScheduleDAO) *CourseService { +func NewCourseService( + courseDAO *dao.CourseDAO, + scheduleDAO *dao.ScheduleDAO, + courseImageResponsesClient *infrallm.ArkResponsesClient, + courseImageConfig CourseImageParseConfig, + courseImageModel string, +) *CourseService { return &CourseService{ - courseDAO: courseDAO, - scheduleDAO: scheduleDAO, + courseDAO: courseDAO, + scheduleDAO: scheduleDAO, + courseImageResponsesClient: courseImageResponsesClient, + courseImageConfig: courseImageConfig, + courseImageModel: strings.TrimSpace(courseImageModel), } } diff --git a/backend/service/course_parse.go b/backend/service/course_parse.go new file mode 100644 index 0000000..0ca5ae4 --- /dev/null +++ b/backend/service/course_parse.go @@ -0,0 +1,295 @@ +package service + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/LoveLosita/smartflow/backend/model" +) + +const ( + defaultCourseImageMaxBytes = 5 * 1024 * 1024 + defaultCourseImageMaxTokens = 16384 + maxCourseImageDraftRows = 256 + courseImageParseTemperature = 0.1 +) + +var ( + ErrCourseImageParserUnavailable = errors.New("course image parser is not configured") + ErrCourseImageTooLarge = errors.New("course image is too large") + ErrCourseImageUnsupportedMIME = errors.New("course image mime type is not supported") + ErrCourseImageEmpty = errors.New("course image is empty") +) + +type CourseImageParseConfig struct { + MaxImageBytes int64 + MaxTokens int +} + +func NewCourseImageParseConfig(maxImageBytes int64, maxTokens int) CourseImageParseConfig { + if maxImageBytes <= 0 { + maxImageBytes = defaultCourseImageMaxBytes + } + if maxTokens <= 0 { + maxTokens = defaultCourseImageMaxTokens + } + return CourseImageParseConfig{ + MaxImageBytes: maxImageBytes, + MaxTokens: maxTokens, + } +} + +func normalizeCourseImageParseRequest(req model.CourseImageParseRequest, cfg CourseImageParseConfig) (*model.CourseImageParseRequest, error) { + req.Filename = strings.TrimSpace(req.Filename) + req.MIMEType = strings.TrimSpace(strings.ToLower(req.MIMEType)) + if len(req.ImageBytes) == 0 { + return nil, ErrCourseImageEmpty + } + if int64(len(req.ImageBytes)) > cfg.MaxImageBytes { + return nil, ErrCourseImageTooLarge + } + + detected := strings.ToLower(strings.TrimSpace(http.DetectContentType(req.ImageBytes))) + if req.MIMEType == "" || req.MIMEType == "application/octet-stream" { + req.MIMEType = detected + } + if !isSupportedCourseImageMIME(req.MIMEType) { + if isSupportedCourseImageMIME(detected) { + req.MIMEType = detected + } else { + return nil, ErrCourseImageUnsupportedMIME + } + } + + if req.Filename == "" { + req.Filename = "course-table" + } + return &req, nil +} + +func isSupportedCourseImageMIME(mimeType string) bool { + switch strings.TrimSpace(strings.ToLower(mimeType)) { + case "image/jpeg", "image/png", "image/webp": + return true + default: + return false + } +} + +func normalizeCourseImageParseResponse(resp *model.CourseImageParseResponse) (*model.CourseImageParseResponse, error) { + if resp == nil { + return nil, errors.New("course image parse response is nil") + } + + resp.DraftStatus = model.CourseImageParseDraftStatus(strings.ToLower(strings.TrimSpace(string(resp.DraftStatus)))) + resp.Message = strings.TrimSpace(resp.Message) + resp.Warnings = normalizeWarningList(resp.Warnings) + resp.Rows = normalizeCourseImageParseRows(resp.Rows, &resp.Warnings) + + switch resp.DraftStatus { + case model.CourseImageParseDraftStatusSuccess: + if len(resp.Rows) == 0 { + return nil, errors.New("course image parse response has no rows in success status") + } + for idx := range resp.Rows { + if err := validateCourseImageParseRow(&resp.Rows[idx], true); err != nil { + return nil, fmt.Errorf("course image parse success row %d invalid: %w", idx+1, err) + } + } + case model.CourseImageParseDraftStatusPartial: + if len(resp.Rows) == 0 { + return nil, errors.New("course image parse response has no rows in partial status") + } + for idx := range resp.Rows { + if err := validateCourseImageParseRow(&resp.Rows[idx], false); err != nil { + return nil, fmt.Errorf("course image parse partial row %d invalid: %w", idx+1, err) + } + } + case model.CourseImageParseDraftStatusReject: + resp.Rows = make([]model.CourseImageParseRow, 0) + default: + return nil, fmt.Errorf("unsupported draft_status: %s", resp.DraftStatus) + } + + if resp.Message == "" { + resp.Message = defaultCourseImageParseMessage(resp.DraftStatus, len(resp.Rows)) + } + return resp, nil +} + +func normalizeCourseImageParseRows(rows []model.CourseImageParseRow, warnings *[]string) []model.CourseImageParseRow { + if len(rows) == 0 { + return make([]model.CourseImageParseRow, 0) + } + if len(rows) > maxCourseImageDraftRows { + rows = rows[:maxCourseImageDraftRows] + appendUniqueWarning(warnings, "识别结果行数超过上限,后端已截断为 256 行,请重点核对。") + } + + normalized := make([]model.CourseImageParseRow, 0, len(rows)) + for idx := range rows { + row := rows[idx] + row.RowID = strings.TrimSpace(row.RowID) + if row.RowID == "" { + row.RowID = fmt.Sprintf("row_%03d", idx+1) + } + row.CourseName = strings.TrimSpace(row.CourseName) + row.Location = strings.TrimSpace(row.Location) + row.WeekType = normalizeCourseImageWeekType(row.WeekType) + row.RawText = strings.TrimSpace(row.RawText) + row.RowWarnings = normalizeWarningList(row.RowWarnings) + normalizeOptionalPositiveInt(&row.StartWeek) + normalizeOptionalPositiveInt(&row.EndWeek) + normalizeOptionalPositiveInt(&row.DayOfWeek) + normalizeOptionalPositiveInt(&row.StartSection) + normalizeOptionalPositiveInt(&row.EndSection) + if row.Confidence < 0 { + row.Confidence = 0 + } + if row.Confidence > 1 { + row.Confidence = 1 + } + if row.CourseName == "" && + row.StartWeek == nil && + row.EndWeek == nil && + row.DayOfWeek == nil && + row.StartSection == nil && + row.EndSection == nil && + row.RawText == "" { + appendUniqueWarning(warnings, fmt.Sprintf("存在空白草稿行,后端已自动忽略:%s", row.RowID)) + continue + } + normalized = append(normalized, row) + } + + return normalized +} + +func validateCourseImageParseRow(row *model.CourseImageParseRow, strict bool) error { + if row == nil { + return errors.New("row is nil") + } + if strict && row.CourseName == "" { + return errors.New("course_name is empty") + } + if strict && row.WeekType == "" { + return errors.New("week_type is empty") + } + if row.WeekType != "" && row.WeekType != "all" && row.WeekType != "odd" && row.WeekType != "even" { + return fmt.Errorf("week_type is invalid: %s", row.WeekType) + } + + if err := validateOptionalCourseIntPair(row.StartWeek, row.EndWeek, 1, 24, "week", strict); err != nil { + return err + } + if err := validateOptionalCourseIntPair(row.StartSection, row.EndSection, 1, 12, "section", strict); err != nil { + return err + } + if strict && row.DayOfWeek == nil { + return errors.New("day_of_week is empty") + } + if row.DayOfWeek != nil && (*row.DayOfWeek < 1 || *row.DayOfWeek > 7) { + return fmt.Errorf("day_of_week out of range: %d", *row.DayOfWeek) + } + return nil +} + +func validateOptionalCourseIntPair(start *int, end *int, min int, max int, field string, strict bool) error { + if strict { + if start == nil || end == nil { + return fmt.Errorf("%s range is incomplete", field) + } + } + if start == nil && end == nil { + return nil + } + if start == nil || end == nil { + return fmt.Errorf("%s range is incomplete", field) + } + if *start < min || *start > max { + return fmt.Errorf("%s start out of range: %d", field, *start) + } + if *end < min || *end > max { + return fmt.Errorf("%s end out of range: %d", field, *end) + } + if *start > *end { + return fmt.Errorf("%s start is greater than end: %d > %d", field, *start, *end) + } + return nil +} + +func normalizeOptionalPositiveInt(target **int) { + if target == nil || *target == nil { + return + } + if **target <= 0 { + *target = nil + } +} + +func normalizeCourseImageWeekType(raw string) string { + normalized := strings.ToLower(strings.TrimSpace(raw)) + switch normalized { + case "", "unknown", "null": + return "" + case "all", "every", "weekly", "each week", "每周", "全周", "全部": + return "all" + case "odd", "single", "单", "单周": + return "odd" + case "even", "double", "双", "双周": + return "even" + default: + return normalized + } +} + +func normalizeWarningList(items []string) []string { + if len(items) == 0 { + return make([]string, 0) + } + seen := make(map[string]struct{}, len(items)) + result := make([]string, 0, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + return result +} + +func appendUniqueWarning(target *[]string, warningText string) { + if target == nil { + return + } + trimmed := strings.TrimSpace(warningText) + if trimmed == "" { + return + } + for _, existing := range *target { + if strings.TrimSpace(existing) == trimmed { + return + } + } + *target = append(*target, trimmed) +} + +func defaultCourseImageParseMessage(status model.CourseImageParseDraftStatus, rowCount int) string { + switch status { + case model.CourseImageParseDraftStatusSuccess: + return fmt.Sprintf("已识别 %d 条课程安排,请重点核对周次、星期和节次。", rowCount) + case model.CourseImageParseDraftStatusPartial: + return fmt.Sprintf("已识别 %d 条课程安排,但仍存在不确定字段,请结合 warning 逐项核对。", rowCount) + case model.CourseImageParseDraftStatusReject: + return "图片信息不足,建议重新上传完整、清晰、包含表头和节次栏的总课表截图。" + default: + return "课程表图片识别已完成,请人工核对后再导入。" + } +} diff --git a/backend/service/course_parse_ark.go b/backend/service/course_parse_ark.go new file mode 100644 index 0000000..5fc338f --- /dev/null +++ b/backend/service/course_parse_ark.go @@ -0,0 +1,224 @@ +package service + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "strings" + "time" + + infrallm "github.com/LoveLosita/smartflow/backend/infra/llm" + "github.com/LoveLosita/smartflow/backend/model" +) + +// ParseCourseTableImage 使用 Ark SDK Responses 解析课程表图片。 +func (ss *CourseService) ParseCourseTableImage(ctx context.Context, req model.CourseImageParseRequest) (*model.CourseImageParseResponse, error) { + if ss == nil || ss.courseImageResponsesClient == nil { + log.Printf( + "[COURSE_PARSE][SERVICE] parser unavailable model_name=%q filename=%q mime=%q bytes=%d", + ss.courseImageModel, + req.Filename, + req.MIMEType, + len(req.ImageBytes), + ) + return nil, ErrCourseImageParserUnavailable + } + + normalizedReq, err := normalizeCourseImageParseRequest(req, ss.courseImageConfig) + if err != nil { + log.Printf( + "[COURSE_PARSE][SERVICE] request normalization failed filename=%q mime=%q bytes=%d err=%v", + req.Filename, + req.MIMEType, + len(req.ImageBytes), + err, + ) + return nil, err + } + + log.Printf( + "[COURSE_PARSE][SERVICE] normalized request model_name=%q filename=%q mime=%q bytes=%d max_bytes=%d", + ss.courseImageModel, + normalizedReq.Filename, + normalizedReq.MIMEType, + len(normalizedReq.ImageBytes), + ss.courseImageConfig.MaxImageBytes, + ) + + messages, base64Chars, promptChars := buildCourseImageParseResponsesMessages(normalizedReq) + startAt := time.Now() + log.Printf( + "[COURSE_PARSE][SERVICE] model invoke start model_name=%q filename=%q mime=%q message_count=%d base64_chars=%d prompt_chars=%d payload_chars_estimate=%d thinking=%s temperature=%.2f max_output_tokens=%d text_format=%s", + ss.courseImageModel, + normalizedReq.Filename, + normalizedReq.MIMEType, + len(messages), + base64Chars, + promptChars, + base64Chars+promptChars+len(strings.TrimSpace(courseImageParseSystemPrompt)), + infrallm.ThinkingModeDisabled, + courseImageParseTemperature, + ss.courseImageConfig.MaxTokens, + "json_object", + ) + + // 1. 课程表图片识别输出体量大,显式透传 max_output_tokens,避免被默认值截断。 + // 2. text_format 固定为 json_object,降低输出混入解释文本导致解析失败的概率。 + // 3. thinking 显式关闭,优先保证课程导入链路稳定性。 + draft, rawResult, err := infrallm.GenerateArkResponsesJSON[model.CourseImageParseResponse](ctx, ss.courseImageResponsesClient, messages, infrallm.ArkResponsesOptions{ + Temperature: courseImageParseTemperature, + MaxOutputTokens: ss.courseImageConfig.MaxTokens, + Thinking: infrallm.ThinkingModeDisabled, + TextFormat: "json_object", + }) + if err != nil { + rawText := "" + rawChars := 0 + status := "" + incompleteReason := "" + errorCode := "" + errorMessage := "" + inputTokens := int64(0) + outputTokens := int64(0) + totalTokens := int64(0) + if rawResult != nil { + rawText = strings.TrimSpace(rawResult.Text) + rawChars = len(rawText) + status = strings.TrimSpace(rawResult.Status) + incompleteReason = strings.TrimSpace(rawResult.IncompleteReason) + errorCode = strings.TrimSpace(rawResult.ErrorCode) + errorMessage = strings.TrimSpace(rawResult.ErrorMessage) + if rawResult.Usage != nil { + inputTokens = rawResult.Usage.InputTokens + outputTokens = rawResult.Usage.OutputTokens + totalTokens = rawResult.Usage.TotalTokens + } + } + log.Printf( + "[COURSE_PARSE][SERVICE] model invoke failed model_name=%q filename=%q mime=%q cost_ms=%d err=%v status=%q incomplete_reason=%q error_code=%q error_message=%q input_tokens=%d output_tokens=%d total_tokens=%d raw_chars=%d raw_full=\n%s", + ss.courseImageModel, + normalizedReq.Filename, + normalizedReq.MIMEType, + time.Since(startAt).Milliseconds(), + err, + status, + incompleteReason, + errorCode, + errorMessage, + inputTokens, + outputTokens, + totalTokens, + rawChars, + rawText, + ) + if isCourseImageOutputTruncated(rawResult) { + return nil, fmt.Errorf( + "课程表识别输出疑似被 max_output_tokens 截断:status=%s incomplete_reason=%s output_tokens=%d max_output_tokens=%d", + status, + incompleteReason, + outputTokens, + ss.courseImageConfig.MaxTokens, + ) + } + return nil, err + } + + rawText := "" + rawChars := 0 + status := "" + incompleteReason := "" + errorCode := "" + errorMessage := "" + inputTokens := int64(0) + outputTokens := int64(0) + totalTokens := int64(0) + if rawResult != nil { + rawText = strings.TrimSpace(rawResult.Text) + rawChars = len(rawText) + status = strings.TrimSpace(rawResult.Status) + incompleteReason = strings.TrimSpace(rawResult.IncompleteReason) + errorCode = strings.TrimSpace(rawResult.ErrorCode) + errorMessage = strings.TrimSpace(rawResult.ErrorMessage) + if rawResult.Usage != nil { + inputTokens = rawResult.Usage.InputTokens + outputTokens = rawResult.Usage.OutputTokens + totalTokens = rawResult.Usage.TotalTokens + } + } + log.Printf( + "[COURSE_PARSE][SERVICE] model invoke success model_name=%q filename=%q mime=%q cost_ms=%d status=%q incomplete_reason=%q error_code=%q error_message=%q input_tokens=%d output_tokens=%d total_tokens=%d raw_chars=%d raw_full=\n%s", + ss.courseImageModel, + normalizedReq.Filename, + normalizedReq.MIMEType, + time.Since(startAt).Milliseconds(), + status, + incompleteReason, + errorCode, + errorMessage, + inputTokens, + outputTokens, + totalTokens, + rawChars, + rawText, + ) + + normalizedDraft, err := normalizeCourseImageParseResponse(draft) + if err != nil { + log.Printf( + "[COURSE_PARSE][SERVICE] draft normalization failed model_name=%q filename=%q err=%v draft_status=%v row_count=%d", + ss.courseImageModel, + normalizedReq.Filename, + err, + draft.DraftStatus, + len(draft.Rows), + ) + return nil, err + } + + log.Printf( + "[COURSE_PARSE][SERVICE] draft normalization success model_name=%q filename=%q draft_status=%s rows=%d warnings=%d", + ss.courseImageModel, + normalizedReq.Filename, + normalizedDraft.DraftStatus, + len(normalizedDraft.Rows), + len(normalizedDraft.Warnings), + ) + + return normalizedDraft, nil +} + +func buildCourseImageParseResponsesMessages(req *model.CourseImageParseRequest) ([]infrallm.ArkResponsesMessage, int, int) { + userPrompt := fmt.Sprintf(courseImageParseUserPromptTemplate, req.Filename, req.MIMEType) + base64Data := base64.StdEncoding.EncodeToString(req.ImageBytes) + imageDataURL := fmt.Sprintf("data:%s;base64,%s", req.MIMEType, base64Data) + + messages := []infrallm.ArkResponsesMessage{ + { + Role: "system", + Text: strings.TrimSpace(courseImageParseSystemPrompt), + }, + { + Role: "user", + Text: strings.TrimSpace(userPrompt), + ImageURL: imageDataURL, + ImageDetail: "high", + }, + } + return messages, len(base64Data), len(strings.TrimSpace(userPrompt)) +} + +func isCourseImageOutputTruncated(rawResult *infrallm.ArkResponsesResult) bool { + if rawResult == nil { + return false + } + + reason := strings.ToLower(strings.TrimSpace(rawResult.IncompleteReason)) + if strings.Contains(reason, "max_output_tokens") || + strings.Contains(reason, "max_tokens") || + strings.Contains(reason, "length") { + return true + } + + return strings.EqualFold(strings.TrimSpace(rawResult.Status), "incomplete") && reason == "" +} diff --git a/backend/service/course_parse_prompt.go b/backend/service/course_parse_prompt.go new file mode 100644 index 0000000..4b7d779 --- /dev/null +++ b/backend/service/course_parse_prompt.go @@ -0,0 +1,59 @@ +package service + +const courseImageParseSystemPrompt = ` +你是 SmartFlow 的“总课表图片识别器”。你的唯一任务是读取用户上传的总课表图片,输出结构化 JSON 草稿,供前端人工核对后再导入系统。 + +必须遵守以下规则: +1. 只能输出一个 JSON 对象,禁止输出 Markdown、代码块、解释文字或额外前后缀。 +2. 顶层 JSON 结构必须是: +{ + "draft_status": "success | partial | reject", + "message": "字符串", + "warnings": ["字符串"], + "rows": [ + { + "row_id": "字符串,可为空", + "course_name": "字符串", + "location": "字符串", + "is_allow_tasks": false, + "start_week": 1, + "end_week": 16, + "day_of_week": 1, + "start_section": 1, + "end_section": 2, + "week_type": "all | odd | even", + "confidence": 0.92, + "raw_text": "原图中对应的近似文本", + "row_warnings": ["字符串"] + } + ] +} +3. rows 中一行只表达一个“课程安排片段”,不要把同一门课的多个时间段强行合并成一行。 +4. is_allow_tasks 无法从课表图片稳定识别时,一律返回 false,不要自行猜测。 +5. 若图片完整且大部分字段明确,可返回 success。 +6. 若图片可识别出部分行,但存在裁切、模糊、遮挡、单双周不清晰、节次/周次不确定等问题,返回 partial。 +7. 若图片严重不完整、分辨率过低、主体不是课表、无法可靠识别,返回 reject,同时 rows 置为空数组。 +8. 不要编造信息。看不清的数值字段请返回 null,并在 row_warnings 或 warnings 中明确说明原因。 +9. week_type 只能是: + - all:每周/未标注单双周 + - odd:单周 + - even:双周 +10. day_of_week 使用 1-7 表示周一到周日。 +11. start_section/end_section 使用原子节次编号,例如 1-2 节应输出 start_section=1, end_section=2。 +12. confidence 取 0 到 1 之间的小数;不确定时可以偏保守。 +13. 如果 rows 不为空,优先保证“周次、星期、节次”准确,地点可为空字符串。 +14. 当图片信息不足时,应明确拒绝或降级为 partial,而不是强行补全。 +15. 填写json中course_name时,严格按照截图的课程名称来。例如,有的课可能既有本体,又有实验课,这算是两门不同的课。 +16. 周信息是可能出现中断的,例如一节课可能是第1周和第6-12周,这是正常的课程安排,请不要擅自更改。 +` + +const courseImageParseUserPromptTemplate = ` +请识别这张总课表图片,并严格按照约定 JSON 输出草稿。 + +补充约束: +1. 文件名:%s +2. MIME 类型:%s +3. 这是一张供学生核对的“导入草稿”,不是最终真值;不确定就留空或写 warning。 +4. 如果图片右侧、底部、表头、周次栏、节次栏有缺失,请优先返回 partial 或 reject。 +5. rows 里尽量保留 raw_text,方便前端逐行回显核对。 +` diff --git a/docs/frontend/course-image-import-对接说明.md b/docs/frontend/course-image-import-对接说明.md new file mode 100644 index 0000000..96b9c43 --- /dev/null +++ b/docs/frontend/course-image-import-对接说明.md @@ -0,0 +1,540 @@ +# 课表图片识别与正式导入前端对接说明 + +## 目标 + +这套流程拆成两步: + +1. 先调用识图接口,把“总课表图片”转成前端可编辑的草稿表格数据。 +2. 用户确认无误后,再调用正式导入接口,把课程写入后端日程表。 + +这样做的好处是: + +- 识图接口保持无状态,只负责“图片 -> 草稿” +- 前端完全掌握编辑态,不依赖后端保存临时草稿 +- 正式落库仍统一走现有 `/api/v1/course/import` + +## 总体流程 + +推荐前端流程: + +1. 用户上传总课表图片。 +2. 前端调用 `/api/v1/course/parse-image` 获取识别草稿。 +3. 前端把 `rows` 存在本地页面状态中,渲染成可编辑表格。 +4. 用户修改并确认所有行数据。 +5. 前端把表格行数据组装成 `/api/v1/course/import` 所需结构。 +6. 前端携带 `X-Idempotency-Key` 调用正式导入接口。 +7. 导入成功后刷新课表页;若有冲突则提示用户处理。 + +## 一、识图接口 + +### 1.1 接口定义 + +- 方法:`POST` +- 路径:`/api/v1/course/parse-image` +- 鉴权:需要登录态 +- Content-Type:`multipart/form-data` +- 文件字段名:`image` + +说明: + +- 该接口不需要 `X-Idempotency-Key` +- 该接口不落库,只返回草稿 + +### 1.2 请求示例 + +```ts +async function parseCourseImage(file: File, token: string) { + const formData = new FormData() + formData.append('image', file) + + const response = await fetch('/api/v1/course/parse-image', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }) + + const result = await response.json() + if (!response.ok) { + throw new Error(result?.info || '课表图片识别失败') + } + return result.data +} +``` + +### 1.3 成功响应 + +外层仍复用项目统一响应结构: + +```json +{ + "status": "10000", + "info": "success", + "data": { + "draft_status": "success", + "message": "已识别 12 条课程安排,请重点核对周次、星期和节次。", + "warnings": [ + "图片右下角疑似被裁切,请重点检查最后几列课程。" + ], + "rows": [ + { + "row_id": "row_001", + "course_name": "高等数学", + "location": "教一203", + "is_allow_tasks": false, + "start_week": 1, + "end_week": 16, + "day_of_week": 1, + "start_section": 1, + "end_section": 2, + "week_type": "all", + "confidence": 0.92, + "raw_text": "高等数学 1-16周 周一1-2节 教一203", + "row_warnings": [] + } + ] + } +} +``` + +### 1.4 `draft_status` 语义 + +- `success` + 说明图片整体较完整,前端可直接进入表格核对。 + +- `partial` + 说明后端识别到了部分安排,但仍存在不确定字段。前端仍可进入表格核对,但应醒目展示 `warnings` 与 `row_warnings`。 + +- `reject` + 说明图片不适合继续使用,例如裁切严重、模糊、主体不是课表等。此时 `rows` 为空数组,前端应提示用户重新上传,而不是进入编辑表格。 + +### 1.5 行字段说明 + +每一行表示一个“课程安排片段”,不是一门课的完整聚合对象。 + +- `row_id` + 前端表格的稳定 key。即使用户编辑字段,也建议保留该 key。 + +- `course_name` + 课程名。`success` 场景下通常应有值;`partial` 场景下可能为空,前端要允许人工补齐。 + +- `location` + 地点。允许为空字符串。 + +- `is_allow_tasks` + 当前后端默认给 `false`。因为该字段无法从课表图片稳定识别,建议前端提供开关让用户手改。 + +- `start_week` / `end_week` + 周次范围。可能为 `null`,前端应允许编辑。 + +- `day_of_week` + 星期,取值 `1-7`,分别对应周一到周日。可能为 `null`。 + +- `start_section` / `end_section` + 原子节次编号,例如 `1-2 节` 应表示为 `1` 和 `2`。可能为 `null`。 + +- `week_type` + 仅允许以下三种值: + - `all` + - `odd` + - `even` + +- `confidence` + 0 到 1 之间的小数,用于前端做低置信度高亮。建议把 `<0.75` 的行重点提示用户复核。 + +- `raw_text` + 模型从图片中抽出的近似原文,建议在“展开详情”或 tooltip 中展示,帮助用户比对。 + +- `row_warnings` + 当前行的局部问题提示,例如“单双周不清晰”“教室模糊”等。 + +### 1.6 前端表格建议列 + +建议首版直接用可编辑表格,不必先做课表画布。 + +推荐列: + +- 课程名 +- 地点 +- 起始周 +- 结束周 +- 星期 +- 开始节次 +- 结束节次 +- 周类型 +- 允许嵌入任务 +- 置信度 +- 原文 +- 行级警告 + +### 1.7 识图接口常见失败响应 + +- `50003`:`course image parser is not configured` + 后端尚未配置多模态模型。 + +- `40064`:`course image too large` + 图片超过后端配置的字节上限。 + +- `40065`:`unsupported course image format` + 当前仅支持 `jpg/jpeg/png/webp`。 + +- `40066`:`course image is empty` + 上传文件为空。 + +### 1.8 前端交互建议 + +- `draft_status=reject` 时,不进入表格页,直接提示重新上传。 +- `draft_status=partial` 时,允许进入表格页,但默认展开 warning 区域。 +- 当某行 `confidence < 0.75` 或 `row_warnings` 非空时,给该行加醒目边框或标签。 + +## 二、正式导入接口 + +### 2.1 接口定义 + +- 方法:`POST` +- 路径:`/api/v1/course/import` +- 鉴权:需要登录态 +- Content-Type:`application/json` +- 必传请求头:`X-Idempotency-Key` + +说明: + +- 正式导入接口会真正写库 +- 该接口已经挂了幂等中间件,前端必须传 `X-Idempotency-Key` + +### 2.2 请求体结构 + +后端正式导入接口需要的是: + +```json +{ + "courses": [ + { + "course_name": "高等数学", + "location": "教一203", + "is_allow_tasks": false, + "arrangements": [ + { + "start_week": 1, + "end_week": 16, + "day_of_week": 1, + "start_section": 1, + "end_section": 2, + "week_type": "all" + } + ] + } + ] +} +``` + +字段语义: + +- `course_name`: 课程名 +- `location`: 地点 +- `is_allow_tasks`: 是否允许该课程时段嵌入任务 +- `arrangements`: 该课程的所有上课安排 + +### 2.3 前端如何从草稿行表组装正式导入 payload + +推荐规则: + +1. 先过滤掉仍未补齐必填字段的行。 +2. 以 `course_name + location + is_allow_tasks` 作为分组键。 +3. 同组行合并为同一个 `course`。 +4. 每一行转成该 `course` 下的一个 `arrangements` 项。 + +推荐的前端类型: + +```ts +type CourseDraftRow = { + row_id: string + course_name: string + location: string + is_allow_tasks: boolean + start_week: number | null + end_week: number | null + day_of_week: number | null + start_section: number | null + end_section: number | null + week_type: 'all' | 'odd' | 'even' | '' + confidence: number + raw_text: string + row_warnings: string[] +} + +type CourseImportPayload = { + courses: Array<{ + course_name: string + location: string + is_allow_tasks: boolean + arrangements: Array<{ + start_week: number + end_week: number + day_of_week: number + start_section: number + end_section: number + week_type: 'all' | 'odd' | 'even' + }> + }> +} +``` + +组装示例: + +```ts +function buildCourseImportPayload(rows: CourseDraftRow[]): CourseImportPayload { + const validRows = rows.filter((row) => + row.course_name.trim() && + row.start_week != null && + row.end_week != null && + row.day_of_week != null && + row.start_section != null && + row.end_section != null && + (row.week_type === 'all' || row.week_type === 'odd' || row.week_type === 'even'), + ) + + const grouped = new Map() + + for (const row of validRows) { + const key = `${row.course_name}__${row.location}__${row.is_allow_tasks}` + const arrangement = { + start_week: row.start_week!, + end_week: row.end_week!, + day_of_week: row.day_of_week!, + start_section: row.start_section!, + end_section: row.end_section!, + week_type: row.week_type as 'all' | 'odd' | 'even', + } + + if (!grouped.has(key)) { + grouped.set(key, { + course_name: row.course_name.trim(), + location: row.location.trim(), + is_allow_tasks: row.is_allow_tasks, + arrangements: [arrangement], + }) + continue + } + + grouped.get(key)!.arrangements.push(arrangement) + } + + return { + courses: Array.from(grouped.values()), + } +} +``` + +### 2.4 调用正式导入接口示例 + +```ts +function createIdempotencyKey(prefix = 'course-import') { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +async function importCourses( + payload: CourseImportPayload, + token: string, + idempotencyKey = createIdempotencyKey(), +) { + const response = await fetch('/api/v1/course/import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'X-Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify(payload), + }) + + const result = await response.json() + + if (!response.ok) { + const error = new Error(result?.info || '课程导入失败') + ;(error as any).status = response.status + ;(error as any).payload = result + throw error + } + + return result +} +``` + +### 2.5 正式导入成功响应 + +```json +{ + "status": "10000", + "info": "success" +} +``` + +## 三、前端在正式导入前应做的本地校验 + +建议在调用 `/api/v1/course/import` 前先做一次本地校验,避免把明显不完整的数据提交给后端。 + +建议校验项: + +1. `course_name` 非空 +2. `start_week` / `end_week` 非空,且 `1 <= start_week <= end_week <= 24` +3. `day_of_week` 在 `1-7` +4. `start_section` / `end_section` 非空,且 `1 <= start_section <= end_section <= 12` +5. `week_type` 只能是 `all | odd | even` + +如果校验失败,建议直接在表格中标红,不要发请求。 + +## 四、正式导入接口的常见失败响应 + +### 4.1 缺少幂等键 + +HTTP 状态:`400` + +```json +{ + "status": "40037", + "info": "missing idempotency key" +} +``` + +前端处理建议: + +- 检查是否传了 `X-Idempotency-Key` +- 每次“点击确认导入”生成一个新的 key +- 不要在一次导入流程里反复变化同一个 key + +### 4.2 请求处理中 + +HTTP 状态:`409` + +```json +{ + "status": "40038", + "info": "request is processing, please do not repeat click" +} +``` + +前端处理建议: + +- 导入按钮进入 loading 态 +- 阻止用户连续点击 +- 若收到该错误,提示“正在导入,请勿重复提交” + +### 4.3 课程结构不合法 + +HTTP 状态:`400` + +```json +{ + "status": "40019", + "info": "wrong course info" +} +``` + +前端处理建议: + +- 说明当前编辑结果不满足导入约束 +- 引导用户重点检查周次、星期、节次、周类型 + +### 4.4 重复导入课程 + +HTTP 状态:`400` + +```json +{ + "status": "40029", + "info": "insert course twice" +} +``` + +前端处理建议: + +- 提示用户当前课程可能已经导入过 +- 可提供“返回课表页查看现有课程”的入口 + +### 4.5 与已有非课程日程冲突 + +HTTP 状态:`409` + +响应示例: + +```json +{ + "status": "40026", + "info": "schedule conflict", + "data": [ + { + "event_id": 101, + "name": "实验报告", + "location": "图书馆", + "day_of_week": 1, + "week": 3, + "sections": [1, 2], + "start_section": 1, + "end_section": 2, + "type": "task", + "embedded_tasks": [] + } + ] +} +``` + +前端处理建议: + +- 不要把冲突吞掉 +- 可以弹窗列出冲突项 +- 提示用户先处理已有日程,再重新导入 + +## 五、推荐的前端调用顺序 + +推荐把整个导入流程写成这三个阶段: + +### 阶段一:识图 + +- 上传图片 +- 调用 `/course/parse-image` +- 若 `draft_status=reject`,直接结束 +- 若 `success` 或 `partial`,进入草稿编辑页 + +### 阶段二:草稿编辑 + +- 在本地状态中维护 `rows` +- 支持用户改课程名、周次、节次、地点、周类型、`is_allow_tasks` +- 在页面上展示 `warnings`、`row_warnings`、`confidence` + +### 阶段三:正式导入 + +- 先做本地校验 +- 再调用 `buildCourseImportPayload(rows)` +- 生成 `X-Idempotency-Key` +- 调用 `/course/import` +- 成功后提示并刷新课表 + +## 六、可直接复用的页面状态建议 + +```ts +type CourseImageImportPageState = { + imageFile: File | null + parseLoading: boolean + importLoading: boolean + draftStatus: 'success' | 'partial' | 'reject' | null + draftMessage: string + warnings: string[] + rows: CourseDraftRow[] +} +``` + +## 七、配置提醒 + +后端需要额外配置一个多模态模型: + +```yaml +courseImport: + visionModel: "你的多模态模型名" + maxImageBytes: 5242880 + maxTokens: 8192 +``` + +若 `visionModel` 留空,则主服务仍可启动,但 `/course/parse-image` 会返回“识图模型未配置”。 +若后端日志出现 `finish_reason="length"`,说明模型输出被长度上限截断,应优先增大 `courseImport.maxTokens`。 diff --git a/frontend/src/api/scheduleCenter.ts b/frontend/src/api/scheduleCenter.ts index 0d64fb9..09b34ae 100644 --- a/frontend/src/api/scheduleCenter.ts +++ b/frontend/src/api/scheduleCenter.ts @@ -1,3 +1,4 @@ +import axios from 'axios' import http from '@/api/http' import type { ApiResponse, PlainResponse } from '@/types/api' import type { @@ -7,6 +8,9 @@ import type { TaskClassCreatePayload, TaskClassDetail, TaskClassListItem, + CourseDraftRow, + CourseImportPayload, + CourseImageParseResponse, } from '@/types/schedule' import { extractErrorMessage } from '@/utils/http' import { createIdempotencyKey } from '@/utils/idempotency' @@ -147,3 +151,33 @@ export async function deleteTaskClassItem(taskItemId: number, idempotencyKey = c throw new Error(extractErrorMessage(error, '\u5220\u9664\u4efb\u52a1\u5757\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5')) } } + +export async function parseCourseImage(file: File, signal?: AbortSignal) { + try { + const formData = new FormData() + formData.append('image', file) + const response = await http.post>('/course/parse-image', formData, { + timeout: 300000, + signal, + }) + return response.data.data + } catch (error) { + if (axios.isCancel(error)) { + throw new Error('\u8bc6\u522b\u5df2\u53d6\u6d88') + } + throw new Error(extractErrorMessage(error, '\u8bfe\u8868\u56fe\u7247\u8bc6\u522b\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5')) + } +} + +export async function importCourses(payload: CourseImportPayload, idempotencyKey = createIdempotencyKey('course-import')) { + try { + const response = await http.post('/course/import', payload, { + headers: { + 'X-Idempotency-Key': idempotencyKey, + }, + }) + return response.data + } catch (error) { + throw new Error(extractErrorMessage(error, '\u8bfe\u7a0b\u5bfc\u5165\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5')) + } +} diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index 32a9607..6e32be2 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -1189,7 +1189,9 @@ function shouldShowDisplayReasoningBox(dm: DisplayMessage): boolean { } function shouldShowDisplayAnsweringIndicator(dm: DisplayMessage): boolean { - return isDisplayStreaming(dm) && dm.sources.every(m => thinkingMessageMap[m.id] !== true) + return isDisplayStreaming(dm) && + dm.sources.every(m => thinkingMessageMap[m.id] !== true) && + !dm.content.trim() } function isDisplayReasoningCollapsed(dm: DisplayMessage): boolean { @@ -2714,10 +2716,8 @@ onBeforeUnmount(() => { v-html="renderMessageMarkdown(block.text)" />
-
- - - +
+ 正在思考
@@ -2735,10 +2735,8 @@ onBeforeUnmount(() => {
-
- - - +
+ 正在思考
@@ -4652,26 +4650,33 @@ onBeforeUnmount(() => { border-color: rgba(15, 23, 42, 0.08); } -.typing-indicator { - display: flex; +.thinking-indicator { + display: inline-flex; align-items: center; - gap: 6px; } -.typing-indicator span { - width: 8px; - height: 8px; - border-radius: 999px; - background: #86a9dd; - animation: typing-bounce 1.2s ease-in-out infinite; +.thinking-indicator__text { + font-size: 15px; + font-weight: 600; + color: #64748b; + background: linear-gradient( + 90deg, + #64748b 0%, + #64748b 25%, + #e2e8f0 50%, + #64748b 75%, + #64748b 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: thinking-shimmer 2s infinite linear; } -.typing-indicator span:nth-child(2) { - animation-delay: 0.12s; -} - -.typing-indicator span:nth-child(3) { - animation-delay: 0.24s; +@keyframes thinking-shimmer { + from { background-position: 200% 0; } + to { background-position: 0% 0; } } @keyframes confirm-card-enter { @@ -4679,11 +4684,6 @@ onBeforeUnmount(() => { 100% { opacity: 1; transform: translateY(0) scale(1); } } -@keyframes typing-bounce { - 0%, 80%, 100% { transform: translateY(0); opacity: 0.5; } - 40% { transform: translateY(-4px); opacity: 1; } -} - @keyframes pulse-dot { 0% { box-shadow: 0 0 0 0 rgba(90, 152, 255, 0.34); } 70% { box-shadow: 0 0 0 8px rgba(90, 152, 255, 0); } diff --git a/frontend/src/components/schedule/CourseImageImportDialog.vue b/frontend/src/components/schedule/CourseImageImportDialog.vue new file mode 100644 index 0000000..4da5584 --- /dev/null +++ b/frontend/src/components/schedule/CourseImageImportDialog.vue @@ -0,0 +1,927 @@ + + + + + diff --git a/frontend/src/components/schedule/WeekPlanningBoard.vue b/frontend/src/components/schedule/WeekPlanningBoard.vue index 990b7b8..a3507b1 100644 --- a/frontend/src/components/schedule/WeekPlanningBoard.vue +++ b/frontend/src/components/schedule/WeekPlanningBoard.vue @@ -64,6 +64,54 @@ const eventLookup = computed(() => { return map }) +// isFullyCovered 负责判断一个 Slot (2节课) 是否被之前的跨行事件完全遮挡。 +// 如果只是部分遮挡(如 span=3 占用了下一 Slot 的第一节),则不视为完全遮挡,允许渲染以维持布局完整性。 +function isFullyCovered(dayOfWeek: number, order: number) { + const endSectionOfOrder = order * 2 + + for (let prevOrder = 1; prevOrder < order; prevOrder++) { + const event = resolveEvent(dayOfWeek, prevOrder) + if (event && event.type !== 'empty') { + const eventEndSection = (prevOrder - 1) * 2 + (event.span || 2) + if (eventEndSection >= endSectionOfOrder) { + return true + } + } + } + return false +} + +// resolveGridRow 负责计算格子的精确 Grid 布局位置。 +// 特别处理了“部分遮挡”的情况:如果一个空格子被上方的长课时占用了第一节,则该格子会自动下移并缩短,从而紧贴在课程下方。 +function resolveGridRow(dayOfWeek: number, order: number) { + const event = resolveEvent(dayOfWeek, order) + const startRow = (order - 1) * 2 + 2 // 该 Slot 默认的起始行(Header 占 1 行,每节课 1 行) + + // 1. 如果是真实课程/任务,遵循其原始 order 对应的起点,并按其实际 span 拉伸。 + if (event && event.type !== 'empty') { + return `${startRow} / span ${event.span || 2}` + } + + // 2. 如果是空格子,检查上方是否有跨行事件侵入了当前 Slot。 + let currentStartRow = startRow + const currentEndRow = startRow + 2 // 一个 Slot 默认占 2 行 + + for (let prevOrder = 1; prevOrder < order; prevOrder++) { + const prevEvent = resolveEvent(dayOfWeek, prevOrder) + if (prevEvent && prevEvent.type !== 'empty') { + // 计算上方事件在网格中的结束行 + const prevEndRow = (prevOrder - 1) * 2 + 2 + (prevEvent.span || 2) + // 如果上方事件的结束行超过了当前格子的起始行,则需要将起点下移 + if (prevEndRow > currentStartRow) { + currentStartRow = prevEndRow + } + } + } + + const finalSpan = currentEndRow - currentStartRow + return `${currentStartRow} / span ${Math.max(0, finalSpan)}` +} + function resolveEvent(dayOfWeek: number, order: number) { return eventLookup.value.get(`${dayOfWeek}-${order}`) } @@ -346,21 +394,30 @@ function handlePreviewDragEnd() {
-
+
-
+
{{ header.label }} {{ header.dateLabel }}