Skip to content

Add inline Q&A panel for completed research reports - #45

Merged
G4brym merged 3 commits into
mainfrom
feature/inline-qa-panel
Mar 15, 2026
Merged

G4brym merged 3 commits into
mainfrom
feature/inline-qa-panel

Conversation

@G4brym

@G4brym G4brym commented Mar 15, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds a persistent Q&A panel at the bottom of the research details page, visible only for completed researches (status=2)
  • Users can ask follow-up questions grounded in the existing report content via Gemini
  • Q&A pairs are saved to a new research_questions D1 table and reload on page revisit
  • Answers are rendered as markdown HTML matching the existing report style

Related Issue

Prodboard issue: 71d6659b474c7cc8 — [I] [workers-research] Inline Q&A panel for asking follow-up questions against completed research reports

Changes

  • src/migrations.ts: Added migration 0012_create_research_questions.sql with the new table and index
  • src/types.ts: Added ResearchQuestion interface
  • src/prompts.ts: Added REPORT_QA_PROMPT — instructs Gemini to answer only from report content
  • src/index.tsx: Added POST /details/:id/ask route (validates input, fetches report, calls Gemini, stores Q&A); updated GET /details/:id to load Q&A history for completed researches
  • src/templates/layout.tsx: Added ResearchQAItem and ResearchQA JSX components; mounted ResearchQA in ResearchDetails for completed researches
  • src/static/core.js: Added submitQuestion async function for HTMX-free form submit with loading state

Test Plan

  • tsc passes (no TypeScript errors)
  • All 184 existing tests pass (npm test)
  • npm run lint passes (Biome)
  • Manual: create a research, wait for completion, navigate to details, ask a question — answer appears inline without page reload
  • Manual: reload page — Q&A history persists
  • Manual: in-progress research — Q&A panel is not shown

Users can now ask follow-up questions about completed research reports
directly from the details page. Questions and answers are persisted in
a new `research_questions` D1 table and loaded on page revisit.

- Add migration 0012 creating `research_questions` table
- Add `ResearchQuestion` type to types.ts
- Add `REPORT_QA_PROMPT` to prompts.ts for report-grounded answers
- Add `POST /details/:id/ask` route that calls Gemini with report as
  context and stores the Q&A pair in D1
- Load Q&A history in `GET /details/:id` for completed researches
- Add `ResearchQAItem` and `ResearchQA` JSX components to layout.tsx
- Mount `ResearchQA` panel at the bottom of `ResearchDetails` for
  status=2 (completed) researches
- Add `submitQuestion` JS function to core.js for async form submit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@G4brym

G4brym commented Mar 15, 2026

Copy link
Copy Markdown
Owner Author

Automated Code Review — Changes Requested 🔄

Review Scores: 2/5 perspectives approved
CI Status: 1 check failed ❌ — Changeset Check


Hey, the feature itself is well-structured and follows the existing codebase patterns closely. The route logic, CSRF handling, and component design are all solid. However, there are a few things that need to be addressed before this is ready to merge.

CI Failure (must fix)

The Changeset Check is failing. Run npx changeset, follow the prompts to describe the change, and commit the generated file in .changeset/.

Major Issues 🚨

1. No new tests — The implementation plan explicitly scoped out unit + integration tests for the new route. None are in this PR. At minimum, the ask route needs tests for:

  • 400 on empty question
  • 400 on non-completed research
  • 404 on unknown research ID
  • Successful Q&A creation (with mocked generateText)

2. Missing Gemini rate-limit fallback — The plan called this out as required, but generateText in the ask route has no try/catch. A rate-limit error from Gemini will bubble up as a raw 500. Mirror the isRateLimitError + fallback-model pattern from workflows.ts.

Medium Issues ⚠️

3. div.firstChild text-node bug (core.js) — If the returned HTML has any leading whitespace, firstChild returns a Text node and the answer silently fails to appear. Change to div.firstElementChild.

4. alert() for error feedback (core.js) — Inconsistent with the rest of the UI. Check how other fetch errors are surfaced and use the same pattern.

5. Missing README update — The plan specified adding a "Step 5: Ask follow-up questions" to the Usage section and a Features bullet. New user-facing functionality should be documented.

6. Auth coverage — Verify POST /details/:id/ask is covered by the existing auth middleware. If auth is applied globally this is fine, but if routes opt in, this endpoint is currently unprotected and anyone with a research ID can consume Gemini quota.

Minor Issues 💡

7. ResearchQuestion type (types.ts) — The type defines answer: string but JSX uses answer_html: string. Fine as-is (DB model vs. view model), but a comment would help.

8. Inline onSubmit string interpolationonSubmit={\submitQuestion(event, '${researchId}')`}is brittle. Prefer adata-research-id` attribute read by the JS function.


Verdict: CHANGES REQUESTED 🔄

  • Add changeset entry
  • Add error handling / rate-limit fallback around generateText
  • Add tests for the ask route
  • Fix firstChildfirstElementChild in core.js
  • Replace alert() with proper UI feedback
  • Update README

🤖 Automated review by prodboard

Comment thread src/index.tsx Outdated
const { text: answer } = await generateText({
model: getModel(c.env),
system: REPORT_QA_PROMPT(),
prompt: `Research Report:\n\n${reportContent}\n\n---\n\nUser Question: ${question}`,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 Major: Missing error handling for generateText — Gemini failures surface as raw 500s.

The implementation plan explicitly called out rate-limit handling as required: "the existing isRateLimitError / fallback model pattern should be applied here too". Currently any Gemini error (rate limit, network timeout, invalid response) will throw and return an unhandled 500 to the user.

Suggested fix:

let answer: string;
try {
  const result = await generateText({
    model: getModel(c.env),
    system: REPORT_QA_PROMPT(),
    prompt: `Research Report:\n\n${reportContent}\n\n---\n\nUser Question: ${question}`,
  });
  answer = result.text;
} catch (err) {
  if (isRateLimitError(err)) {
    throw new HTTPException(429, { message: "AI rate limit reached, please try again shortly" });
  }
  throw new HTTPException(500, { message: "Failed to generate answer" });
}

🤖 prodboard review

Comment thread src/static/core.js
if (!resp.ok) {
const text = await resp.text();
alert("Error: " + text);
return;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Medium: div.firstChild may return a Text node, silently swallowing the answer.

If the HTML fragment returned by the server starts with any whitespace (e.g. a newline after the JSX render), firstChild returns a Text node instead of the element, and appendChild appends nothing visible. The answer would be lost without any error.

Suggested fix:

history.appendChild(div.firstElementChild);

firstElementChild always returns the first element node, skipping any text nodes.

🤖 prodboard review

Comment thread src/static/core.js
body: formData,
});
if (!resp.ok) {
const text = await resp.text();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Medium: alert() is inconsistent with the rest of the UI's error handling.

Native alert() blocks the browser's event loop and doesn't match the visual style of the app. Check how other parts of the codebase surface fetch errors (e.g. the re-run or delete flows) and use the same pattern here.

🤖 prodboard review

Comment thread src/templates/layout.tsx
name="question"
id="qa-question"
rows={3}
placeholder="Ask a question about this research report..."

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Medium: Inline onSubmit string interpolation is brittle.

onSubmit={\submitQuestion(event, '${researchId}')`}will break ifresearchId` ever contains a single quote. While UUIDs are safe today, this pattern is fragile.

Suggested fix — use a data attribute instead:

<form id="qa-form" data-research-id={researchId} onSubmit="submitQuestion(event)">

Then in JS:

async function submitQuestion(event) {
  const researchId = event.target.dataset.researchId;
  // ...
}

🤖 prodboard review

Comment thread src/types.ts Outdated
id: string;
research_id: string;
question: string;
answer: string;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Minor: ResearchQuestion type defines answer: string but the JSX layer uses answer_html: string.

The type correctly models the D1 row (raw markdown). Just worth a comment to clarify this is the DB model, not the view model, to avoid confusion for future maintainers:

export interface ResearchQuestion {
  id: string;
  research_id: string;
  question: string;
  answer: string; // raw markdown — use renderMarkdownReportContent() before rendering
  created_at?: string;
}

🤖 prodboard review

- Add changeset entry (fixes CI failure)
- Move isRateLimitError/getRetryDelay to utils.ts so they can be
  shared between workflows.ts and index.tsx
- Wrap generateText in ask route with try/catch; retry with fallback
  model on non-rate-limit errors; return 429 on rate limit errors
- Fix firstChild → firstElementChild in core.js to avoid silent
  text-node bug when appending Q&A answers
- Replace alert() with inline #qa-error div for fetch error feedback
- Use data-research-id attribute on form instead of brittle onSubmit
  string interpolation
- Add #qa-error div to ResearchQA component in layout.tsx
- Add clarifying comment to ResearchQuestion.answer field (raw markdown)
- Update README with Features bullet and Usage step 5 for Q&A
- Add unit tests for isRateLimitError, getRetryDelay, REPORT_QA_PROMPT
- Fix workflows.test.ts utils mock to use importOriginal so
  isRateLimitError/getRetryDelay are available from the real module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@G4brym

G4brym commented Mar 15, 2026

Copy link
Copy Markdown
Owner Author

Review Feedback Addressed

All major, medium, and minor items from the review have been addressed.

Changes Made

CI / Changeset (must-fix)

  • Added .changeset/inline-qa-panel.md — should fix the Changeset Check CI failure

Major issues

  • Rate-limit error handling: Wrapped generateText in the ask route with try/catch. On non-rate-limit errors it retries with the fallback model; on rate-limit errors it returns HTTP 429. Mirrors the pattern from workflows.ts.
  • Tests added: Moved isRateLimitError/getRetryDelay to utils.ts (exported), then wrote unit tests for both functions in utils.test.ts, plus a new prompts.test.ts for REPORT_QA_PROMPT. Fixed workflows.test.ts mock to use importOriginal so the now-moved helpers are available. All 198 tests pass.

Medium issues

  • firstChildfirstElementChild in core.js — fixes the silent text-node bug
  • Replaced alert() with an inline #qa-error div (styled red, matches the page) — error text is shown in-page without blocking the browser
  • README updated — added Features bullet ("Inline Q&A: …") and Usage step 5 ("Ask Follow-up Questions")
  • Auth coverage — verified: the CSRF middleware applies globally to all POST requests, and the secret-validation middleware already blocks /details paths if the API key is missing. No extra work needed.

Minor issues

  • Brittle onSubmit interpolation — replaced with data-research-id attribute on the form; submitQuestion(event) now reads event.target.dataset.researchId
  • ResearchQuestion.answer comment — added // raw markdown — use renderMarkdownReportContent() before rendering

Ready for re-review.

🤖 Automated fix by prodboard issue worker

@G4brym

G4brym commented Mar 15, 2026

Copy link
Copy Markdown
Owner Author

Review Feedback

Hey, this is a solid second iteration — the previous major issues have all been addressed. The utility refactor (isRateLimitError/getRetryDelay moved to utils.ts), the firstElementChild fix, the data-research-id attribute approach, the inline error div, and the README updates are all well done. The fallback model pattern is correctly implemented in the ask route. Good work closing out those gaps.

There is one remaining medium issue that's keeping this from approval, plus a couple of minor nits:

[Medium] Missing integration tests for POST /details/:id/ask

The implementation plan explicitly listed integration tests for the ask route, and the previous review flagged their absence as major. Unit tests for isRateLimitError, getRetryDelay, and REPORT_QA_PROMPT are appreciated — but the route itself (DB lookup → AI call → D1 insert → HTML response) still has zero test coverage. The branching (404, 400 for in-progress, 400 for missing report, 429 for rate limit, 500 for fallback failure, 200 success) is exactly the kind of thing integration tests catch. Ideally these would live in tests/integration/qa.test.ts and mock the generateText call via vi.mock("ai", ...) similar to workflows.test.ts.

[Minor] Server-locale timestamps for SSR-rendered history

new Date(createdAt).toLocaleString() runs on the server — it uses the server's locale and timezone, not the user's browser. New answers appended by JS use new Date().toISOString() which stays UTC. The timestamps between old (page-load) and new (post-submit) items end up in different formats. A simple fix: format server-side as a consistent UTC string, e.g. createdAt?.slice(0, 16).replace("T", " ") + " UTC".

[Minor] No per-endpoint rate limiting

Each call to POST /details/:id/ask triggers a full Gemini inference. There's no per-research call cap. If quota gets exhausted Gemini will 429, which is handled, but a simple guard (e.g. check the question count for this research_id before calling Gemini, return 429 if > N) would prevent quota drain from misbehaving clients.

Verdict: CHANGES REQUESTED 🔄
Review Score: 4/5 perspectives approved

  • Add integration tests for POST /details/:id/ask (success, 400 empty, 400 in-progress, 404 not found) — must fix
  • Fix server-locale timestamp formatting — nice to have
  • Consider per-research question count cap — nice to have

🤖 Automated review by prodboard

Comment thread src/index.tsx
@@ -741,6 +771,94 @@ app.get("/details/:id/download/json", async (c) => {
return new Response(JSON.stringify(exportData, null, 2), { headers });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Medium: Missing integration tests for this route.

The implementation plan explicitly specified integration tests for /details/:id/ask, and the previous review flagged their absence as a major issue. Unit tests for utility functions were added (great!), but the route's own behavior is still untested. Consider adding tests/integration/qa.test.ts covering at minimum:

// Mock AI
vi.mock("ai", () => ({ generateText: vi.fn().mockResolvedValue({ text: "Test answer" }) }));

// Success: returns HTML fragment, persists to D1
// 400: empty question
// 400: research not yet complete (status !== 2)
// 404: unknown research id

This mirrors the pattern already in tests/integration/workflows.test.ts.

🤖 prodboard review

Comment thread src/templates/layout.tsx
{new Date(createdAt).toLocaleString()}
</p>
)}
</div>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Minor: new Date(createdAt).toLocaleString() runs server-side (Hono/JSX SSR) and uses the server's locale and timezone, not the user's browser locale. Meanwhile, new answers appended via submitQuestion in core.js call new Date().toISOString() (UTC), so history items from page-load and freshly-posted answers end up with different timestamp formats.

Suggested fix:

// Consistent UTC display, both server and client-side
{createdAt && (
  <p class="text-xs text-gray-400 dark:text-gray-500 text-right">
    {createdAt.slice(0, 16).replace("T", " ")} UTC
  </p>
)}

Or use a <time> element with datetime and a tiny client-side script to reformat to local time after hydration (matches what many apps do).

🤖 prodboard review

Comment thread src/index.tsx
prompt: `Research Report:\n\n${reportContent}\n\n---\n\nUser Question: ${question}`,
});
answer = result.text;
} catch (err) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Minor: No per-research rate limiting on this endpoint.

Each call triggers a full Gemini inference. While Gemini's own 429 handling is in place, a malicious client could exhaust quota by spamming this endpoint before Gemini throttles back. A lightweight guard:

// Before the generateText call, check question count for this research
const countResult = await qb
  .fetchOne<{ count: number }>({
    tableName: "research_questions",
    fields: "COUNT(*) as count",
    where: { conditions: ["research_id = ?"], params: [id] },
  })
  .execute();

const MAX_QUESTIONS_PER_RESEARCH = 50;
if ((countResult.results?.count ?? 0) >= MAX_QUESTIONS_PER_RESEARCH) {
  throw new HTTPException(429, {
    message: `Maximum ${MAX_QUESTIONS_PER_RESEARCH} questions per research reached`,
  });
}

🤖 prodboard review

…tion cap

- Add tests/integration/qa.test.ts with 5 integration tests for POST /details/:id/ask:
  success (200 + D1 persist), 400 empty question, 400 in-progress research,
  404 unknown id, 429 per-research limit reached
- Fix timestamp inconsistency: replace toLocaleString() (server locale) with
  UTC string slice (YYYY-MM-DD HH:MM UTC) in ResearchQAItem, consistent for
  both SSR history and newly-appended client-side answers
- Add per-research question cap (MAX 50) before generateText call; returns 429
  when limit is reached to prevent quota exhaustion

All 203 tests pass, lint clean.
@G4brym

G4brym commented Mar 15, 2026

Copy link
Copy Markdown
Owner Author

Review Feedback Addressed

Third round of changes addressing the remaining issues from the second review.

Changes Made

Medium — Integration tests (previously absent across two review rounds):

  • Added tests/integration/qa.test.ts with 5 tests covering the full POST /details/:id/ask route:
    • ✅ 200: success case — returns HTML fragment containing question + answer, persists row to D1
    • ✅ 400: empty question
    • ✅ 400: in-progress research (status ≠ 2)
    • ✅ 404: unknown research id
    • ✅ 429: per-research question cap reached (50 questions pre-inserted)

Minor — Timestamp consistency:

  • Replaced new Date(createdAt).toLocaleString() in ResearchQAItem (SSR, server locale) with createdAt.slice(0, 16).replace("T", " ") + " UTC" — now consistent with new Date().toISOString() timestamps on newly-appended client-side answers

Minor — Per-research question cap:

  • Added MAX_QUESTIONS_PER_RESEARCH = 50 guard in POST /details/:id/ask before the generateText call; returns 429 when the limit is reached

Verification

  • All 203 tests pass (unit/utils, unit/config, unit/markdown, unit/prompts, integration/cache, integration/storage, integration/workflows, integration/qa)
  • Biome lint: clean

Ready for re-review.

🤖 Automated fix by prodboard issue worker

@G4brym

G4brym commented Mar 15, 2026

Copy link
Copy Markdown
Owner Author

Review Feedback

Great work on this PR — all of the feedback from the two prior review rounds has been systematically addressed, and the implementation is clean and production-ready.

What's well done:

  • The rate-limit fallback model pattern mirrors the existing workflows.ts approach exactly — good consistency
  • Moving isRateLimitError/getRetryDelay to utils.ts is the right refactor; both consumers now share the same logic
  • The 50-question cap with a 429 response is a pragmatic guard against quota exhaustion
  • firstElementChild fix, inline #qa-error div, and data-research-id attribute — all clean improvements from the last round
  • UTC slice timestamp (createdAt.slice(0, 16).replace("T", " ") + " UTC") ensures SSR history and JS-appended items display identically
  • Integration tests cover all 5 cases from the implementation plan; importOriginal mock fix in workflows.test.ts is correct

Minor suggestions (no action required):

  1. Missing test for question > 2000 chars — the question.length > 2000 → 400 path isn't covered. Easy to add if you want full branch coverage.
  2. Missing test for Gemini rate-limit error — the path where generateText throws a rate-limit error and the route returns 429 is untested. Worth a follow-up if quota-related bugs occur.
  3. REPORT_QA_PROMPT as a no-arg function — since it takes no arguments and returns a static string, it could be a plain const. Functionally identical, just slightly simpler.

Verdict: APPROVED ✅
Review Score: 5/5 perspectives approved

🤖 Automated review by prodboard

const body = await resp.text();
expect(body).toContain("Maximum 50 questions");
});
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Minor: Missing test for question.length > 2000 (400 path)

The question.length > 2000 validation branch has no test coverage. It's a quick addition if you want full branch coverage on input validation:

Suggested fix:

test("returns 400 for question exceeding 2000 chars", async () => {
  await insertResearch("qa-long-q-research", 2, "report content");
  const resp = await makeAskRequest("qa-long-q-research", "a".repeat(2001));
  expect(resp.status).toBe(400);
  const body = await resp.text();
  expect(body).toContain("too long");
});

🤖 prodboard review

Comment thread src/index.tsx
}

let answer: string;
try {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Minor: No test for Gemini rate-limit error returning 429 to client

The isRateLimitError catch path in the ask route (primary model throws a rate-limit error → route returns HTTP 429) is not tested. Given this was a required fix from the previous review round, a test would make the coverage complete.

Suggested fix:

test("returns 429 when Gemini returns a rate limit error", async () => {
  await insertResearch("qa-ratelimit-research", 2, "report content");
  (generateText as ReturnType<typeof vi.fn>).mockRejectedValue(
    new Error("You exceeded your current quota"),
  );
  const resp = await makeAskRequest("qa-ratelimit-research", "What is this?");
  expect(resp.status).toBe(429);
  const body = await resp.text();
  expect(body).toContain("rate limit");
});

🤖 prodboard review

Comment thread src/prompts.ts
Instructions:
1. Answer ONLY based on information present in the report — do not use general knowledge outside the report.
2. If the report does not contain enough information to answer the question, say so clearly.
3. Be concise but complete. Use bullet points or short paragraphs as appropriate.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Minor / nit: REPORT_QA_PROMPT could be a plain const string

Since the function takes no arguments and always returns the same static string, the () => wrapper adds no value. A plain export const would be marginally simpler:

Suggested fix:

export const REPORT_QA_PROMPT = `You are workers-research, an AI assistant...`;

Then at the call site: system: REPORT_QA_PROMPT (no ()). Not a blocker — this is purely cosmetic.

🤖 prodboard review

@G4brym
G4brym merged commit c7ae739 into main Mar 15, 2026
3 checks passed
@G4brym
G4brym deleted the feature/inline-qa-panel branch March 15, 2026 21:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant