From ac24624112f1693fae7c05951d8930cabecd19f4 Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Thu, 6 Aug 2026 14:48:32 +0300 Subject: [PATCH 1/3] CON-188: add per-post Notes entity (post_notes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Note is a small standalone record attached to a Post (title?/body/type/ origin/created_by/timestamps) in a tenant-scoped post_notes table, so ancillary content lives outside the post body. - content-plan now leaves the post body empty and stores the generated bullet thesis as a draft_thesis note (origin=content_plan) - post assistant gains a createNote tool (image_prompt/note), reads a post's existing notes as context, and supports a "noted" action — standalone or alongside an edit in the same turn - REST CRUD under /api/posts/:post_id/notes backed by a shared notes.Service (also used by the assistant tool) with draft_thesis-first ordering - http-client file + unit/integration tests --- http-client/posts/notes.http | 91 +++++++ .../20260806000001_post_notes.down.sql | 2 + .../20260806000001_post_notes.up.sql | 23 ++ src/genkit/flows/content_plan/flow.go | 4 + src/genkit/flows/content_plan/generate.go | 43 ++- .../flows/content_plan/generate_test.go | 105 +++++++- src/genkit/flows/post_assistant/context.go | 46 ++++ .../post_assistant/post_note_tool_test.go | 70 +++++ src/genkit/flows/post_assistant/prewarm.go | 2 +- .../prompts/post_assistant.tmpl | 14 + src/genkit/flows/post_assistant/run.go | 37 ++- src/genkit/flows/post_assistant/tools.go | 70 +++++ src/genkit/flows/post_assistant/types.go | 32 ++- src/handlers/post_notes.go | 225 ++++++++++++++++ src/integration/post_notes_test.go | 249 ++++++++++++++++++ src/models/post_note.go | 80 ++++++ src/notes/service.go | 168 ++++++++++++ src/notes/service_test.go | 104 ++++++++ src/repository/post_notes.go | 91 +++++++ src/server/genkit_runtime.go | 6 +- src/server/post_assistant.go | 3 + src/server/server.go | 14 + 22 files changed, 1471 insertions(+), 8 deletions(-) create mode 100644 http-client/posts/notes.http create mode 100644 src/database/migrations/20260806000001_post_notes.down.sql create mode 100644 src/database/migrations/20260806000001_post_notes.up.sql create mode 100644 src/genkit/flows/post_assistant/post_note_tool_test.go create mode 100644 src/handlers/post_notes.go create mode 100644 src/integration/post_notes_test.go create mode 100644 src/models/post_note.go create mode 100644 src/notes/service.go create mode 100644 src/notes/service_test.go create mode 100644 src/repository/post_notes.go diff --git a/http-client/posts/notes.http b/http-client/posts/notes.http new file mode 100644 index 00000000..d45d229a --- /dev/null +++ b/http-client/posts/notes.http @@ -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 diff --git a/src/database/migrations/20260806000001_post_notes.down.sql b/src/database/migrations/20260806000001_post_notes.down.sql new file mode 100644 index 00000000..459d70cc --- /dev/null +++ b/src/database/migrations/20260806000001_post_notes.down.sql @@ -0,0 +1,2 @@ +-- CON-188: drop the per-post Notes table. +DROP TABLE IF EXISTS post_notes; diff --git a/src/database/migrations/20260806000001_post_notes.up.sql b/src/database/migrations/20260806000001_post_notes.up.sql new file mode 100644 index 00000000..2d56fba6 --- /dev/null +++ b/src/database/migrations/20260806000001_post_notes.up.sql @@ -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); diff --git a/src/genkit/flows/content_plan/flow.go b/src/genkit/flows/content_plan/flow.go index a306ed79..919a6e64 100644 --- a/src/genkit/flows/content_plan/flow.go +++ b/src/genkit/flows/content_plan/flow.go @@ -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 diff --git a/src/genkit/flows/content_plan/generate.go b/src/genkit/flows/content_plan/generate.go index afcc2816..5cf274e6 100644 --- a/src/genkit/flows/content_plan/generate.go +++ b/src/genkit/flows/content_plan/generate.go @@ -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 @@ -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) { id, err := models.NewID() if err != nil { return "", err @@ -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, @@ -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 { diff --git a/src/genkit/flows/content_plan/generate_test.go b/src/genkit/flows/content_plan/generate_test.go index 6aaf7ff4..3bbc0dd8 100644 --- a/src/genkit/flows/content_plan/generate_test.go +++ b/src/genkit/flows/content_plan/generate_test.go @@ -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 @@ -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") + } +} diff --git a/src/genkit/flows/post_assistant/context.go b/src/genkit/flows/post_assistant/context.go index 6f53d73c..7e2d0ced 100644 --- a/src/genkit/flows/post_assistant/context.go +++ b/src/genkit/flows/post_assistant/context.go @@ -141,6 +141,11 @@ func assembleContext( return nil, err } + noteSummaries, err := buildNoteSummaries(ctx, post, repos) + if err != nil { + return nil, err + } + // Available platforms power the clonePost tool's platform resolution. // Best-effort: a load failure (or no platforms repo) just omits the // section — the tool still validates server-side. @@ -164,6 +169,7 @@ func assembleContext( Assets: summaries, Platforms: platforms, Versions: versions, + Notes: noteSummaries, } systemPrompt, err := renderTemplate(systemTmpl, data) @@ -194,6 +200,46 @@ type contextTemplateData struct { Assets []assetSummary Platforms []platformOption Versions []versionSummary + Notes []noteSummary +} + +// noteBodyPreviewChars bounds a single note's body in the context block so a +// long note can't blow the prompt budget. Draft theses are already short +// (≤500 chars); image prompts and side notes are typically short too. +const noteBodyPreviewChars = 800 + +// noteSummary is a single note surfaced to the model (CON-188). Body is +// included (notes are short) so the assistant can act on the captured thesis or +// prompt directly. +type noteSummary struct { + Type string + Title string + Body string +} + +// buildNoteSummaries lists the post's notes for the context block, draft +// theses first then oldest-first (the repo's ordering). Best-effort: a load +// error is propagated; no notes simply omits the section. Rendered into the +// cached context block — the same 5-minute TTL staleness window as the version +// history applies (a note added mid-session may not appear until the cache +// entry expires or the post content changes). +func buildNoteSummaries(ctx context.Context, post *models.Post, repos PostAssistantRepos) ([]noteSummary, error) { + if repos.Notes == nil { + return nil, nil + } + list, err := repos.Notes.ListByPostID(ctx, post.ID) + if err != nil { + return nil, err + } + out := make([]noteSummary, 0, len(list)) + for _, n := range list { + out = append(out, noteSummary{ + Type: string(n.Type), + Title: n.Title, + Body: truncateRunes(n.Body, noteBodyPreviewChars), + }) + } + return out, nil } // versionSummary is a single entry in the post's version history, diff --git a/src/genkit/flows/post_assistant/post_note_tool_test.go b/src/genkit/flows/post_assistant/post_note_tool_test.go new file mode 100644 index 00000000..4786b5bf --- /dev/null +++ b/src/genkit/flows/post_assistant/post_note_tool_test.go @@ -0,0 +1,70 @@ +package post_assistant + +import ( + "context" + "testing" + + "github.com/ogen-app/ogen/src/models" + "github.com/ogen-app/ogen/src/notes" + "github.com/ogen-app/ogen/src/repository" +) + +type captureNoteRepo struct { + repository.PostNoteRepository + created []*models.PostNote +} + +func (r *captureNoteRepo) Create(_ context.Context, n *models.PostNote) error { + r.created = append(r.created, n) + return nil +} + +func TestToolCreateNote(t *testing.T) { + repo := &captureNoteRepo{} + st := &requestState{postID: "post1", actor: "user1", noteSvc: notes.New(repo)} + ctx := withRequestState(context.Background(), st) + + out, err := toolCreateNote(ctx, CreateNoteInput{Type: "image_prompt", Title: "Prompt", Body: "a photorealistic banana"}) + if err != nil { + t.Fatalf("toolCreateNote: %v", err) + } + if out.ID == "" || out.Type != string(models.PostNoteTypeImagePrompt) { + t.Errorf("unexpected output: %+v", out) + } + + // draft_thesis is reserved for content-plan; the tool must downgrade it to + // a free-form note rather than create a draft thesis. + if _, err := toolCreateNote(ctx, CreateNoteInput{Type: "draft_thesis", Body: "should become note"}); err != nil { + t.Fatalf("toolCreateNote (reserved type): %v", err) + } + + if len(repo.created) != 2 { + t.Fatalf("expected 2 notes persisted, got %d", len(repo.created)) + } + first, second := repo.created[0], repo.created[1] + if first.Type != models.PostNoteTypeImagePrompt { + t.Errorf("first note type = %q, want image_prompt", first.Type) + } + if second.Type != models.PostNoteTypeNote { + t.Errorf("reserved draft_thesis note type = %q, want note", second.Type) + } + for _, n := range repo.created { + if n.Origin != models.PostNoteOriginAssistant { + t.Errorf("origin = %q, want assistant", n.Origin) + } + if n.PostID != "post1" || n.CreatedBy != "user1" { + t.Errorf("note not stamped with request state: %+v", n) + } + } + if len(st.noteResults) != 2 { + t.Errorf("noteResults = %d, want 2 (read by the runner to finalise the response)", len(st.noteResults)) + } +} + +func TestToolCreateNote_Unavailable(t *testing.T) { + st := &requestState{postID: "post1", actor: "user1"} // noteSvc nil + ctx := withRequestState(context.Background(), st) + if _, err := toolCreateNote(ctx, CreateNoteInput{Type: "note", Body: "x"}); err == nil { + t.Fatal("expected an error when the note service is unavailable") + } +} diff --git a/src/genkit/flows/post_assistant/prewarm.go b/src/genkit/flows/post_assistant/prewarm.go index 142ad650..7760444c 100644 --- a/src/genkit/flows/post_assistant/prewarm.go +++ b/src/genkit/flows/post_assistant/prewarm.go @@ -35,7 +35,7 @@ func prewarmToolCache(g *genkit.Genkit, cfg PostAssistantFlowConfig, t *toolSet) ai.WithPrompt("warmup"), ai.WithTools( t.listAssets, t.getAssetChunks, t.searchAssetChunks, t.getCurrentContent, - t.clonePost, t.restoreVersion, t.schedulePost, + t.clonePost, t.restoreVersion, t.schedulePost, t.createNote, ), ai.WithMaxTurns(1), cfg.Provider.CallConfig(1), // max_tokens: 1 — grammar compiles during prep diff --git a/src/genkit/flows/post_assistant/prompts/post_assistant.tmpl b/src/genkit/flows/post_assistant/prompts/post_assistant.tmpl index b1bdfa67..6268679a 100644 --- a/src/genkit/flows/post_assistant/prompts/post_assistant.tmpl +++ b/src/genkit/flows/post_assistant/prompts/post_assistant.tmpl @@ -35,6 +35,11 @@ You have two response modes. Both use the same JSON envelope (see "Response form - If the readiness summary says the draft is NOT publishable, decline and tell the user exactly what to fix — do NOT schedule. - If the time is in the past or genuinely ambiguous, ask instead of guessing. If the post has no platform, ask the user to set one first. If the tool returns an error, relay it and ask the user (answer mode); do not retry blindly. +**6. Notes mode** — for "make an image prompt" / "write a Nano Banana prompt" / "jot a note" / "save this as a note", or any request to produce a side artifact that should NOT go into the post body. Call the **createNote** tool with `type` `image_prompt` (for an image-generation prompt) or `note` (for anything else) and the artifact in `body`. Do NOT put the artifact in `updatedContent`. +- **Note only**: after the tool returns, set `action` to `"noted"`, leave `updatedContent` empty, and confirm in `explanation`. +- **Edit AND note in one turn**: if the user asks to change the copy AND capture a note (e.g. "tighten the intro and add an image prompt"), produce the edited post in `updatedContent` with `action: "edited"` AND call createNote — both take effect. The note is captured separately from the body. +- A **draft_thesis** note in the context below is the bullet-point outline for this post: expand it into `updatedContent` (edit mode) when the user asks you to write/draft the post. Do not create draft_thesis notes yourself — createNote only accepts image_prompt or note. + Never write prose outside of the JSON envelope. Even for purely informational answers, your full response must be the JSON object — the `explanation` field carries your prose. ## Tools @@ -46,6 +51,7 @@ You have access to tools to retrieve asset content when needed: - **clonePost**: Duplicate the current post as a new draft (see Clone mode above). Pass targetPlatform + adapted content to clone for a different platform. - **restoreVersion**: Roll the post back to an earlier saved version (see Restore mode above). Pass versionNumber (e.g. 2) or relative: "previous". - **schedulePost**: Schedule the post for publishing at a confirmed absolute time (see Schedule mode above). Only call it AFTER the user confirms the resolved time and the auto/manual mode. +- **createNote**: Save a side artifact (image prompt or free-form note) attached to the post instead of putting it in the body (see Notes mode above). **Important**: Only call tools when the user explicitly references assets or asks to incorporate external content. For rephrasing, shortening, expanding, or tone adjustments, work directly from the current post content shown in the context below — do NOT call tools for these tasks. @@ -105,6 +111,14 @@ Respond ONLY with the JSON object. No markdown fences, no extra text.{{end}} ## Current post content {{if .PostContent}}{{.PostContent}}{{else}}(empty — no content yet){{end}} +{{- if .Notes}} + +## Notes +Notes attached to this post (draft theses first). A **draft_thesis** is the bullet-point outline to expand into the post body when asked; **image_prompt** notes are prompts for image generation; **note** entries are free-form ideas. Use these as source material — do NOT dump them verbatim into the post body unless the user asks: +{{- range .Notes}} +- **{{.Type}}**{{if .Title}} — {{.Title}}{{end}}: {{.Body}} +{{- end}} +{{- end}} {{- if .Versions}} ## Version history diff --git a/src/genkit/flows/post_assistant/run.go b/src/genkit/flows/post_assistant/run.go index d5b638f0..0ba33628 100644 --- a/src/genkit/flows/post_assistant/run.go +++ b/src/genkit/flows/post_assistant/run.go @@ -163,6 +163,7 @@ func runPostAssistant( cloneSvc: cfg.CloneService, restoreSvc: cfg.RestoreService, scheduleSvc: cfg.ScheduleService, + noteSvc: cfg.NoteService, actor: post.CreatedBy, platforms: platforms, onEvent: onEvent, @@ -273,7 +274,7 @@ func runPostAssistant( ai.WithSystem(systemBlock), ai.WithMessages(history...), ai.WithPrompt(prompt), - ai.WithTools(tools.listAssets, tools.getAssetChunks, tools.searchAssetChunks, tools.getCurrentContent, tools.clonePost, tools.restoreVersion, tools.schedulePost), + ai.WithTools(tools.listAssets, tools.getAssetChunks, tools.searchAssetChunks, tools.getCurrentContent, tools.clonePost, tools.restoreVersion, tools.schedulePost, tools.createNote), ai.WithMaxTurns(maxTurns), ai.WithStreaming(streamCb), cfg.Provider.CallConfig(maxTokens), @@ -395,6 +396,38 @@ func runPostAssistant( } } + // ── Note handling (CON-188) ────────────────────────────────────────────── + // The createNote tool persisted its notes at call time (origin=assistant). + // Notes are additive: a turn may create notes on their own or alongside an + // edit, so this never clears an edit's updatedContent. When notes are the + // only effect — no content edit and no other authoritative tool action — + // the action is "noted". + if len(st.noteResults) > 0 { + result.NotesCreated = make([]NotePayload, 0, len(st.noteResults)) + for _, n := range st.noteResults { + result.NotesCreated = append(result.NotesCreated, NotePayload{ + ID: n.ID, + Type: string(n.Type), + Title: n.Title, + Body: n.Body, + }) + } + if result.Action != "edited" && st.cloneResult == nil && st.restoreResult == nil && st.scheduleResult == nil { + result.Action = "noted" + result.UpdatedContent = "" + result.SaveVersion = false + } + // Ensure a usable explanation so the "no usable fields" guard below + // doesn't misfire on a notes-only turn where the model left it empty. + if result.Explanation == "" { + if len(st.noteResults) == 1 { + result.Explanation = "Saved a note." + } else { + result.Explanation = fmt.Sprintf("Saved %d notes.", len(st.noteResults)) + } + } + } + // Pure-prose recovery: occasionally the model ignores the JSON // envelope entirely and answers in plain prose (often when the user // asks an informational question). Salvage the raw text as the @@ -473,11 +506,13 @@ func runPostAssistant( Explanation string `json:"explanation"` SaveVersion bool `json:"saveVersion"` VersionNote string `json:"versionNote,omitempty"` + NoteCount int `json:"noteCount,omitempty"` }{ Action: result.Action, Explanation: result.Explanation, SaveVersion: result.SaveVersion, VersionNote: result.VersionNote, + NoteCount: len(result.NotesCreated), }) if err != nil { return nil, fmt.Errorf("marshal model history: %w", err) diff --git a/src/genkit/flows/post_assistant/tools.go b/src/genkit/flows/post_assistant/tools.go index 8367761a..f63dd460 100644 --- a/src/genkit/flows/post_assistant/tools.go +++ b/src/genkit/flows/post_assistant/tools.go @@ -13,6 +13,7 @@ import ( "github.com/ogen-app/ogen/src/genkit/embedopts" "github.com/ogen-app/ogen/src/models" + "github.com/ogen-app/ogen/src/notes" "github.com/ogen-app/ogen/src/post_actions/clone" "github.com/ogen-app/ogen/src/post_actions/restore" "github.com/ogen-app/ogen/src/post_actions/schedule" @@ -61,6 +62,12 @@ type requestState struct { // generation to finalise the response. scheduleSvc *schedule.Service scheduleResult *schedule.Result + + // Note support (CON-188). noteSvc backs the createNote tool; noteResults + // collects every note created this turn, read by the runner after + // generation to finalise the response. + noteSvc *notes.Service + noteResults []*models.PostNote } func withRequestState(ctx context.Context, s *requestState) context.Context { @@ -155,6 +162,19 @@ type SchedulePostOutput struct { Promoted bool `json:"promoted"` } +// CreateNoteInput is the input for the createNote tool (CON-188). +type CreateNoteInput struct { + Type string `json:"type" jsonschema:"description=The kind of note. Use image_prompt for an image-generation prompt (e.g. a Nano Banana prompt); use note for any other side note or idea.,enum=image_prompt,enum=note"` + Title string `json:"title,omitempty" jsonschema:"description=Optional short title for the note."` + Body string `json:"body" jsonschema:"description=The note content (the prompt text, idea, or note body)."` +} + +// CreateNoteOutput is returned to the model after a note is created. +type CreateNoteOutput struct { + ID string `json:"id"` + Type string `json:"type"` +} + // ── Tool registration ──────────────────────────────────────────────────────── type toolSet struct { @@ -165,6 +185,7 @@ type toolSet struct { clonePost ai.ToolRef restoreVersion ai.ToolRef schedulePost ai.ToolRef + createNote ai.ToolRef } func defineTools(g *genkit.Genkit) *toolSet { @@ -226,6 +247,17 @@ func defineTools(g *genkit.Genkit) *toolSet { }, ) + createNote := genkit.DefineTool(g, "createNote", + "Saves a standalone note attached to the current post — an image-generation prompt "+ + "(type image_prompt, e.g. a Nano Banana prompt) or a free-form side note/idea "+ + "(type note). Use this instead of putting such artifacts in the post body. You can "+ + "call it alongside an edit to both change the body AND capture a note in the same turn. "+ + "Returns the new note's id.", + func(ctx *ai.ToolContext, in CreateNoteInput) (*CreateNoteOutput, error) { + return toolCreateNote(ctx, in) + }, + ) + return &toolSet{ listAssets: list, getAssetChunks: getChunks, @@ -234,6 +266,7 @@ func defineTools(g *genkit.Genkit) *toolSet { clonePost: clonePost, restoreVersion: restoreVersion, schedulePost: schedulePost, + createNote: createNote, } } @@ -427,6 +460,43 @@ func toolSchedulePost(ctx context.Context, in SchedulePostInput) (*SchedulePostO }, nil } +func toolCreateNote(ctx context.Context, in CreateNoteInput) (*CreateNoteOutput, error) { + st := getRequestState(ctx) + if st.noteSvc == nil { + return nil, fmt.Errorf("notes are not available") + } + + // draft_thesis is reserved for the content-plan flow; the assistant may + // only create image_prompt or note. Anything else (incl. empty) defaults + // to a free-form note. + nt := models.PostNoteType(in.Type) + if nt != models.PostNoteTypeImagePrompt && nt != models.PostNoteTypeNote { + nt = models.PostNoteTypeNote + } + + note, err := st.noteSvc.Create(ctx, notes.CreateInput{ + PostID: st.postID, + Type: nt, + Title: in.Title, + Body: in.Body, + Origin: models.PostNoteOriginAssistant, + CreatedBy: st.actor, + }) + if err != nil { + return nil, err + } + st.noteResults = append(st.noteResults, note) + + emit(st.onEvent, SSEEventNoteCreated, NoteCreatedEventPayload{ + ID: note.ID, + Type: string(note.Type), + Title: note.Title, + Body: note.Body, + }) + + return &CreateNoteOutput{ID: note.ID, Type: string(note.Type)}, nil +} + // parseScheduledAt accepts the ISO-8601 instant the model resolved. It // requires an explicit timezone (offset or Z) so the stored UTC instant // is unambiguous; a naive local datetime is rejected with guidance the diff --git a/src/genkit/flows/post_assistant/types.go b/src/genkit/flows/post_assistant/types.go index 85c0452a..1aa45aa5 100644 --- a/src/genkit/flows/post_assistant/types.go +++ b/src/genkit/flows/post_assistant/types.go @@ -4,6 +4,7 @@ import ( "github.com/firebase/genkit/go/ai" "github.com/ogen-app/ogen/src/eventhub" + "github.com/ogen-app/ogen/src/notes" "github.com/ogen-app/ogen/src/post_actions/clone" "github.com/ogen-app/ogen/src/post_actions/restore" "github.com/ogen-app/ogen/src/post_actions/schedule" @@ -25,7 +26,7 @@ type PostAssistantRequest struct { type PostAssistantResponse struct { Explanation string `json:"explanation" jsonschema:"description=Human-readable explanation of what was changed or why the request was declined"` UpdatedContent string `json:"updatedContent" jsonschema:"description=The full updated post content as Markdown; empty when action is declined"` - Action string `json:"action" jsonschema:"description=edited when content was changed; declined when the request is out of scope or you are asking the user to confirm; cloned when a clone was created; restored when the post was rolled back to an earlier version; scheduled when the post was scheduled for publishing,enum=edited,enum=declined,enum=cloned,enum=restored,enum=scheduled"` + Action string `json:"action" jsonschema:"description=edited when content was changed; declined when the request is out of scope or you are asking the user to confirm; cloned when a clone was created; restored when the post was rolled back to an earlier version; scheduled when the post was scheduled for publishing; noted when one or more notes were created without changing the post body,enum=edited,enum=declined,enum=cloned,enum=restored,enum=scheduled,enum=noted"` SaveVersion bool `json:"saveVersion" jsonschema:"description=True when a new version snapshot should be created"` VersionNote string `json:"versionNote,omitempty" jsonschema:"description=Short note describing the version; only present when saveVersion is true"` // CloneResult is populated by the server (not the model) when the @@ -40,6 +41,18 @@ type PostAssistantResponse struct { // schedulePost tool ran this turn. Action is then "scheduled" and // UpdatedContent is empty — scheduling doesn't change content. ScheduleResult *ScheduleResultPayload `json:"scheduleResult,omitempty" jsonschema:"-"` + // NotesCreated is populated by the server (not the model) with the notes + // the createNote tool persisted this turn (CON-188). Notes are additive: + // they may accompany an "edited" turn or stand alone as a "noted" turn. + NotesCreated []NotePayload `json:"notesCreated,omitempty" jsonschema:"-"` +} + +// NotePayload describes a note created by the createNote tool this turn. +type NotePayload struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title,omitempty"` + Body string `json:"body"` } // CloneResultPayload describes the post created by the clonePost tool. @@ -88,6 +101,10 @@ type PostAssistantRepos struct { // so it can pre-empt a promote-time validation failure. nil → the // readiness summary omits attachment-based rules. Attachments repository.PostAttachmentRepository + // Notes surfaces the post's existing notes (draft theses, image prompts, + // side notes) to the model as context (CON-188). nil omits the notes + // section. Writes go through the shared NoteService, not this repo. + Notes repository.PostNoteRepository } // PostAssistantFlowConfig holds settings for the post assistant flow. @@ -131,6 +148,9 @@ type PostAssistantFlowConfig struct { // ScheduleService backs the schedulePost tool (CON-78). nil disables // the tool — the assistant then has no scheduling capability. ScheduleService *schedule.Service + // NoteService backs the createNote tool (CON-188). nil disables the + // tool — the assistant then cannot capture notes. + NoteService *notes.Service } // ValidationError is returned when preconditions are not met (HTTP 400). @@ -168,10 +188,20 @@ const ( SSEEventRestoreComplete SSEEventKind = "restore_complete" SSEEventScheduleStarted SSEEventKind = "schedule_started" SSEEventScheduleComplete SSEEventKind = "schedule_complete" + SSEEventNoteCreated SSEEventKind = "note_created" SSEEventComplete SSEEventKind = "complete" SSEEventError SSEEventKind = "error" ) +// NoteCreatedEventPayload is emitted each time the createNote tool persists a +// note during a turn (CON-188), so the UI can surface it live. +type NoteCreatedEventPayload struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title,omitempty"` + Body string `json:"body"` +} + // CloneStartedEventPayload is emitted when the clonePost tool begins // creating a clone, so the UI can show progress. type CloneStartedEventPayload struct { diff --git a/src/handlers/post_notes.go b/src/handlers/post_notes.go new file mode 100644 index 00000000..47d9bfc3 --- /dev/null +++ b/src/handlers/post_notes.go @@ -0,0 +1,225 @@ +package handlers + +import ( + "database/sql" + "errors" + + "github.com/gofiber/fiber/v2" + + "github.com/ogen-app/ogen/src/activity" + "github.com/ogen-app/ogen/src/models" + "github.com/ogen-app/ogen/src/notes" + "github.com/ogen-app/ogen/src/repository" +) + +// PostNotesHandler exposes the CRUD API for per-post Notes (CON-188), nested +// under a post so a UI can manage draft theses, image prompts, and free-form +// notes by hand. Writes go through the shared notes.Service the assistant also +// uses. +type PostNotesHandler struct { + svc *notes.Service + postRepo repository.PostRepository + auth fiber.Handler + activity *activity.Recorder +} + +func NewPostNotesHandler(svc *notes.Service, postRepo repository.PostRepository, auth fiber.Handler) *PostNotesHandler { + return &PostNotesHandler{svc: svc, postRepo: postRepo, auth: auth} +} + +// SetActivityRecorder wires the CON-125 activity recorder. nil is a no-op. +func (h *PostNotesHandler) SetActivityRecorder(r *activity.Recorder) { + h.activity = r +} + +func (h *PostNotesHandler) recordActivity(c *fiber.Ctx, typ string, opts ...activity.Option) { + h.activity.Record(c.Context(), activity.CategoryPost, typ, + append([]activity.Option{activity.WithSource(activity.SourceAPI)}, opts...)...) +} + +func (h *PostNotesHandler) Register(app *fiber.App) { + g := app.Group("/api/posts/:post_id/notes", h.auth) + g.Get("/", h.List) + g.Post("/", h.Create) + g.Get("/:id", h.Get) + g.Patch("/:id", h.Update) + g.Delete("/:id", h.Delete) +} + +// loadPostOrErr fetches the parent post, returning 404 when it is missing or +// belongs to another tenant. +func (h *PostNotesHandler) loadPostOrErr(c *fiber.Ctx) (*models.Post, error) { + post, err := h.postRepo.GetByID(c.Context(), c.Params("post_id")) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fiber.NewError(fiber.StatusNotFound, "post not found") + } + return nil, err + } + return post, nil +} + +// loadNoteOrErr fetches a note and verifies it belongs to the post in the path, +// returning 404 otherwise (so a note id from another post can't be reached). +func (h *PostNotesHandler) loadNoteOrErr(c *fiber.Ctx, postID string) (*models.PostNote, error) { + note, err := h.svc.Get(c.Context(), c.Params("id")) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fiber.NewError(fiber.StatusNotFound, "note not found") + } + return nil, err + } + if note.PostID != postID { + return nil, fiber.NewError(fiber.StatusNotFound, "note not found") + } + return note, nil +} + +// createNoteRequest is the POST body. Origin is always stamped `manual` +// server-side for REST-created notes. +type createNoteRequest struct { + Type string `json:"type" validate:"required"` + Title string `json:"title"` + Body string `json:"body" validate:"required"` +} + +// updateNoteRequest is the PATCH body. Every field is optional; at least one +// must be present. +type updateNoteRequest struct { + Type *string `json:"type"` + Title *string `json:"title"` + Body *string `json:"body"` +} + +func (h *PostNotesHandler) List(c *fiber.Ctx) error { + post, err := h.loadPostOrErr(c) + if err != nil { + return err + } + list, err := h.svc.List(c.Context(), post.ID) + if err != nil { + return err + } + // Never return a null body for an empty list. + if list == nil { + list = []models.PostNote{} + } + return c.JSON(list) +} + +func (h *PostNotesHandler) Get(c *fiber.Ctx) error { + post, err := h.loadPostOrErr(c) + if err != nil { + return err + } + note, err := h.loadNoteOrErr(c, post.ID) + if err != nil { + return err + } + return c.JSON(note) +} + +func (h *PostNotesHandler) Create(c *fiber.Ctx) error { + post, err := h.loadPostOrErr(c) + if err != nil { + return err + } + + var req createNoteRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if err := validate.Struct(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, validationError(err).Error()) + } + + session := c.Locals("session").(*models.Session) + note, err := h.svc.Create(c.Context(), notes.CreateInput{ + PostID: post.ID, + Type: models.PostNoteType(req.Type), + Title: req.Title, + Body: req.Body, + Origin: models.PostNoteOriginManual, + CreatedBy: session.UserID, + }) + if err != nil { + if notes.IsValidation(err) { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + return err + } + + h.recordActivity(c, "note_created", + activity.WithEntity("note", note.ID), + activity.WithPayload(map[string]any{"post_id": note.PostID, "type": string(note.Type), "origin": string(note.Origin)}), + ) + return c.Status(fiber.StatusCreated).JSON(note) +} + +func (h *PostNotesHandler) Update(c *fiber.Ctx) error { + post, err := h.loadPostOrErr(c) + if err != nil { + return err + } + note, err := h.loadNoteOrErr(c, post.ID) + if err != nil { + return err + } + + var req updateNoteRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + if req.Type == nil && req.Title == nil && req.Body == nil { + return fiber.NewError(fiber.StatusBadRequest, "at least one of type, title or body is required") + } + + var typePatch *models.PostNoteType + if req.Type != nil { + t := models.PostNoteType(*req.Type) + typePatch = &t + } + updated, err := h.svc.Update(c.Context(), note, notes.UpdateInput{ + Title: req.Title, + Body: req.Body, + Type: typePatch, + }) + if err != nil { + switch { + case notes.IsValidation(err): + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + case errors.Is(err, notes.ErrNotFound): + return fiber.NewError(fiber.StatusNotFound, "note not found") + default: + return err + } + } + + h.recordActivity(c, "note_updated", + activity.WithEntity("note", updated.ID), + activity.WithPayload(map[string]any{"post_id": updated.PostID, "type": string(updated.Type)}), + ) + return c.JSON(updated) +} + +func (h *PostNotesHandler) Delete(c *fiber.Ctx) error { + post, err := h.loadPostOrErr(c) + if err != nil { + return err + } + // Verify the note exists and belongs to this post before deleting, so a + // wrong-post id returns 404 rather than silently succeeding. + note, err := h.loadNoteOrErr(c, post.ID) + if err != nil { + return err + } + deleted, err := h.svc.Delete(c.Context(), note.ID) + if err != nil { + return err + } + if !deleted { + return fiber.NewError(fiber.StatusNotFound, "note not found") + } + h.recordActivity(c, "note_deleted", activity.WithEntity("note", note.ID)) + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/src/integration/post_notes_test.go b/src/integration/post_notes_test.go new file mode 100644 index 00000000..4a454677 --- /dev/null +++ b/src/integration/post_notes_test.go @@ -0,0 +1,249 @@ +//go:build integration + +package integration_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "time" + + "github.com/gofiber/fiber/v2" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/uptrace/bun" + + "github.com/ogen-app/ogen/src/handlers" + "github.com/ogen-app/ogen/src/models" + "github.com/ogen-app/ogen/src/notes" + "github.com/ogen-app/ogen/src/repository" +) + +var _ = Describe("Post notes CRUD (CON-188)", Ordered, func() { + var ( + app *fiber.App + db *bun.DB + authCookie *http.Cookie + campaignID string + ) + + BeforeAll(func() { + db = mustOpenIntegrationDB() + }) + + BeforeEach(func() { + app = fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"error": err.Error()}) + }, + }) + userRepo := repository.NewUserRepository(db) + sessionRepo := repository.NewSessionRepository(db) + settingRepo := repository.NewSettingRepository(db) + tagRepo := repository.NewTagRepository(db) + campaignTypeRepo := repository.NewCampaignTypeRepository(db) + campaignRepo := repository.NewCampaignRepository(db, tagRepo, repository.NewPlatformRepository(db), campaignTypeRepo) + postRepo := repository.NewPostRepository(db) + postVersionRepo := repository.NewPostVersionRepository(db) + postMessageRepo := repository.NewPostAssistantMessageRepository(db) + postAttRepo := repository.NewPostAttachmentRepository(db) + noteSvc := notes.New(repository.NewPostNoteRepository(db)) + auth := handlers.RequireAuth(sessionRepo, "test_session") + + handlers.NewUsersHandler(userRepo, settingRepo, auth).Register(app) + handlers.NewSessionsHandler(userRepo, sessionRepo, "test_session", false).Register(app) + handlers.NewCampaignsHandler(campaignRepo, campaignTypeRepo, auth, nil, nil, nil, nil, nil).Register(app) + handlers.NewPostsHandler(postRepo, postVersionRepo, postMessageRepo, repository.NewPlatformRepository(db), postAttRepo, auth, nil, nil).Register(app) + handlers.NewPostNotesHandler(noteSvc, postRepo, auth).Register(app) + + seedTenantUser(db, "Admin", "notes-it@example.com", "it-password") + + loginBody, _ := json.Marshal(fiber.Map{"email": "notes-it@example.com", "password": "it-password"}) + loginReq := httptest.NewRequest("POST", "/api/sessions", bytes.NewReader(loginBody)) + loginReq.Header.Set("Content-Type", "application/json") + loginResp, err := app.Test(loginReq) + Expect(err).NotTo(HaveOccurred()) + Expect(loginResp.StatusCode).To(Equal(fiber.StatusCreated)) + authCookie = loginResp.Cookies()[0] + + cBody, _ := json.Marshal(fiber.Map{"name": "Notes Campaign", "campaign_type_id": "Uk"}) + cReq := httptest.NewRequest("POST", "/api/campaigns", bytes.NewReader(cBody)) + cReq.Header.Set("Content-Type", "application/json") + cReq.AddCookie(authCookie) + cResp, err := app.Test(cReq) + Expect(err).NotTo(HaveOccurred()) + Expect(cResp.StatusCode).To(Equal(fiber.StatusCreated)) + var camp models.Campaign + Expect(json.NewDecoder(cResp.Body).Decode(&camp)).To(Succeed()) + campaignID = camp.ID + }) + + AfterEach(func() { + ctx := tenantCtx() + _, _ = db.NewDelete().TableExpr("post_notes").Where("1 = 1").Exec(ctx) + _, _ = db.NewDelete().TableExpr("post_attachments").Where("1 = 1").Exec(ctx) + _, _ = db.NewDelete().TableExpr("post_versions").Where("1 = 1").Exec(ctx) + _, _ = db.NewDelete().TableExpr("post_assistant_messages").Where("1 = 1").Exec(ctx) + _, _ = db.NewDelete().TableExpr("posts").Where("1 = 1").Exec(ctx) + _, _ = db.NewDelete().TableExpr("campaigns").Where("1 = 1").Exec(ctx) + _, _ = db.NewDelete().TableExpr("sessions").Where("1 = 1").Exec(ctx) + _, _ = db.NewDelete().TableExpr("users").Where("1 = 1").Exec(ctx) + }) + + createPost := func() string { + body, _ := json.Marshal(fiber.Map{ + "campaign_id": campaignID, + "platform_id": linkedinPlatformID, + "platform_post_type": "article", + "title": "Notes Post", + }) + req := httptest.NewRequest("POST", "/api/posts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(authCookie) + resp, err := app.Test(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(fiber.StatusCreated)) + var p models.Post + Expect(json.NewDecoder(resp.Body).Decode(&p)).To(Succeed()) + return p.ID + } + + createNote := func(postID, typ, title, body string) (*models.PostNote, int) { + payload := fiber.Map{"type": typ, "body": body} + if title != "" { + payload["title"] = title + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/api/posts/"+postID+"/notes", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(authCookie) + resp, err := app.Test(req) + Expect(err).NotTo(HaveOccurred()) + if resp.StatusCode != fiber.StatusCreated { + return nil, resp.StatusCode + } + var n models.PostNote + Expect(json.NewDecoder(resp.Body).Decode(&n)).To(Succeed()) + return &n, resp.StatusCode + } + + It("#1 creates a note and reads it back", func() { + postID := createPost() + n, code := createNote(postID, "note", "Fact-check", "Verify the figure.") + Expect(code).To(Equal(fiber.StatusCreated)) + Expect(n.ID).NotTo(BeEmpty()) + Expect(n.PostID).To(Equal(postID)) + Expect(n.Type).To(Equal(models.PostNoteTypeNote)) + Expect(n.Title).To(Equal("Fact-check")) + Expect(n.Body).To(Equal("Verify the figure.")) + Expect(n.Origin).To(Equal(models.PostNoteOriginManual)) + Expect(n.CreatedBy).NotTo(BeEmpty()) + + req := httptest.NewRequest("GET", "/api/posts/"+postID+"/notes/"+n.ID, nil) + req.AddCookie(authCookie) + resp, err := app.Test(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(fiber.StatusOK)) + var got models.PostNote + Expect(json.NewDecoder(resp.Body).Decode(&got)).To(Succeed()) + Expect(got.ID).To(Equal(n.ID)) + }) + + It("#2 lists notes draft_thesis-first then oldest-first", func() { + postID := createPost() + // Created in an order that proves both rules: a plain note, then an + // image prompt, then a draft_thesis created LAST — it must still sort + // to the top; the other two keep created_at order. + _, c1 := createNote(postID, "note", "", "first note") + Expect(c1).To(Equal(fiber.StatusCreated)) + time.Sleep(5 * time.Millisecond) + _, c2 := createNote(postID, "image_prompt", "", "an image prompt") + Expect(c2).To(Equal(fiber.StatusCreated)) + time.Sleep(5 * time.Millisecond) + _, c3 := createNote(postID, "draft_thesis", "Draft thesis", "- point 1\n- point 2") + Expect(c3).To(Equal(fiber.StatusCreated)) + + req := httptest.NewRequest("GET", "/api/posts/"+postID+"/notes", nil) + req.AddCookie(authCookie) + resp, err := app.Test(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(fiber.StatusOK)) + var list []models.PostNote + Expect(json.NewDecoder(resp.Body).Decode(&list)).To(Succeed()) + Expect(list).To(HaveLen(3)) + Expect(list[0].Type).To(Equal(models.PostNoteTypeDraftThesis)) + Expect(list[1].Body).To(Equal("first note")) + Expect(list[2].Body).To(Equal("an image prompt")) + }) + + It("#3 updates a note (body + type) via PATCH", func() { + postID := createPost() + n, _ := createNote(postID, "note", "", "original") + + patch, _ := json.Marshal(fiber.Map{"body": "updated body", "type": "image_prompt"}) + req := httptest.NewRequest("PATCH", "/api/posts/"+postID+"/notes/"+n.ID, bytes.NewReader(patch)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(authCookie) + resp, err := app.Test(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(fiber.StatusOK)) + var got models.PostNote + Expect(json.NewDecoder(resp.Body).Decode(&got)).To(Succeed()) + Expect(got.Body).To(Equal("updated body")) + Expect(got.Type).To(Equal(models.PostNoteTypeImagePrompt)) + }) + + It("#4 deletes a note; it is then gone", func() { + postID := createPost() + n, _ := createNote(postID, "note", "", "to delete") + + delReq := httptest.NewRequest("DELETE", "/api/posts/"+postID+"/notes/"+n.ID, nil) + delReq.AddCookie(authCookie) + delResp, err := app.Test(delReq) + Expect(err).NotTo(HaveOccurred()) + Expect(delResp.StatusCode).To(Equal(fiber.StatusNoContent)) + + getReq := httptest.NewRequest("GET", "/api/posts/"+postID+"/notes/"+n.ID, nil) + getReq.AddCookie(authCookie) + getResp, err := app.Test(getReq) + Expect(err).NotTo(HaveOccurred()) + Expect(getResp.StatusCode).To(Equal(fiber.StatusNotFound)) + }) + + It("#5 rejects an empty body and an invalid type with 400", func() { + postID := createPost() + _, code := createNote(postID, "note", "title only", "") + Expect(code).To(Equal(fiber.StatusBadRequest)) + _, code = createNote(postID, "nonsense", "", "some body") + Expect(code).To(Equal(fiber.StatusBadRequest)) + }) + + It("#6 does not expose a note through a different post (404)", func() { + postA := createPost() + postB := createPost() + n, _ := createNote(postA, "note", "", "belongs to A") + + req := httptest.NewRequest("GET", "/api/posts/"+postB+"/notes/"+n.ID, nil) + req.AddCookie(authCookie) + resp, err := app.Test(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(fiber.StatusNotFound)) + }) + + It("#7 returns an empty array for a post with no notes", func() { + postID := createPost() + req := httptest.NewRequest("GET", "/api/posts/"+postID+"/notes", nil) + req.AddCookie(authCookie) + resp, err := app.Test(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(fiber.StatusOK)) + var list []models.PostNote + Expect(json.NewDecoder(resp.Body).Decode(&list)).To(Succeed()) + Expect(list).To(BeEmpty()) + }) +}) diff --git a/src/models/post_note.go b/src/models/post_note.go new file mode 100644 index 00000000..62580680 --- /dev/null +++ b/src/models/post_note.go @@ -0,0 +1,80 @@ +package models + +import ( + "time" + + "github.com/uptrace/bun" +) + +// PostNoteType is the closed vocabulary of note kinds (CON-188). The set is +// expected to grow, so it is validated in Go against these consts rather than a +// DB CHECK constraint (mirroring how PostStatus is enforced in app code) — a new +// type is a code-only change, no migration. +type PostNoteType string + +const ( + // PostNoteTypeDraftThesis is the bullet-point thesis the content-plan flow + // captures instead of writing it into the post body (CON-188). Pinned to + // the top of a post's note list. + PostNoteTypeDraftThesis PostNoteType = "draft_thesis" + // PostNoteTypeImagePrompt is an image-generation prompt (e.g. a Nano Banana + // prompt) produced by the post assistant. Stored as text only — it does + // not trigger image generation (that is CON-105, a separate consumer). + PostNoteTypeImagePrompt PostNoteType = "image_prompt" + // PostNoteTypeNote is a free-form side note. + PostNoteTypeNote PostNoteType = "note" +) + +// Valid reports whether t is a known note type. +func (t PostNoteType) Valid() bool { + switch t { + case PostNoteTypeDraftThesis, PostNoteTypeImagePrompt, PostNoteTypeNote: + return true + default: + return false + } +} + +// PostNoteOrigin records how a note came to exist, so the UI can distinguish +// AI-authored notes from hand-written ones (CON-188). CreatedBy always points +// at a real user regardless of origin. +type PostNoteOrigin string + +const ( + // PostNoteOriginManual is a note created by a user through the REST CRUD. + PostNoteOriginManual PostNoteOrigin = "manual" + // PostNoteOriginAssistant is a note created by the post assistant's + // createNote tool. + PostNoteOriginAssistant PostNoteOrigin = "assistant" + // PostNoteOriginContentPlan is a draft_thesis note created by the + // content-plan generation flow. + PostNoteOriginContentPlan PostNoteOrigin = "content_plan" +) + +// Valid reports whether o is a known origin. +func (o PostNoteOrigin) Valid() bool { + switch o { + case PostNoteOriginManual, PostNoteOriginAssistant, PostNoteOriginContentPlan: + return true + default: + return false + } +} + +// PostNote is a small standalone record attached to a Post (CON-188): a draft +// thesis, an image prompt, or a free-form note. Ancillary content lives here +// instead of in the post body. +type PostNote struct { + bun.BaseModel `bun:"table:post_notes,alias:pn" swaggerignore:"true"` + TenantScoped // tenant_id column + central scoping hooks (CON-97) + + ID string `bun:"id,pk" json:"id"` + PostID string `bun:"post_id,notnull" json:"post_id"` + Type PostNoteType `bun:"type,notnull" json:"type"` + Title string `bun:"title,notnull,default:''" json:"title"` + Body string `bun:"body,notnull" json:"body"` + Origin PostNoteOrigin `bun:"origin,notnull,default:'manual'" json:"origin"` + CreatedBy string `bun:"created_by,notnull" json:"created_by"` + CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"` + UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp" json:"updated_at"` +} diff --git a/src/notes/service.go b/src/notes/service.go new file mode 100644 index 00000000..6f525f9d --- /dev/null +++ b/src/notes/service.go @@ -0,0 +1,168 @@ +// Package notes implements the shared "per-post Note" operations (CON-188). +// It is the single source of truth for note writes used by the REST CRUD +// (/api/posts/:post_id/notes) and the Post Assistant's createNote tool, so the +// two entry points can never drift on validation or origin stamping. The +// content-plan flow writes its draft_thesis notes through the repository +// directly (trusted, no user input to validate). +package notes + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/ogen-app/ogen/src/models" + "github.com/ogen-app/ogen/src/repository" +) + +// Length bounds guard the unbounded TEXT columns against abuse; they are +// generous relative to any real note. +const ( + MaxTitleLen = 200 + MaxBodyLen = 50000 +) + +// Validation errors. Callers (the REST handler) map these to HTTP 400. +var ( + ErrEmptyBody = errors.New("note body is required") + ErrInvalidType = errors.New("invalid note type") + ErrTitleTooLong = errors.New("note title is too long") + ErrBodyTooLong = errors.New("note body is too long") +) + +// ErrNotFound is returned by Update when no note matches (unknown id or +// cross-tenant). Handlers map it to 404. +var ErrNotFound = errors.New("note not found") + +// IsValidation reports whether err is one of the note validation errors, so a +// handler can return 400 without enumerating each sentinel. +func IsValidation(err error) bool { + return errors.Is(err, ErrEmptyBody) || + errors.Is(err, ErrInvalidType) || + errors.Is(err, ErrTitleTooLong) || + errors.Is(err, ErrBodyTooLong) +} + +// Service persists notes with shared validation + origin stamping. +type Service struct { + repo repository.PostNoteRepository +} + +// New returns a note service over the given repository. +func New(repo repository.PostNoteRepository) *Service { + return &Service{repo: repo} +} + +// CreateInput is the data needed to create a note. Origin defaults to manual +// when empty. Title/Body are trimmed before validation. +type CreateInput struct { + PostID string + Type models.PostNoteType + Title string + Body string + Origin models.PostNoteOrigin + CreatedBy string +} + +// Create validates the input, stamps id/origin/timestamps, and persists the +// note. The parent-post existence check is left to the caller (the REST handler +// loads the post first; a mid-write post deletion trips the post_id FK). +func (s *Service) Create(ctx context.Context, in CreateInput) (*models.PostNote, error) { + title := strings.TrimSpace(in.Title) + body := strings.TrimSpace(in.Body) + if err := validateFields(in.Type, title, body); err != nil { + return nil, err + } + + origin := in.Origin + if origin == "" { + origin = models.PostNoteOriginManual + } + + id, err := models.NewID() + if err != nil { + return nil, err + } + now := time.Now().UTC() + note := &models.PostNote{ + ID: id, + PostID: in.PostID, + Type: in.Type, + Title: title, + Body: body, + Origin: origin, + CreatedBy: in.CreatedBy, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.Create(ctx, note); err != nil { + return nil, err + } + return note, nil +} + +// UpdateInput carries the partial edits for a note. A nil field is left +// unchanged. +type UpdateInput struct { + Title *string + Body *string + Type *models.PostNoteType +} + +// Update applies the patch to the already-loaded note, validates the result, +// and persists it. Returns the updated note. existing must be a note the caller +// has already fetched and authorized (its post scope checked). +func (s *Service) Update(ctx context.Context, existing *models.PostNote, in UpdateInput) (*models.PostNote, error) { + if in.Title != nil { + existing.Title = strings.TrimSpace(*in.Title) + } + if in.Body != nil { + existing.Body = strings.TrimSpace(*in.Body) + } + if in.Type != nil { + existing.Type = *in.Type + } + if err := validateFields(existing.Type, existing.Title, existing.Body); err != nil { + return nil, err + } + ok, err := s.repo.Update(ctx, existing) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrNotFound + } + return existing, nil +} + +// List returns a post's notes, draft_thesis first then oldest-first (CON-188). +func (s *Service) List(ctx context.Context, postID string) ([]models.PostNote, error) { + return s.repo.ListByPostID(ctx, postID) +} + +// Get returns a single note by id. +func (s *Service) Get(ctx context.Context, id string) (*models.PostNote, error) { + return s.repo.GetByID(ctx, id) +} + +// Delete removes a note by id, reporting whether a row was deleted. +func (s *Service) Delete(ctx context.Context, id string) (bool, error) { + return s.repo.Delete(ctx, id) +} + +func validateFields(t models.PostNoteType, title, body string) error { + if !t.Valid() { + return ErrInvalidType + } + if body == "" { + return ErrEmptyBody + } + if len([]rune(title)) > MaxTitleLen { + return ErrTitleTooLong + } + if len([]rune(body)) > MaxBodyLen { + return ErrBodyTooLong + } + return nil +} diff --git a/src/notes/service_test.go b/src/notes/service_test.go new file mode 100644 index 00000000..16ff0c86 --- /dev/null +++ b/src/notes/service_test.go @@ -0,0 +1,104 @@ +package notes + +import ( + "context" + "errors" + "testing" + + "github.com/ogen-app/ogen/src/models" + "github.com/ogen-app/ogen/src/repository" +) + +// stubRepo embeds the interface so it satisfies the type; only Create is +// implemented for these validation tests. +type stubRepo struct { + repository.PostNoteRepository + created *models.PostNote +} + +func (s *stubRepo) Create(_ context.Context, n *models.PostNote) error { + s.created = n + return nil +} + +func TestCreate_ValidDefaultsOriginAndTrims(t *testing.T) { + repo := &stubRepo{} + svc := New(repo) + + note, err := svc.Create(context.Background(), CreateInput{ + PostID: "post1", + Type: models.PostNoteTypeNote, + Title: " Title ", + Body: " body ", + CreatedBy: "user1", + // Origin intentionally empty → should default to manual. + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if note.ID == "" { + t.Error("expected a non-empty id") + } + if note.Origin != models.PostNoteOriginManual { + t.Errorf("origin = %q, want manual", note.Origin) + } + if note.Title != "Title" || note.Body != "body" { + t.Errorf("title/body not trimmed: %q / %q", note.Title, note.Body) + } + if note.CreatedAt.IsZero() || note.UpdatedAt.IsZero() { + t.Error("expected timestamps to be stamped") + } + if repo.created == nil { + t.Error("expected the note to be persisted") + } +} + +func TestCreate_Validation(t *testing.T) { + svc := New(&stubRepo{}) + + if _, err := svc.Create(context.Background(), CreateInput{PostID: "p", Type: "bogus", Body: "x"}); !errors.Is(err, ErrInvalidType) { + t.Errorf("invalid type: got %v, want ErrInvalidType", err) + } + if _, err := svc.Create(context.Background(), CreateInput{PostID: "p", Type: models.PostNoteTypeNote, Body: " "}); !errors.Is(err, ErrEmptyBody) { + t.Errorf("empty body: got %v, want ErrEmptyBody", err) + } + + if !IsValidation(ErrInvalidType) || !IsValidation(ErrEmptyBody) { + t.Error("IsValidation should recognize the validation sentinels") + } + if IsValidation(errors.New("other")) { + t.Error("IsValidation should not match unrelated errors") + } +} + +func TestUpdate_AppliesPatchAndValidates(t *testing.T) { + repo := &stubRepo{} + // Make Update report a matched row. + svc := New(&updatingStubRepo{stubRepo: repo}) + + existing := &models.PostNote{ID: "n1", PostID: "p1", Type: models.PostNoteTypeNote, Body: "old"} + newBody := "new body" + newType := models.PostNoteTypeImagePrompt + updated, err := svc.Update(context.Background(), existing, UpdateInput{Body: &newBody, Type: &newType}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Body != "new body" || updated.Type != models.PostNoteTypeImagePrompt { + t.Errorf("patch not applied: %+v", updated) + } + + // Patching to an empty body is rejected. + empty := " " + if _, err := svc.Update(context.Background(), existing, UpdateInput{Body: &empty}); !errors.Is(err, ErrEmptyBody) { + t.Errorf("empty body patch: got %v, want ErrEmptyBody", err) + } +} + +// updatingStubRepo reports a matched row on Update so Service.Update succeeds. +type updatingStubRepo struct { + *stubRepo +} + +func (s *updatingStubRepo) Update(_ context.Context, n *models.PostNote) (bool, error) { + return true, nil +} diff --git a/src/repository/post_notes.go b/src/repository/post_notes.go new file mode 100644 index 00000000..48f6de9c --- /dev/null +++ b/src/repository/post_notes.go @@ -0,0 +1,91 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/uptrace/bun" + + "github.com/ogen-app/ogen/src/models" +) + +// PostNoteRepository persists per-post Notes (CON-188). Every query runs through +// bun's Model API so the TenantScoped hooks auto-stamp/scope tenant_id — no +// method has to remember the tenant predicate. +type PostNoteRepository interface { + Create(ctx context.Context, note *models.PostNote) error + ListByPostID(ctx context.Context, postID string) ([]models.PostNote, error) + GetByID(ctx context.Context, id string) (*models.PostNote, error) + // Update writes title/body/type and bumps updated_at. Returns false when + // no row matched (unknown id or cross-tenant). + Update(ctx context.Context, note *models.PostNote) (bool, error) + Delete(ctx context.Context, id string) (bool, error) +} + +type postNoteRepository struct { + db *bun.DB +} + +func NewPostNoteRepository(db *bun.DB) PostNoteRepository { + return &postNoteRepository{db: db} +} + +func (r *postNoteRepository) Create(ctx context.Context, note *models.PostNote) error { + _, err := r.db.NewInsert().Model(note).Exec(ctx) + return err +} + +// ListByPostID returns a post's notes with draft_thesis notes pinned to the top +// (CON-188), everything else following in oldest-first order. +func (r *postNoteRepository) ListByPostID(ctx context.Context, postID string) ([]models.PostNote, error) { + var notes []models.PostNote + err := r.db.NewSelect(). + Model(¬es). + Where("pn.post_id = ?", postID). + OrderExpr("(pn.type = ?) DESC, pn.created_at ASC", string(models.PostNoteTypeDraftThesis)). + Scan(ctx) + if err != nil { + return nil, err + } + return notes, nil +} + +func (r *postNoteRepository) GetByID(ctx context.Context, id string) (*models.PostNote, error) { + note := new(models.PostNote) + err := r.db.NewSelect().Model(note).Where("pn.id = ?", id).Scan(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, err + } + return note, nil +} + +func (r *postNoteRepository) Update(ctx context.Context, note *models.PostNote) (bool, error) { + note.UpdatedAt = time.Now().UTC() + res, err := r.db.NewUpdate(). + Model(note). + Column("title", "body", "type", "updated_at"). + WherePK(). + Exec(ctx) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +func (r *postNoteRepository) Delete(ctx context.Context, id string) (bool, error) { + res, err := r.db.NewDelete(). + Model((*models.PostNote)(nil)). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} diff --git a/src/server/genkit_runtime.go b/src/server/genkit_runtime.go index cb87ceab..85d842f0 100644 --- a/src/server/genkit_runtime.go +++ b/src/server/genkit_runtime.go @@ -21,6 +21,7 @@ import ( "github.com/ogen-app/ogen/src/genkit/flows/post_assistant" "github.com/ogen-app/ogen/src/genkit/flows/post_quality" "github.com/ogen-app/ogen/src/logging" + "github.com/ogen-app/ogen/src/notes" "github.com/ogen-app/ogen/src/post_actions/clone" "github.com/ogen-app/ogen/src/post_actions/restore" "github.com/ogen-app/ogen/src/post_actions/schedule" @@ -72,6 +73,7 @@ type genkitRuntime struct { cloneSvc *clone.Service restoreSvc *restore.Service scheduleSvc *schedule.Service + noteSvc *notes.Service recorder *usage.Recorder checker *usage.Checker } @@ -91,6 +93,7 @@ type genkitDeps struct { cloneSvc *clone.Service restoreSvc *restore.Service scheduleSvc *schedule.Service + noteSvc *notes.Service recorder *usage.Recorder checker *usage.Checker } @@ -115,6 +118,7 @@ func newGenkitRuntime(ctx context.Context, deps genkitDeps, store secrets.Store) cloneSvc: deps.cloneSvc, restoreSvc: deps.restoreSvc, scheduleSvc: deps.scheduleSvc, + noteSvc: deps.noteSvc, recorder: deps.recorder, checker: deps.checker, } @@ -295,7 +299,7 @@ func (r *genkitRuntime) rebuild(ctx context.Context, store secrets.Store) error if err != nil { return fmt.Errorf("init content plan: %w", err) } - postAssistantFn, err := initPostAssistant(g, r.cfg, provider, r.recorder, r.checker, r.embedder, r.hub, r.postAssistRepos, r.cloneSvc, r.restoreSvc, r.scheduleSvc) + postAssistantFn, err := initPostAssistant(g, r.cfg, provider, r.recorder, r.checker, r.embedder, r.hub, r.postAssistRepos, r.cloneSvc, r.restoreSvc, r.scheduleSvc, r.noteSvc) if err != nil { return fmt.Errorf("init post assistant: %w", err) } diff --git a/src/server/post_assistant.go b/src/server/post_assistant.go index 88d8d540..6d1f3d3c 100644 --- a/src/server/post_assistant.go +++ b/src/server/post_assistant.go @@ -10,6 +10,7 @@ import ( "github.com/ogen-app/ogen/src/config" "github.com/ogen-app/ogen/src/eventhub" "github.com/ogen-app/ogen/src/genkit/flows/post_assistant" + "github.com/ogen-app/ogen/src/notes" "github.com/ogen-app/ogen/src/post_actions/clone" "github.com/ogen-app/ogen/src/post_actions/restore" "github.com/ogen-app/ogen/src/post_actions/schedule" @@ -31,6 +32,7 @@ func initPostAssistant( cloneSvc *clone.Service, restoreSvc *restore.Service, scheduleSvc *schedule.Service, + noteSvc *notes.Service, ) (func(ctx context.Context, req post_assistant.PostAssistantRequest, onEvent post_assistant.OnEventFunc) (*post_assistant.PostAssistantResponse, error), error) { flowCfg := post_assistant.PostAssistantFlowConfig{ Provider: provider, @@ -43,6 +45,7 @@ func initPostAssistant( CloneService: cloneSvc, RestoreService: restoreSvc, ScheduleService: scheduleSvc, + NoteService: noteSvc, // CON-112: pre-warm the strict-tool grammar cache at boot when we're also // stabilizing tool order (otherwise the warmed key wouldn't match). PrewarmTools: cfg.AnthropicStableToolOrder, diff --git a/src/server/server.go b/src/server/server.go index a8383e0f..bfaa35af 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -31,6 +31,7 @@ import ( "github.com/ogen-app/ogen/src/jobs" "github.com/ogen-app/ogen/src/jobs/queues" "github.com/ogen-app/ogen/src/logging" + "github.com/ogen-app/ogen/src/notes" "github.com/ogen-app/ogen/src/pdfclient" "github.com/ogen-app/ogen/src/post_actions/clone" "github.com/ogen-app/ogen/src/post_actions/restore" @@ -106,6 +107,8 @@ func New(ctx context.Context, db, analyticsDB *bun.DB, cfg *config.Config, secre // replaces the per-card GET /:id/posts N+1 (CON-127). campaignSummariesSvc := summaries.New(postRepo) postAttachmentRepo := repository.NewPostAttachmentRepository(db) + // CON-188: per-post notes (draft theses, image prompts, side notes). + postNoteRepo := repository.NewPostNoteRepository(db) postLogRepo := repository.NewPostLogRepository(db) postEvaluationRepo := repository.NewPostEvaluationRepository(db) // CON-125 Track B: post analytics snapshots live in the isolated analytics @@ -458,6 +461,9 @@ func New(ctx context.Context, db, analyticsDB *bun.DB, cfg *config.Config, secre id, _, err := zernioRT.Settings.Get(ctx, pubzernio.SettingProfileID) return id, err }) + // CON-188: one note service, shared by the REST CRUD and the assistant's + // createNote tool, so validation + origin stamping never drift. + noteSvc := notes.New(postNoteRepo) gkRuntime, err := newGenkitRuntime(ctx, genkitDeps{ cfg: cfg, @@ -469,6 +475,7 @@ func New(ctx context.Context, db, analyticsDB *bun.DB, cfg *config.Config, secre Chunks: chunksRepo, Platforms: platformRepo, Posts: postRepo, + Notes: postNoteRepo, }, postAssistRepos: post_assistant.PostAssistantRepos{ Posts: postRepo, @@ -481,6 +488,7 @@ func New(ctx context.Context, db, analyticsDB *bun.DB, cfg *config.Config, secre Settings: settingRepo, Allowlist: autoPublishAllowlistRepo, Attachments: postAttachmentRepo, + Notes: postNoteRepo, }, postQualityRepos: post_quality.PostQualityRepos{ Posts: postRepo, @@ -507,6 +515,7 @@ func New(ctx context.Context, db, analyticsDB *bun.DB, cfg *config.Config, secre cloneSvc: cloneSvc, restoreSvc: restoreSvc, scheduleSvc: scheduleSvc, + noteSvc: noteSvc, recorder: usageWiring.recorder, checker: usageWiring.checker, }, secretStore) @@ -600,6 +609,11 @@ func New(ctx context.Context, db, analyticsDB *bun.DB, cfg *config.Config, secre } handlers.NewPostAttachmentsHandler(postAttachmentRepo, postRepo, store, attachmentRenderer, attachmentProber, auth).Register(app) + // CON-188: per-post notes CRUD, nested under a post. + postNotesHandler := handlers.NewPostNotesHandler(noteSvc, postRepo, auth) + postNotesHandler.SetActivityRecorder(activityWiring.recorder) + postNotesHandler.Register(app) + // The React SPA is deployed separately (CON-98) — the API serves only // /api/* (plus SSE). Non-API routes fall through to a 404. return app, nil From 254671562c56ae55690c34e60e5289a6667b1fed Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Thu, 6 Aug 2026 16:33:33 +0300 Subject: [PATCH 2/3] CON-188: address review feedback on Post Notes - content_plan: fix DraftPost.Body schema description to match the prompt and persistOne (a bullet-point thesis stored as a draft_thesis note, not copy) - post_assistant context: bound the aggregate note section (count + rune budget) in repository order so draft_thesis is never the entry dropped; add a boundary/ordering test - post_assistant: invalidate the context cache after createNote persists a note (notes aren't part of the cache fingerprint) - post_assistant prompt: allow action "noted" in the response schema and scope the "don't call tools" note to the asset-retrieval tools so createNote is permitted for ordinary note requests - post_assistant run: guard the notes-only fallback on empty updatedContent so a truncated combined edit-and-note turn keeps its edit --- src/genkit/flows/content_plan/types.go | 2 +- src/genkit/flows/post_assistant/context.go | 37 ++++++++++++++++--- .../post_assistant/post_note_tool_test.go | 32 ++++++++++++++++ .../prompts/post_assistant.tmpl | 6 +-- src/genkit/flows/post_assistant/run.go | 8 +++- src/genkit/flows/post_assistant/tools.go | 3 ++ 6 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/genkit/flows/content_plan/types.go b/src/genkit/flows/content_plan/types.go index ba15c11d..f577e37c 100644 --- a/src/genkit/flows/content_plan/types.go +++ b/src/genkit/flows/content_plan/types.go @@ -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"` diff --git a/src/genkit/flows/post_assistant/context.go b/src/genkit/flows/post_assistant/context.go index 7e2d0ced..1a4d36c0 100644 --- a/src/genkit/flows/post_assistant/context.go +++ b/src/genkit/flows/post_assistant/context.go @@ -110,6 +110,16 @@ func assembleContextCached( return actx, nil } +// invalidateContextCache drops any cached context block for a post so the next +// turn re-reads fresh data. Notes are not part of the cache fingerprint (only +// content/assets/phase are), so a note-only change would otherwise stay unseen +// for up to the TTL — the createNote tool calls this after persisting a note. +func invalidateContextCache(postID string) { + contextCacheMu.Lock() + delete(contextCache, postID) + contextCacheMu.Unlock() +} + func assembleContext( ctx context.Context, post *models.Post, @@ -208,6 +218,15 @@ type contextTemplateData struct { // (≤500 chars); image prompts and side notes are typically short too. const noteBodyPreviewChars = 800 +// maxNotesInContext and maxNotesContextRunes bound the aggregate note section so +// a post with many (or many long) notes can't blow the prompt budget. Notes are +// consumed in repository order (draft_thesis first), so the pinned thesis is +// never the one dropped. +const ( + maxNotesInContext = 20 + maxNotesContextRunes = 4000 +) + // noteSummary is a single note surfaced to the model (CON-188). Body is // included (notes are short) so the assistant can act on the captured thesis or // prompt directly. @@ -232,12 +251,20 @@ func buildNoteSummaries(ctx context.Context, post *models.Post, repos PostAssist return nil, err } out := make([]noteSummary, 0, len(list)) + total := 0 for _, n := range list { - out = append(out, noteSummary{ - Type: string(n.Type), - Title: n.Title, - Body: truncateRunes(n.Body, noteBodyPreviewChars), - }) + if len(out) >= maxNotesInContext { + break + } + body := truncateRunes(n.Body, noteBodyPreviewChars) + bodyRunes := utf8.RuneCountInString(body) + // Always include the first note (draft_thesis); after that, stop once the + // combined body budget would be exceeded. + if len(out) > 0 && total+bodyRunes > maxNotesContextRunes { + break + } + out = append(out, noteSummary{Type: string(n.Type), Title: n.Title, Body: body}) + total += bodyRunes } return out, nil } diff --git a/src/genkit/flows/post_assistant/post_note_tool_test.go b/src/genkit/flows/post_assistant/post_note_tool_test.go index 4786b5bf..0c00239a 100644 --- a/src/genkit/flows/post_assistant/post_note_tool_test.go +++ b/src/genkit/flows/post_assistant/post_note_tool_test.go @@ -2,6 +2,7 @@ package post_assistant import ( "context" + "strings" "testing" "github.com/ogen-app/ogen/src/models" @@ -68,3 +69,34 @@ func TestToolCreateNote_Unavailable(t *testing.T) { t.Fatal("expected an error when the note service is unavailable") } } + +type listNoteRepo struct { + repository.PostNoteRepository + notes []models.PostNote +} + +func (r *listNoteRepo) ListByPostID(_ context.Context, _ string) ([]models.PostNote, error) { + return r.notes, nil +} + +// buildNoteSummaries must bound the aggregate note context (count cap) while +// keeping the repository order — the pinned draft_thesis stays first and is +// never the entry dropped when the limit is hit. +func TestBuildNoteSummaries_LimitAndOrdering(t *testing.T) { + list := []models.PostNote{{Type: models.PostNoteTypeDraftThesis, Body: "the pinned thesis"}} + for i := 0; i < maxNotesInContext+15; i++ { + list = append(list, models.PostNote{Type: models.PostNoteTypeNote, Body: strings.Repeat("x", 10)}) + } + repos := PostAssistantRepos{Notes: &listNoteRepo{notes: list}} + + out, err := buildNoteSummaries(context.Background(), &models.Post{ID: "p1"}, repos) + if err != nil { + t.Fatalf("buildNoteSummaries: %v", err) + } + if len(out) != maxNotesInContext { + t.Errorf("len(out) = %d, want the count cap %d", len(out), maxNotesInContext) + } + if out[0].Type != string(models.PostNoteTypeDraftThesis) || out[0].Body != "the pinned thesis" { + t.Errorf("draft_thesis must stay first, got %+v", out[0]) + } +} diff --git a/src/genkit/flows/post_assistant/prompts/post_assistant.tmpl b/src/genkit/flows/post_assistant/prompts/post_assistant.tmpl index 6268679a..25456020 100644 --- a/src/genkit/flows/post_assistant/prompts/post_assistant.tmpl +++ b/src/genkit/flows/post_assistant/prompts/post_assistant.tmpl @@ -53,7 +53,7 @@ You have access to tools to retrieve asset content when needed: - **schedulePost**: Schedule the post for publishing at a confirmed absolute time (see Schedule mode above). Only call it AFTER the user confirms the resolved time and the auto/manual mode. - **createNote**: Save a side artifact (image prompt or free-form note) attached to the post instead of putting it in the body (see Notes mode above). -**Important**: Only call tools when the user explicitly references assets or asks to incorporate external content. For rephrasing, shortening, expanding, or tone adjustments, work directly from the current post content shown in the context below — do NOT call tools for these tasks. +**Important**: Only call the asset-retrieval tools (listAssets, getAssetChunks, searchAssetChunks) when the user explicitly references assets or asks to incorporate external content. For rephrasing, shortening, expanding, or tone adjustments, work directly from the current post content shown in the context below — do NOT call the asset tools for these tasks. The action tools (clonePost, restoreVersion, schedulePost, createNote) are driven by their own modes above: in particular, call createNote whenever the user asks to save an image prompt or a note, even for an otherwise ordinary request. ## Versioning rules Set saveVersion to true for: @@ -75,8 +75,8 @@ user-visible fields (`explanation`, then `updatedContent`) start streaming before the smaller metadata fields: { "explanation": "edit mode: what you changed. answer mode: your answer to the user's question or the reason you can't help.", - "updatedContent": "edit mode: the full updated post as Markdown. answer mode: empty string.", - "action": "edited" or "declined", + "updatedContent": "edit mode: the full updated post as Markdown. answer/notes mode: empty string (unless you also edited the post in the same turn).", + "action": "edited", "declined", or "noted" (the tool-driven modes above also set cloned / restored / scheduled), "saveVersion": true or false, "versionNote": "short note describing the version (only in edit mode when saveVersion is true)" } diff --git a/src/genkit/flows/post_assistant/run.go b/src/genkit/flows/post_assistant/run.go index 0ba33628..7e8c2a04 100644 --- a/src/genkit/flows/post_assistant/run.go +++ b/src/genkit/flows/post_assistant/run.go @@ -412,9 +412,13 @@ func runPostAssistant( Body: n.Body, }) } - if result.Action != "edited" && st.cloneResult == nil && st.restoreResult == nil && st.scheduleResult == nil { + // Only claim a notes-only turn when there is no edited content. Guarding + // on updatedContent == "" protects a combined edit-and-note turn that was + // truncated before the model emitted action: the trailing switch below + // then infers "edited" from the non-empty content, and the notes still + // attach — we never discard the edit by clearing it here. + if result.Action != "edited" && result.UpdatedContent == "" && st.cloneResult == nil && st.restoreResult == nil && st.scheduleResult == nil { result.Action = "noted" - result.UpdatedContent = "" result.SaveVersion = false } // Ensure a usable explanation so the "no usable fields" guard below diff --git a/src/genkit/flows/post_assistant/tools.go b/src/genkit/flows/post_assistant/tools.go index f63dd460..25e4efc4 100644 --- a/src/genkit/flows/post_assistant/tools.go +++ b/src/genkit/flows/post_assistant/tools.go @@ -486,6 +486,9 @@ func toolCreateNote(ctx context.Context, in CreateNoteInput) (*CreateNoteOutput, return nil, err } st.noteResults = append(st.noteResults, note) + // Notes aren't part of the context-cache fingerprint, so bust the cached + // block for this post — the next turn must see the note just created. + invalidateContextCache(st.postID) emit(st.onEvent, SSEEventNoteCreated, NoteCreatedEventPayload{ ID: note.ID, From 92d1b87589705e0c77ed6c852b57c000b638f323 Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Thu, 6 Aug 2026 16:49:12 +0300 Subject: [PATCH 3/3] CON-188: fix context cache repopulation race on note invalidation Track a per-post generation bumped by invalidateContextCache. Capture it before the unlocked assemble and only write the result back if unchanged, so an invalidation racing an in-flight assembly can't be undone by the stale result repopulating the cache (notes aren't in the fingerprint). --- src/genkit/flows/post_assistant/context.go | 30 +++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/genkit/flows/post_assistant/context.go b/src/genkit/flows/post_assistant/context.go index 1a4d36c0..3aea5ca1 100644 --- a/src/genkit/flows/post_assistant/context.go +++ b/src/genkit/flows/post_assistant/context.go @@ -53,8 +53,15 @@ type contextCacheEntry struct { } var ( - contextCache = map[string]*contextCacheEntry{} - contextCacheMu sync.Mutex + contextCache = map[string]*contextCacheEntry{} + // contextCacheGen tracks a per-post generation number, bumped by every + // invalidateContextCache call. assembleContextCached captures it before the + // (unlocked) assemble and only writes the result back if it is still + // unchanged — otherwise an invalidation that raced an in-flight assembly + // would be silently undone by the stale result repopulating the cache. + // Kept in its own map so the generation survives deletion of the entry. + contextCacheGen = map[string]uint64{} + contextCacheMu sync.Mutex ) // postFingerprint returns a stable string that changes whenever any @@ -92,6 +99,7 @@ func assembleContextCached( } delete(contextCache, post.ID) } + gen := contextCacheGen[post.ID] contextCacheMu.Unlock() actx, err := assembleContext(ctx, post, repos, systemTmpl, contextTmpl) @@ -99,11 +107,17 @@ func assembleContextCached( return nil, err } + // Only cache the result if no invalidation raced this assembly. If the + // generation moved, the underlying data changed (e.g. a note was added) + // after we read it, so actx is already stale — drop it rather than + // repopulate the cache with data an invalidation just cleared. contextCacheMu.Lock() - contextCache[post.ID] = &contextCacheEntry{ - ctx: actx, - fingerprint: fp, - expiresAt: time.Now().Add(contextCacheTTL), + if contextCacheGen[post.ID] == gen { + contextCache[post.ID] = &contextCacheEntry{ + ctx: actx, + fingerprint: fp, + expiresAt: time.Now().Add(contextCacheTTL), + } } contextCacheMu.Unlock() @@ -116,6 +130,10 @@ func assembleContextCached( // for up to the TTL — the createNote tool calls this after persisting a note. func invalidateContextCache(postID string) { contextCacheMu.Lock() + // Bump the generation so any assembly already in flight (which captured the + // old generation before this invalidation) refuses to write its now-stale + // result back into the cache. + contextCacheGen[postID]++ delete(contextCache, postID) contextCacheMu.Unlock() }