-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add Answer agent for automatic post answer generation #99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import { stepCountIs, streamText, tool } from "ai" | ||
| import { eq } from "drizzle-orm" | ||
| import { updateTag } from "next/cache" | ||
| import { z } from "zod" | ||
| import { db } from "@/lib/db/client" | ||
| import { posts } from "@/lib/db/schema" | ||
| import { getSiteOrigin } from "@/lib/utils" | ||
| import type { NoAnswerReason, PostAnswer } from "./types" | ||
|
|
||
| export async function runAnswerAgent({ | ||
| postId, | ||
| owner, | ||
| repo, | ||
| postNumber, | ||
| currentAnswer, | ||
| }: { | ||
| postId: string | ||
| owner: string | ||
| repo: string | ||
| postNumber: number | ||
| currentAnswer?: PostAnswer | null | ||
| }) { | ||
| const result: { answer?: PostAnswer } = {} | ||
|
|
||
| const llmsTxtUrl = `${getSiteOrigin()}/${owner}/${repo}/${postNumber}/llms.txt` | ||
|
|
||
| let transcript: string | ||
| try { | ||
| const res = await fetch(llmsTxtUrl) | ||
| if (!res.ok) { | ||
| console.error("Failed to fetch llms.txt:", res.status) | ||
| return | ||
| } | ||
| transcript = await res.text() | ||
| } catch (err) { | ||
| console.error("Failed to fetch llms.txt:", err) | ||
| return | ||
| } | ||
|
|
||
| const currentAnswerContext = currentAnswer | ||
| ? currentAnswer.type === "answer" | ||
| ? `\n\nYour previous answer was:\n${currentAnswer.text}\n\nReview if this is still accurate given any new comments.` | ||
| : `\n\nYou previously determined this was not a question (reason: ${currentAnswer.reason}).` | ||
| : "" | ||
|
|
||
| const systemPrompt = `You are a forum answer assistant. Your job is to analyze a forum post transcript and determine the best answer to the original question (the first comment in the post). | ||
|
|
||
| Rules: | ||
| - Focus on answering the ORIGINAL question (first comment) - the post may have evolved but we want to answer what was initially asked | ||
| - If the post contains a clear answer to the original question (from any participant), use SetAnswer with a concise summary | ||
| - If the post is NOT a question (it's an announcement, discussion, or purely informational), use SetNoAnswer with reason "not-a-question" | ||
| - If the question is unclear or cannot be answered from the available context, use SetNoAnswer with reason "unclear" or "needs-more-context" | ||
| - Be extremely concise in your answer - summarize the key points without unnecessary verbosity | ||
| - Only call ONE tool: either SetAnswer OR SetNoAnswer${currentAnswerContext}` | ||
|
|
||
| const tools: Parameters<typeof streamText>[0]["tools"] = { | ||
| SetAnswer: tool({ | ||
| description: | ||
| "Set the answer to the original question. Use when a clear answer exists in the transcript.", | ||
| inputSchema: z.object({ | ||
| text: z | ||
| .string() | ||
| .describe( | ||
| "Concise answer to the original question, summarizing the key points" | ||
| ), | ||
| }), | ||
| // biome-ignore lint/suspicious/useAwait: . | ||
| execute: async (params) => { | ||
| result.answer = { | ||
| type: "answer", | ||
| text: params.text, | ||
| updatedAt: Date.now(), | ||
| } | ||
| return { ok: true } | ||
| }, | ||
| }), | ||
| SetNoAnswer: tool({ | ||
| description: | ||
| "Indicate that no answer can be provided. Use when the post is not a question or lacks enough context.", | ||
| inputSchema: z.object({ | ||
| reason: z | ||
| .enum(["not-a-question", "unclear", "needs-more-context"]) | ||
| .describe("Why no answer can be provided"), | ||
| }), | ||
| // biome-ignore lint/suspicious/useAwait: . | ||
| execute: async (params) => { | ||
| result.answer = { | ||
| type: "no-answer", | ||
| reason: params.reason as NoAnswerReason, | ||
| updatedAt: Date.now(), | ||
| } | ||
| return { ok: true } | ||
| }, | ||
| }), | ||
| } | ||
|
|
||
| const stream = streamText({ | ||
| model: "anthropic/claude-haiku-4.5", | ||
| system: systemPrompt, | ||
| prompt: `Here's the full post transcript:\n\n${transcript}`, | ||
| tools, | ||
| stopWhen: stepCountIs(3), | ||
| }) | ||
|
|
||
| await stream.finishReason | ||
|
|
||
| if (!result.answer) { | ||
| console.log("Answer agent did not produce an answer") | ||
| return | ||
| } | ||
|
|
||
| await db | ||
| .update(posts) | ||
| .set({ answer: result.answer, updatedAt: Date.now() }) | ||
| .where(eq(posts.id, postId)) | ||
|
|
||
| updateTag(`repo:${owner}:${repo}`) | ||
| updateTag(`post:${postId}`) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import type { PostAnswer } from "@/agent/types" | ||
| import { cn } from "@/lib/utils" | ||
|
|
||
| export function PostAnswerBox({ answer }: { answer: PostAnswer }) { | ||
| if (answer.type !== "answer") { | ||
| return null | ||
| } | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| "relative border-2 border-green-600/40 bg-green-950/20 p-4", | ||
| "before:absolute before:top-0 before:left-0 before:h-full before:w-1 before:bg-green-500" | ||
| )} | ||
| > | ||
| <div className="mb-2 flex items-center gap-2"> | ||
| <span className="font-semibold text-green-400 text-sm uppercase tracking-wide"> | ||
| Answer | ||
| </span> | ||
| </div> | ||
| <div className="prose prose-sm prose-invert max-w-none text-foreground"> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The answer.text is rendered as plain text without markdown parsing, causing markdown formatting like bold, lists, and links to appear as literal text in the UI View Details📝 Patch Detailsdiff --git a/app/[owner]/[repo]/[postNumber]/post-answer.tsx b/app/[owner]/[repo]/[postNumber]/post-answer.tsx
index 94d8374..5ca106b 100644
--- a/app/[owner]/[repo]/[postNumber]/post-answer.tsx
+++ b/app/[owner]/[repo]/[postNumber]/post-answer.tsx
@@ -1,6 +1,55 @@
+"use client"
+
+import type { ComponentProps } from "react"
+import { Streamdown } from "streamdown"
import type { PostAnswer } from "@/agent/types"
import { cn } from "@/lib/utils"
+const streamdownComponents: ComponentProps<typeof Streamdown>["components"] = {
+ h1: (props) => <h1 className="mt-6 mb-2 font-semibold text-lg first:mt-0" {...props} />,
+ h2: (props) => <h2 className="mt-6 mb-2 font-semibold text-base first:mt-0" {...props} />,
+ h3: (props) => <h3 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+ h4: (props) => <h4 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+ h5: (props) => <h5 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+ h6: (props) => <h6 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+ p: (props) => <p className="my-2 leading-relaxed first:mt-0 last:mb-0" {...props} />,
+ a: (props) => (
+ <a
+ className="text-blue-400 underline-offset-2 hover:underline"
+ rel="noopener noreferrer"
+ target="_blank"
+ {...props}
+ />
+ ),
+ strong: (props) => <strong className="font-semibold" {...props} />,
+ em: (props) => <em className="italic" {...props} />,
+ ul: (props) => <ul className="my-2 list-disc space-y-1 pl-4" {...props} />,
+ ol: (props) => <ol className="my-2 list-decimal space-y-1 pl-6" {...props} />,
+ li: (props) => <li {...props} />,
+ blockquote: (props) => (
+ <blockquote className="my-2 border-l-2 border-green-400/30 pl-3 italic opacity-75" {...props} />
+ ),
+ hr: () => <hr className="my-2 border-green-400/20" />,
+ code: (props) => (
+ <code
+ className="break-all bg-green-950/40 px-1 py-0.5 font-mono text-sm"
+ {...props}
+ />
+ ),
+ pre: (props) => {
+ // biome-ignore lint/suspicious/noExplicitAny: .
+ const childProps = (props.children as any).props as {
+ className: string
+ children: string
+ }
+ return (
+ <pre className="my-2 overflow-x-auto bg-green-950/40 p-2 text-sm" {...props}>
+ <code>{childProps.children}</code>
+ </pre>
+ )
+ },
+}
+
export function PostAnswerBox({ answer }: { answer: PostAnswer }) {
if (answer.type !== "answer") {
return null
@@ -18,8 +67,14 @@ export function PostAnswerBox({ answer }: { answer: PostAnswer }) {
Answer
</span>
</div>
- <div className="prose prose-sm prose-invert max-w-none text-foreground">
- {answer.text}
+ <div className="text-foreground">
+ <Streamdown
+ components={streamdownComponents}
+ mode="static"
+ shikiTheme={["github-light", "github-dark"]}
+ >
+ {answer.text}
+ </Streamdown>
</div>
</div>
)
AnalysisWhy This Bug ExistsThe <div className="prose prose-sm prose-invert max-w-none text-foreground">
{answer.text}
</div>The answer agent ( However, when markdown text is rendered as plain JSX children in a div, it appears as literal text. For example, Meanwhile, elsewhere in the codebase ( The FixThe fix implements markdown parsing for the answer box by:
The styling is tailored for the answer box with green accents for consistency with the visual design, but ensures all markdown formatting (bold, lists, links, code blocks, blockquotes, etc.) is properly parsed and displayed. |
||
| {answer.text} | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PostAnswerBox component type signature requires non-null PostAnswer but receives potentially null answer value
View Details
📝 Patch Details
Analysis
Bug Explanation
At line 337 in comment-thread.tsx,
PostAnswerBoxis called with{answer}whereansweris typed asPostAnswer | null | undefined(from line 236:answer?: PostAnswer | null). However, the component's type signature originally declared{ answer: PostAnswer }, which does not accept null values.The guard condition at line 287 is
const showAnswer = isRootComment && answer?.type === "answer". While this logically ensures that whenshowAnsweris true,answermust be valid with type "answer", TypeScript's type narrowing cannot infer this because:showAnswer)if (showAnswer)at line 334, TypeScript doesn't understand the original conditionanswerremains typed asPostAnswer | null | undefinedrather than being narrowed toPostAnswerThis is a type safety violation where the caller passes a potentially-null value to a component that claims to require a non-null value.
Fix Explanation
I updated the PostAnswerBox component signature from:
To:
This fix:
PostAnswerBox answer={answer}