Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions http-client/posts/notes.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
@baseUrl = http://localhost:9001

# Per-post Notes CRUD (CON-188). A note is a small standalone record attached to
# a post: a draft thesis, an image prompt, or a free-form note.
#
# All endpoints require authentication.
# Run "Login" from sessions/sessions.http first — the HTTP client stores and
# sends the cookie automatically on subsequent requests to the same host.
#
# Prerequisites:
# - A post ID stored in {{postId}} (run posts/posts.http → createPost)
#
# Notes are returned draft_thesis-first, then oldest-first by created_at.
# `type` — draft_thesis | image_prompt | note (validated in Go; the set grows)
# `origin` — manual (REST) | assistant | content_plan (server-stamped, read-only)

### List a post's notes [protected]
GET {{baseUrl}}/api/posts/{{postId}}/notes
Accept: application/json

### Create note — free-form note [protected]
# @name createNote
POST {{baseUrl}}/api/posts/{{postId}}/notes
Content-Type: application/json

{
"type": "note",
"title": "Fact-check",
"body": "Verify the Q3 revenue figure before publishing."
}

> {% client.global.set("noteId", response.body.id) %}

### Create note — image prompt (no title) [protected]
POST {{baseUrl}}/api/posts/{{postId}}/notes
Content-Type: application/json

{
"type": "image_prompt",
"body": "A photorealistic ripe banana on a neon-lit desk, shallow depth of field."
}

### Create note — missing body (expects 400) [protected]
POST {{baseUrl}}/api/posts/{{postId}}/notes
Content-Type: application/json

{
"type": "note",
"title": "Empty"
}

### Create note — invalid type (expects 400) [protected]
POST {{baseUrl}}/api/posts/{{postId}}/notes
Content-Type: application/json

{
"type": "nonsense",
"body": "This should be rejected."
}

### Get note by ID [protected]
GET {{baseUrl}}/api/posts/{{postId}}/notes/{{noteId}}
Accept: application/json

### Update note — edit the body [protected]
PATCH {{baseUrl}}/api/posts/{{postId}}/notes/{{noteId}}
Content-Type: application/json

{
"body": "Verify the Q3 AND Q4 revenue figures before publishing."
}

### Update note — reclassify as an image prompt [protected]
PATCH {{baseUrl}}/api/posts/{{postId}}/notes/{{noteId}}
Content-Type: application/json

{
"type": "image_prompt"
}

### Update note — empty patch (expects 400) [protected]
PATCH {{baseUrl}}/api/posts/{{postId}}/notes/{{noteId}}
Content-Type: application/json

{}

### Delete note [protected]
DELETE {{baseUrl}}/api/posts/{{postId}}/notes/{{noteId}}

### Delete note — non-existent ID (expects 404) [protected]
DELETE {{baseUrl}}/api/posts/{{postId}}/notes/nonexistent
2 changes: 2 additions & 0 deletions src/database/migrations/20260806000001_post_notes.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- CON-188: drop the per-post Notes table.
DROP TABLE IF EXISTS post_notes;
23 changes: 23 additions & 0 deletions src/database/migrations/20260806000001_post_notes.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- CON-188: per-post Notes. A note is a small standalone record (draft thesis,
-- image prompt, or free-form note) attached to a post, so ancillary content
-- lives here instead of in the post body. Tenant-scoped from the start (CON-97).
--
-- `type` has no CHECK constraint on purpose: the vocabulary is expected to grow
-- and is validated in Go (models.PostNoteType). `origin` is a stable, closed
-- set, so it carries a CHECK.
CREATE TABLE post_notes (
id TEXT PRIMARY KEY,
post_id TEXT NOT NULL REFERENCES posts (id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
origin TEXT NOT NULL DEFAULT 'manual'
CHECK (origin IN ('manual', 'assistant', 'content_plan')),
tenant_id TEXT NOT NULL REFERENCES tenants (id),
created_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_post_notes_post_id ON post_notes (post_id);
CREATE INDEX idx_post_notes_tenant_id ON post_notes (tenant_id);
4 changes: 4 additions & 0 deletions src/genkit/flows/content_plan/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ type ContentPlanRepos struct {
Chunks repository.AssetChunksRepository
Platforms repository.PlatformRepository
Posts repository.PostRepository
// Notes captures each generated post's bullet-point thesis as a
// draft_thesis note instead of the post body (CON-188). nil skips note
// creation (the post is still created with an empty body).
Notes repository.PostNoteRepository
}

// InitContentPlan registers the generateContentPlan Genkit flow. It must be
Expand Down
43 changes: 40 additions & 3 deletions src/genkit/flows/content_plan/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ func generatePosts(
// records an empty list.
grounded := idSet(assetIDsOf(assets))
persistFn := func(ctx context.Context, dp DraftPost) (string, error) {
return persistOne(ctx, dp, campaign, repos.Posts, groundedRefs(dp.AssetRefs, grounded))
return persistOne(ctx, dp, campaign, repos.Posts, repos.Notes, groundedRefs(dp.AssetRefs, grounded))
}

// Fill the parallel budget (CON-112 perf): a plan that fits in one batch is
Expand Down Expand Up @@ -543,7 +543,11 @@ func withinCount(persisted, expectedCount int) bool {
// CreateBatch — a client disconnect mid-stream now leaves whatever was
// already persisted in the database, and a hard *AIError from one batch
// no longer rolls back the surviving batches' rows.
func persistOne(ctx context.Context, dp DraftPost, campaign *models.Campaign, postRepo repository.PostRepository, usedAssetIDs []string) (string, error) {
//
// CON-188: the model's bullet-point thesis (dp.Body) is no longer written into
// the post body. The post is created with an empty body and the thesis is
// stored as a draft_thesis note, so the assistant can later expand it into copy.
func persistOne(ctx context.Context, dp DraftPost, campaign *models.Campaign, postRepo repository.PostRepository, noteRepo repository.PostNoteRepository, usedAssetIDs []string) (string, error) {
Comment on lines +546 to +550

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Align the generated-field contract.

persistOne now treats DraftPost.Body as a bullet-point thesis. src/genkit/flows/content_plan/types.go still instructs the model to generate “Complete post copy adapted to the platform”.

The model can generate publishable copy, but this flow stores it as draft_thesis and leaves Post.Content empty. Update the DraftPost.Body JSON schema description and the content-plan prompt to request a thesis consistently.

Proposed contract update
- Body string `json:"body" jsonschema:"description=Complete post copy adapted to the platform"`
+ Body string `json:"body" jsonschema:"description=Bullet-point thesis for a future post expansion"`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/genkit/flows/content_plan/generate.go` around lines 546 - 550, Update the
DraftPost.Body JSON schema description in the content-plan types and the
associated content-plan prompt to request a concise bullet-point thesis rather
than complete platform-adapted post copy. Keep the contract aligned with
persistOne, which stores Body as draft_thesis while leaving Post.Content empty.

id, err := models.NewID()
if err != nil {
return "", err
Expand All @@ -565,7 +569,7 @@ func persistOne(ctx context.Context, dp DraftPost, campaign *models.Campaign, po
PlatformID: dp.PlatformID,
PlatformPostType: dp.ContentType,
Title: dp.Title,
Content: dp.Body,
Content: "",
MediaURLs: models.StringSlice{},
Status: models.PostStatusDraft,
CTAType: models.CTATypeNone,
Expand All @@ -579,9 +583,42 @@ func persistOne(ctx context.Context, dp DraftPost, campaign *models.Campaign, po
if err := postRepo.Create(ctx, row); err != nil {
return "", err
}

// Capture the thesis as a draft_thesis note (CON-188). Best-effort: a
// note-write failure must not discard the already-persisted post (CON-66),
// so it is logged and swallowed rather than returned. An empty thesis
// creates no note.
if noteRepo != nil {
if body := strings.TrimSpace(dp.Body); body != "" {
if err := createDraftThesisNote(ctx, noteRepo, id, campaign.CreatedBy, body); err != nil {
slog.ErrorContext(ctx, "draft thesis note create failed", logging.AttrComponent, "genkit.content_plan", "post_id", id, logging.AttrError, err)
}
}
}
return id, nil
}

// createDraftThesisNote persists the content-plan thesis as a draft_thesis note
// (origin content_plan, authored by the campaign owner).
func createDraftThesisNote(ctx context.Context, noteRepo repository.PostNoteRepository, postID, createdBy, body string) error {
noteID, err := models.NewID()
if err != nil {
return err
}
now := time.Now().UTC()
return noteRepo.Create(ctx, &models.PostNote{
ID: noteID,
PostID: postID,
Type: models.PostNoteTypeDraftThesis,
Title: "Draft thesis",
Body: body,
Origin: models.PostNoteOriginContentPlan,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
})
}

// tailOf returns up to n bytes from the end of s, suitable for log output.
func tailOf(s string, n int) string {
if len(s) <= n {
Expand Down
105 changes: 104 additions & 1 deletion src/genkit/flows/content_plan/generate_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package content_plan

import "testing"
import (
"context"
"testing"

"github.com/ogen-app/ogen/src/models"
"github.com/ogen-app/ogen/src/repository"
)

// CON-114: the streaming persist path stops at the batch's requested count, so
// an over-producing model can't turn "generate exactly 1" into 3 persisted
Expand All @@ -24,3 +30,100 @@ func TestWithinCount(t *testing.T) {
}
}
}

// stubPostRepo embeds the interface so it satisfies the type; only Create is
// implemented (the rest panic if unexpectedly called).
type stubPostRepo struct {
repository.PostRepository
created *models.Post
}

func (s *stubPostRepo) Create(_ context.Context, p *models.Post) error {
s.created = p
return nil
}

type stubNoteRepo struct {
repository.PostNoteRepository
created *models.PostNote
}

func (s *stubNoteRepo) Create(_ context.Context, n *models.PostNote) error {
s.created = n
return nil
}

// CON-188: content-plan no longer writes the thesis into the post body — the
// post is created with an empty body and the thesis is captured as a
// draft_thesis note (origin content_plan, authored by the campaign owner).
func TestPersistOne_DraftThesisNote(t *testing.T) {
postRepo := &stubPostRepo{}
noteRepo := &stubNoteRepo{}
campaign := &models.Campaign{ID: "camp1", CreatedBy: "user1"}
dp := DraftPost{Title: "T", Body: "- point 1\n- point 2", PlatformID: "linkedin", ContentType: "article"}

id, err := persistOne(context.Background(), dp, campaign, postRepo, noteRepo, nil)
if err != nil {
t.Fatalf("persistOne: %v", err)
}
if id == "" {
t.Fatal("expected a non-empty post id")
}
if postRepo.created == nil {
t.Fatal("expected a post to be created")
}
if postRepo.created.Content != "" {
t.Errorf("post body = %q, want empty (thesis goes to a note)", postRepo.created.Content)
}
if noteRepo.created == nil {
t.Fatal("expected a draft_thesis note to be created")
}
n := noteRepo.created
if n.Type != models.PostNoteTypeDraftThesis {
t.Errorf("note type = %q, want draft_thesis", n.Type)
}
if n.Origin != models.PostNoteOriginContentPlan {
t.Errorf("note origin = %q, want content_plan", n.Origin)
}
if n.Body != dp.Body {
t.Errorf("note body = %q, want %q", n.Body, dp.Body)
}
if n.PostID != id {
t.Errorf("note post_id = %q, want %q", n.PostID, id)
}
if n.CreatedBy != "user1" {
t.Errorf("note created_by = %q, want user1", n.CreatedBy)
}
}

// An empty thesis creates no note (but the post is still created).
func TestPersistOne_EmptyThesisNoNote(t *testing.T) {
postRepo := &stubPostRepo{}
noteRepo := &stubNoteRepo{}
campaign := &models.Campaign{ID: "camp1", CreatedBy: "user1"}
dp := DraftPost{Title: "T", Body: " ", PlatformID: "linkedin", ContentType: "article"}

if _, err := persistOne(context.Background(), dp, campaign, postRepo, noteRepo, nil); err != nil {
t.Fatalf("persistOne: %v", err)
}
if postRepo.created == nil {
t.Fatal("expected a post to be created")
}
if noteRepo.created != nil {
t.Errorf("expected no note for an empty thesis, got %+v", noteRepo.created)
}
}

// A nil note repo must not panic — the post is still created (note skipped).
func TestPersistOne_NilNoteRepo(t *testing.T) {
postRepo := &stubPostRepo{}
campaign := &models.Campaign{ID: "camp1", CreatedBy: "user1"}
dp := DraftPost{Title: "T", Body: "- point 1", PlatformID: "linkedin", ContentType: "article"}

if _, err := persistOne(context.Background(), dp, campaign, postRepo, nil, nil); err != nil {
t.Fatalf("persistOne: %v", err)
}
if postRepo.created == nil {
t.Fatal("expected a post to be created even with a nil note repo")
}
}
2 changes: 1 addition & 1 deletion src/genkit/flows/content_plan/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type GeneratePostsRequest struct {
// the shape persisted as a Post record with status=draft.
type DraftPost struct {
Title string `json:"title" jsonschema:"description=Short descriptive title for the post"`
Body string `json:"body" jsonschema:"description=Complete post copy adapted to the platform"`
Body string `json:"body" jsonschema:"description=A concise bullet-point thesis: 5-7 short key-point lines to expand later, NOT finished copy (max 500 chars). Stored as a draft_thesis note, not the post body."`
ContentType string `json:"contentType" jsonschema:"description=Content format slug e.g. text-post article carousel thread video reel"`
PlatformID string `json:"platformId" jsonschema:"description=Exact platform ID string from the campaign e.g. linkedin x-twitter instagram"`
PublishDate string `json:"publishDate" jsonschema:"description=ISO 8601 date YYYY-MM-DD within the campaign date range"`
Expand Down
Loading
Loading