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: [],
|
constraints: [],
|
||||||
operating_policy: {},
|
operating_policy: {},
|
||||||
brand_voice: "",
|
brand_voice: "",
|
||||||
|
company_name: "",
|
||||||
updated_at: null,
|
updated_at: null,
|
||||||
updated_by: null,
|
updated_by: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
|||||||
|
|
||||||
const [northStar, setNorthStar] = useState<string | null>(null);
|
const [northStar, setNorthStar] = useState<string | null>(null);
|
||||||
const [brandVoice, setBrandVoice] = 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 [constraints, setConstraints] = useState<string | null>(null);
|
||||||
const [objectives, setObjectives] = useState<
|
const [objectives, setObjectives] = useState<
|
||||||
Record<string, unknown>[] | null
|
Record<string, unknown>[] | null
|
||||||
@@ -257,6 +258,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
|||||||
|
|
||||||
const northStarVal = northStar ?? goals.north_star ?? "";
|
const northStarVal = northStar ?? goals.north_star ?? "";
|
||||||
const brandVoiceVal = brandVoice ?? goals.brand_voice ?? "";
|
const brandVoiceVal = brandVoice ?? goals.brand_voice ?? "";
|
||||||
|
const companyNameVal = companyName ?? goals.company_name ?? "";
|
||||||
const constraintsVal = constraints ?? (goals.constraints ?? []).join("\n");
|
const constraintsVal = constraints ?? (goals.constraints ?? []).join("\n");
|
||||||
const objectivesVal = objectives ?? goals.objectives ?? [];
|
const objectivesVal = objectives ?? goals.objectives ?? [];
|
||||||
const policyVal = policy ?? goals.operating_policy ?? {};
|
const policyVal = policy ?? goals.operating_policy ?? {};
|
||||||
@@ -267,6 +269,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ["company-goals"] });
|
void queryClient.invalidateQueries({ queryKey: ["company-goals"] });
|
||||||
setNorthStar(null);
|
setNorthStar(null);
|
||||||
setBrandVoice(null);
|
setBrandVoice(null);
|
||||||
|
setCompanyName(null);
|
||||||
setConstraints(null);
|
setConstraints(null);
|
||||||
setObjectives(null);
|
setObjectives(null);
|
||||||
setPolicy(null);
|
setPolicy(null);
|
||||||
@@ -281,6 +284,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
|||||||
saveMutation.mutate({
|
saveMutation.mutate({
|
||||||
north_star: northStarVal,
|
north_star: northStarVal,
|
||||||
brand_voice: brandVoiceVal,
|
brand_voice: brandVoiceVal,
|
||||||
|
company_name: companyNameVal,
|
||||||
objectives: objectivesVal,
|
objectives: objectivesVal,
|
||||||
constraints: constraintsVal
|
constraints: constraintsVal
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -333,6 +337,20 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Constraints */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<HelpTip label="Hard rules injected into every agent's briefing (e.g. licensing, no external data egress)">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Save */}
|
{/* 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">
|
<span className="inline-block">
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
|||||||
@@ -141,6 +141,32 @@ describe("VideoPostQueue", () => {
|
|||||||
expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument();
|
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 () => {
|
it("shows the fetched title and script instead of dropping them", async () => {
|
||||||
render(withQueryClient(<VideoPostQueue />));
|
render(withQueryClient(<VideoPostQueue />));
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -93,6 +93,26 @@ describe("XPostQueue", () => {
|
|||||||
expect(screen.getByText(/Playbook curation/)).toBeInTheDocument();
|
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 () => {
|
it("disables only the row being approved, not every row's Approve", async () => {
|
||||||
render(withQueryClient(<XPostQueue />));
|
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 { ProjectSelector } from "@/components/projects/project-selector";
|
||||||
import { useProjects } from "@/hooks/use-projects";
|
import { useProjects } from "@/hooks/use-projects";
|
||||||
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
|
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
|
||||||
|
import { ProjectBadge } from "@/components/dashboard/project-badge";
|
||||||
import { CheckCircle2, Film, Sparkles, XCircle } from "lucide-react";
|
import { CheckCircle2, Film, Sparkles, XCircle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -222,6 +223,11 @@ function VideoPostRow({
|
|||||||
<span className="font-medium">{meta.label}</span>
|
<span className="font-medium">{meta.label}</span>
|
||||||
</span>
|
</span>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
|
<ProjectBadge
|
||||||
|
slug={post.project_slug}
|
||||||
|
name={post.project_name}
|
||||||
|
label="The project (repository) this video targets"
|
||||||
|
/>
|
||||||
{post.occasion && (
|
{post.occasion && (
|
||||||
<HelpTip label="The occasion/event this video was drafted for">
|
<HelpTip label="The occasion/event this video was drafted for">
|
||||||
<Badge variant="outline">{post.occasion}</Badge>
|
<Badge variant="outline">{post.occasion}</Badge>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { HelpTip } from "@/components/ui/help-tip";
|
import { HelpTip } from "@/components/ui/help-tip";
|
||||||
|
import { ProjectBadge } from "@/components/dashboard/project-badge";
|
||||||
import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react";
|
import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -108,6 +109,11 @@ function XPostRow({
|
|||||||
<span className="font-medium">{meta.label}</span>
|
<span className="font-medium">{meta.label}</span>
|
||||||
</span>
|
</span>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
|
<ProjectBadge
|
||||||
|
slug={post.project_slug}
|
||||||
|
name={post.project_name}
|
||||||
|
label="The project (repository) this draft targets"
|
||||||
|
/>
|
||||||
{post.release_version && (
|
{post.release_version && (
|
||||||
<HelpTip label="The release this post announces">
|
<HelpTip label="The release this post announces">
|
||||||
<Badge variant="outline">v{post.release_version}</Badge>
|
<Badge variant="outline">v{post.release_version}</Badge>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface CompanyGoals {
|
|||||||
constraints: string[];
|
constraints: string[];
|
||||||
operating_policy: Record<string, unknown>;
|
operating_policy: Record<string, unknown>;
|
||||||
brand_voice: string;
|
brand_voice: string;
|
||||||
|
company_name: string;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
updated_by?: string | null;
|
updated_by?: string | null;
|
||||||
}
|
}
|
||||||
@@ -18,6 +19,7 @@ export type CompanyGoalsUpdate = Partial<
|
|||||||
| "constraints"
|
| "constraints"
|
||||||
| "operating_policy"
|
| "operating_policy"
|
||||||
| "brand_voice"
|
| "brand_voice"
|
||||||
|
| "company_name"
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface VideoPost {
|
|||||||
// "not stale" everywhere below, so the re-render control degrades safely.
|
// "not stale" everywhere below, so the re-render control degrades safely.
|
||||||
composition_id?: string | null;
|
composition_id?: string | null;
|
||||||
render_status?: string | null; // null | "rendered" | "failed"
|
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
|
// One in-flight source=video authoring task — GET /video/pipeline
|
||||||
@@ -46,6 +48,8 @@ export interface VideoPipelineItem {
|
|||||||
render_attempts: number;
|
render_attempts: number;
|
||||||
max_attempts: number;
|
max_attempts: number;
|
||||||
render_error: string | null;
|
render_error: string | null;
|
||||||
|
project_slug?: string | null;
|
||||||
|
project_name?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoPostExecuteResult {
|
export interface VideoPostExecuteResult {
|
||||||
@@ -69,6 +73,8 @@ export interface VideoPostHistoryEntry {
|
|||||||
posted: Record<string, string>; // platform -> posted id
|
posted: Record<string, string>; // platform -> posted id
|
||||||
acted_at: string;
|
acted_at: string;
|
||||||
source_task_id?: string | null; // the authoring task this draft rendered from
|
source_task_id?: string | null; // the authoring task this draft rendered from
|
||||||
|
project_slug?: string | null;
|
||||||
|
project_name?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoRequestResult {
|
export interface VideoRequestResult {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface XPost {
|
|||||||
mention?: XMentionRef | null;
|
mention?: XMentionRef | null;
|
||||||
feature?: XFeatureRef | null;
|
feature?: XFeatureRef | null;
|
||||||
reject_reason?: string | null;
|
reject_reason?: string | null;
|
||||||
|
project_slug?: string | null;
|
||||||
|
project_name?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XPostExecuteResult {
|
export interface XPostExecuteResult {
|
||||||
@@ -51,6 +53,8 @@ export interface XPostHistoryEntry {
|
|||||||
tweet_id?: string | null;
|
tweet_id?: string | null;
|
||||||
reject_reason?: string | null;
|
reject_reason?: string | null;
|
||||||
acted_at: string;
|
acted_at: string;
|
||||||
|
project_slug?: string | null;
|
||||||
|
project_name?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XCredentialsStatus {
|
export interface XCredentialsStatus {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from fastapi import APIRouter, HTTPException, Query, status
|
|||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
|
||||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
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 (
|
from roboco.api.schemas.video import (
|
||||||
TikTokCredentialsSetRequest,
|
TikTokCredentialsSetRequest,
|
||||||
TikTokCredentialsStatus,
|
TikTokCredentialsStatus,
|
||||||
@@ -143,6 +144,7 @@ def _status_value(task: TaskTable) -> str:
|
|||||||
|
|
||||||
def _to_response(task: TaskTable) -> VideoPostResponse:
|
def _to_response(task: TaskTable) -> VideoPostResponse:
|
||||||
draft = markers.get_video_draft(task) or {}
|
draft = markers.get_video_draft(task) or {}
|
||||||
|
project_slug, project_name = task_project_fields(task)
|
||||||
return VideoPostResponse(
|
return VideoPostResponse(
|
||||||
task_id=str(task.id),
|
task_id=str(task.id),
|
||||||
source=task.source,
|
source=task.source,
|
||||||
@@ -156,6 +158,8 @@ def _to_response(task: TaskTable) -> VideoPostResponse:
|
|||||||
reject_reason=markers.get_video_reject_reason(task),
|
reject_reason=markers.get_video_reject_reason(task),
|
||||||
mp4_paths=dict(draft.get("mp4_paths") or {}),
|
mp4_paths=dict(draft.get("mp4_paths") or {}),
|
||||||
source_task_id=draft.get("source_task_id"),
|
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:
|
def _to_pipeline_item(task: TaskTable) -> VideoPipelineItemResponse:
|
||||||
draft = markers.get_video_draft(task) or {}
|
draft = markers.get_video_draft(task) or {}
|
||||||
|
project_slug, project_name = task_project_fields(task)
|
||||||
return VideoPipelineItemResponse(
|
return VideoPipelineItemResponse(
|
||||||
task_id=str(task.id),
|
task_id=str(task.id),
|
||||||
title=task.title,
|
title=task.title,
|
||||||
@@ -195,6 +200,8 @@ def _to_pipeline_item(task: TaskTable) -> VideoPipelineItemResponse:
|
|||||||
render_status=draft.get("render_status"),
|
render_status=draft.get("render_status"),
|
||||||
render_attempts=int(draft.get("render_attempts", 0)),
|
render_attempts=int(draft.get("render_attempts", 0)),
|
||||||
render_error=draft.get("render_error"),
|
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:
|
def _to_history_response(task: TaskTable) -> VideoPostHistoryResponse:
|
||||||
draft = markers.get_video_draft(task) or {}
|
draft = markers.get_video_draft(task) or {}
|
||||||
|
project_slug, project_name = task_project_fields(task)
|
||||||
return VideoPostHistoryResponse(
|
return VideoPostHistoryResponse(
|
||||||
task_id=str(task.id),
|
task_id=str(task.id),
|
||||||
source=task.source,
|
source=task.source,
|
||||||
@@ -314,6 +322,8 @@ def _to_history_response(task: TaskTable) -> VideoPostHistoryResponse:
|
|||||||
posted=_posted_ids(draft),
|
posted=_posted_ids(draft),
|
||||||
acted_at=task.updated_at or task.created_at,
|
acted_at=task.updated_at or task.created_at,
|
||||||
source_task_id=draft.get("source_task_id"),
|
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 fastapi import APIRouter, HTTPException, Query, status
|
||||||
|
|
||||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
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 (
|
from roboco.api.schemas.x import (
|
||||||
XCredentialsSetRequest,
|
XCredentialsSetRequest,
|
||||||
XCredentialsStatus,
|
XCredentialsStatus,
|
||||||
@@ -46,6 +47,7 @@ def _to_response(task: "TaskTable") -> XPostResponse:
|
|||||||
body = markers.get_x_draft_body(task) or task.description or ""
|
body = markers.get_x_draft_body(task) or task.description or ""
|
||||||
mention = markers.get_x_mention_ref(task)
|
mention = markers.get_x_mention_ref(task)
|
||||||
feature = markers.get_x_feature_ref(task)
|
feature = markers.get_x_feature_ref(task)
|
||||||
|
project_slug, project_name = task_project_fields(task)
|
||||||
return XPostResponse(
|
return XPostResponse(
|
||||||
task_id=str(task.id),
|
task_id=str(task.id),
|
||||||
source=task.source,
|
source=task.source,
|
||||||
@@ -57,6 +59,8 @@ def _to_response(task: "TaskTable") -> XPostResponse:
|
|||||||
mention=XMentionRefModel(**mention) if mention else None,
|
mention=XMentionRefModel(**mention) if mention else None,
|
||||||
feature=XFeatureRefModel(**feature) if feature else None,
|
feature=XFeatureRefModel(**feature) if feature else None,
|
||||||
reject_reason=markers.get_x_reject_reason(task),
|
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 ""
|
body = markers.get_x_draft_body(task) or task.description or ""
|
||||||
mention = markers.get_x_mention_ref(task)
|
mention = markers.get_x_mention_ref(task)
|
||||||
feature = markers.get_x_feature_ref(task)
|
feature = markers.get_x_feature_ref(task)
|
||||||
|
project_slug, project_name = task_project_fields(task)
|
||||||
return XPostHistoryResponse(
|
return XPostHistoryResponse(
|
||||||
task_id=str(task.id),
|
task_id=str(task.id),
|
||||||
source=task.source,
|
source=task.source,
|
||||||
@@ -87,6 +92,8 @@ def _to_history_response(task: "TaskTable") -> XPostHistoryResponse:
|
|||||||
tweet_id=markers.get_x_posted_tweet_id(task),
|
tweet_id=markers.get_x_posted_tweet_id(task),
|
||||||
reject_reason=markers.get_x_reject_reason(task),
|
reject_reason=markers.get_x_reject_reason(task),
|
||||||
acted_at=task.updated_at or task.created_at,
|
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]
|
constraints: list[str]
|
||||||
operating_policy: dict[str, Any]
|
operating_policy: dict[str, Any]
|
||||||
brand_voice: str = ""
|
brand_voice: str = ""
|
||||||
|
company_name: str = ""
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
updated_by: str | None = None
|
updated_by: str | None = None
|
||||||
|
|
||||||
@@ -25,3 +26,4 @@ class CompanyGoalsUpdate(BaseModel):
|
|||||||
constraints: list[str] | None = Field(default=None)
|
constraints: list[str] | None = Field(default=None)
|
||||||
operating_policy: dict[str, Any] | None = Field(default=None)
|
operating_policy: dict[str, Any] | None = Field(default=None)
|
||||||
brand_voice: str | 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
|
reject_reason: str | None = None
|
||||||
mp4_paths: dict[str, str] = Field(default_factory=dict)
|
mp4_paths: dict[str, str] = Field(default_factory=dict)
|
||||||
source_task_id: str | None = None # the authoring task this draft rendered from
|
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):
|
class VideoPostApproveRequest(BaseModel):
|
||||||
@@ -84,6 +86,8 @@ class VideoPostHistoryResponse(BaseModel):
|
|||||||
posted: dict[str, str] = Field(default_factory=dict) # platform -> posted id
|
posted: dict[str, str] = Field(default_factory=dict) # platform -> posted id
|
||||||
acted_at: datetime
|
acted_at: datetime
|
||||||
source_task_id: str | None = None # the authoring task this draft rendered from
|
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):
|
class VideoPipelineItemResponse(BaseModel):
|
||||||
@@ -103,6 +107,8 @@ class VideoPipelineItemResponse(BaseModel):
|
|||||||
render_attempts: int = 0
|
render_attempts: int = 0
|
||||||
max_attempts: int = MAX_VIDEO_RENDER_ATTEMPTS
|
max_attempts: int = MAX_VIDEO_RENDER_ATTEMPTS
|
||||||
render_error: str | None = None
|
render_error: str | None = None
|
||||||
|
project_slug: str | None = None
|
||||||
|
project_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TikTokCredentialsStatus(BaseModel):
|
class TikTokCredentialsStatus(BaseModel):
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ class XPostResponse(BaseModel):
|
|||||||
mention: XMentionRefModel | None = None
|
mention: XMentionRefModel | None = None
|
||||||
feature: XFeatureRefModel | None = None
|
feature: XFeatureRefModel | None = None
|
||||||
reject_reason: str | None = None
|
reject_reason: str | None = None
|
||||||
|
project_slug: str | None = None
|
||||||
|
project_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class XPostApproveRequest(BaseModel):
|
class XPostApproveRequest(BaseModel):
|
||||||
@@ -73,6 +75,8 @@ class XPostHistoryResponse(BaseModel):
|
|||||||
tweet_id: str | None = None
|
tweet_id: str | None = None
|
||||||
reject_reason: str | None = None
|
reject_reason: str | None = None
|
||||||
acted_at: datetime
|
acted_at: datetime
|
||||||
|
project_slug: str | None = None
|
||||||
|
project_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class XCredentialsStatus(BaseModel):
|
class XCredentialsStatus(BaseModel):
|
||||||
|
|||||||
@@ -1716,6 +1716,11 @@ class CompanyGoalsTable(Base):
|
|||||||
# JSON blob, so it gets the same discoverability. Feeds XEngine._voice_guide
|
# 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.
|
# and the Head of Marketing's briefing; empty until the CEO sets it.
|
||||||
brand_voice: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
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(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
default=lambda: datetime.now(UTC),
|
default=lambda: datetime.now(UTC),
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from roboco.services.base import BaseService
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from roboco.db.tables import ProjectTable
|
||||||
|
|
||||||
# Canonical single-row marker — the charter is a singleton.
|
# Canonical single-row marker — the charter is a singleton.
|
||||||
SINGLETON_ID = UUID("00000000-0000-0000-0000-000000000000")
|
SINGLETON_ID = UUID("00000000-0000-0000-0000-000000000000")
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ _EMPTY: dict[str, Any] = {
|
|||||||
"constraints": [],
|
"constraints": [],
|
||||||
"operating_policy": {},
|
"operating_policy": {},
|
||||||
"brand_voice": "",
|
"brand_voice": "",
|
||||||
|
"company_name": "",
|
||||||
"updated_at": None,
|
"updated_at": None,
|
||||||
"updated_by": None,
|
"updated_by": None,
|
||||||
}
|
}
|
||||||
@@ -64,11 +67,28 @@ class CompanyGoalsService(BaseService):
|
|||||||
row.operating_policy = data["operating_policy"]
|
row.operating_policy = data["operating_policy"]
|
||||||
if "brand_voice" in data:
|
if "brand_voice" in data:
|
||||||
row.brand_voice = data["brand_voice"]
|
row.brand_voice = data["brand_voice"]
|
||||||
|
if "company_name" in data:
|
||||||
|
row.company_name = data["company_name"]
|
||||||
if updated_by is not None:
|
if updated_by is not None:
|
||||||
row.updated_by = updated_by
|
row.updated_by = updated_by
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return self._to_dict(row)
|
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
|
@staticmethod
|
||||||
def _to_dict(row: CompanyGoalsTable) -> dict[str, Any]:
|
def _to_dict(row: CompanyGoalsTable) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -77,6 +97,7 @@ class CompanyGoalsService(BaseService):
|
|||||||
"constraints": row.constraints or [],
|
"constraints": row.constraints or [],
|
||||||
"operating_policy": row.operating_policy or {},
|
"operating_policy": row.operating_policy or {},
|
||||||
"brand_voice": row.brand_voice 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_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
"updated_by": str(row.updated_by) if row.updated_by 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 ""
|
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)
|
highlight = _first_changelog_bullet(changelog)
|
||||||
lead = f": {highlight}" if highlight else ""
|
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 (
|
return (
|
||||||
"You are RoboCo's marketing team, writing a short voiceover script "
|
f"You are {product_name}'s marketing team, writing a short voiceover "
|
||||||
"for a bespoke motion-graphics video announcing a release. Plain "
|
"script for a bespoke motion-graphics video announcing a release. "
|
||||||
"text, 2-3 short sentences, energetic but factual — no invented "
|
"Plain text, 2-3 short sentences, energetic but factual — no "
|
||||||
"facts.\n\n"
|
"invented facts.\n\n"
|
||||||
f"Write the script for RoboCo v{version}, based on this CHANGELOG "
|
f"Write the script for {product_name} v{version}, based on this "
|
||||||
f"entry:\n{changelog[:1000]}\n"
|
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
|
"""The structured release brief: the (capped) CHANGELOG section for this
|
||||||
version plus its highlights list — replaces the old one-liner-as-
|
version plus its highlights list — replaces the old one-liner-as-
|
||||||
description. The LLM script stays a separate ``script`` prop suggestion,
|
description. The LLM script stays a separate ``script`` prop suggestion,
|
||||||
never the whole brief."""
|
never the whole brief."""
|
||||||
section = changelog[:_CHANGELOG_BRIEF_CHARS].strip() or "(no changelog entry)"
|
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:
|
if highlights:
|
||||||
bullets = "\n".join(f"- {h}" for h in highlights)
|
bullets = "\n".join(f"- {h}" for h in highlights)
|
||||||
parts.append(f"Highlights:\n{bullets}")
|
parts.append(f"Highlights:\n{bullets}")
|
||||||
@@ -455,9 +457,17 @@ class VideoEngine(BaseService):
|
|||||||
"""
|
"""
|
||||||
if not (settings.video_engine_enabled and settings.video_on_release):
|
if not (settings.video_engine_enabled and settings.video_on_release):
|
||||||
return None
|
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)
|
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(
|
return await self.open_video_task(
|
||||||
occasion=f"release {version}",
|
occasion=f"release {version}",
|
||||||
script=script,
|
script=script,
|
||||||
@@ -467,16 +477,20 @@ class VideoEngine(BaseService):
|
|||||||
project_id=project_id,
|
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:
|
try:
|
||||||
draft = await _chat(_release_video_prompt(version, changelog))
|
draft = await _chat(_release_video_prompt(version, changelog, product_name))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"video-engine: local-model script draft failed (fallback template)",
|
"video-engine: local-model script draft failed (fallback template)",
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
)
|
)
|
||||||
draft = None
|
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) -------
|
# ---- held draft (materialized once a render pass produces MP4s) -------
|
||||||
|
|
||||||
|
|||||||
+38
-18
@@ -78,11 +78,14 @@ if TYPE_CHECKING:
|
|||||||
_CHAT_TIMEOUT_SECONDS = 60.0
|
_CHAT_TIMEOUT_SECONDS = 60.0
|
||||||
_MIN_MENTION_CHARS = 3
|
_MIN_MENTION_CHARS = 3
|
||||||
|
|
||||||
_HOM_VOICE = (
|
|
||||||
"You are RoboCo's Head of Marketing, posting on the company's X (Twitter) "
|
def _hom_voice(product_name: str) -> str:
|
||||||
"account. Confident and concise, no emoji spam, no hashtags unless truly "
|
return (
|
||||||
"apt. Speak as 'we'. Plain text only, no markdown, no thread — one post."
|
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:
|
def _clamp_tweet(text: str) -> str:
|
||||||
@@ -93,16 +96,20 @@ def _clamp_tweet(text: str) -> str:
|
|||||||
return collapsed[: MAX_TWEET_CHARS - 1].rstrip() + "…"
|
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"
|
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"
|
bullets = "\n".join(f"- {h}" for h in highlights[:5]) or "- routine improvements"
|
||||||
return (
|
return (
|
||||||
f"{voice}\n\n"
|
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"v{version} just shipped. Lead with the most user-visible change.\n\n"
|
||||||
f"Highlights:\n{bullets}\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 get_project_service(self.session).get(project_id)
|
||||||
return await self._roboco_project()
|
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.
|
"""Baseline house style plus the CEO's brand-voice sample, when set.
|
||||||
|
|
||||||
The CEO-supplied sample lives in the company charter (``company_goals.
|
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()
|
charter = await get_company_goals_service(self.session).get()
|
||||||
brand_voice = (charter.get("brand_voice") or "").strip()
|
brand_voice = (charter.get("brand_voice") or "").strip()
|
||||||
|
hom_voice = _hom_voice(product_name)
|
||||||
if not brand_voice:
|
if not brand_voice:
|
||||||
return _HOM_VOICE
|
return hom_voice
|
||||||
return (
|
return (
|
||||||
f"{_HOM_VOICE}\n\n"
|
f"{hom_voice}\n\n"
|
||||||
f"Additional brand-voice direction from the CEO:\n{brand_voice}"
|
f"Additional brand-voice direction from the CEO:\n{brand_voice}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -306,7 +314,10 @@ class XEngine(BaseService):
|
|||||||
version=version,
|
version=version,
|
||||||
)
|
)
|
||||||
return None
|
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(
|
task = await self._originate_post(
|
||||||
title=f"X post: release v{version}",
|
title=f"X post: release v{version}",
|
||||||
body=body,
|
body=body,
|
||||||
@@ -318,17 +329,23 @@ class XEngine(BaseService):
|
|||||||
self.log.info("x-engine: release post drafted (held for CEO)", version=version)
|
self.log.info("x-engine: release post drafted (held for CEO)", version=version)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
async def _draft_release_body(self, version: str, highlights: list[str]) -> str:
|
async def _draft_release_body(
|
||||||
voice = await self._voice_guide()
|
self, version: str, highlights: list[str], product_name: str
|
||||||
|
) -> str:
|
||||||
|
voice = await self._voice_guide(product_name)
|
||||||
try:
|
try:
|
||||||
draft = await _chat(_release_prompt(version, highlights, voice))
|
draft = await _chat(
|
||||||
|
_release_prompt(version, highlights, voice, product_name)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"x-engine: local-model draft failed (fallback template)",
|
"x-engine: local-model draft failed (fallback template)",
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
)
|
)
|
||||||
draft = None
|
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)
|
return _clamp_tweet(body)
|
||||||
|
|
||||||
# ---- mentions (periodic poll) ------------------------------------------
|
# ---- mentions (periodic poll) ------------------------------------------
|
||||||
@@ -443,7 +460,10 @@ class XEngine(BaseService):
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
async def _draft_reply_body(self, screened_mention_text: str) -> str:
|
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:
|
try:
|
||||||
draft = await _chat(_reply_prompt(screened_mention_text, voice))
|
draft = await _chat(_reply_prompt(screened_mention_text, voice))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -3,22 +3,40 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from roboco.api.routes.company_goals import get_company_goals, update_company_goals
|
from roboco.api.routes.company_goals import get_company_goals, update_company_goals
|
||||||
from roboco.api.schemas.company_goals import CompanyGoalsUpdate
|
from roboco.api.schemas.company_goals import CompanyGoalsUpdate
|
||||||
|
from roboco.db.tables import CompanyGoalsTable
|
||||||
from roboco.models import AgentRole
|
from roboco.models import AgentRole
|
||||||
from roboco.models.permissions import AgentContext
|
from roboco.models.permissions import AgentContext
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
|
||||||
def _agent(role: AgentRole) -> AgentContext:
|
def _agent(role: AgentRole) -> AgentContext:
|
||||||
return AgentContext(agent_id=uuid4(), role=role, team=None)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_get_returns_charter_to_any_agent(db_session: Any) -> None:
|
async def test_get_returns_charter_to_any_agent(db_session: Any) -> None:
|
||||||
resp = await get_company_goals(db_session, _agent(AgentRole.DEVELOPER))
|
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."
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_non_ceo_cannot_update() -> None:
|
async def test_non_ceo_cannot_update() -> None:
|
||||||
# The CEO check fires before any DB access, so a dummy session suffices.
|
# 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
|
db_session: AsyncSession, ceo_client: AsyncClient
|
||||||
) -> None:
|
) -> None:
|
||||||
task = await _seed_draft(db_session)
|
task = await _seed_draft(db_session)
|
||||||
|
project = await db_session.get(ProjectTable, task.project_id)
|
||||||
resp = await ceo_client.get("/api/video/posts")
|
resp = await ceo_client.get("/api/video/posts")
|
||||||
assert resp.status_code == HTTPStatus.OK
|
assert resp.status_code == HTTPStatus.OK
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
@@ -425,6 +426,9 @@ async def test_list_posts_returns_open_draft(
|
|||||||
"square": "/render/out/1-square.mp4",
|
"square": "/render/out/1-square.mp4",
|
||||||
"vertical": "/render/out/1-vertical.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
|
@pytest.mark.asyncio
|
||||||
@@ -471,6 +475,7 @@ async def test_pipeline_lists_non_terminal_authoring_task(
|
|||||||
db_session: AsyncSession, ceo_client: AsyncClient
|
db_session: AsyncSession, ceo_client: AsyncClient
|
||||||
) -> None:
|
) -> None:
|
||||||
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
|
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")
|
resp = await ceo_client.get("/api/video/pipeline")
|
||||||
assert resp.status_code == HTTPStatus.OK
|
assert resp.status_code == HTTPStatus.OK
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
@@ -481,6 +486,9 @@ async def test_pipeline_lists_non_terminal_authoring_task(
|
|||||||
assert row["render_attempts"] == 0
|
assert row["render_attempts"] == 0
|
||||||
assert row["max_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
|
assert row["max_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
|
||||||
assert row["render_error"] is None
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -706,6 +714,7 @@ async def test_history_returns_posted_and_rejected_newest_first(
|
|||||||
json={"reason": "wrong occasion"},
|
json={"reason": "wrong occasion"},
|
||||||
)
|
)
|
||||||
posted = await _seed_draft(db_session, platforms=["x"])
|
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)
|
creds_svc = get_x_credentials_service(db_session)
|
||||||
await creds_svc.set_credentials(
|
await creds_svc.set_credentials(
|
||||||
api_key="ak", api_secret="as", access_token="at", access_token_secret="ats"
|
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))
|
posted_row = next(row for row in body if row["task_id"] == str(posted.id))
|
||||||
assert posted_row["status"] == "completed"
|
assert posted_row["status"] == "completed"
|
||||||
assert posted_row["posted"] == {"x": "xid42"}
|
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))
|
rejected_row = next(row for row in body if row["task_id"] == str(rejected.id))
|
||||||
assert rejected_row["status"] == "cancelled"
|
assert rejected_row["status"] == "cancelled"
|
||||||
assert rejected_row["reject_reason"] == "wrong occasion"
|
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
|
db_session: AsyncSession, ceo_client: AsyncClient
|
||||||
) -> None:
|
) -> None:
|
||||||
task = await _seed_draft(db_session)
|
task = await _seed_draft(db_session)
|
||||||
|
project = await db_session.get(ProjectTable, task.project_id)
|
||||||
resp = await ceo_client.get("/api/x/posts")
|
resp = await ceo_client.get("/api/x/posts")
|
||||||
assert resp.status_code == HTTPStatus.OK
|
assert resp.status_code == HTTPStatus.OK
|
||||||
body = resp.json()
|
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]["task_id"] == str(task.id)
|
||||||
assert body[0]["body"] == "draft body"
|
assert body[0]["body"] == "draft body"
|
||||||
assert body[0]["release_version"] == "0.17.0"
|
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
|
@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"}
|
f"/api/x/posts/{rejected.id}/reject", json={"reason": "off-brand tone"}
|
||||||
)
|
)
|
||||||
posted = await _seed_draft(db_session)
|
posted = await _seed_draft(db_session)
|
||||||
|
posted_project = await db_session.get(ProjectTable, posted.project_id)
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"roboco.services.x_post_service.build_x_client",
|
"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))
|
posted_row = next(row for row in body if row["task_id"] == str(posted.id))
|
||||||
assert posted_row["status"] == "completed"
|
assert posted_row["status"] == "completed"
|
||||||
assert posted_row["tweet_id"] == "42"
|
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))
|
rejected_row = next(row for row in body if row["task_id"] == str(rejected.id))
|
||||||
assert rejected_row["status"] == "cancelled"
|
assert rejected_row["status"] == "cancelled"
|
||||||
assert rejected_row["reject_reason"] == "off-brand tone"
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
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["constraints"] == []
|
||||||
assert goals["operating_policy"] == {}
|
assert goals["operating_policy"] == {}
|
||||||
assert goals["brand_voice"] == ""
|
assert goals["brand_voice"] == ""
|
||||||
|
assert goals["company_name"] == ""
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -89,3 +91,70 @@ async def test_brand_voice_untouched_by_partial_upsert(db_session: Any) -> None:
|
|||||||
goals = await svc.get()
|
goals = await svc.get()
|
||||||
assert goals["brand_voice"] == "Speak as 'we'."
|
assert goals["brand_voice"] == "Speak as 'we'."
|
||||||
assert goals["north_star"] == "Ship a delightful product"
|
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"]
|
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
|
# brief enrichment shared by every occasion — brand voice + motion pointer
|
||||||
# (spec Task 2: "Feed the video brief")
|
# (spec Task 2: "Feed the video brief")
|
||||||
|
|||||||
@@ -1334,8 +1334,8 @@ async def test_voice_guide_falls_back_when_brand_voice_unset(
|
|||||||
) -> None:
|
) -> None:
|
||||||
await get_company_goals_service(db_session).upsert({"brand_voice": ""})
|
await get_company_goals_service(db_session).upsert({"brand_voice": ""})
|
||||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||||
voice = await engine._voice_guide()
|
voice = await engine._voice_guide("RoboCo")
|
||||||
assert voice == x_engine_module._HOM_VOICE
|
assert voice == x_engine_module._hom_voice("RoboCo")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."}
|
{"brand_voice": "Dry wit, never an exclamation point."}
|
||||||
)
|
)
|
||||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||||
voice = await engine._voice_guide()
|
voice = await engine._voice_guide("RoboCo")
|
||||||
assert x_engine_module._HOM_VOICE in voice
|
assert x_engine_module._hom_voice("RoboCo") in voice
|
||||||
assert "Dry wit, never an exclamation point." 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