From ed9e447dd324cdedfba96ca46044d101c89b11a2 Mon Sep 17 00:00:00 2001 From: walker <13750528578@163.com> Date: Tue, 16 Jun 2026 22:34:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(deck):=20=E6=94=AF=E6=8C=81=20manifest=20?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E6=AD=A3=E6=96=87=E6=AE=B5=E8=90=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/card_manifest_deck.go | 169 +++++++++++++++++++++++++++-- internal/app/service_test.go | 140 ++++++++++++++++++++++++ 2 files changed, 300 insertions(+), 9 deletions(-) diff --git a/internal/app/card_manifest_deck.go b/internal/app/card_manifest_deck.go index 101d190..efb760b 100644 --- a/internal/app/card_manifest_deck.go +++ b/internal/app/card_manifest_deck.go @@ -34,15 +34,21 @@ type cardManifestDocument struct { } type cardManifestItem struct { - ID string `json:"id"` - Category string `json:"category"` - Title string `json:"title"` - Summary string `json:"summary"` - Impact string `json:"impact"` - Source string `json:"source"` - PublishedAt string `json:"published_at"` - URL string `json:"url"` - Image cardManifestImage `json:"image"` + ID string `json:"id"` + Category string `json:"category"` + Title string `json:"title"` + Summary string `json:"summary"` + Impact string `json:"impact"` + Sections []cardManifestSection `json:"sections"` + Source string `json:"source"` + PublishedAt string `json:"published_at"` + URL string `json:"url"` + Image cardManifestImage `json:"image"` +} + +type cardManifestSection struct { + Label string `json:"label"` + Body string `json:"body"` } type cardManifestImage struct { @@ -50,6 +56,11 @@ type cardManifestImage struct { Alt string `json:"alt"` } +type cardManifestBodySection struct { + Label string + Body string +} + func (s Service) buildCardManifestDeckJSON(path string) (string, error) { data, err := s.effectiveReadFile()(path) if err != nil { @@ -168,6 +179,9 @@ func cardManifestBodyMaxRunes(variant string) int { } func cardManifestItemBody(item cardManifestItem, maxRunes int) string { + if body := cardManifestSectionsBody(item.Sections, maxRunes); body != "" { + return body + } summary, impact := cardManifestFitSummaryImpact(item.Summary, item.Impact, maxRunes) parts := make([]string, 0, 2) if summary != "" { @@ -179,6 +193,143 @@ func cardManifestItemBody(item cardManifestItem, maxRunes int) string { return strings.Join(parts, "\n\n") } +func cardManifestSectionsBody(sections []cardManifestSection, maxRunes int) string { + normalized := cardManifestNormalizeSections(sections) + if len(normalized) == 0 { + return "" + } + return cardManifestFitSections(normalized, maxRunes) +} + +func cardManifestNormalizeSections(sections []cardManifestSection) []cardManifestBodySection { + normalized := make([]cardManifestBodySection, 0, len(sections)) + for _, section := range sections { + body := cardManifestNormalizeText(section.Body) + if body == "" { + continue + } + normalized = append(normalized, cardManifestBodySection{ + Label: cardManifestNormalizeText(section.Label), + Body: body, + }) + } + return normalized +} + +func cardManifestFitSections(sections []cardManifestBodySection, maxRunes int) string { + if len(sections) == 0 || maxRunes <= 0 { + return "" + } + text := cardManifestSectionsText(sections) + if runeCount(text) <= maxRunes { + return text + } + fixedRunes := cardManifestSectionsFixedRuneCount(sections) + contentBudget := maxRunes - fixedRunes + if contentBudget < len(sections) { + return cardManifestFitText(text, maxRunes) + } + budgets := cardManifestSectionBudgets(sections, contentBudget) + fitted := make([]cardManifestBodySection, 0, len(sections)) + for i, section := range sections { + body := cardManifestFitText(section.Body, budgets[i]) + if body == "" { + continue + } + fitted = append(fitted, cardManifestBodySection{Label: section.Label, Body: body}) + } + text = cardManifestSectionsText(fitted) + for runeCount(text) > maxRunes { + index := cardManifestLongestSectionBodyIndex(fitted) + if index < 0 { + return cardManifestFitText(text, maxRunes) + } + nextBudget := runeCount(fitted[index].Body) - 1 + if nextBudget <= 0 { + fitted = append(fitted[:index], fitted[index+1:]...) + if len(fitted) == 0 { + return "" + } + } else { + fitted[index].Body = cardManifestFitText(fitted[index].Body, nextBudget) + } + text = cardManifestSectionsText(fitted) + } + return text +} + +func cardManifestSectionsText(sections []cardManifestBodySection) string { + parts := make([]string, 0, len(sections)) + for _, section := range sections { + parts = append(parts, cardManifestSectionText(section)) + } + return strings.Join(parts, "\n\n") +} + +func cardManifestSectionText(section cardManifestBodySection) string { + if section.Label == "" { + return section.Body + } + return section.Label + ":" + section.Body +} + +func cardManifestSectionsFixedRuneCount(sections []cardManifestBodySection) int { + count := 0 + for i, section := range sections { + if i > 0 { + count += runeCount("\n\n") + } + if section.Label != "" { + count += runeCount(section.Label + ":") + } + } + return count +} + +func cardManifestSectionBudgets(sections []cardManifestBodySection, contentBudget int) []int { + budgets := make([]int, len(sections)) + remaining := contentBudget + for remaining > 0 { + active := make([]int, 0, len(sections)) + for i, section := range sections { + if budgets[i] < runeCount(section.Body) { + active = append(active, i) + } + } + if len(active) == 0 { + break + } + share := remaining / len(active) + if share < 1 { + share = 1 + } + for _, index := range active { + need := runeCount(sections[index].Body) - budgets[index] + add := minInt(share, need) + add = minInt(add, remaining) + budgets[index] += add + remaining -= add + if remaining == 0 { + break + } + } + } + return budgets +} + +func cardManifestLongestSectionBodyIndex(sections []cardManifestBodySection) int { + index := -1 + maxRunes := 0 + for i, section := range sections { + bodyRunes := runeCount(section.Body) + if bodyRunes > maxRunes { + index = i + maxRunes = bodyRunes + } + } + return index +} + func cardManifestFitSummaryImpact(summary string, impact string, maxRunes int) (string, string) { summary = cardManifestNormalizeText(summary) impact = cardManifestNormalizeText(impact) diff --git a/internal/app/service_test.go b/internal/app/service_test.go index b95d897..b012d1f 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -323,6 +323,146 @@ func TestServiceGeneratePreviewBuildsNoImageCardManifestItemsAsTextCaption(t *te } } +func TestServiceGeneratePreviewBuildsCardManifestSectionsWithoutSummaryImpactLabels(t *testing.T) { + root := t.TempDir() + manifestPath := filepath.Join(root, "manifest.json") + manifest := `{ + "schema_version":"card-article-manifest/v1", + "source_app":"vidtrace", + "document":{"title":"每日电子榨菜"}, + "items":[ + { + "id":"BV111", + "category":"B站热门", + "title":"海底生存实况", + "summary":"不应该出现的摘要", + "impact":"不应该出现的影响", + "source":"游戏人阿管", + "sections":[ + {"label":"内容概览","body":"这是一段海底生存游戏实况解说。"}, + {"label":"互动数据","body":"点赞 12,345|收藏 6,789|投币 2,345"} + ] + }, + {"id":"BV222","category":"B站热门","title":"第二条视频","sections":[{"label":"内容概览","body":"第二条内容。"}]} + ] + }` + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile(manifest) error = %v", err) + } + r := &fakeRenderer{} + svc := Service{ + LoadConfig: func(string) (*config.Config, error) { + return &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}}, nil + }, + ReadFile: func(path string) ([]byte, error) { + if path == "article.md" { + return []byte("# ignored"), nil + } + return os.ReadFile(path) + }, + NewRenderer: func(Options) DeckRenderer { return r }, + } + + _, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath}) + if err != nil { + t.Fatalf("GeneratePreview() error = %v", err) + } + body := r.rendered.Pages[1].Content.Body + want := "内容概览:这是一段海底生存游戏实况解说。\n\n互动数据:点赞 12,345|收藏 6,789|投币 2,345" + if body != want { + t.Fatalf("section body = %q, want %q", body, want) + } + for _, forbidden := range []string{"摘要:", "影响:", "不应该出现"} { + if strings.Contains(body, forbidden) { + t.Fatalf("section body should not contain %q: %q", forbidden, body) + } + } +} + +func TestServiceGeneratePreviewFitsCardManifestSections(t *testing.T) { + root := t.TempDir() + manifestPath := filepath.Join(root, "manifest.json") + longOverview := strings.Repeat("这是一段很长的视频内容概览,用来说明视频主题、叙事结构和主要看点。", 10) + longKeyPoints := strings.Repeat("这是一段很长的关键看点,用来覆盖反转、冲突、制作细节和观看理由。", 8) + manifest := fmt.Sprintf(`{ + "schema_version":"card-article-manifest/v1", + "source_app":"vidtrace", + "document":{"title":"每日电子榨菜"}, + "items":[ + {"id":"BV111","category":"B站热门","title":"配图视频","source":"游戏人阿管","image":{"src":"https://example.com/cover.jpg","alt":"视频封面"},"sections":[{"label":"内容概览","body":%q},{"label":"关键看点","body":%q},{"label":"互动数据","body":"点赞 12,345|收藏 6,789|投币 2,345"}]}, + {"id":"BV222","category":"B站热门","title":"无图视频","source":"游戏人阿管","sections":[{"label":"内容概览","body":%q},{"label":"关键看点","body":%q},{"label":"互动数据","body":"点赞 22,345|收藏 7,789|投币 3,345"}]} + ] + }`, longOverview, longKeyPoints, longOverview, longKeyPoints) + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile(manifest) error = %v", err) + } + r := &fakeRenderer{} + svc := Service{ + LoadConfig: func(string) (*config.Config, error) { + return &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}}, nil + }, + ReadFile: func(path string) ([]byte, error) { + if path == "article.md" { + return []byte("# ignored"), nil + } + return os.ReadFile(path) + }, + NewRenderer: func(Options) DeckRenderer { return r }, + } + + _, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath}) + if err != nil { + t.Fatalf("GeneratePreview() error = %v", err) + } + imageBody := r.rendered.Pages[1].Content.Body + if utf8.RuneCountInString(imageBody) > cardManifestImageCaptionBodyMaxRunes { + t.Fatalf("image-caption section body runes = %d, want <= %d", utf8.RuneCountInString(imageBody), cardManifestImageCaptionBodyMaxRunes) + } + for _, want := range []string{"内容概览:", "关键看点:", "互动数据:", "…"} { + if !strings.Contains(imageBody, want) { + t.Fatalf("image-caption section body should contain %q: %q", want, imageBody) + } + } + textBody := r.rendered.Pages[2].Content.Body + if utf8.RuneCountInString(textBody) > cardManifestTextCaptionBodyMaxRunes { + t.Fatalf("text-caption section body runes = %d, want <= %d", utf8.RuneCountInString(textBody), cardManifestTextCaptionBodyMaxRunes) + } + for _, want := range []string{"内容概览:", "关键看点:", "互动数据:", "…"} { + if !strings.Contains(textBody, want) { + t.Fatalf("text-caption section body should contain %q: %q", want, textBody) + } + } +} + +func TestServiceGeneratePreviewReturnsErrorForUnknownCardManifestSectionField(t *testing.T) { + root := t.TempDir() + manifestPath := filepath.Join(root, "manifest.json") + manifest := `{"schema_version":"card-article-manifest/v1","document":{"title":"每日电子榨菜"},"items":[{"id":"BV111","title":"海底生存实况","sections":[{"label":"内容概览","body":"正文","unknown":"bad"}]}]}` + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile(manifest) error = %v", err) + } + svc := Service{ + LoadConfig: func(string) (*config.Config, error) { + return &config.Config{Output: config.OutputCfg{Dir: root}, Deck: config.DeckCfg{MaxPages: 12}}, nil + }, + ReadFile: func(path string) ([]byte, error) { + if path == "article.md" { + return []byte("# ignored"), nil + } + return os.ReadFile(path) + }, + NewRenderer: func(Options) DeckRenderer { return &fakeRenderer{} }, + } + + _, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2, CardManifestPath: manifestPath}) + if err == nil { + t.Fatalf("GeneratePreview() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "parse card manifest json") || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("GeneratePreview() error = %v", err) + } +} + func TestServiceGeneratePreviewFitsCardManifestTextForNewsCards(t *testing.T) { root := t.TempDir() manifestPath := filepath.Join(root, "manifest.json")