Version: 0.9.59.dev.260430
后端: 1. 主动调度预览确认主链路落地——新增主动调度数据模型、DAO 与事件契约;接入 dry-run pipeline 与任务触发的 job upsert/cancel;新增 preview 查询与 confirm API,支持 apply_id 幂等确认并同步写入 task_pool 日程 2. 同步更新主动调度实施文档的阶段状态与验收记录 前端: 3. AssistantPanel 脚本层继续解耦——私有类型迁移到独立类型文件,并抽离会话、工具轨迹、思考摘要、任务表单等纯函数辅助逻辑;保持助手面板模板与样式不变,降低表现层回归风险
This commit is contained in:
358
backend/active_scheduler/preview/converter.go
Normal file
358
backend/active_scheduler/preview/converter.go
Normal file
@@ -0,0 +1,358 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
|
||||
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
func candidateDTO(item candidate.Candidate) CandidateDTO {
|
||||
return CandidateDTO{
|
||||
CandidateID: item.CandidateID,
|
||||
CandidateType: string(item.CandidateType),
|
||||
Title: item.Title,
|
||||
Summary: item.Summary,
|
||||
Target: CandidateTargetDTO{
|
||||
TargetType: item.Target.TargetType,
|
||||
TargetID: item.Target.TargetID,
|
||||
Title: item.Target.Title,
|
||||
},
|
||||
Changes: changeDTOs(item.CandidateID, item.Changes),
|
||||
BeforeSummary: item.BeforeSummary,
|
||||
AfterSummary: item.AfterSummary,
|
||||
Risk: item.Risk,
|
||||
Score: item.Score,
|
||||
Validation: item.Validation,
|
||||
Source: item.Source,
|
||||
}
|
||||
}
|
||||
|
||||
func changeDTOs(candidateID string, changes []candidate.ChangeItem) []ActiveScheduleChangeItem {
|
||||
result := make([]ActiveScheduleChangeItem, 0, len(changes))
|
||||
for index, change := range changes {
|
||||
var fromSlot *SlotDTO
|
||||
if change.FromSlot != nil {
|
||||
value := slotDTO(*change.FromSlot)
|
||||
fromSlot = &value
|
||||
}
|
||||
var toSlot *SlotSpanDTO
|
||||
if change.ToSlot != nil {
|
||||
value := slotSpanDTO(*change.ToSlot)
|
||||
toSlot = &value
|
||||
}
|
||||
result = append(result, ActiveScheduleChangeItem{
|
||||
ChangeID: fmt.Sprintf("%s:chg_%d", candidateID, index+1),
|
||||
ChangeType: string(change.ChangeType),
|
||||
TargetType: change.TargetType,
|
||||
TargetID: change.TargetID,
|
||||
FromSlot: fromSlot,
|
||||
ToSlot: toSlot,
|
||||
DurationSections: change.DurationSections,
|
||||
AffectedEventIDs: append([]int(nil), change.AffectedEventIDs...),
|
||||
EditedAllowed: change.EditedAllowed,
|
||||
Metadata: copyStringMap(change.Metadata),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func contextSummaryDTO(activeContext *schedulercontext.ActiveScheduleContext) ContextSummaryDTO {
|
||||
if activeContext == nil {
|
||||
return ContextSummaryDTO{}
|
||||
}
|
||||
return ContextSummaryDTO{
|
||||
UserID: activeContext.User.UserID,
|
||||
Timezone: activeContext.User.Timezone,
|
||||
TriggerSource: string(activeContext.Trigger.Source),
|
||||
RequestedAt: activeContext.Trigger.RequestedAt,
|
||||
WindowStart: activeContext.Window.StartAt,
|
||||
WindowEnd: activeContext.Window.EndAt,
|
||||
WindowReason: activeContext.Window.WindowReason,
|
||||
TargetType: string(activeContext.Trigger.TargetType),
|
||||
TargetID: activeContext.Trigger.TargetID,
|
||||
TargetTitle: activeContext.Target.Title,
|
||||
MissingInfo: append([]string(nil), activeContext.DerivedFacts.MissingInfo...),
|
||||
TraceSteps: append([]string(nil), activeContext.Trace.BuildSteps...),
|
||||
Warnings: append([]string(nil), activeContext.Trace.Warnings...),
|
||||
}
|
||||
}
|
||||
|
||||
func buildBeforeSummary(activeContext *schedulercontext.ActiveScheduleContext, selected candidate.Candidate, changes []ActiveScheduleChangeItem) SchedulePreviewVersion {
|
||||
version := SchedulePreviewVersion{
|
||||
Title: "调整前",
|
||||
WindowStart: activeContext.Window.StartAt,
|
||||
WindowEnd: activeContext.Window.EndAt,
|
||||
SummaryLines: compactLines(selected.BeforeSummary),
|
||||
}
|
||||
affected := affectedEventSet(changes)
|
||||
for _, event := range activeContext.ScheduleFacts.Events {
|
||||
entry := entryFromEvent(event)
|
||||
if affected[event.ID] || (selected.Target.TargetType == "schedule_event" && selected.Target.TargetID == event.ID) {
|
||||
entry.Status = "affected"
|
||||
}
|
||||
version.Entries = append(version.Entries, entry)
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func buildAfterSummary(before SchedulePreviewVersion, selected candidate.Candidate, changes []ActiveScheduleChangeItem) SchedulePreviewVersion {
|
||||
after := SchedulePreviewVersion{
|
||||
Title: "调整后",
|
||||
WindowStart: before.WindowStart,
|
||||
WindowEnd: before.WindowEnd,
|
||||
Entries: append([]SchedulePreviewEntry(nil), before.Entries...),
|
||||
SummaryLines: compactLines(selected.AfterSummary),
|
||||
}
|
||||
for _, change := range changes {
|
||||
// 1. 只把会产生可视化新块的 change 追加到 after;ask_user / none 不伪造正式日程。
|
||||
// 2. 该 entry 仅用于展示和后续 confirm 校验输入,不代表已经写入 schedule_events / schedules。
|
||||
if change.ToSlot == nil || (change.ChangeType != string(candidate.ChangeTypeAdd) && change.ChangeType != string(candidate.ChangeTypeCreateMakeup)) {
|
||||
continue
|
||||
}
|
||||
after.Entries = append(after.Entries, SchedulePreviewEntry{
|
||||
EntryID: "preview:" + change.ChangeID,
|
||||
SourceType: change.TargetType,
|
||||
SourceID: change.TargetID,
|
||||
Title: selected.Target.Title,
|
||||
StartAt: change.ToSlot.Start.StartAt,
|
||||
EndAt: change.ToSlot.End.EndAt,
|
||||
Week: change.ToSlot.Start.Week,
|
||||
DayOfWeek: change.ToSlot.Start.DayOfWeek,
|
||||
SectionFrom: change.ToSlot.Start.Section,
|
||||
SectionTo: change.ToSlot.End.Section,
|
||||
Status: "added",
|
||||
Editable: change.EditedAllowed,
|
||||
})
|
||||
}
|
||||
return after
|
||||
}
|
||||
|
||||
func entryFromEvent(event ports.ScheduleEventFact) SchedulePreviewEntry {
|
||||
slots := append([]ports.Slot(nil), event.Slots...)
|
||||
sort.Slice(slots, func(i, j int) bool {
|
||||
if !slots[i].StartAt.IsZero() && !slots[j].StartAt.IsZero() && !slots[i].StartAt.Equal(slots[j].StartAt) {
|
||||
return slots[i].StartAt.Before(slots[j].StartAt)
|
||||
}
|
||||
if slots[i].Week != slots[j].Week {
|
||||
return slots[i].Week < slots[j].Week
|
||||
}
|
||||
if slots[i].DayOfWeek != slots[j].DayOfWeek {
|
||||
return slots[i].DayOfWeek < slots[j].DayOfWeek
|
||||
}
|
||||
return slots[i].Section < slots[j].Section
|
||||
})
|
||||
|
||||
entry := SchedulePreviewEntry{
|
||||
EntryID: fmt.Sprintf("%s:%d", event.SourceType, event.ID),
|
||||
SourceType: event.SourceType,
|
||||
SourceID: event.ID,
|
||||
Title: event.Title,
|
||||
Status: "unchanged",
|
||||
Editable: event.IsDynamicTask,
|
||||
}
|
||||
if len(slots) == 0 {
|
||||
return entry
|
||||
}
|
||||
first := slots[0]
|
||||
last := slots[len(slots)-1]
|
||||
entry.StartAt = first.StartAt
|
||||
entry.EndAt = last.EndAt
|
||||
entry.Week = first.Week
|
||||
entry.DayOfWeek = first.DayOfWeek
|
||||
entry.SectionFrom = first.Section
|
||||
entry.SectionTo = last.Section
|
||||
return entry
|
||||
}
|
||||
|
||||
func riskDTO(selected candidate.Candidate, observation observe.Result, changes []ActiveScheduleChangeItem) RiskDTO {
|
||||
affectedIDs := make([]int, 0)
|
||||
seen := make(map[int]bool)
|
||||
for _, change := range changes {
|
||||
for _, id := range change.AffectedEventIDs {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
affectedIDs = append(affectedIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
level := "low"
|
||||
if !selected.Validation.Valid {
|
||||
level = "high"
|
||||
} else if observation.Metrics.Risk.RequiresReorder || len(affectedIDs) > 0 {
|
||||
level = "medium"
|
||||
}
|
||||
return RiskDTO{
|
||||
Level: level,
|
||||
Summary: selected.Risk,
|
||||
Validation: selected.Validation,
|
||||
RiskMetrics: observation.Metrics.Risk,
|
||||
AffectedIDs: affectedIDs,
|
||||
RequiresLLM: observation.Decision.LLMSelectionRequired,
|
||||
FallbackUsed: observation.Decision.FallbackCandidateID == selected.CandidateID,
|
||||
}
|
||||
}
|
||||
|
||||
func buildBaseVersion(activeContext *schedulercontext.ActiveScheduleContext, changes []ActiveScheduleChangeItem) string {
|
||||
type eventVersion struct {
|
||||
ID int `json:"id"`
|
||||
Slots []SlotDTO `json:"slots"`
|
||||
}
|
||||
events := make([]eventVersion, 0, len(activeContext.ScheduleFacts.Events))
|
||||
for _, event := range activeContext.ScheduleFacts.Events {
|
||||
slots := make([]SlotDTO, 0, len(event.Slots))
|
||||
for _, slot := range event.Slots {
|
||||
slots = append(slots, slotDTO(slot))
|
||||
}
|
||||
events = append(events, eventVersion{ID: event.ID, Slots: slots})
|
||||
}
|
||||
payload := struct {
|
||||
UserID int `json:"user_id"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
WindowStart time.Time `json:"window_start"`
|
||||
WindowEnd time.Time `json:"window_end"`
|
||||
Events []eventVersion `json:"events"`
|
||||
Changes []ActiveScheduleChangeItem `json:"changes"`
|
||||
}{
|
||||
UserID: activeContext.User.UserID,
|
||||
TargetType: string(activeContext.Trigger.TargetType),
|
||||
TargetID: activeContext.Trigger.TargetID,
|
||||
WindowStart: activeContext.Window.StartAt,
|
||||
WindowEnd: activeContext.Window.EndAt,
|
||||
Events: events,
|
||||
Changes: changes,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func detailFromModel(row *model.ActiveSchedulePreview, now time.Time) (ActiveSchedulePreviewDetail, error) {
|
||||
selected, err := decodeJSONField(row.SelectedCandidateJSON, CandidateDTO{})
|
||||
if err != nil {
|
||||
return ActiveSchedulePreviewDetail{}, err
|
||||
}
|
||||
candidates, err := decodeJSONField(row.CandidatesJSON, []CandidateDTO{})
|
||||
if err != nil {
|
||||
return ActiveSchedulePreviewDetail{}, err
|
||||
}
|
||||
decision, err := decodeJSONField(row.DecisionJSON, observe.Decision{})
|
||||
if err != nil {
|
||||
return ActiveSchedulePreviewDetail{}, err
|
||||
}
|
||||
metrics, err := decodeJSONField(row.MetricsJSON, observe.Metrics{})
|
||||
if err != nil {
|
||||
return ActiveSchedulePreviewDetail{}, err
|
||||
}
|
||||
issues, err := decodeJSONField(row.IssuesJSON, []observe.Issue{})
|
||||
if err != nil {
|
||||
return ActiveSchedulePreviewDetail{}, err
|
||||
}
|
||||
contextSummary, err := decodeJSONField(row.ContextSummaryJSON, ContextSummaryDTO{})
|
||||
if err != nil {
|
||||
return ActiveSchedulePreviewDetail{}, err
|
||||
}
|
||||
before, err := decodeJSONField(row.BeforeSummaryJSON, SchedulePreviewVersion{})
|
||||
if err != nil {
|
||||
return ActiveSchedulePreviewDetail{}, err
|
||||
}
|
||||
changes, err := decodeJSONField(row.PreviewChangesJSON, []ActiveScheduleChangeItem{})
|
||||
if err != nil {
|
||||
return ActiveSchedulePreviewDetail{}, err
|
||||
}
|
||||
after, err := decodeJSONField(row.AfterSummaryJSON, SchedulePreviewVersion{})
|
||||
if err != nil {
|
||||
return ActiveSchedulePreviewDetail{}, err
|
||||
}
|
||||
risk, err := decodeJSONField(row.RiskJSON, RiskDTO{})
|
||||
if err != nil {
|
||||
return ActiveSchedulePreviewDetail{}, err
|
||||
}
|
||||
|
||||
expired := !row.ExpiresAt.After(now)
|
||||
canConfirm := row.Status == model.ActiveSchedulePreviewStatusReady && row.ApplyStatus == model.ActiveScheduleApplyStatusNone && !expired
|
||||
canIgnore := row.Status == model.ActiveSchedulePreviewStatusReady && row.ApplyStatus == model.ActiveScheduleApplyStatusNone && !expired
|
||||
|
||||
return ActiveSchedulePreviewDetail{
|
||||
PreviewID: row.ID,
|
||||
Status: row.Status,
|
||||
ApplyStatus: row.ApplyStatus,
|
||||
ExpiresAt: row.ExpiresAt,
|
||||
GeneratedAt: row.GeneratedAt,
|
||||
Expired: expired,
|
||||
Trigger: PreviewTriggerDTO{
|
||||
TriggerID: row.TriggerID,
|
||||
TriggerType: row.TriggerType,
|
||||
Source: contextSummary.TriggerSource,
|
||||
TargetType: row.TargetType,
|
||||
TargetID: row.TargetID,
|
||||
RequestedAt: contextSummary.RequestedAt,
|
||||
},
|
||||
Explanation: row.ExplanationText,
|
||||
Notification: row.NotificationSummary,
|
||||
SelectedCandidate: selected,
|
||||
Candidates: candidates,
|
||||
Decision: decision,
|
||||
Metrics: metrics,
|
||||
Issues: issues,
|
||||
ContextSummary: contextSummary,
|
||||
Before: before,
|
||||
After: after,
|
||||
Changes: changes,
|
||||
Risk: risk,
|
||||
BaseVersion: row.BaseVersion,
|
||||
CanConfirm: canConfirm,
|
||||
CanIgnore: canIgnore,
|
||||
TraceID: row.TraceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func jsonString(value any) (string, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func compactLines(lines ...string) []string {
|
||||
result := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func affectedEventSet(changes []ActiveScheduleChangeItem) map[int]bool {
|
||||
result := make(map[int]bool)
|
||||
for _, change := range changes {
|
||||
for _, id := range change.AffectedEventIDs {
|
||||
result[id] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func copyStringMap(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
output := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
232
backend/active_scheduler/preview/dto.go
Normal file
232
backend/active_scheduler/preview/dto.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
|
||||
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
|
||||
)
|
||||
|
||||
// CreatePreviewRequest 是把 dry-run 结果固化成主动调度预览的请求 DTO。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责承载 preview 写库所需的 dry-run 结果与可选覆盖字段;
|
||||
// 2. 不承载 confirm/apply 请求,也不允许调用方传入正式日程写入参数;
|
||||
// 3. GeneratedAt 为空时由 Service 时钟生成,ExpiresAt 固定由 generated_at + 1h 推导。
|
||||
type CreatePreviewRequest struct {
|
||||
ActiveContext *schedulercontext.ActiveScheduleContext `json:"-"`
|
||||
Observation observe.Result `json:"-"`
|
||||
Candidates []candidate.Candidate `json:"-"`
|
||||
PreviewID string `json:"preview_id,omitempty"`
|
||||
TriggerID string `json:"trigger_id,omitempty"`
|
||||
BaseVersion string `json:"base_version,omitempty"`
|
||||
GeneratedAt time.Time `json:"generated_at,omitempty"`
|
||||
ExplanationText string `json:"explanation_text,omitempty"`
|
||||
NotificationSummary string `json:"notification_summary,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePreviewResponse 是写入 preview 后可直接返回给 API 的响应 DTO。
|
||||
type CreatePreviewResponse struct {
|
||||
Detail ActiveSchedulePreviewDetail `json:"detail"`
|
||||
}
|
||||
|
||||
// GetPreviewRequest 是查询 preview 详情的请求 DTO。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. UserID 来自鉴权上下文,不能信任前端透传;
|
||||
// 2. PreviewID 来自路由参数;
|
||||
// 3. 查询只返回预览快照,不执行过期状态回写、不触发 apply。
|
||||
type GetPreviewRequest struct {
|
||||
UserID int `json:"user_id"`
|
||||
PreviewID string `json:"preview_id"`
|
||||
}
|
||||
|
||||
// ActiveSchedulePreviewDetail 是主动调度预览详情页响应 DTO。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把 active_schedule_previews 中的 JSON 快照还原成前端可展示结构;
|
||||
// 2. 不包含正式写日程能力,不代表 confirm 请求已通过校验;
|
||||
// 3. CanConfirm 只表达当前快照状态可发起确认,最终是否能应用仍由 confirm/apply 链路重校验。
|
||||
type ActiveSchedulePreviewDetail struct {
|
||||
PreviewID string `json:"preview_id"`
|
||||
Status string `json:"status"`
|
||||
ApplyStatus string `json:"apply_status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Expired bool `json:"expired"`
|
||||
Trigger PreviewTriggerDTO `json:"trigger"`
|
||||
Explanation string `json:"explanation"`
|
||||
Notification string `json:"notification_summary"`
|
||||
SelectedCandidate CandidateDTO `json:"selected_candidate"`
|
||||
Candidates []CandidateDTO `json:"candidates"`
|
||||
Decision observe.Decision `json:"decision"`
|
||||
Metrics observe.Metrics `json:"metrics"`
|
||||
Issues []observe.Issue `json:"issues"`
|
||||
ContextSummary ContextSummaryDTO `json:"context_summary"`
|
||||
Before SchedulePreviewVersion `json:"before"`
|
||||
After SchedulePreviewVersion `json:"after"`
|
||||
Changes []ActiveScheduleChangeItem `json:"changes"`
|
||||
Risk RiskDTO `json:"risk"`
|
||||
BaseVersion string `json:"base_version"`
|
||||
CanConfirm bool `json:"can_confirm"`
|
||||
CanIgnore bool `json:"can_ignore"`
|
||||
TraceID string `json:"trace_id"`
|
||||
}
|
||||
|
||||
type PreviewTriggerDTO struct {
|
||||
TriggerID string `json:"trigger_id"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
Source string `json:"source"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
}
|
||||
|
||||
type CandidateDTO struct {
|
||||
CandidateID string `json:"candidate_id"`
|
||||
CandidateType string `json:"candidate_type"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Target CandidateTargetDTO `json:"target"`
|
||||
Changes []ActiveScheduleChangeItem `json:"changes"`
|
||||
BeforeSummary string `json:"before_summary"`
|
||||
AfterSummary string `json:"after_summary"`
|
||||
Risk string `json:"risk"`
|
||||
Score int `json:"score"`
|
||||
Validation candidate.Validation `json:"validation"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type CandidateTargetDTO struct {
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type ContextSummaryDTO struct {
|
||||
UserID int `json:"user_id"`
|
||||
Timezone string `json:"timezone"`
|
||||
TriggerSource string `json:"trigger_source"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
WindowStart time.Time `json:"window_start"`
|
||||
WindowEnd time.Time `json:"window_end"`
|
||||
WindowReason string `json:"window_reason"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
TargetTitle string `json:"target_title"`
|
||||
MissingInfo []string `json:"missing_info"`
|
||||
TraceSteps []string `json:"trace_steps"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
type SchedulePreviewVersion struct {
|
||||
Title string `json:"title"`
|
||||
WindowStart time.Time `json:"window_start"`
|
||||
WindowEnd time.Time `json:"window_end"`
|
||||
Entries []SchedulePreviewEntry `json:"entries"`
|
||||
SummaryLines []string `json:"summary_lines"`
|
||||
}
|
||||
|
||||
type SchedulePreviewEntry struct {
|
||||
EntryID string `json:"entry_id"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID int `json:"source_id"`
|
||||
Title string `json:"title"`
|
||||
StartAt time.Time `json:"start_at,omitempty"`
|
||||
EndAt time.Time `json:"end_at,omitempty"`
|
||||
Week int `json:"week,omitempty"`
|
||||
DayOfWeek int `json:"day_of_week,omitempty"`
|
||||
SectionFrom int `json:"section_from,omitempty"`
|
||||
SectionTo int `json:"section_to,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Editable bool `json:"editable"`
|
||||
}
|
||||
|
||||
type ActiveScheduleChangeItem struct {
|
||||
ChangeID string `json:"change_id"`
|
||||
ChangeType string `json:"change_type"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
FromSlot *SlotDTO `json:"from_slot,omitempty"`
|
||||
ToSlot *SlotSpanDTO `json:"to_slot,omitempty"`
|
||||
DurationSections int `json:"duration_sections"`
|
||||
AffectedEventIDs []int `json:"affected_event_ids"`
|
||||
EditedAllowed bool `json:"edited_allowed"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type SlotDTO struct {
|
||||
Week int `json:"week"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
Section int `json:"section"`
|
||||
StartAt time.Time `json:"start_at,omitempty"`
|
||||
EndAt time.Time `json:"end_at,omitempty"`
|
||||
}
|
||||
|
||||
type SlotSpanDTO struct {
|
||||
Start SlotDTO `json:"start"`
|
||||
End SlotDTO `json:"end"`
|
||||
DurationSections int `json:"duration_sections"`
|
||||
}
|
||||
|
||||
type RiskDTO struct {
|
||||
Level string `json:"level"`
|
||||
Summary string `json:"summary"`
|
||||
Validation candidate.Validation `json:"validation"`
|
||||
RiskMetrics observe.RiskMetrics `json:"risk_metrics"`
|
||||
AffectedIDs []int `json:"affected_event_ids"`
|
||||
RequiresLLM bool `json:"requires_llm"`
|
||||
FallbackUsed bool `json:"fallback_used"`
|
||||
}
|
||||
|
||||
// rawPreviewSnapshot 聚合需要写入 active_schedule_previews JSON 字段的快照。
|
||||
type rawPreviewSnapshot struct {
|
||||
selectedCandidate CandidateDTO
|
||||
candidates []CandidateDTO
|
||||
decision observe.Decision
|
||||
metrics observe.Metrics
|
||||
issues []observe.Issue
|
||||
contextSummary ContextSummaryDTO
|
||||
before SchedulePreviewVersion
|
||||
changes []ActiveScheduleChangeItem
|
||||
after SchedulePreviewVersion
|
||||
risk RiskDTO
|
||||
}
|
||||
|
||||
func slotDTO(slot ports.Slot) SlotDTO {
|
||||
return SlotDTO{
|
||||
Week: slot.Week,
|
||||
DayOfWeek: slot.DayOfWeek,
|
||||
Section: slot.Section,
|
||||
StartAt: slot.StartAt,
|
||||
EndAt: slot.EndAt,
|
||||
}
|
||||
}
|
||||
|
||||
func slotSpanDTO(span ports.SlotSpan) SlotSpanDTO {
|
||||
return SlotSpanDTO{
|
||||
Start: slotDTO(span.Start),
|
||||
End: slotDTO(span.End),
|
||||
DurationSections: span.DurationSections,
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJSONField 只负责 preview 包内部 DTO 解码。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 当前任务限制只允许修改 preview 目录,不能把 JSON helper 下沉到公共层;
|
||||
// 2. 因此这里暂时保留包内小函数,后续若第二个 active_scheduler 子包也需要同类能力,再按 AGENTS 规则抽公共层;
|
||||
// 3. 解码失败返回原始错误,避免把损坏快照静默展示给用户。
|
||||
func decodeJSONField[T any](raw *string, fallback T) (T, error) {
|
||||
if raw == nil || *raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
var value T
|
||||
if err := json.Unmarshal([]byte(*raw), &value); err != nil {
|
||||
return fallback, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
281
backend/active_scheduler/preview/service.go
Normal file
281
backend/active_scheduler/preview/service.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
|
||||
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
|
||||
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidPreviewRequest = errors.New("主动调度预览请求不合法")
|
||||
ErrPreviewNotFound = errors.New("主动调度预览不存在")
|
||||
)
|
||||
|
||||
// Repository 是 preview service 依赖的最小持久化端口。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只覆盖本轮 preview 写入和详情查询需要的方法;
|
||||
// 2. 不暴露正式日程写入、通知投递或 confirm apply 能力;
|
||||
// 3. 现有 dao.ActiveScheduleDAO 已满足该接口,后续迁移独立 repo 时可并行替换实现。
|
||||
type Repository interface {
|
||||
CreatePreview(ctx context.Context, preview *model.ActiveSchedulePreview) error
|
||||
GetPreviewByID(ctx context.Context, previewID string) (*model.ActiveSchedulePreview, error)
|
||||
}
|
||||
|
||||
// Service 负责主动调度 preview 的写入和查询。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 将 dry-run 结果固化为 active_schedule_previews 中的 ready 快照;
|
||||
// 2. 查询时校验 user_id,并返回 API 可直接透传的详情 DTO;
|
||||
// 3. 不正式写日程、不发通知、不处理 confirm/apply,也不修改 trigger 状态。
|
||||
type Service struct {
|
||||
repo Repository
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
func NewService(repo Repository) (*Service, error) {
|
||||
if repo == nil {
|
||||
return nil, fmt.Errorf("%w: preview repository 不能为空", ErrInvalidPreviewRequest)
|
||||
}
|
||||
return &Service{repo: repo, clock: time.Now}, nil
|
||||
}
|
||||
|
||||
// SetClock 注入测试时钟。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只影响 generated_at / expires_at 和查询时的 expired 计算;
|
||||
// 2. 不改写 dry-run 上下文中的业务当前时间;
|
||||
// 3. clock 为空时保持原时钟,避免运行期误注入导致 panic。
|
||||
func (s *Service) SetClock(clock func() time.Time) {
|
||||
if s == nil || clock == nil {
|
||||
return
|
||||
}
|
||||
s.clock = clock
|
||||
}
|
||||
|
||||
// CreatePreview 把 dry-run 结果保存为 ready preview。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只消费已经完成的 dry-run 结果,不重新读取任务/日程事实;
|
||||
// 2. MVP 没有 LLM 选择器,固定使用后端排序后的 top1 candidate 作为 selected_candidate;
|
||||
// 3. 写库后只返回详情 DTO,不发布通知、不正式应用候选、不回写 trigger。
|
||||
func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) (*CreatePreviewResponse, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest)
|
||||
}
|
||||
if req.ActiveContext == nil {
|
||||
return nil, fmt.Errorf("%w: dry-run 结果不能为空", ErrInvalidPreviewRequest)
|
||||
}
|
||||
if len(req.Candidates) == 0 {
|
||||
return nil, fmt.Errorf("%w: dry-run 未生成可保存候选", ErrInvalidPreviewRequest)
|
||||
}
|
||||
|
||||
activeContext := req.ActiveContext
|
||||
triggerID := strings.TrimSpace(req.TriggerID)
|
||||
if triggerID == "" {
|
||||
triggerID = strings.TrimSpace(activeContext.Trigger.TriggerID)
|
||||
}
|
||||
if triggerID == "" {
|
||||
return nil, fmt.Errorf("%w: trigger_id 不能为空", ErrInvalidPreviewRequest)
|
||||
}
|
||||
|
||||
generatedAt := req.GeneratedAt
|
||||
if generatedAt.IsZero() {
|
||||
generatedAt = s.now()
|
||||
}
|
||||
previewID := strings.TrimSpace(req.PreviewID)
|
||||
if previewID == "" {
|
||||
previewID = "asp_" + uuid.NewString()
|
||||
}
|
||||
|
||||
// 1. 先构造所有展示快照,再写库;任何 JSON 转换失败都提前返回,避免落入半结构化记录。
|
||||
selected := req.Candidates[0]
|
||||
snapshot := buildSnapshot(activeContext, req.Observation, req.Candidates, selected)
|
||||
baseVersion := strings.TrimSpace(req.BaseVersion)
|
||||
if baseVersion == "" {
|
||||
baseVersion = buildBaseVersion(activeContext, snapshot.changes)
|
||||
}
|
||||
|
||||
explanation := strings.TrimSpace(req.ExplanationText)
|
||||
if explanation == "" {
|
||||
explanation = selected.Summary
|
||||
}
|
||||
notificationSummary := strings.TrimSpace(req.NotificationSummary)
|
||||
if notificationSummary == "" {
|
||||
notificationSummary = selected.Summary
|
||||
}
|
||||
|
||||
row, err := buildPreviewModel(previewID, triggerID, generatedAt, baseVersion, explanation, notificationSummary, activeContext, snapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 写入 active_schedule_previews。这里不包事务写其它表,因为本服务不负责 trigger/notification/apply 状态推进。
|
||||
if err := s.repo.CreatePreview(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
detail, err := detailFromModel(row, s.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CreatePreviewResponse{Detail: detail}, nil
|
||||
}
|
||||
|
||||
// GetPreview 查询 preview 详情,并强制校验归属用户。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. preview_id 不存在或不属于 user_id 时统一返回 ErrPreviewNotFound,避免泄漏其它用户数据;
|
||||
// 2. 查询不会把过期 preview 回写为 expired,过期状态仅在 DTO 中计算;
|
||||
// 3. 不读取正式日程实时状态,因此不会触发 confirm 的 base_version 重校验。
|
||||
func (s *Service) GetPreview(ctx context.Context, userID int, previewID string) (*ActiveSchedulePreviewDetail, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest)
|
||||
}
|
||||
if userID <= 0 || strings.TrimSpace(previewID) == "" {
|
||||
return nil, ErrPreviewNotFound
|
||||
}
|
||||
|
||||
row, err := s.repo.GetPreviewByID(ctx, strings.TrimSpace(previewID))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrPreviewNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if row == nil || row.UserID != userID {
|
||||
return nil, ErrPreviewNotFound
|
||||
}
|
||||
|
||||
detail, err := detailFromModel(row, s.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
func (s *Service) now() time.Time {
|
||||
if s == nil || s.clock == nil {
|
||||
return time.Now()
|
||||
}
|
||||
return s.clock()
|
||||
}
|
||||
|
||||
func buildPreviewModel(
|
||||
previewID string,
|
||||
triggerID string,
|
||||
generatedAt time.Time,
|
||||
baseVersion string,
|
||||
explanation string,
|
||||
notificationSummary string,
|
||||
activeContext *schedulercontext.ActiveScheduleContext,
|
||||
snapshot rawPreviewSnapshot,
|
||||
) (*model.ActiveSchedulePreview, error) {
|
||||
selectedJSON, err := jsonString(snapshot.selectedCandidate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidatesJSON, err := jsonString(snapshot.candidates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decisionJSON, err := jsonString(snapshot.decision)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metricsJSON, err := jsonString(snapshot.metrics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
issuesJSON, err := jsonString(snapshot.issues)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contextJSON, err := jsonString(snapshot.contextSummary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
beforeJSON, err := jsonString(snapshot.before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changesJSON, err := jsonString(snapshot.changes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
afterJSON, err := jsonString(snapshot.after)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
riskJSON, err := jsonString(snapshot.risk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.ActiveSchedulePreview{
|
||||
ID: previewID,
|
||||
UserID: activeContext.User.UserID,
|
||||
TriggerID: triggerID,
|
||||
TriggerType: string(activeContext.Trigger.TriggerType),
|
||||
TargetType: string(activeContext.Trigger.TargetType),
|
||||
TargetID: activeContext.Trigger.TargetID,
|
||||
Status: model.ActiveSchedulePreviewStatusReady,
|
||||
SelectedCandidateID: snapshot.selectedCandidate.CandidateID,
|
||||
CandidateCount: len(snapshot.candidates),
|
||||
SelectedCandidateJSON: &selectedJSON,
|
||||
CandidatesJSON: &candidatesJSON,
|
||||
DecisionJSON: &decisionJSON,
|
||||
MetricsJSON: &metricsJSON,
|
||||
IssuesJSON: &issuesJSON,
|
||||
ContextSummaryJSON: &contextJSON,
|
||||
BeforeSummaryJSON: &beforeJSON,
|
||||
PreviewChangesJSON: &changesJSON,
|
||||
AfterSummaryJSON: &afterJSON,
|
||||
RiskJSON: &riskJSON,
|
||||
ExplanationText: explanation,
|
||||
NotificationSummary: notificationSummary,
|
||||
BaseVersion: baseVersion,
|
||||
ExpiresAt: generatedAt.Add(time.Hour),
|
||||
GeneratedAt: generatedAt,
|
||||
ApplyStatus: model.ActiveScheduleApplyStatusNone,
|
||||
TraceID: activeContext.Trace.TraceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildSnapshot(
|
||||
activeContext *schedulercontext.ActiveScheduleContext,
|
||||
observation observe.Result,
|
||||
candidates []candidate.Candidate,
|
||||
selected candidate.Candidate,
|
||||
) rawPreviewSnapshot {
|
||||
selectedDTO := candidateDTO(selected)
|
||||
candidateDTOs := make([]CandidateDTO, 0, len(candidates))
|
||||
for _, item := range candidates {
|
||||
candidateDTOs = append(candidateDTOs, candidateDTO(item))
|
||||
}
|
||||
changes := changeDTOs(selected.CandidateID, selected.Changes)
|
||||
before := buildBeforeSummary(activeContext, selected, changes)
|
||||
after := buildAfterSummary(before, selected, changes)
|
||||
|
||||
return rawPreviewSnapshot{
|
||||
selectedCandidate: selectedDTO,
|
||||
candidates: candidateDTOs,
|
||||
decision: observation.Decision,
|
||||
metrics: observation.Metrics,
|
||||
issues: observation.Issues,
|
||||
contextSummary: contextSummaryDTO(activeContext),
|
||||
before: before,
|
||||
changes: changes,
|
||||
after: after,
|
||||
risk: riskDTO(selected, observation, changes),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user