mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(marketing): project-branded drafts + project badges on the X/video queues (#570)
Item B+C of the video/X per-project targeting spec, plus the company_goals.company_name field they depend on (migration 075). - CompanyGoalsService.resolve_product_name is the single fallback chain (project name -> charter company_name -> RoboCo); XEngine and VideoEngine both call it and their prompt builders are pure functions taking product_name — release posts/videos stop hardcoding RoboCo. - The X and video queue responses carry project_slug/project_name via one shared unloaded-guard helper (api/schemas/project_fields.py); both panel queues render a shared ProjectBadge so multi-project drafts are tellable apart. - Business -> Goals editor gains the company-name input. - Fixes a pre-existing test-isolation leak: the company-goals routes test commits the charter singleton into the session-scoped test DB and polluted later suites; it now deletes the row on teardown. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"""Add company_goals.company_name — brands X/video drafting prompts.
|
||||
|
||||
CEO-authored product/company name (mirrors ``brand_voice``, migration 061):
|
||||
feeds ``XEngine``/``VideoEngine``'s product-name resolution as the fallback
|
||||
below a project's own name and above the "RoboCo" literal default. Additive
|
||||
and inert until the CEO sets it in the Business -> Goals editor.
|
||||
|
||||
Revision ID: 075_company_goals_company_name
|
||||
Revises: 074_telegram_credentials
|
||||
Create Date: 2026-07-18
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "075_company_goals_company_name"
|
||||
down_revision = "074_telegram_credentials"
|
||||
branch_labels: dict[str, str] | None = None
|
||||
depends_on: dict[str, str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"company_goals",
|
||||
sa.Column("company_name", sa.Text(), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("company_goals", "company_name")
|
||||
@@ -50,6 +50,7 @@ function buildGoals(overrides: Partial<CompanyGoals> = {}): CompanyGoals {
|
||||
constraints: [],
|
||||
operating_policy: {},
|
||||
brand_voice: "",
|
||||
company_name: "",
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
...overrides,
|
||||
|
||||
@@ -249,6 +249,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
||||
|
||||
const [northStar, setNorthStar] = useState<string | null>(null);
|
||||
const [brandVoice, setBrandVoice] = useState<string | null>(null);
|
||||
const [companyName, setCompanyName] = useState<string | null>(null);
|
||||
const [constraints, setConstraints] = useState<string | null>(null);
|
||||
const [objectives, setObjectives] = useState<
|
||||
Record<string, unknown>[] | null
|
||||
@@ -257,6 +258,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
||||
|
||||
const northStarVal = northStar ?? goals.north_star ?? "";
|
||||
const brandVoiceVal = brandVoice ?? goals.brand_voice ?? "";
|
||||
const companyNameVal = companyName ?? goals.company_name ?? "";
|
||||
const constraintsVal = constraints ?? (goals.constraints ?? []).join("\n");
|
||||
const objectivesVal = objectives ?? goals.objectives ?? [];
|
||||
const policyVal = policy ?? goals.operating_policy ?? {};
|
||||
@@ -267,6 +269,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["company-goals"] });
|
||||
setNorthStar(null);
|
||||
setBrandVoice(null);
|
||||
setCompanyName(null);
|
||||
setConstraints(null);
|
||||
setObjectives(null);
|
||||
setPolicy(null);
|
||||
@@ -281,6 +284,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
||||
saveMutation.mutate({
|
||||
north_star: northStarVal,
|
||||
brand_voice: brandVoiceVal,
|
||||
company_name: companyNameVal,
|
||||
objectives: objectivesVal,
|
||||
constraints: constraintsVal
|
||||
.split("\n")
|
||||
@@ -333,6 +337,20 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Company / product name */}
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="Brands X/video marketing drafts when a project name isn't available — falls back to 'RoboCo' when both are unset">
|
||||
<Label htmlFor="company-name">Company / product name</Label>
|
||||
</HelpTip>
|
||||
<Input
|
||||
id="company-name"
|
||||
value={companyNameVal}
|
||||
disabled={saving}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="RoboCo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Constraints */}
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="Hard rules injected into every agent's briefing (e.g. licensing, no external data egress)">
|
||||
@@ -373,7 +391,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<HelpTip label="Saves north star, brand voice, objectives, constraints, and operating policy in one update">
|
||||
<HelpTip label="Saves north star, brand voice, company/product name, objectives, constraints, and operating policy in one update">
|
||||
<span className="inline-block">
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
|
||||
@@ -141,6 +141,32 @@ describe("VideoPostQueue", () => {
|
||||
expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a project badge when project_slug/project_name is present", async () => {
|
||||
listPosts.mockResolvedValueOnce([
|
||||
{
|
||||
task_id: "v-1",
|
||||
source: "video_post",
|
||||
title: "Video: release v0.19.0",
|
||||
status: "pending",
|
||||
occasion: "release",
|
||||
script: "Acme Robotics v0.19.0 just shipped!",
|
||||
platforms: ["x", "tiktok"],
|
||||
x_caption: "Acme Robotics v0.19.0 is here!",
|
||||
tiktok_caption: "New Acme Robotics drop!",
|
||||
mp4_paths: {
|
||||
vertical: "/fake/vertical.mp4",
|
||||
square: "/fake/square.mp4",
|
||||
},
|
||||
project_slug: "acme-robotics",
|
||||
project_name: "Acme Robotics",
|
||||
},
|
||||
] as VideoPost[]);
|
||||
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
|
||||
expect(await screen.findByText("Acme Robotics")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the fetched title and script instead of dropping them", async () => {
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
expect(
|
||||
|
||||
@@ -93,6 +93,26 @@ describe("XPostQueue", () => {
|
||||
expect(screen.getByText(/Playbook curation/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a project badge when project_slug/project_name is present", async () => {
|
||||
listPosts.mockResolvedValueOnce([
|
||||
{
|
||||
task_id: "x-4",
|
||||
source: "x_post",
|
||||
title: "X post: release v0.18.0",
|
||||
status: "pending",
|
||||
body: "Acme Robotics v0.18.0 just shipped!",
|
||||
char_count: 36,
|
||||
release_version: "0.18.0",
|
||||
project_slug: "acme-robotics",
|
||||
project_name: "Acme Robotics",
|
||||
},
|
||||
] as XPost[]);
|
||||
|
||||
render(withQueryClient(<XPostQueue />));
|
||||
|
||||
expect(await screen.findByText("Acme Robotics")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables only the row being approved, not every row's Approve", async () => {
|
||||
render(withQueryClient(<XPostQueue />));
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
|
||||
/**
|
||||
* Shared project (repository) badge — renders nothing when neither field is
|
||||
* present. Used by both x-post-queue.tsx and video-post-queue.tsx, whose
|
||||
* queue rows are otherwise identical here except for the tooltip wording.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ProjectBadge
|
||||
* slug={post.project_slug}
|
||||
* name={post.project_name}
|
||||
* label="The project (repository) this draft targets"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ProjectBadge({
|
||||
slug,
|
||||
name,
|
||||
label,
|
||||
}: {
|
||||
slug?: string | null;
|
||||
name?: string | null;
|
||||
/** Tooltip text — phrase it per caller ("draft" vs "video"). */
|
||||
label: string;
|
||||
}) {
|
||||
if (!slug && !name) return null;
|
||||
return (
|
||||
<HelpTip label={label}>
|
||||
<Badge variant="outline">{name || slug}</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { ProjectSelector } from "@/components/projects/project-selector";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
|
||||
import { ProjectBadge } from "@/components/dashboard/project-badge";
|
||||
import { CheckCircle2, Film, Sparkles, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -222,6 +223,11 @@ function VideoPostRow({
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
</span>
|
||||
</HelpTip>
|
||||
<ProjectBadge
|
||||
slug={post.project_slug}
|
||||
name={post.project_name}
|
||||
label="The project (repository) this video targets"
|
||||
/>
|
||||
{post.occasion && (
|
||||
<HelpTip label="The occasion/event this video was drafted for">
|
||||
<Badge variant="outline">{post.occasion}</Badge>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { ProjectBadge } from "@/components/dashboard/project-badge";
|
||||
import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -108,6 +109,11 @@ function XPostRow({
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
</span>
|
||||
</HelpTip>
|
||||
<ProjectBadge
|
||||
slug={post.project_slug}
|
||||
name={post.project_name}
|
||||
label="The project (repository) this draft targets"
|
||||
/>
|
||||
{post.release_version && (
|
||||
<HelpTip label="The release this post announces">
|
||||
<Badge variant="outline">v{post.release_version}</Badge>
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface CompanyGoals {
|
||||
constraints: string[];
|
||||
operating_policy: Record<string, unknown>;
|
||||
brand_voice: string;
|
||||
company_name: string;
|
||||
updated_at?: string | null;
|
||||
updated_by?: string | null;
|
||||
}
|
||||
@@ -18,6 +19,7 @@ export type CompanyGoalsUpdate = Partial<
|
||||
| "constraints"
|
||||
| "operating_policy"
|
||||
| "brand_voice"
|
||||
| "company_name"
|
||||
>
|
||||
>;
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface VideoPost {
|
||||
// "not stale" everywhere below, so the re-render control degrades safely.
|
||||
composition_id?: string | null;
|
||||
render_status?: string | null; // null | "rendered" | "failed"
|
||||
project_slug?: string | null;
|
||||
project_name?: string | null;
|
||||
}
|
||||
|
||||
// One in-flight source=video authoring task — GET /video/pipeline
|
||||
@@ -46,6 +48,8 @@ export interface VideoPipelineItem {
|
||||
render_attempts: number;
|
||||
max_attempts: number;
|
||||
render_error: string | null;
|
||||
project_slug?: string | null;
|
||||
project_name?: string | null;
|
||||
}
|
||||
|
||||
export interface VideoPostExecuteResult {
|
||||
@@ -69,6 +73,8 @@ export interface VideoPostHistoryEntry {
|
||||
posted: Record<string, string>; // platform -> posted id
|
||||
acted_at: string;
|
||||
source_task_id?: string | null; // the authoring task this draft rendered from
|
||||
project_slug?: string | null;
|
||||
project_name?: string | null;
|
||||
}
|
||||
|
||||
export interface VideoRequestResult {
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface XPost {
|
||||
mention?: XMentionRef | null;
|
||||
feature?: XFeatureRef | null;
|
||||
reject_reason?: string | null;
|
||||
project_slug?: string | null;
|
||||
project_name?: string | null;
|
||||
}
|
||||
|
||||
export interface XPostExecuteResult {
|
||||
@@ -51,6 +53,8 @@ export interface XPostHistoryEntry {
|
||||
tweet_id?: string | null;
|
||||
reject_reason?: string | null;
|
||||
acted_at: string;
|
||||
project_slug?: string | null;
|
||||
project_name?: string | null;
|
||||
}
|
||||
|
||||
export interface XCredentialsStatus {
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi import APIRouter, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
||||
from roboco.api.schemas.project_fields import task_project_fields
|
||||
from roboco.api.schemas.video import (
|
||||
TikTokCredentialsSetRequest,
|
||||
TikTokCredentialsStatus,
|
||||
@@ -143,6 +144,7 @@ def _status_value(task: TaskTable) -> str:
|
||||
|
||||
def _to_response(task: TaskTable) -> VideoPostResponse:
|
||||
draft = markers.get_video_draft(task) or {}
|
||||
project_slug, project_name = task_project_fields(task)
|
||||
return VideoPostResponse(
|
||||
task_id=str(task.id),
|
||||
source=task.source,
|
||||
@@ -156,6 +158,8 @@ def _to_response(task: TaskTable) -> VideoPostResponse:
|
||||
reject_reason=markers.get_video_reject_reason(task),
|
||||
mp4_paths=dict(draft.get("mp4_paths") or {}),
|
||||
source_task_id=draft.get("source_task_id"),
|
||||
project_slug=project_slug,
|
||||
project_name=project_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -185,6 +189,7 @@ async def list_video_posts(
|
||||
|
||||
def _to_pipeline_item(task: TaskTable) -> VideoPipelineItemResponse:
|
||||
draft = markers.get_video_draft(task) or {}
|
||||
project_slug, project_name = task_project_fields(task)
|
||||
return VideoPipelineItemResponse(
|
||||
task_id=str(task.id),
|
||||
title=task.title,
|
||||
@@ -195,6 +200,8 @@ def _to_pipeline_item(task: TaskTable) -> VideoPipelineItemResponse:
|
||||
render_status=draft.get("render_status"),
|
||||
render_attempts=int(draft.get("render_attempts", 0)),
|
||||
render_error=draft.get("render_error"),
|
||||
project_slug=project_slug,
|
||||
project_name=project_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -300,6 +307,7 @@ def _posted_ids(draft: dict[str, Any]) -> dict[str, str]:
|
||||
|
||||
def _to_history_response(task: TaskTable) -> VideoPostHistoryResponse:
|
||||
draft = markers.get_video_draft(task) or {}
|
||||
project_slug, project_name = task_project_fields(task)
|
||||
return VideoPostHistoryResponse(
|
||||
task_id=str(task.id),
|
||||
source=task.source,
|
||||
@@ -314,6 +322,8 @@ def _to_history_response(task: TaskTable) -> VideoPostHistoryResponse:
|
||||
posted=_posted_ids(draft),
|
||||
acted_at=task.updated_at or task.created_at,
|
||||
source_task_id=draft.get("source_task_id"),
|
||||
project_slug=project_slug,
|
||||
project_name=project_name,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
||||
from roboco.api.schemas.project_fields import task_project_fields
|
||||
from roboco.api.schemas.x import (
|
||||
XCredentialsSetRequest,
|
||||
XCredentialsStatus,
|
||||
@@ -46,6 +47,7 @@ def _to_response(task: "TaskTable") -> XPostResponse:
|
||||
body = markers.get_x_draft_body(task) or task.description or ""
|
||||
mention = markers.get_x_mention_ref(task)
|
||||
feature = markers.get_x_feature_ref(task)
|
||||
project_slug, project_name = task_project_fields(task)
|
||||
return XPostResponse(
|
||||
task_id=str(task.id),
|
||||
source=task.source,
|
||||
@@ -57,6 +59,8 @@ def _to_response(task: "TaskTable") -> XPostResponse:
|
||||
mention=XMentionRefModel(**mention) if mention else None,
|
||||
feature=XFeatureRefModel(**feature) if feature else None,
|
||||
reject_reason=markers.get_x_reject_reason(task),
|
||||
project_slug=project_slug,
|
||||
project_name=project_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -74,6 +78,7 @@ def _to_history_response(task: "TaskTable") -> XPostHistoryResponse:
|
||||
body = markers.get_x_draft_body(task) or task.description or ""
|
||||
mention = markers.get_x_mention_ref(task)
|
||||
feature = markers.get_x_feature_ref(task)
|
||||
project_slug, project_name = task_project_fields(task)
|
||||
return XPostHistoryResponse(
|
||||
task_id=str(task.id),
|
||||
source=task.source,
|
||||
@@ -87,6 +92,8 @@ def _to_history_response(task: "TaskTable") -> XPostHistoryResponse:
|
||||
tweet_id=markers.get_x_posted_tweet_id(task),
|
||||
reject_reason=markers.get_x_reject_reason(task),
|
||||
acted_at=task.updated_at or task.created_at,
|
||||
project_slug=project_slug,
|
||||
project_name=project_name,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ class CompanyGoalsResponse(BaseModel):
|
||||
constraints: list[str]
|
||||
operating_policy: dict[str, Any]
|
||||
brand_voice: str = ""
|
||||
company_name: str = ""
|
||||
updated_at: str | None = None
|
||||
updated_by: str | None = None
|
||||
|
||||
@@ -25,3 +26,4 @@ class CompanyGoalsUpdate(BaseModel):
|
||||
constraints: list[str] | None = Field(default=None)
|
||||
operating_policy: dict[str, Any] | None = Field(default=None)
|
||||
brand_voice: str | None = Field(default=None)
|
||||
company_name: str | None = Field(default=None)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Shared (project_slug, project_name) response-builder helper.
|
||||
|
||||
Both the X and video engine queues surface which project a held draft/video
|
||||
targets. Extracted here so the X and video routes' five response builders call
|
||||
ONE implementation instead of five inline copies — the same
|
||||
``sa_inspect(task).unloaded`` guard convention ``task_to_response`` uses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from roboco.db.tables import TaskTable
|
||||
|
||||
|
||||
def task_project_fields(task: TaskTable) -> tuple[str | None, str | None]:
|
||||
"""(project_slug, project_name), or (None, None).
|
||||
|
||||
A freshly-created task can have an unloaded ``project`` relationship, so a
|
||||
sync attribute access would raise MissingGreenlet — checked via
|
||||
``sa_inspect(task).unloaded`` before ever touching ``task.project``.
|
||||
"""
|
||||
if "project" in sa_inspect(task).unloaded or task.project is None:
|
||||
return None, None
|
||||
return task.project.slug, task.project.name
|
||||
@@ -42,6 +42,8 @@ class VideoPostResponse(BaseModel):
|
||||
reject_reason: str | None = None
|
||||
mp4_paths: dict[str, str] = Field(default_factory=dict)
|
||||
source_task_id: str | None = None # the authoring task this draft rendered from
|
||||
project_slug: str | None = None
|
||||
project_name: str | None = None
|
||||
|
||||
|
||||
class VideoPostApproveRequest(BaseModel):
|
||||
@@ -84,6 +86,8 @@ class VideoPostHistoryResponse(BaseModel):
|
||||
posted: dict[str, str] = Field(default_factory=dict) # platform -> posted id
|
||||
acted_at: datetime
|
||||
source_task_id: str | None = None # the authoring task this draft rendered from
|
||||
project_slug: str | None = None
|
||||
project_name: str | None = None
|
||||
|
||||
|
||||
class VideoPipelineItemResponse(BaseModel):
|
||||
@@ -103,6 +107,8 @@ class VideoPipelineItemResponse(BaseModel):
|
||||
render_attempts: int = 0
|
||||
max_attempts: int = MAX_VIDEO_RENDER_ATTEMPTS
|
||||
render_error: str | None = None
|
||||
project_slug: str | None = None
|
||||
project_name: str | None = None
|
||||
|
||||
|
||||
class TikTokCredentialsStatus(BaseModel):
|
||||
|
||||
@@ -36,6 +36,8 @@ class XPostResponse(BaseModel):
|
||||
mention: XMentionRefModel | None = None
|
||||
feature: XFeatureRefModel | None = None
|
||||
reject_reason: str | None = None
|
||||
project_slug: str | None = None
|
||||
project_name: str | None = None
|
||||
|
||||
|
||||
class XPostApproveRequest(BaseModel):
|
||||
@@ -73,6 +75,8 @@ class XPostHistoryResponse(BaseModel):
|
||||
tweet_id: str | None = None
|
||||
reject_reason: str | None = None
|
||||
acted_at: datetime
|
||||
project_slug: str | None = None
|
||||
project_name: str | None = None
|
||||
|
||||
|
||||
class XCredentialsStatus(BaseModel):
|
||||
|
||||
@@ -1716,6 +1716,11 @@ class CompanyGoalsTable(Base):
|
||||
# JSON blob, so it gets the same discoverability. Feeds XEngine._voice_guide
|
||||
# and the Head of Marketing's briefing; empty until the CEO sets it.
|
||||
brand_voice: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# CEO-authored product/company name — brands X/video drafting prompts
|
||||
# when a project name isn't available (mirrors brand_voice's shape).
|
||||
# Feeds XEngine/VideoEngine's product-name resolution; "RoboCo" is the
|
||||
# final fallback when this is also unset.
|
||||
company_name: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
|
||||
@@ -19,6 +19,8 @@ from roboco.services.base import BaseService
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import ProjectTable
|
||||
|
||||
# Canonical single-row marker — the charter is a singleton.
|
||||
SINGLETON_ID = UUID("00000000-0000-0000-0000-000000000000")
|
||||
|
||||
@@ -28,6 +30,7 @@ _EMPTY: dict[str, Any] = {
|
||||
"constraints": [],
|
||||
"operating_policy": {},
|
||||
"brand_voice": "",
|
||||
"company_name": "",
|
||||
"updated_at": None,
|
||||
"updated_by": None,
|
||||
}
|
||||
@@ -64,11 +67,28 @@ class CompanyGoalsService(BaseService):
|
||||
row.operating_policy = data["operating_policy"]
|
||||
if "brand_voice" in data:
|
||||
row.brand_voice = data["brand_voice"]
|
||||
if "company_name" in data:
|
||||
row.company_name = data["company_name"]
|
||||
if updated_by is not None:
|
||||
row.updated_by = updated_by
|
||||
await self.session.flush()
|
||||
return self._to_dict(row)
|
||||
|
||||
async def resolve_product_name(self, project: ProjectTable | None) -> str:
|
||||
"""The shared product-name fallback chain: ``project``'s own name,
|
||||
else this charter's ``company_name``, else the "RoboCo" literal.
|
||||
|
||||
The single source of this resolution — XEngine and VideoEngine both
|
||||
call it (rather than each keeping its own copy) so a drafted post/
|
||||
video is never unbranded when neither identity source is set, and the
|
||||
fallback order can't drift between the two callers.
|
||||
"""
|
||||
if project is not None and project.name:
|
||||
return project.name
|
||||
charter = await self.get()
|
||||
company_name = (charter.get("company_name") or "").strip()
|
||||
return company_name or "RoboCo"
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(row: CompanyGoalsTable) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -77,6 +97,7 @@ class CompanyGoalsService(BaseService):
|
||||
"constraints": row.constraints or [],
|
||||
"operating_policy": row.operating_policy or {},
|
||||
"brand_voice": row.brand_voice or "",
|
||||
"company_name": row.company_name or "",
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"updated_by": str(row.updated_by) if row.updated_by else None,
|
||||
}
|
||||
|
||||
@@ -129,30 +129,32 @@ def _first_changelog_bullet(changelog: str) -> str:
|
||||
return highlights[0] if highlights else ""
|
||||
|
||||
|
||||
def _fallback_release_script(version: str, changelog: str) -> str:
|
||||
def _fallback_release_script(version: str, changelog: str, product_name: str) -> str:
|
||||
highlight = _first_changelog_bullet(changelog)
|
||||
lead = f": {highlight}" if highlight else ""
|
||||
return f"RoboCo v{version} just shipped{lead}."
|
||||
return f"{product_name} v{version} just shipped{lead}."
|
||||
|
||||
|
||||
def _release_video_prompt(version: str, changelog: str) -> str:
|
||||
def _release_video_prompt(version: str, changelog: str, product_name: str) -> str:
|
||||
return (
|
||||
"You are RoboCo's marketing team, writing a short voiceover script "
|
||||
"for a bespoke motion-graphics video announcing a release. Plain "
|
||||
"text, 2-3 short sentences, energetic but factual — no invented "
|
||||
"facts.\n\n"
|
||||
f"Write the script for RoboCo v{version}, based on this CHANGELOG "
|
||||
f"entry:\n{changelog[:1000]}\n"
|
||||
f"You are {product_name}'s marketing team, writing a short voiceover "
|
||||
"script for a bespoke motion-graphics video announcing a release. "
|
||||
"Plain text, 2-3 short sentences, energetic but factual — no "
|
||||
"invented facts.\n\n"
|
||||
f"Write the script for {product_name} v{version}, based on this "
|
||||
f"CHANGELOG entry:\n{changelog[:1000]}\n"
|
||||
)
|
||||
|
||||
|
||||
def _release_video_brief(version: str, changelog: str, highlights: list[str]) -> str:
|
||||
def _release_video_brief(
|
||||
version: str, changelog: str, highlights: list[str], product_name: str
|
||||
) -> str:
|
||||
"""The structured release brief: the (capped) CHANGELOG section for this
|
||||
version plus its highlights list — replaces the old one-liner-as-
|
||||
description. The LLM script stays a separate ``script`` prop suggestion,
|
||||
never the whole brief."""
|
||||
section = changelog[:_CHANGELOG_BRIEF_CHARS].strip() or "(no changelog entry)"
|
||||
parts = [f"RoboCo v{version} release notes:", section]
|
||||
parts = [f"{product_name} v{version} release notes:", section]
|
||||
if highlights:
|
||||
bullets = "\n".join(f"- {h}" for h in highlights)
|
||||
parts.append(f"Highlights:\n{bullets}")
|
||||
@@ -455,9 +457,17 @@ class VideoEngine(BaseService):
|
||||
"""
|
||||
if not (settings.video_engine_enabled and settings.video_on_release):
|
||||
return None
|
||||
script = await self._draft_release_script(version, changelog)
|
||||
project = (
|
||||
await get_project_service(self.session).get(project_id)
|
||||
if project_id is not None
|
||||
else None
|
||||
)
|
||||
product_name = await get_company_goals_service(
|
||||
self.session
|
||||
).resolve_product_name(project)
|
||||
script = await self._draft_release_script(version, changelog, product_name)
|
||||
highlights = _changelog_highlights(changelog)
|
||||
brief = _release_video_brief(version, changelog, highlights)
|
||||
brief = _release_video_brief(version, changelog, highlights, product_name)
|
||||
return await self.open_video_task(
|
||||
occasion=f"release {version}",
|
||||
script=script,
|
||||
@@ -467,16 +477,20 @@ class VideoEngine(BaseService):
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
async def _draft_release_script(self, version: str, changelog: str) -> str:
|
||||
async def _draft_release_script(
|
||||
self, version: str, changelog: str, product_name: str
|
||||
) -> str:
|
||||
try:
|
||||
draft = await _chat(_release_video_prompt(version, changelog))
|
||||
draft = await _chat(_release_video_prompt(version, changelog, product_name))
|
||||
except Exception as exc:
|
||||
self.log.warning(
|
||||
"video-engine: local-model script draft failed (fallback template)",
|
||||
error=str(exc),
|
||||
)
|
||||
draft = None
|
||||
return (draft or "").strip() or _fallback_release_script(version, changelog)
|
||||
return (draft or "").strip() or _fallback_release_script(
|
||||
version, changelog, product_name
|
||||
)
|
||||
|
||||
# ---- held draft (materialized once a render pass produces MP4s) -------
|
||||
|
||||
|
||||
+38
-18
@@ -78,11 +78,14 @@ if TYPE_CHECKING:
|
||||
_CHAT_TIMEOUT_SECONDS = 60.0
|
||||
_MIN_MENTION_CHARS = 3
|
||||
|
||||
_HOM_VOICE = (
|
||||
"You are RoboCo's Head of Marketing, posting on the company's X (Twitter) "
|
||||
"account. Confident and concise, no emoji spam, no hashtags unless truly "
|
||||
"apt. Speak as 'we'. Plain text only, no markdown, no thread — one post."
|
||||
)
|
||||
|
||||
def _hom_voice(product_name: str) -> str:
|
||||
return (
|
||||
f"You are {product_name}'s Head of Marketing, posting on the company's "
|
||||
"X (Twitter) account. Confident and concise, no emoji spam, no "
|
||||
"hashtags unless truly apt. Speak as 'we'. Plain text only, no "
|
||||
"markdown, no thread — one post."
|
||||
)
|
||||
|
||||
|
||||
def _clamp_tweet(text: str) -> str:
|
||||
@@ -93,16 +96,20 @@ def _clamp_tweet(text: str) -> str:
|
||||
return collapsed[: MAX_TWEET_CHARS - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _fallback_release_body(version: str, highlights: list[str]) -> str:
|
||||
def _fallback_release_body(
|
||||
version: str, highlights: list[str], product_name: str
|
||||
) -> str:
|
||||
lead = highlights[0] if highlights else "assorted improvements"
|
||||
return f"RoboCo v{version} is out: {lead}"
|
||||
return f"{product_name} v{version} is out: {lead}"
|
||||
|
||||
|
||||
def _release_prompt(version: str, highlights: list[str], voice: str) -> str:
|
||||
def _release_prompt(
|
||||
version: str, highlights: list[str], voice: str, product_name: str
|
||||
) -> str:
|
||||
bullets = "\n".join(f"- {h}" for h in highlights[:5]) or "- routine improvements"
|
||||
return (
|
||||
f"{voice}\n\n"
|
||||
f"Draft ONE tweet (max 280 characters) announcing that RoboCo "
|
||||
f"Draft ONE tweet (max 280 characters) announcing that {product_name} "
|
||||
f"v{version} just shipped. Lead with the most user-visible change.\n\n"
|
||||
f"Highlights:\n{bullets}\n"
|
||||
)
|
||||
@@ -249,7 +256,7 @@ class XEngine(BaseService):
|
||||
return await get_project_service(self.session).get(project_id)
|
||||
return await self._roboco_project()
|
||||
|
||||
async def _voice_guide(self) -> str:
|
||||
async def _voice_guide(self, product_name: str) -> str:
|
||||
"""Baseline house style plus the CEO's brand-voice sample, when set.
|
||||
|
||||
The CEO-supplied sample lives in the company charter (``company_goals.
|
||||
@@ -259,10 +266,11 @@ class XEngine(BaseService):
|
||||
"""
|
||||
charter = await get_company_goals_service(self.session).get()
|
||||
brand_voice = (charter.get("brand_voice") or "").strip()
|
||||
hom_voice = _hom_voice(product_name)
|
||||
if not brand_voice:
|
||||
return _HOM_VOICE
|
||||
return hom_voice
|
||||
return (
|
||||
f"{_HOM_VOICE}\n\n"
|
||||
f"{hom_voice}\n\n"
|
||||
f"Additional brand-voice direction from the CEO:\n{brand_voice}"
|
||||
)
|
||||
|
||||
@@ -306,7 +314,10 @@ class XEngine(BaseService):
|
||||
version=version,
|
||||
)
|
||||
return None
|
||||
body = await self._draft_release_body(version, highlights)
|
||||
product_name = await get_company_goals_service(
|
||||
self.session
|
||||
).resolve_product_name(project)
|
||||
body = await self._draft_release_body(version, highlights, product_name)
|
||||
task = await self._originate_post(
|
||||
title=f"X post: release v{version}",
|
||||
body=body,
|
||||
@@ -318,17 +329,23 @@ class XEngine(BaseService):
|
||||
self.log.info("x-engine: release post drafted (held for CEO)", version=version)
|
||||
return task
|
||||
|
||||
async def _draft_release_body(self, version: str, highlights: list[str]) -> str:
|
||||
voice = await self._voice_guide()
|
||||
async def _draft_release_body(
|
||||
self, version: str, highlights: list[str], product_name: str
|
||||
) -> str:
|
||||
voice = await self._voice_guide(product_name)
|
||||
try:
|
||||
draft = await _chat(_release_prompt(version, highlights, voice))
|
||||
draft = await _chat(
|
||||
_release_prompt(version, highlights, voice, product_name)
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.warning(
|
||||
"x-engine: local-model draft failed (fallback template)",
|
||||
error=str(exc),
|
||||
)
|
||||
draft = None
|
||||
body = (draft or "").strip() or _fallback_release_body(version, highlights)
|
||||
body = (draft or "").strip() or _fallback_release_body(
|
||||
version, highlights, product_name
|
||||
)
|
||||
return _clamp_tweet(body)
|
||||
|
||||
# ---- mentions (periodic poll) ------------------------------------------
|
||||
@@ -443,7 +460,10 @@ class XEngine(BaseService):
|
||||
return task
|
||||
|
||||
async def _draft_reply_body(self, screened_mention_text: str) -> str:
|
||||
voice = await self._voice_guide()
|
||||
# Reply drafts are always from RoboCo's own X account (company-scoped
|
||||
# by design, unlike a release/spotlight post which can target any
|
||||
# project) — the literal is intentional, not a missed thread.
|
||||
voice = await self._voice_guide("RoboCo")
|
||||
try:
|
||||
draft = await _chat(_reply_prompt(screened_mention_text, voice))
|
||||
except Exception as exc:
|
||||
|
||||
@@ -3,22 +3,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import HTTPException
|
||||
from roboco.api.routes.company_goals import get_company_goals, update_company_goals
|
||||
from roboco.api.schemas.company_goals import CompanyGoalsUpdate
|
||||
from roboco.db.tables import CompanyGoalsTable
|
||||
from roboco.models import AgentRole
|
||||
from roboco.models.permissions import AgentContext
|
||||
from sqlalchemy import delete
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
def _agent(role: AgentRole) -> AgentContext:
|
||||
return AgentContext(agent_id=uuid4(), role=role, team=None)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _cleanup_company_goals(db_session: Any) -> AsyncIterator[None]:
|
||||
yield
|
||||
# update_company_goals() calls db.commit() (real behavior — see the route
|
||||
# docstring), so a CEO update here persists past the per-test rollback
|
||||
# into every later test in this session-scoped DB run. Delete the
|
||||
# singleton row so it reads back to empty defaults for whatever runs
|
||||
# next (mirrors the ceo_client teardown in test_release_routes.py).
|
||||
await db_session.execute(delete(CompanyGoalsTable))
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_charter_to_any_agent(db_session: Any) -> None:
|
||||
resp = await get_company_goals(db_session, _agent(AgentRole.DEVELOPER))
|
||||
@@ -52,6 +70,19 @@ async def test_ceo_can_update_and_persist_brand_voice(db_session: Any) -> None:
|
||||
assert again.brand_voice == "Confident, dry wit."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ceo_can_update_and_persist_company_name(db_session: Any) -> None:
|
||||
ceo = _agent(AgentRole.CEO)
|
||||
resp = await update_company_goals(
|
||||
CompanyGoalsUpdate(company_name="Acme Robotics"), db_session, ceo
|
||||
)
|
||||
assert resp.company_name == "Acme Robotics"
|
||||
# Persisted and readable by a non-CEO agent (round-trips through the
|
||||
# Pydantic response schema, not just the service-layer dict).
|
||||
again = await get_company_goals(db_session, _agent(AgentRole.QA))
|
||||
assert again.company_name == "Acme Robotics"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_ceo_cannot_update() -> None:
|
||||
# The CEO check fires before any DB access, so a dummy session suffices.
|
||||
|
||||
@@ -414,6 +414,7 @@ async def test_list_posts_returns_open_draft(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
task = await _seed_draft(db_session)
|
||||
project = await db_session.get(ProjectTable, task.project_id)
|
||||
resp = await ceo_client.get("/api/video/posts")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
body = resp.json()
|
||||
@@ -425,6 +426,9 @@ async def test_list_posts_returns_open_draft(
|
||||
"square": "/render/out/1-square.mp4",
|
||||
"vertical": "/render/out/1-vertical.mp4",
|
||||
}
|
||||
assert project is not None
|
||||
assert body[0]["project_slug"] == project.slug
|
||||
assert body[0]["project_name"] == project.name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -471,6 +475,7 @@ async def test_pipeline_lists_non_terminal_authoring_task(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
|
||||
project = await db_session.get(ProjectTable, task.project_id)
|
||||
resp = await ceo_client.get("/api/video/pipeline")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
body = resp.json()
|
||||
@@ -481,6 +486,9 @@ async def test_pipeline_lists_non_terminal_authoring_task(
|
||||
assert row["render_attempts"] == 0
|
||||
assert row["max_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
|
||||
assert row["render_error"] is None
|
||||
assert project is not None
|
||||
assert row["project_slug"] == project.slug
|
||||
assert row["project_name"] == project.name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -706,6 +714,7 @@ async def test_history_returns_posted_and_rejected_newest_first(
|
||||
json={"reason": "wrong occasion"},
|
||||
)
|
||||
posted = await _seed_draft(db_session, platforms=["x"])
|
||||
posted_project = await db_session.get(ProjectTable, posted.project_id)
|
||||
creds_svc = get_x_credentials_service(db_session)
|
||||
await creds_svc.set_credentials(
|
||||
api_key="ak", api_secret="as", access_token="at", access_token_secret="ats"
|
||||
@@ -736,6 +745,9 @@ async def test_history_returns_posted_and_rejected_newest_first(
|
||||
posted_row = next(row for row in body if row["task_id"] == str(posted.id))
|
||||
assert posted_row["status"] == "completed"
|
||||
assert posted_row["posted"] == {"x": "xid42"}
|
||||
assert posted_project is not None
|
||||
assert posted_row["project_slug"] == posted_project.slug
|
||||
assert posted_row["project_name"] == posted_project.name
|
||||
rejected_row = next(row for row in body if row["task_id"] == str(rejected.id))
|
||||
assert rejected_row["status"] == "cancelled"
|
||||
assert rejected_row["reject_reason"] == "wrong occasion"
|
||||
|
||||
@@ -132,6 +132,7 @@ async def test_list_posts_returns_open_draft(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
task = await _seed_draft(db_session)
|
||||
project = await db_session.get(ProjectTable, task.project_id)
|
||||
resp = await ceo_client.get("/api/x/posts")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
body = resp.json()
|
||||
@@ -139,6 +140,9 @@ async def test_list_posts_returns_open_draft(
|
||||
assert body[0]["task_id"] == str(task.id)
|
||||
assert body[0]["body"] == "draft body"
|
||||
assert body[0]["release_version"] == "0.17.0"
|
||||
assert project is not None
|
||||
assert body[0]["project_slug"] == project.slug
|
||||
assert body[0]["project_name"] == project.name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -204,6 +208,7 @@ async def test_history_returns_posted_and_rejected_newest_first(
|
||||
f"/api/x/posts/{rejected.id}/reject", json={"reason": "off-brand tone"}
|
||||
)
|
||||
posted = await _seed_draft(db_session)
|
||||
posted_project = await db_session.get(ProjectTable, posted.project_id)
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.x_post_service.build_x_client",
|
||||
@@ -224,6 +229,9 @@ async def test_history_returns_posted_and_rejected_newest_first(
|
||||
posted_row = next(row for row in body if row["task_id"] == str(posted.id))
|
||||
assert posted_row["status"] == "completed"
|
||||
assert posted_row["tweet_id"] == "42"
|
||||
assert posted_project is not None
|
||||
assert posted_row["project_slug"] == posted_project.slug
|
||||
assert posted_row["project_name"] == posted_project.name
|
||||
rejected_row = next(row for row in body if row["task_id"] == str(rejected.id))
|
||||
assert rejected_row["status"] == "cancelled"
|
||||
assert rejected_row["reject_reason"] == "off-brand tone"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""``roboco/api/routes/video.py`` response-builder wiring for project_slug/
|
||||
project_name. The sa_inspect(task).unloaded guard branches themselves are
|
||||
covered once on the shared helper in tests/unit/api/schemas/test_project_fields.py
|
||||
— this only asserts the three builders actually populate the response from it
|
||||
(loaded case; a real ORM task always resolves the "loaded" branch since
|
||||
``project`` is lazy="joined")."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from roboco.api.routes.video import (
|
||||
_to_history_response,
|
||||
_to_pipeline_item,
|
||||
_to_response,
|
||||
)
|
||||
|
||||
|
||||
def _stub_task(*, with_project: bool = False) -> Any:
|
||||
"""A TaskTable stand-in matching the three response builders' reads."""
|
||||
return SimpleNamespace(
|
||||
id="task-1",
|
||||
source="video_post",
|
||||
title="Video post: release 1.0.0",
|
||||
status="pending",
|
||||
pr_number=None,
|
||||
orchestration_markers=None,
|
||||
project=(
|
||||
SimpleNamespace(slug="acme-robotics", name="Acme Robotics")
|
||||
if with_project
|
||||
else None
|
||||
),
|
||||
updated_at=None,
|
||||
created_at="2026-07-18T00:00:00+00:00",
|
||||
)
|
||||
|
||||
|
||||
def _loaded_inspector() -> MagicMock:
|
||||
inspector = MagicMock()
|
||||
inspector.unloaded = set()
|
||||
return inspector
|
||||
|
||||
|
||||
def test_to_response_includes_project_fields_when_loaded() -> None:
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect",
|
||||
return_value=_loaded_inspector(),
|
||||
):
|
||||
resp = _to_response(_stub_task(with_project=True))
|
||||
assert resp.project_slug == "acme-robotics"
|
||||
assert resp.project_name == "Acme Robotics"
|
||||
|
||||
|
||||
def test_to_pipeline_item_includes_project_fields_when_loaded() -> None:
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect",
|
||||
return_value=_loaded_inspector(),
|
||||
):
|
||||
resp = _to_pipeline_item(_stub_task(with_project=True))
|
||||
assert resp.project_slug == "acme-robotics"
|
||||
assert resp.project_name == "Acme Robotics"
|
||||
|
||||
|
||||
def test_to_pipeline_item_omits_project_fields_when_project_unset() -> None:
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect",
|
||||
return_value=_loaded_inspector(),
|
||||
):
|
||||
resp = _to_pipeline_item(_stub_task(with_project=False))
|
||||
assert resp.project_slug is None
|
||||
assert resp.project_name is None
|
||||
|
||||
|
||||
def test_to_history_response_includes_project_fields_when_loaded() -> None:
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect",
|
||||
return_value=_loaded_inspector(),
|
||||
):
|
||||
resp = _to_history_response(_stub_task(with_project=True))
|
||||
assert resp.project_slug == "acme-robotics"
|
||||
assert resp.project_name == "Acme Robotics"
|
||||
|
||||
|
||||
def test_to_history_response_omits_project_fields_when_project_unset() -> None:
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect",
|
||||
return_value=_loaded_inspector(),
|
||||
):
|
||||
resp = _to_history_response(_stub_task(with_project=False))
|
||||
assert resp.project_slug is None
|
||||
assert resp.project_name is None
|
||||
@@ -0,0 +1,79 @@
|
||||
"""``roboco/api/routes/x.py`` response-builder wiring for project_slug/
|
||||
project_name. The sa_inspect(task).unloaded guard branches themselves are
|
||||
covered once on the shared helper in tests/unit/api/schemas/test_project_fields.py
|
||||
— this only asserts _to_response/_to_history_response actually populate
|
||||
the response from it (loaded case; a real ORM task always resolves the
|
||||
"loaded" branch since ``project`` is lazy="joined")."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from roboco.api.routes.x import _to_history_response, _to_response
|
||||
|
||||
|
||||
def _stub_task(*, with_project: bool = False) -> Any:
|
||||
"""A TaskTable stand-in matching _to_response/_to_history_response's reads."""
|
||||
return SimpleNamespace(
|
||||
id="task-1",
|
||||
source="x_post",
|
||||
title="X post: release v1.0.0",
|
||||
status="pending",
|
||||
description="",
|
||||
orchestration_markers=None,
|
||||
project=(
|
||||
SimpleNamespace(slug="acme-robotics", name="Acme Robotics")
|
||||
if with_project
|
||||
else None
|
||||
),
|
||||
updated_at=None,
|
||||
created_at="2026-07-18T00:00:00+00:00",
|
||||
)
|
||||
|
||||
|
||||
def _loaded_inspector() -> MagicMock:
|
||||
inspector = MagicMock()
|
||||
inspector.unloaded = set()
|
||||
return inspector
|
||||
|
||||
|
||||
def test_to_response_includes_project_fields_when_loaded() -> None:
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect",
|
||||
return_value=_loaded_inspector(),
|
||||
):
|
||||
resp = _to_response(_stub_task(with_project=True))
|
||||
assert resp.project_slug == "acme-robotics"
|
||||
assert resp.project_name == "Acme Robotics"
|
||||
|
||||
|
||||
def test_to_response_omits_project_fields_when_project_unset() -> None:
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect",
|
||||
return_value=_loaded_inspector(),
|
||||
):
|
||||
resp = _to_response(_stub_task(with_project=False))
|
||||
assert resp.project_slug is None
|
||||
assert resp.project_name is None
|
||||
|
||||
|
||||
def test_to_history_response_includes_project_fields_when_loaded() -> None:
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect",
|
||||
return_value=_loaded_inspector(),
|
||||
):
|
||||
resp = _to_history_response(_stub_task(with_project=True))
|
||||
assert resp.project_slug == "acme-robotics"
|
||||
assert resp.project_name == "Acme Robotics"
|
||||
|
||||
|
||||
def test_to_history_response_omits_project_fields_when_project_unset() -> None:
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect",
|
||||
return_value=_loaded_inspector(),
|
||||
):
|
||||
resp = _to_history_response(_stub_task(with_project=False))
|
||||
assert resp.project_slug is None
|
||||
assert resp.project_name is None
|
||||
@@ -0,0 +1,52 @@
|
||||
"""``task_project_fields`` — the shared (project_slug, project_name)
|
||||
response-builder helper the X and video routes' five builders all call.
|
||||
Mirrors tests/unit/api/test_schemas_tasks.py's task_to_response project_slug
|
||||
coverage for the same sa_inspect(task).unloaded guard convention."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from roboco.api.schemas.project_fields import task_project_fields
|
||||
|
||||
|
||||
def _stub_task(*, with_project: bool = False) -> Any:
|
||||
return SimpleNamespace(
|
||||
project=(
|
||||
SimpleNamespace(slug="acme-robotics", name="Acme Robotics")
|
||||
if with_project
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_omits_fields_when_project_unloaded() -> None:
|
||||
stub = _stub_task(with_project=False)
|
||||
fake_inspector = MagicMock()
|
||||
fake_inspector.unloaded = {"project"}
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect", return_value=fake_inspector
|
||||
):
|
||||
assert task_project_fields(stub) == (None, None)
|
||||
|
||||
|
||||
def test_omits_fields_when_project_id_unset() -> None:
|
||||
stub = _stub_task(with_project=False)
|
||||
fake_inspector = MagicMock()
|
||||
fake_inspector.unloaded = set() # loaded, but task.project is None
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect", return_value=fake_inspector
|
||||
):
|
||||
assert task_project_fields(stub) == (None, None)
|
||||
|
||||
|
||||
def test_returns_slug_and_name_when_loaded() -> None:
|
||||
stub = _stub_task(with_project=True)
|
||||
fake_inspector = MagicMock()
|
||||
fake_inspector.unloaded = set()
|
||||
with patch(
|
||||
"roboco.api.schemas.project_fields.sa_inspect", return_value=fake_inspector
|
||||
):
|
||||
assert task_project_fields(stub) == ("acme-robotics", "Acme Robotics")
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -29,6 +30,7 @@ async def test_get_returns_empty_defaults_when_unset(db_session: Any) -> None:
|
||||
assert goals["constraints"] == []
|
||||
assert goals["operating_policy"] == {}
|
||||
assert goals["brand_voice"] == ""
|
||||
assert goals["company_name"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -89,3 +91,70 @@ async def test_brand_voice_untouched_by_partial_upsert(db_session: Any) -> None:
|
||||
goals = await svc.get()
|
||||
assert goals["brand_voice"] == "Speak as 'we'."
|
||||
assert goals["north_star"] == "Ship a delightful product"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_company_name_roundtrips(db_session: Any) -> None:
|
||||
svc = get_company_goals_service(db_session)
|
||||
await svc.upsert({"company_name": "Acme Robotics"})
|
||||
goals = await svc.get()
|
||||
assert goals["company_name"] == "Acme Robotics"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_company_name_untouched_by_partial_upsert(db_session: Any) -> None:
|
||||
svc = get_company_goals_service(db_session)
|
||||
await svc.upsert({"company_name": "Acme Robotics"})
|
||||
# Mirrors brand_voice's partial-update contract.
|
||||
await svc.upsert({"north_star": "Ship a delightful product"})
|
||||
goals = await svc.get()
|
||||
assert goals["company_name"] == "Acme Robotics"
|
||||
assert goals["north_star"] == "Ship a delightful product"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# resolve_product_name — the shared fallback chain XEngine and VideoEngine
|
||||
# both brand their drafting prompts with (project name -> company_name ->
|
||||
# "RoboCo"). A SimpleNamespace stands in for a ProjectTable: the method only
|
||||
# reads ``.name``, so a full project/agent seed adds nothing here.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _project_stub(name: str) -> Any:
|
||||
"""A ProjectTable stand-in for resolve_product_name, which only reads
|
||||
``.name`` — returning ``Any`` (not the SimpleNamespace type) so it type-
|
||||
checks against the real ``ProjectTable | None`` parameter with no cast."""
|
||||
return SimpleNamespace(name=name)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_product_name_uses_project_name(db_session: Any) -> None:
|
||||
svc = get_company_goals_service(db_session)
|
||||
name = await svc.resolve_product_name(_project_stub("Acme Robotics"))
|
||||
assert name == "Acme Robotics"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_product_name_falls_back_to_company_name(db_session: Any) -> None:
|
||||
svc = get_company_goals_service(db_session)
|
||||
await svc.upsert({"company_name": "Acme Robotics"})
|
||||
name = await svc.resolve_product_name(None)
|
||||
assert name == "Acme Robotics"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_product_name_ignores_a_project_with_no_name(
|
||||
db_session: Any,
|
||||
) -> None:
|
||||
svc = get_company_goals_service(db_session)
|
||||
await svc.upsert({"company_name": "Acme Robotics"})
|
||||
name = await svc.resolve_product_name(_project_stub(""))
|
||||
assert name == "Acme Robotics"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_product_name_defaults_to_roboco(db_session: Any) -> None:
|
||||
svc = get_company_goals_service(db_session)
|
||||
await svc.upsert({"company_name": ""})
|
||||
name = await svc.resolve_product_name(None)
|
||||
assert name == "RoboCo"
|
||||
|
||||
@@ -675,6 +675,49 @@ async def test_draft_release_video_brief_contains_brand_voice_when_set(
|
||||
assert "Dry wit, never an exclamation point." in draft["brief"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Product-name resolution (release-video prompts brand off the target
|
||||
# project, not a hardcoded "RoboCo" literal). The fallback-chain unit
|
||||
# coverage (project name -> company_name -> "RoboCo") lives on the shared
|
||||
# helper, CompanyGoalsService.resolve_product_name, in
|
||||
# test_company_goals_service.py — this only asserts the end-to-end wiring
|
||||
# through draft_release_video.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_release_video_uses_project_name_when_set(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, video_on_release=True)
|
||||
acme = ProjectTable(
|
||||
name="Acme Robotics",
|
||||
slug="acme-robotics",
|
||||
git_url="https://github.com/x/acme.git",
|
||||
default_branch="master",
|
||||
protected_branches=["master"],
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=SYSTEM_UUID,
|
||||
is_active=True,
|
||||
video_engine_enabled=True,
|
||||
)
|
||||
db_session.add(acme)
|
||||
await db_session.flush()
|
||||
_mock_local_model(monkeypatch, None) # force the deterministic fallback template
|
||||
engine = video_engine_module.VideoEngine(db_session)
|
||||
task = await engine.draft_release_video(
|
||||
version="1.0.0", changelog=_CHANGELOG, project_id=cast("UUID", acme.id)
|
||||
)
|
||||
assert task is not None
|
||||
draft = markers.get_video_draft(task)
|
||||
assert draft is not None
|
||||
assert "Acme Robotics" in draft["script"]
|
||||
assert "RoboCo" not in draft["script"]
|
||||
assert "Acme Robotics" in draft["brief"]
|
||||
assert "RoboCo" not in draft["brief"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# brief enrichment shared by every occasion — brand voice + motion pointer
|
||||
# (spec Task 2: "Feed the video brief")
|
||||
|
||||
@@ -1334,8 +1334,8 @@ async def test_voice_guide_falls_back_when_brand_voice_unset(
|
||||
) -> None:
|
||||
await get_company_goals_service(db_session).upsert({"brand_voice": ""})
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
voice = await engine._voice_guide()
|
||||
assert voice == x_engine_module._HOM_VOICE
|
||||
voice = await engine._voice_guide("RoboCo")
|
||||
assert voice == x_engine_module._hom_voice("RoboCo")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1346,6 +1346,55 @@ async def test_voice_guide_appends_brand_voice_when_set(
|
||||
{"brand_voice": "Dry wit, never an exclamation point."}
|
||||
)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
voice = await engine._voice_guide()
|
||||
assert x_engine_module._HOM_VOICE in voice
|
||||
voice = await engine._voice_guide("RoboCo")
|
||||
assert x_engine_module._hom_voice("RoboCo") in voice
|
||||
assert "Dry wit, never an exclamation point." in voice
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_guide_uses_the_given_product_name(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
voice = await engine._voice_guide("Acme Robotics")
|
||||
assert "Acme Robotics" in voice
|
||||
assert "RoboCo" not in voice
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Product-name resolution (release-post prompts brand off the target project,
|
||||
# not a hardcoded "RoboCo" literal). The fallback-chain unit coverage
|
||||
# (project name -> company_name -> "RoboCo") lives on the shared helper,
|
||||
# CompanyGoalsService.resolve_product_name, in test_company_goals_service.py —
|
||||
# this only asserts the end-to-end wiring through draft_release_post.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_release_post_uses_project_name_when_set(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch)
|
||||
acme = ProjectTable(
|
||||
name="Acme Robotics",
|
||||
slug="acme-robotics",
|
||||
git_url="https://github.com/x/acme.git",
|
||||
default_branch="master",
|
||||
protected_branches=["master"],
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=SYSTEM_UUID,
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(acme)
|
||||
await db_session.flush()
|
||||
_mock_local_model(monkeypatch, None) # force the deterministic fallback template
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
task = await engine.draft_release_post(
|
||||
version=_VERSION, highlights=["feat: x"], project_id=cast("UUID", acme.id)
|
||||
)
|
||||
assert task is not None
|
||||
body = markers.get_x_draft_body(task)
|
||||
assert body is not None
|
||||
assert "Acme Robotics" in body
|
||||
assert "RoboCo" not in body
|
||||
|
||||
Reference in New Issue
Block a user