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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 25 additions & 23 deletions app/agents/writer_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
@dataclass
class ApprovedPost:
linkedin_content: str
x_content: str
# x_content: str # Uncomment - If added credits on X platform.


class DraftPost(TypedDict):
Expand All @@ -33,15 +33,15 @@ def _draft_post(topic: Topic) -> list[DraftPost]:
prompt = f"""
Role: {CREATOR_PERSONA}

Task: Write an engaging LinkedIn and X (Twitter) post about "{topic.title}".
Task: Write an engaging LinkedIn post about "{topic.title}".
Context: {topic.description}
Include relevant hashtags only in the LinkedIn post; the X post must have no hashtags.
Include relevant hashtags only in the LinkedIn post;

Output Format:
Return ONLY a valid JSON array of 2 objects (LinkedIn and X). Do not include Markdown, code fences, unnecessary emojis, or additional text.
Return ONLY a valid JSON array of 1 object LinkedIn. Do not include Markdown, code fences, unnecessary emojis, or additional text.

Each object must contain:
- "platform": LinkedIn/X
- "platform": LinkedIn
- "post_content": The complete post

The post should read like a human wrote it, in simple English.
Expand All @@ -53,6 +53,7 @@ def _draft_post(topic: Topic) -> list[DraftPost]:
config=types.GenerateContentConfig(
response_mime_type="application/json",
temperature=0.4,
max_output_tokens=2048
),
)

Expand All @@ -65,8 +66,8 @@ def _draft_post(topic: Topic) -> list[DraftPost]:
except json.JSONDecodeError as e:
raise ValueError(f"Failed to parse JSON from the response: {e}") from e

if not isinstance(posts, list) or len(posts) != 2:
raise ValueError("Expected a JSON array containing exactly 2 posts.")
if not isinstance(posts, list) or len(posts) != 1:
raise ValueError("Expected a JSON array containing exactly 1 post.")

validated_posts: list[DraftPost] = []

Expand Down Expand Up @@ -113,36 +114,37 @@ async def generate_post(topic: Topic) -> ApprovedPost | None:
None,
)

x_post = next(
(
p["post_content"]
for p in posts
if p["platform"] == "X"
),
None,
)
# Uncomment - If added credits on X platform.
# x_post = next(
# (
# p["post_content"]
# for p in posts
# if p["platform"] == "X"
# ),
# None,
# )

if linkedin_post is None:
raise ValueError(
"Gemini response does not contain a valid LinkedIn post."
)

if x_post is None:
raise ValueError(
"Gemini response does not contain a valid X post."
)
# Uncomment - If added credits on X platform.
# if x_post is None:
# raise ValueError(
# "Gemini response does not contain a valid X post."
# )

# Add `x_post` this in the below parameter when posting on X as well.
embed = build_post_preview_embed(
topic.title,
linkedin_post,
x_post,
)
)

view = PostApprovalView()
view.message = await send_for_approval(embed, view)

decision = await view.wait_for_decision()

logger.info(
"Reviewer decision for '%s': %s",
topic.title,
Expand All @@ -152,6 +154,6 @@ async def generate_post(topic: Topic) -> ApprovedPost | None:
if decision == ApprovalDecision.RETRY:
continue
if decision == ApprovalDecision.ACCEPTED:
return ApprovedPost(linkedin_content=linkedin_post, x_content=x_post)
return ApprovedPost(linkedin_content=linkedin_post) # Add `x_content=x_post` this in the parameter when posting on X as well.

return None
18 changes: 10 additions & 8 deletions app/database/models/approval.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
from __future__ import annotations

from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime

from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.database.base import Base


class Approval(Base):
__tablename__ = "approvals"

id = Column(Integer, primary_key=True, index=True)
post_id = Column(Integer, ForeignKey("posts.id"), nullable=False)
approver_name = Column(String(255), nullable=False)
approval_status = Column(String(50), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
post_id: Mapped[int] = mapped_column(Integer, ForeignKey("posts.id"), nullable=False)
approver_name: Mapped[str] = mapped_column(String(255), nullable=False)
approval_status: Mapped[str] = mapped_column(String(50), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

post = relationship("Post", back_populates="approvals", uselist=False) # type: ignore

20 changes: 11 additions & 9 deletions app/database/models/post.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
from __future__ import annotations

from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime

from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.database.base import Base


class Post(Base):
__tablename__ = "posts"

id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
topic_id = Column(Integer, ForeignKey("topics.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
topic_id: Mapped[int] = mapped_column(Integer, ForeignKey("topics.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

topic = relationship("Topic", back_populates="posts", uselist=False) # type: ignore
approvals = relationship("Approval", back_populates="post", uselist=True) # type: ignore
publications = relationship("Publication", back_populates="post", uselist=True) # type: ignore
sources = relationship("Source", back_populates="post", uselist=True) # type: ignore
sources = relationship("Source", back_populates="post", uselist=True) # type: ignore

20 changes: 11 additions & 9 deletions app/database/models/publication.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
from __future__ import annotations

from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime

from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.database.base import Base


class Publication(Base):
__tablename__ = "publications"

id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
post_id = Column(Integer, ForeignKey("posts.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
post_id: Mapped[int] = mapped_column(Integer, ForeignKey("posts.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

post = relationship("Post", back_populates="publications", uselist=False) # type: ignore
post = relationship("Post", back_populates="publications", uselist=False) # type: ignore

20 changes: 11 additions & 9 deletions app/database/models/source.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
from __future__ import annotations

from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime

from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.database.base import Base


class Source(Base):
__tablename__ = "sources"

id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
url = Column(String(255), nullable=False)
post_id = Column(Integer, ForeignKey("posts.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
url: Mapped[str] = mapped_column(String(255), nullable=False)
post_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("posts.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

post = relationship("Post", back_populates="sources", uselist=False) # type: ignore
post = relationship("Post", back_populates="sources", uselist=False) # type: ignore

24 changes: 13 additions & 11 deletions app/database/models/topic.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

from sqlalchemy import CheckConstraint, Column, DateTime, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime

from sqlalchemy import CheckConstraint, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.database.base import Base

Expand All @@ -20,12 +21,13 @@ class Topic(Base):
),
)

id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
rank = Column(Integer, nullable=False)
explanation = Column(Text, nullable=True)
status = Column(String(20), nullable=False, default=STATUS_PENDING)
created_at = Column(DateTime(timezone=True), server_default=func.now())
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
rank: Mapped[int] = mapped_column(Integer, nullable=False)
explanation: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default=STATUS_PENDING)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

posts = relationship("Post", back_populates="topic", uselist=True) # type: ignore
posts = relationship("Post", back_populates="topic", uselist=True) # type: ignore

9 changes: 6 additions & 3 deletions app/discord/embeds.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import discord


def build_post_preview_embed(topic_title: str, linkedin_content: str, x_content: str) -> discord.Embed:
# Add `x_content: str` this in the below parameter when posting on X as well.
def build_post_preview_embed(topic_title: str, linkedin_content: str) -> discord.Embed:
embed = discord.Embed(
title=f"New post idea: {topic_title}",
description=linkedin_content[:4096],
color=discord.Color.blurple(),
)

embed.add_field(name="LinkedIn", value=linkedin_content[:1024], inline=False)
embed.add_field(name="X", value=x_content[:1024], inline=False)
# Uncomment below lines if posting on X as well. Also remove line 8.
# embed.add_field(name="LinkedIn", value=linkedin_content[:4096], inline=False)
# embed.add_field(name="X", value=x_content[:1024], inline=False)
return embed
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[tool.mypy]
plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"]
plugins = ["pydantic.mypy"]
8 changes: 4 additions & 4 deletions tests/agents/test_writer_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def __init__(self, title="Caching Strategies", description="description"):

SAMPLE_POSTS = [
{"platform": "LinkedIn", "post_content": "LinkedIn body"},
{"platform": "X", "post_content": "X body"},
# {"platform": "X", "post_content": "X body"}, # Uncomment - If added credits on X platform.
]


Expand All @@ -31,7 +31,7 @@ def test_draft_post_returns_validated_posts(mocker):


def test_draft_post_raises_on_wrong_item_count(mocker):
fake_response = mocker.Mock(text=json.dumps([SAMPLE_POSTS[0]]))
fake_response = mocker.Mock(text=json.dumps(SAMPLE_POSTS + SAMPLE_POSTS))
mock_client = mocker.Mock()
mock_client.models.generate_content.return_value = fake_response
mocker.patch("app.agents.writer_agent.get_client", return_value=mock_client)
Expand All @@ -41,7 +41,7 @@ def test_draft_post_raises_on_wrong_item_count(mocker):


def test_draft_post_raises_on_non_string_platform(mocker):
bad_posts = [{"platform": 123, "post_content": "X"}, SAMPLE_POSTS[1]]
bad_posts = [{"platform": 123, "post_content": "X"}]
fake_response = mocker.Mock(text=json.dumps(bad_posts))
mock_client = mocker.Mock()
mock_client.models.generate_content.return_value = fake_response
Expand All @@ -64,7 +64,7 @@ async def test_generate_post_returns_approved_post_on_accept(mocker):

assert result is not None
assert result.linkedin_content == "LinkedIn body"
assert result.x_content == "X body"
# assert result.x_content == "X body" # Uncomment - If added credits on X platform.


@pytest.mark.asyncio
Expand Down
19 changes: 10 additions & 9 deletions tests/discord/test_embeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@


def test_build_post_preview_embed_contains_platform_content():
embed = build_post_preview_embed("Caching 101", "LinkedIn body", "X body")
embed = build_post_preview_embed("Caching 101", "LinkedIn body with #hashtags") # Add `X body` this in the parameter when posting on X as well.

assert embed.title == "New post idea: Caching 101"
assert embed.fields[0].name == "LinkedIn"
assert embed.fields[0].value == "LinkedIn body"
assert embed.fields[1].name == "X"
assert embed.fields[1].value == "X body"
assert embed.description == "LinkedIn body with #hashtags"

# Uncomment - If added credits on X platform.
# assert embed.fields[1].name == "X"
# assert embed.fields[1].value == "X body"


def test_build_post_preview_embed_truncates_long_content():
long_text = "a" * 2000
long_text = "a" * 5000

embed = build_post_preview_embed("Topic", long_text, long_text)
embed = build_post_preview_embed("Topic", long_text) # Add `long_text` this in the parameter when posting on X as well.

assert len(embed.fields[0].value) == 1024
assert len(embed.fields[1].value) == 1024
assert len(embed.description) == 4096
# assert len(embed.fields[1].value) == 4096 # Uncomment - If added credits on X platform.
Loading