Version: 0.9.42.dev.260424
后端: 1. 新增课表图片识别接口,支持上传截图后返回“可编辑草稿”(success / partial / reject),并补齐大图、空图、格式不支持、识别能力未配置等错误分支。 2. 课表识别服务接入多模态 Responses 链路,完善图片请求归一化与安全校验(大小、MIME、内容探测),并对识别结果做结构化清洗、强/弱约束校验、告警去重与默认文案兜底。 3. 新增 Ark Responses 统一客户端抽象,支持文本+图片输入、JSON对象输出、usage统计透传与不完整输出识别;同时补齐模型返回 finish_reason 透传,便于定位截断问题。 4. 启动阶段增加课表识图模型与参数注入(模型名、最大图片字节、最大输出token),并将配置示例收敛为“仅保留当前代码实际读取项”。 前端: 5. 课表中心新增“导入课表”完整闭环:上传图片识别、草稿编辑校对、正式导入落库;并新增对应 API 与类型定义。 6. 导入弹窗支持识别中止、全局告警与行级告警展示、低置信度提示、行内编辑、手动新增、删除、拖拽排序、本地校验与提交前二次确认。 7. 正式导入前将草稿按“课程名+地点+是否允许嵌入”聚合为导入结构,并统一携带幂等键请求头,降低重复提交风险。 8. 周课表画板修复跨节次事件遮挡导致的网格错位问题,改进“完全遮挡/部分遮挡”渲染判定与 grid 行定位。 9. 助手流式区域优化“思考中”指示逻辑与样式,避免已有正文时仍展示回答中占位;同时补充全局组件视觉统一(弹窗/按钮)样式。 仓库: 10. 新增课表图片识别前端对接说明文档,补充主动优化能力 PRD 讨论稿,并在协作规范中新增“实现 Eino 新能力前需先查官方文档”的约束。
This commit is contained in:
@@ -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 官方文档并确认对应能力的推荐接入方式与参数语义,禁止在未查文档的情况下直接编码。
|
||||
|
||||
## 注释规范(强制)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 流式文本生成。
|
||||
|
||||
337
backend/infra/llm/ark_responses_client.go
Normal file
337
backend/infra/llm/ark_responses_client.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -45,6 +45,8 @@ type GenerateOptions struct {
|
||||
type TextResult struct {
|
||||
Text string
|
||||
Usage *schema.TokenUsage
|
||||
// FinishReason 透传 provider 的停止原因,便于上层判断是否因 length 等原因被截断。
|
||||
FinishReason string
|
||||
}
|
||||
|
||||
// StreamReader 抽象了“可逐块 Recv 的流式返回器”。
|
||||
|
||||
@@ -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,
|
||||
|
||||
38
backend/model/course_parse.go
Normal file
38
backend/model/course_parse.go
Normal file
@@ -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
|
||||
}
|
||||
732
backend/newAgent/prd文档.md
Normal file
732
backend/newAgent/prd文档.md
Normal file
@@ -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 连续观测-调整-复盘-收口的优化过程 |
|
||||
| 收口 | 达到阈值后停止迭代并输出最终方案 |
|
||||
| 主问题域 | 单轮优化聚焦的首要问题类型 |
|
||||
@@ -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")
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"
|
||||
)
|
||||
@@ -14,13 +15,25 @@ type CourseService struct {
|
||||
// 伸出手:准备接住 DAO
|
||||
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,
|
||||
courseImageResponsesClient: courseImageResponsesClient,
|
||||
courseImageConfig: courseImageConfig,
|
||||
courseImageModel: strings.TrimSpace(courseImageModel),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
295
backend/service/course_parse.go
Normal file
295
backend/service/course_parse.go
Normal file
@@ -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 "课程表图片识别已完成,请人工核对后再导入。"
|
||||
}
|
||||
}
|
||||
224
backend/service/course_parse_ark.go
Normal file
224
backend/service/course_parse_ark.go
Normal file
@@ -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 == ""
|
||||
}
|
||||
59
backend/service/course_parse_prompt.go
Normal file
59
backend/service/course_parse_prompt.go
Normal file
@@ -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,方便前端逐行回显核对。
|
||||
`
|
||||
540
docs/frontend/course-image-import-对接说明.md
Normal file
540
docs/frontend/course-image-import-对接说明.md
Normal file
@@ -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<string, CourseImportPayload['courses'][number]>()
|
||||
|
||||
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`。
|
||||
@@ -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<ApiResponse<CourseImageParseResponse>>('/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<PlainResponse>('/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'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
<div v-else class="chat-message__streaming chat-message__streaming--reasoning">
|
||||
<div class="typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<div class="thinking-indicator">
|
||||
<span class="thinking-indicator__text">正在思考</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2735,10 +2735,8 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<div v-else-if="block.type === 'content_indicator'" class="assistant-timeline__answering-indicator">
|
||||
<div class="typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<div class="thinking-indicator">
|
||||
<span class="thinking-indicator__text">正在思考</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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); }
|
||||
|
||||
927
frontend/src/components/schedule/CourseImageImportDialog.vue
Normal file
927
frontend/src/components/schedule/CourseImageImportDialog.vue
Normal file
@@ -0,0 +1,927 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { parseCourseImage, importCourses } from '@/api/scheduleCenter'
|
||||
import type { CourseDraftRow, CourseImportPayload } from '@/types/schedule'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 状态管理
|
||||
const step = ref<'upload' | 'edit'>('upload')
|
||||
const imageFile = ref<File | null>(null)
|
||||
const imagePreview = ref('')
|
||||
const parseLoading = ref(false)
|
||||
const importLoading = ref(false)
|
||||
const abortController = ref<AbortController | null>(null)
|
||||
|
||||
const draftStatus = ref<'success' | 'partial' | 'reject' | null>(null)
|
||||
const draftMessage = ref('')
|
||||
const warnings = ref<string[]>([])
|
||||
const rows = ref<CourseDraftRow[]>([])
|
||||
|
||||
// 新增课程相关的状态
|
||||
const addDialogVisible = ref(false)
|
||||
const newRowTemplate = (): CourseDraftRow => ({
|
||||
row_id: 'manual_' + Date.now(),
|
||||
course_name: '',
|
||||
location: '',
|
||||
start_week: 1,
|
||||
end_week: 16,
|
||||
day_of_week: 1,
|
||||
start_section: 1,
|
||||
end_section: 2,
|
||||
week_type: 'all',
|
||||
is_allow_tasks: false,
|
||||
confidence: 1,
|
||||
raw_text: '手动添加',
|
||||
row_warnings: []
|
||||
})
|
||||
const newRowData = ref<CourseDraftRow>(newRowTemplate())
|
||||
|
||||
// 拖拽相关状态
|
||||
const draggingIndex = ref<number | null>(null)
|
||||
|
||||
// 模拟示例图 URL
|
||||
const exampleImageUrl = 'https://dl2.lecspace.com/SmartFlow-Agent/%E8%AF%BE%E8%A1%A8%E4%B8%8A%E4%BC%A0%E7%A4%BA%E4%BE%8B%E5%9B%BE%E7%89%87.png'
|
||||
|
||||
const handleFileChange = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0]
|
||||
// 简单校验格式
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||
ElMessage.warning('仅支持 JPG/PNG/WEBP 格式的图片')
|
||||
return
|
||||
}
|
||||
imageFile.value = file
|
||||
imagePreview.value = URL.createObjectURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
const startRecognition = async () => {
|
||||
if (!imageFile.value) return
|
||||
|
||||
parseLoading.value = true
|
||||
abortController.value = new AbortController()
|
||||
|
||||
try {
|
||||
const data = await parseCourseImage(imageFile.value, abortController.value.signal)
|
||||
if (data.draft_status === 'reject') {
|
||||
await ElMessageBox.alert(data.message || '图片识别失败,请重新上传清晰的课表图片。', '识别被拒绝', {
|
||||
type: 'error',
|
||||
confirmButtonText: '我知道了'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
draftStatus.value = data.draft_status
|
||||
draftMessage.value = data.message
|
||||
warnings.value = data.warnings
|
||||
|
||||
// 对识别出的行进行排序:先按课程名聚类,再按起始周从小到大排列
|
||||
rows.value = [...data.rows]
|
||||
sortRows()
|
||||
|
||||
step.value = 'edit'
|
||||
} catch (error: any) {
|
||||
if (error.message !== '识别已取消') {
|
||||
ElMessage.error(error.message || '识别过程中出现错误')
|
||||
}
|
||||
} finally {
|
||||
parseLoading.value = false
|
||||
abortController.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecognition = () => {
|
||||
if (abortController.value) {
|
||||
abortController.value.abort()
|
||||
ElMessage.info('识别已手动停止')
|
||||
}
|
||||
}
|
||||
|
||||
const sortRows = () => {
|
||||
rows.value.sort((a, b) => {
|
||||
const nameCompare = (a.course_name || '').localeCompare(b.course_name || '')
|
||||
if (nameCompare !== 0) return nameCompare
|
||||
return (a.start_week || 0) - (b.start_week || 0)
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenAddDialog = () => {
|
||||
newRowData.value = newRowTemplate()
|
||||
addDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleConfirmAddRow = () => {
|
||||
if (!newRowData.value.course_name.trim()) {
|
||||
ElMessage.warning('课程名不能为空')
|
||||
return
|
||||
}
|
||||
rows.value.push({ ...newRowData.value })
|
||||
sortRows()
|
||||
addDialogVisible.value = false
|
||||
ElMessage.success('课程已添加并自动排序')
|
||||
}
|
||||
|
||||
const handleRemoveRow = async (index: number) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这一行课程安排吗?', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
rows.value.splice(index, 1)
|
||||
ElMessage.success('已删除')
|
||||
} catch {
|
||||
// 用户取消删除
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽逻辑实现
|
||||
const onDragStart = (index: number) => {
|
||||
draggingIndex.value = index
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
e.preventDefault() // 允许放置
|
||||
}
|
||||
|
||||
const onDrop = (index: number) => {
|
||||
if (draggingIndex.value === null || draggingIndex.value === index) return
|
||||
|
||||
const draggedRow = rows.value[draggingIndex.value]
|
||||
rows.value.splice(draggingIndex.value, 1)
|
||||
rows.value.splice(index, 0, draggedRow)
|
||||
draggingIndex.value = null
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
step.value = 'upload'
|
||||
imageFile.value = null
|
||||
imagePreview.value = ''
|
||||
rows.value = []
|
||||
draftStatus.value = null
|
||||
warnings.value = []
|
||||
}
|
||||
|
||||
// 校验与提交逻辑
|
||||
const validateRows = () => {
|
||||
const errors: string[] = []
|
||||
rows.value.forEach((row, index) => {
|
||||
if (!row.course_name.trim()) errors.push(`第 ${index + 1} 行:课程名不能为空`)
|
||||
if (row.start_week == null || row.end_week == null || row.start_week < 1 || row.end_week > 24 || row.start_week > row.end_week) {
|
||||
errors.push(`第 ${index + 1} 行:周次范围非法 (1-24)`)
|
||||
}
|
||||
if (row.day_of_week == null || row.day_of_week < 1 || row.day_of_week > 7) {
|
||||
errors.push(`第 ${index + 1} 行:星期选择非法`)
|
||||
}
|
||||
if (row.start_section == null || row.end_section == null || row.start_section < 1 || row.end_section > 12 || row.start_section > row.end_section) {
|
||||
errors.push(`第 ${index + 1} 行:节次范围非法 (1-12)`)
|
||||
}
|
||||
if (!['all', 'odd', 'even'].includes(row.week_type)) {
|
||||
errors.push(`第 ${index + 1} 行:周类型非法`)
|
||||
}
|
||||
})
|
||||
return errors
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
const errors = validateRows()
|
||||
if (errors.length > 0) {
|
||||
ElMessage.error({
|
||||
message: errors[0], // 只显示第一个错误
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要导入识别出的课程吗?这可能会覆盖或冲突现有日程。', '确认导入', {
|
||||
confirmButtonText: '确定导入',
|
||||
cancelButtonText: '我再想想',
|
||||
type: 'info'
|
||||
})
|
||||
|
||||
importLoading.value = true
|
||||
const payload = buildPayload(rows.value)
|
||||
await importCourses(payload)
|
||||
ElMessage.success('课程导入成功!')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '导入失败')
|
||||
}
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildPayload = (draftRows: CourseDraftRow[]): CourseImportPayload => {
|
||||
const grouped = new Map<string, CourseImportPayload['courses'][number]>()
|
||||
|
||||
for (const row of draftRows) {
|
||||
// 恢复原来的聚合逻辑:以 课程名 + 地点 + 是否允许任务 为主键
|
||||
const key = `${row.course_name.trim()}__${row.location.trim()}__${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]
|
||||
})
|
||||
} else {
|
||||
grouped.get(key)!.arrangements.push(arrangement)
|
||||
}
|
||||
}
|
||||
|
||||
return { courses: Array.from(grouped.values()) }
|
||||
}
|
||||
|
||||
const getRowStyle = ({ row }: { row: CourseDraftRow }) => {
|
||||
if (row.confidence < 0.75 || row.row_warnings.length > 0) {
|
||||
return { backgroundColor: 'rgba(245, 158, 11, 0.05)' }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="导入课表"
|
||||
width="98%"
|
||||
top="2vh"
|
||||
class="import-dialog"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@closed="reset"
|
||||
>
|
||||
<!-- 阶段一:上传识别 -->
|
||||
<div v-if="step === 'upload'" class="upload-stage">
|
||||
<div class="stage-header">
|
||||
<h3>1. 上传课表截图</h3>
|
||||
<p>请上传完整的课表图片,AI 将自动识别课程信息。</p>
|
||||
<div class="upload-tips">
|
||||
<span class="tip-item">⏱️ 预计识别时长约 2 分钟</span>
|
||||
<span class="tip-item">🤖 结果由 AI 生成,请务必仔细审核</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-container">
|
||||
<div class="example-box">
|
||||
<span class="badge">截图示例</span>
|
||||
<img :src="exampleImageUrl" alt="Example" class="example-img" />
|
||||
</div>
|
||||
|
||||
<div class="upload-box" :class="{ 'has-file': !!imageFile }">
|
||||
<input
|
||||
type="file"
|
||||
id="course-upload-input"
|
||||
accept="image/*"
|
||||
hidden
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<label for="course-upload-input" class="upload-trigger">
|
||||
<div v-if="!imagePreview" class="placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
||||
</svg>
|
||||
<span>点击选择或拖拽图片</span>
|
||||
</div>
|
||||
<img v-else :src="imagePreview" alt="Preview" class="preview-img" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage-footer">
|
||||
<button
|
||||
v-if="parseLoading"
|
||||
class="btn-ghost"
|
||||
@click="stopRecognition"
|
||||
>
|
||||
停止识别
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="!imageFile || parseLoading"
|
||||
@click="startRecognition"
|
||||
>
|
||||
<span v-if="parseLoading" class="spinner"></span>
|
||||
{{ parseLoading ? '正在识别中...' : '开始识别' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 阶段二:核对修改 -->
|
||||
<div v-else class="edit-stage">
|
||||
<div class="stage-header">
|
||||
<div class="header-left">
|
||||
<h3>2. 核对识别结果</h3>
|
||||
<p>{{ draftMessage }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-ghost btn-sm" @click="handleOpenAddDialog">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;margin-right:4px;">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
新增课程行
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="warnings.length > 0" class="warning-banner">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-icon">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01"/>
|
||||
</svg>
|
||||
<div class="w-list">
|
||||
<div v-for="(w, i) in warnings" :key="i" class="w-item">{{ w }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<el-table
|
||||
:data="rows"
|
||||
style="width: 100%"
|
||||
:row-style="getRowStyle"
|
||||
max-height="500px"
|
||||
class="flat-table"
|
||||
>
|
||||
<el-table-column width="50" align="center">
|
||||
<template #default="{ $index }">
|
||||
<div
|
||||
class="drag-handle"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($index)"
|
||||
@dragover="onDragOver"
|
||||
@drop="onDrop($index)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 8h16M4 16h16"/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="课程名" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.course_name" placeholder="请输入" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="地点" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.location" placeholder="地点" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="周次" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="week-range">
|
||||
<el-input-number v-model="row.start_week" :min="1" :max="24" controls-position="right" size="small" />
|
||||
<span>-</span>
|
||||
<el-input-number v-model="row.end_week" :min="1" :max="24" controls-position="right" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="星期" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.day_of_week" size="small">
|
||||
<el-option v-for="i in 7" :key="i" :label="'周' + ['一','二','三','四','五','六','日'][i-1]" :value="i" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="节次" width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="section-range">
|
||||
<el-input-number v-model="row.start_section" :min="1" :max="12" controls-position="right" size="small" />
|
||||
<span>-</span>
|
||||
<el-input-number v-model="row.end_section" :min="1" :max="12" controls-position="right" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.week_type" size="small">
|
||||
<el-option label="每周" value="all" />
|
||||
<el-option label="单周" value="odd" />
|
||||
<el-option label="双周" value="even" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="允许嵌入" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.is_allow_tasks" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.row_warnings.length > 0" class="row-warning-dot">
|
||||
<el-tooltip :content="row.row_warnings.join('; ')" placement="top">
|
||||
<span class="warning-icon">⚠️</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-else-if="row.confidence < 0.75" class="row-warning-dot">
|
||||
<el-tooltip content="置信度较低,请人工核对" placement="top">
|
||||
<span class="info-icon">💡</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<span v-else class="success-dot"></span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ $index }">
|
||||
<button class="btn-icon-danger" @click="handleRemoveRow($index)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="stage-footer">
|
||||
<button class="btn-ghost" @click="step = 'upload'">重新上传</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="importLoading"
|
||||
@click="handleImport"
|
||||
>
|
||||
<span v-if="importLoading" class="spinner"></span>
|
||||
{{ importLoading ? '正在导入...' : '导入课程' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增课程子弹窗 -->
|
||||
<el-dialog
|
||||
v-model="addDialogVisible"
|
||||
title="手动新增课程"
|
||||
width="500px"
|
||||
append-to-body
|
||||
class="sub-dialog"
|
||||
>
|
||||
<div class="add-row-form">
|
||||
<div class="form-item">
|
||||
<label>课程名</label>
|
||||
<el-input v-model="newRowData.course_name" placeholder="例如:高等数学" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>地点</label>
|
||||
<el-input v-model="newRowData.location" placeholder="例如:教一203" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-item">
|
||||
<label>周次范围</label>
|
||||
<div class="range-inputs">
|
||||
<el-input-number v-model="newRowData.start_week" :min="1" :max="24" controls-position="right" />
|
||||
<span>至</span>
|
||||
<el-input-number v-model="newRowData.end_week" :min="1" :max="24" controls-position="right" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-item">
|
||||
<label>星期</label>
|
||||
<el-select v-model="newRowData.day_of_week" style="width: 100%">
|
||||
<el-option v-for="i in 7" :key="i" :label="'周' + ['一','二','三','四','五','六','日'][i-1]" :value="i" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>周类型</label>
|
||||
<el-select v-model="newRowData.week_type" style="width: 100%">
|
||||
<el-option label="每周" value="all" />
|
||||
<el-option label="单周" value="odd" />
|
||||
<el-option label="双周" value="even" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>节次范围</label>
|
||||
<div class="range-inputs">
|
||||
<el-input-number v-model="newRowData.start_section" :min="1" :max="12" controls-position="right" />
|
||||
<span>至</span>
|
||||
<el-input-number v-model="newRowData.end_section" :min="1" :max="12" controls-position="right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item flex-row">
|
||||
<label>允许在该时段嵌入任务</label>
|
||||
<el-switch v-model="newRowData.is_allow_tasks" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<button class="btn-ghost" @click="addDialogVisible = false">取消</button>
|
||||
<button class="btn-primary" @click="handleConfirmAddRow">确定添加</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import-dialog :deep(.el-dialog) {
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.import-dialog :deep(.el-dialog__header) {
|
||||
padding: 24px 24px 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.import-dialog :deep(.el-dialog__title) {
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stage-header {
|
||||
padding: 0 24px 20px;
|
||||
}
|
||||
|
||||
.stage-header h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 18px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.stage-header p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.upload-tips {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #f8fafc;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.upload-container {
|
||||
padding: 0 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.example-box {
|
||||
position: relative;
|
||||
background: #f8fafc;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #e2e8f0;
|
||||
height: 550px;
|
||||
}
|
||||
|
||||
.example-box .badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.example-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
background: #ffffff;
|
||||
border: 2px dashed #e2e8f0;
|
||||
border-radius: 16px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
height: 550px;
|
||||
}
|
||||
|
||||
.upload-box:hover {
|
||||
border-color: #3b82f6;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.upload-box.has-file {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-trigger .placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-trigger svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upload-trigger span {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.stage-footer {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
height: 44px;
|
||||
padding: 0 24px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
height: 44px;
|
||||
padding: 0 24px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
margin-top: 12px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fef3c7;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.w-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #d97706;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.w-list {
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.flat-table :deep(.el-table__header-wrapper) th {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.flat-table :deep(.el-table__row) td {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.week-range, .section-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.week-range span, .section-range span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.success-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.row-warning-dot {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.btn-icon-danger {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-icon-danger:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.btn-icon-danger svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper), :deep(.el-input-number), :deep(.el-select) {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.drag-handle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.add-row-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-item label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.range-inputs span {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
<div class="planning-board__grid" @dragover="handleExternalDragOver">
|
||||
<div class="planning-board__corner" />
|
||||
<div class="planning-board__corner" style="grid-row: 1; grid-column: 1;" />
|
||||
|
||||
<div v-for="header in weekHeaders" :key="header.dayOfWeek" class="planning-board__day-head">
|
||||
<div
|
||||
v-for="header in weekHeaders"
|
||||
:key="header.dayOfWeek"
|
||||
class="planning-board__day-head"
|
||||
:style="{ gridRow: 1, gridColumn: header.dayOfWeek + 1 }"
|
||||
>
|
||||
<span>{{ header.label }}</span>
|
||||
<small>{{ header.dateLabel }}</small>
|
||||
</div>
|
||||
|
||||
<template v-for="slot in sectionSlots" :key="slot.order">
|
||||
<div class="planning-board__time-cell">
|
||||
<div
|
||||
class="planning-board__time-cell"
|
||||
:style="{ gridRow: `${(slot.order - 1) * 2 + 2} / span 2`, gridColumn: 1 }"
|
||||
>
|
||||
<strong>{{ slot.title }}</strong>
|
||||
<small>{{ slot.timeRange }}</small>
|
||||
</div>
|
||||
|
||||
<article
|
||||
v-for="header in weekHeaders"
|
||||
v-show="!isFullyCovered(header.dayOfWeek, slot.order)"
|
||||
:key="`${weekData?.week ?? 0}-${header.dayOfWeek}-${slot.order}`"
|
||||
class="planning-board__cell"
|
||||
:class="[
|
||||
@@ -375,7 +432,12 @@ function handlePreviewDragEnd() {
|
||||
'planning-board__cell--dragover': dragOverCellKey === buildCellKey(header.dayOfWeek, slot.order),
|
||||
},
|
||||
]"
|
||||
:style="{ '--anim-delay': (header.dayOfWeek - 1) * 0.035 + (slot.order - 1) * 0.045 + 's' }"
|
||||
:style="{
|
||||
'--anim-delay': (header.dayOfWeek - 1) * 0.035 + (slot.order - 1) * 0.045 + 's',
|
||||
gridRow: resolveGridRow(header.dayOfWeek, slot.order),
|
||||
gridColumn: header.dayOfWeek + 1,
|
||||
zIndex: (resolveEvent(header.dayOfWeek, slot.order) && resolveEvent(header.dayOfWeek, slot.order)!.type !== 'empty') ? 2 : 1
|
||||
}"
|
||||
:draggable="isWholeCellDraggable(resolveEvent(header.dayOfWeek, slot.order))"
|
||||
@dragstart="handlePreviewDragStart(header.dayOfWeek, slot.order, $event)"
|
||||
@dragover="handlePreviewDragOver(header.dayOfWeek, slot.order, $event)"
|
||||
@@ -467,6 +529,7 @@ function handlePreviewDragEnd() {
|
||||
--planning-time-column-width: 68px;
|
||||
--planning-day-column-min: 96px;
|
||||
--planning-cell-height: clamp(72px, 9.2vh, 112px);
|
||||
--planning-section-height: calc(var(--planning-cell-height) / 2);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: 20px;
|
||||
@@ -490,6 +553,7 @@ function handlePreviewDragEnd() {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: var(--planning-time-column-width) repeat(7, minmax(var(--planning-day-column-min), 1fr));
|
||||
grid-template-rows: auto repeat(12, var(--planning-section-height));
|
||||
gap: var(--planning-grid-gap-y) var(--planning-grid-gap-x);
|
||||
padding: var(--planning-grid-padding-y) var(--planning-grid-padding-x) 24px;
|
||||
overflow: auto;
|
||||
@@ -518,7 +582,7 @@ function handlePreviewDragEnd() {
|
||||
}
|
||||
|
||||
.planning-board__time-cell {
|
||||
min-height: var(--planning-cell-height);
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: end;
|
||||
@@ -541,7 +605,7 @@ function handlePreviewDragEnd() {
|
||||
|
||||
.planning-board__cell {
|
||||
position: relative;
|
||||
min-height: var(--planning-cell-height);
|
||||
min-height: 0;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
padding: 14px;
|
||||
@@ -857,7 +921,7 @@ function handlePreviewDragEnd() {
|
||||
|
||||
.planning-board__time-cell,
|
||||
.planning-board__cell {
|
||||
min-height: 98px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.planning-board__cell {
|
||||
|
||||
@@ -66,3 +66,25 @@ a {
|
||||
box-shadow: var(--shadow-soft);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Element Plus Overrides for Flat Modern Style */
|
||||
.el-dialog, .el-message-box {
|
||||
border-radius: 24px !important;
|
||||
border: 1px solid rgba(15, 23, 42, 0.05) !important;
|
||||
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.1) !important;
|
||||
}
|
||||
|
||||
.el-message-box__headerbtn, .el-dialog__headerbtn {
|
||||
top: 16px !important;
|
||||
right: 16px !important;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: #3b82f6 !important;
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
@@ -97,3 +97,42 @@ export interface ScheduleDeletePayloadItem {
|
||||
delete_course: boolean
|
||||
delete_embedded_task: boolean
|
||||
}
|
||||
|
||||
export interface 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[]
|
||||
}
|
||||
|
||||
export interface 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'
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
export interface CourseImageParseResponse {
|
||||
draft_status: 'success' | 'partial' | 'reject'
|
||||
message: string
|
||||
warnings: string[]
|
||||
rows: CourseDraftRow[]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import CreateTaskClassDialog from '@/components/schedule/CreateTaskClassDialog.vue'
|
||||
import TaskClassSidebar from '@/components/schedule/TaskClassSidebar.vue'
|
||||
import WeekPlanningBoard from '@/components/schedule/WeekPlanningBoard.vue'
|
||||
import CourseImageImportDialog from '@/components/schedule/CourseImageImportDialog.vue'
|
||||
import type { ApplyBatchIntoScheduleItem, ScheduleWeekData, ScheduleWeekEvent, TaskClassDetail, TaskClassListItem } from '@/types/schedule'
|
||||
import { formatHeaderDate } from '@/utils/date'
|
||||
|
||||
@@ -130,6 +131,7 @@ const applyingLoading = ref(false)
|
||||
const deletingLoading = ref(false)
|
||||
const createDialogVisible = ref(false)
|
||||
const createDialogLoading = ref(false)
|
||||
const courseImportDialogVisible = ref(false)
|
||||
|
||||
const taskClasses = ref<TaskClassListItem[]>([])
|
||||
const expandedTaskClassId = ref<number | null>(null)
|
||||
@@ -1216,6 +1218,15 @@ onMounted(async () => {
|
||||
取消多选
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!manualEditMode && !scheduleSelectionMode"
|
||||
type="button"
|
||||
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
|
||||
@click="courseImportDialogVisible = true"
|
||||
>
|
||||
导入课表
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showSmartPlanningButton"
|
||||
type="button"
|
||||
@@ -1299,6 +1310,11 @@ onMounted(async () => {
|
||||
:loading="createDialogLoading"
|
||||
@submit="handleCreateTaskClass"
|
||||
/>
|
||||
|
||||
<CourseImageImportDialog
|
||||
v-model="courseImportDialogVisible"
|
||||
@success="loadWeekData(currentWeek ?? undefined, { force: true })"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user