Merge pull request #1240 from AnishSarkar22/feat/resume-builder
Some checks failed
Build and Push Docker Images / tag_release (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Has been cancelled
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Has been cancelled

feat: resume builder
This commit is contained in:
Rohan Verma 2026-04-17 13:41:32 -07:00 committed by GitHub
commit 2b2453e015
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1917 additions and 60 deletions

View file

@ -0,0 +1,42 @@
"""126_add_report_content_type
Revision ID: 126
Revises: 125
Create Date: 2026-04-15
Adds content_type column to reports table to distinguish between
Markdown reports and Typst-based resumes.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "126"
down_revision: str | None = "125"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
columns = [c["name"] for c in sa.inspect(conn).get_columns("reports")]
if "content_type" in columns:
return
op.add_column(
"reports",
sa.Column(
"content_type",
sa.String(20),
nullable=False,
server_default="markdown",
),
)
def downgrade() -> None:
op.drop_column("reports", "content_type")

View file

@ -0,0 +1,43 @@
"""127_seed_build_resume_prompt
Revision ID: 127
Revises: 126
Create Date: 2026-04-15
Seeds the 'Build Resume' default prompt for all existing users.
New users get it automatically via SYSTEM_PROMPT_DEFAULTS on signup.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "127"
down_revision: str | None = "126"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
INSERT INTO prompts
(user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)
SELECT u.id, 'build-resume', 'Build Resume',
E'Build me a professional resume. Here is my information:\\n\\n{selection}',
'explore'::prompt_mode, 1, false, now()
FROM "user" u
ON CONFLICT (user_id, default_prompt_slug) DO NOTHING
"""
)
)
def downgrade() -> None:
op.execute("DELETE FROM prompts WHERE default_prompt_slug = 'build-resume'")

View file

@ -443,6 +443,52 @@ _TOOL_EXAMPLES["web_search"] = """
- Call: `web_search(query="weather New York today")`
"""
_TOOL_INSTRUCTIONS["generate_resume"] = """
- generate_resume: Generate or revise a professional resume as a Typst document.
- WHEN TO CALL: The user asks to create, build, generate, write, or draft a resume or CV.
Also when they ask to modify, update, or revise an existing resume from this conversation.
- WHEN NOT TO CALL: General career advice, resume tips, cover letters, or reviewing
a resume without making changes. For cover letters, use generate_report instead.
- The tool produces Typst source code that is compiled to a PDF preview automatically.
- Args:
- user_info: The user's resume content — work experience, education, skills, contact
info, etc. Can be structured or unstructured text.
CRITICAL: user_info must be COMPREHENSIVE. Do NOT just pass the user's raw message.
You MUST gather and consolidate ALL available information:
* Content from referenced/mentioned documents (e.g., uploaded resumes, CVs, LinkedIn profiles)
that appear in the conversation context extract and include their FULL content.
* Information the user shared across multiple messages in the conversation.
* Any relevant details from knowledge base search results in the context.
The more complete the user_info, the better the resume. Include names, contact info,
work experience with dates, education, skills, projects, certifications everything available.
- user_instructions: Optional style or content preferences (e.g. "emphasize leadership",
"keep it to one page"). For revisions, describe what to change.
- parent_report_id: Set this when the user wants to MODIFY an existing resume from
this conversation. Use the report_id from a previous generate_resume result.
- Returns: Dict with status, report_id, title, and content_type.
- After calling: Give a brief confirmation. Do NOT paste resume content in chat. Do NOT mention report_id or any internal IDs the resume card is shown automatically.
- VERSIONING: Same rules as generate_report set parent_report_id for modifications
of an existing resume, leave as None for new resumes.
"""
_TOOL_EXAMPLES["generate_resume"] = """
- User: "Build me a resume. I'm John Doe, engineer at Acme Corp..."
- Call: `generate_resume(user_info="John Doe, engineer at Acme Corp...")`
- WHY: Has creation verb "build" + resume call the tool.
- User: "Create my CV with this info: [experience, education, skills]"
- Call: `generate_resume(user_info="[experience, education, skills]")`
- User: "Build me a resume" (and there is a resume/CV document in the conversation context)
- Extract the FULL content from the document in context, then call:
`generate_resume(user_info="Name: John Doe\\nEmail: john@example.com\\n\\nExperience:\\n- Senior Engineer at Acme Corp (2020-2024)\\n Led team of 5...\\n\\nEducation:\\n- BS Computer Science, MIT (2016-2020)\\n\\nSkills: Python, TypeScript, AWS...")`
- WHY: Document content is available in context extract ALL of it into user_info. Do NOT ignore referenced documents.
- User: (after resume generated) "Change my title to Senior Engineer"
- Call: `generate_resume(user_info="", user_instructions="Change the job title to Senior Engineer", parent_report_id=<previous_report_id>)`
- WHY: Modification verb "change" + refers to existing resume set parent_report_id.
- User: "How should I structure my resume?"
- Do NOT call generate_resume. Answer in chat with advice.
- WHY: No creation/modification verb.
"""
# All tool names that have prompt instructions (order matters for prompt readability)
_ALL_TOOL_NAMES_ORDERED = [
"search_surfsense_docs",
@ -450,6 +496,7 @@ _ALL_TOOL_NAMES_ORDERED = [
"generate_podcast",
"generate_video_presentation",
"generate_report",
"generate_resume",
"generate_image",
"scrape_webpage",
"update_memory",

View file

@ -92,6 +92,7 @@ from .onedrive import (
)
from .podcast import create_generate_podcast_tool
from .report import create_generate_report_tool
from .resume import create_generate_resume_tool
from .scrape_webpage import create_scrape_webpage_tool
from .search_surfsense_docs import create_search_surfsense_docs_tool
from .update_memory import create_update_memory_tool, create_update_team_memory_tool
@ -171,6 +172,16 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
# are optional — when missing, source_strategy="kb_search" degrades
# gracefully to "provided"
),
# Resume generation tool (Typst-based, uses rendercv package)
ToolDefinition(
name="generate_resume",
description="Generate a professional resume as a Typst document",
factory=lambda deps: create_generate_resume_tool(
search_space_id=deps["search_space_id"],
thread_id=deps["thread_id"],
),
requires=["search_space_id", "thread_id"],
),
# Generate image tool - creates images using AI models (DALL-E, GPT Image, etc.)
ToolDefinition(
name="generate_image",

View file

@ -0,0 +1,665 @@
"""
Resume generation tool for the SurfSense agent.
Generates a structured resume as Typst source code using the rendercv package.
The LLM outputs only the content body (= heading, sections, entries) while
the template header (import + show rule) is hardcoded and prepended by the
backend. This eliminates LLM errors in the complex configuration block.
Templates are stored in a registry so new designs can be added by defining
a new entry in _TEMPLATES.
Uses the same short-lived session pattern as generate_report so no DB
connection is held during the long LLM call.
"""
import logging
import re
from datetime import UTC, datetime
from typing import Any
import typst
from langchain_core.callbacks import dispatch_custom_event
from langchain_core.messages import HumanMessage
from langchain_core.tools import tool
from app.db import Report, shielded_async_session
from app.services.llm_service import get_document_summary_llm
logger = logging.getLogger(__name__)
# ─── Template Registry ───────────────────────────────────────────────────────
# Each template defines:
# header - Typst import + show rule with {name}, {year}, {month}, {day} placeholders
# component_reference - component docs shown to the LLM
# rules - generation rules for the LLM
_TEMPLATES: dict[str, dict[str, str]] = {
"classic": {
"header": """\
#import "@preview/rendercv:0.3.0": *
#show: rendercv.with(
name: "{name}",
title: "{name} - Resume",
footer: context {{ [#emph[{name} -- #str(here().page())\\/#str(counter(page).final().first())]] }},
top-note: [ #emph[Last updated in {month_name} {year}] ],
locale-catalog-language: "en",
text-direction: ltr,
page-size: "us-letter",
page-top-margin: 0.7in,
page-bottom-margin: 0.7in,
page-left-margin: 0.7in,
page-right-margin: 0.7in,
page-show-footer: false,
page-show-top-note: true,
colors-body: rgb(0, 0, 0),
colors-name: rgb(0, 0, 0),
colors-headline: rgb(0, 0, 0),
colors-connections: rgb(0, 0, 0),
colors-section-titles: rgb(0, 0, 0),
colors-links: rgb(0, 0, 0),
colors-footer: rgb(128, 128, 128),
colors-top-note: rgb(128, 128, 128),
typography-line-spacing: 0.6em,
typography-alignment: "justified",
typography-date-and-location-column-alignment: right,
typography-font-family-body: "XCharter",
typography-font-family-name: "XCharter",
typography-font-family-headline: "XCharter",
typography-font-family-connections: "XCharter",
typography-font-family-section-titles: "XCharter",
typography-font-size-body: 10pt,
typography-font-size-name: 25pt,
typography-font-size-headline: 10pt,
typography-font-size-connections: 10pt,
typography-font-size-section-titles: 1.2em,
typography-small-caps-name: false,
typography-small-caps-headline: false,
typography-small-caps-connections: false,
typography-small-caps-section-titles: false,
typography-bold-name: false,
typography-bold-headline: false,
typography-bold-connections: false,
typography-bold-section-titles: true,
links-underline: true,
links-show-external-link-icon: false,
header-alignment: center,
header-photo-width: 3.5cm,
header-space-below-name: 0.7cm,
header-space-below-headline: 0.7cm,
header-space-below-connections: 0.7cm,
header-connections-hyperlink: true,
header-connections-show-icons: false,
header-connections-display-urls-instead-of-usernames: true,
header-connections-separator: "|",
header-connections-space-between-connections: 0.5cm,
section-titles-type: "with_full_line",
section-titles-line-thickness: 0.5pt,
section-titles-space-above: 0.5cm,
section-titles-space-below: 0.3cm,
sections-allow-page-break: true,
sections-space-between-text-based-entries: 0.15cm,
sections-space-between-regular-entries: 0.42cm,
entries-date-and-location-width: 4.15cm,
entries-side-space: 0cm,
entries-space-between-columns: 0.1cm,
entries-allow-page-break: false,
entries-short-second-row: false,
entries-degree-width: 1cm,
entries-summary-space-left: 0cm,
entries-summary-space-above: 0.08cm,
entries-highlights-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt),
entries-highlights-nested-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt),
entries-highlights-space-left: 0cm,
entries-highlights-space-above: 0.08cm,
entries-highlights-space-between-items: 0.08cm,
entries-highlights-space-between-bullet-and-text: 0.3em,
date: datetime(
year: {year},
month: {month},
day: {day},
),
)
""",
"component_reference": """\
Available components (use ONLY these):
= Full Name // Top-level heading person's full name
#connections( // Contact info row (pipe-separated)
[City, Country],
[#link("mailto:email@example.com", icon: false, if-underline: false, if-color: false)[email\\@example.com]],
[#link("https://linkedin.com/in/user", icon: false, if-underline: false, if-color: false)[linkedin.com\\/in\\/user]],
[#link("https://github.com/user", icon: false, if-underline: false, if-color: false)[github.com\\/user]],
)
== Section Title // Section heading (arbitrary name)
#regular-entry( // Work experience, projects, publications, etc.
[
#strong[Role/Title], Company Name -- Location
],
[
Start -- End
],
main-column-second-row: [
- Achievement or responsibility
- Another bullet point
],
)
#education-entry( // Education entries
[
#strong[Institution], Degree in Field -- Location
],
[
Start -- End
],
main-column-second-row: [
- GPA, honours, relevant coursework
],
)
#summary([Short paragraph summary]) // Optional summary inside an entry
#content-area([Free-form content]) // Freeform text block
For skills sections, use bold labels directly:
#strong[Category:] item1, item2, item3
For simple list sections (e.g. Honors), use plain bullet points:
- Item one
- Item two
""",
"rules": """\
RULES:
- Do NOT include any #import or #show lines. Start directly with = Full Name.
- Output ONLY valid Typst content. No explanatory text before or after.
- Do NOT wrap output in ```typst code fences.
- The = heading MUST use the person's COMPLETE full name exactly as provided. NEVER shorten or abbreviate.
- Escape @ symbols inside link labels with a backslash: email\\@example.com
- Escape forward slashes in link display text: linkedin.com\\/in\\/user
- Every section MUST use == heading.
- Use #regular-entry() for experience, projects, publications, certifications, and similar entries.
- Use #education-entry() for education.
- Use #strong[Label:] for skills categories.
- Keep content professional, concise, and achievement-oriented.
- Use action verbs for bullet points (Led, Built, Designed, Reduced, etc.).
- This template works for ALL professions adapt sections to the user's field.
""",
},
}
DEFAULT_TEMPLATE = "classic"
# ─── Template Helpers ─────────────────────────────────────────────────────────
def _get_template(template_id: str | None = None) -> dict[str, str]:
"""Get a template by ID, falling back to default."""
return _TEMPLATES.get(template_id or DEFAULT_TEMPLATE, _TEMPLATES[DEFAULT_TEMPLATE])
_MONTH_NAMES = [
"",
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]
def _build_header(template: dict[str, str], name: str) -> str:
"""Build the template header with the person's name and current date."""
now = datetime.now(tz=UTC)
return (
template["header"]
.replace("{name}", name)
.replace("{year}", str(now.year))
.replace("{month}", str(now.month))
.replace("{day}", str(now.day))
.replace("{month_name}", _MONTH_NAMES[now.month])
)
def _strip_header(full_source: str) -> str:
"""Strip the import + show rule from stored source to get the body only.
Finds the closing parenthesis of the rendercv.with(...) block by tracking
nesting depth, then returns everything after it.
"""
show_match = re.search(r"#show:\s*rendercv\.with\(", full_source)
if not show_match:
return full_source
start = show_match.end()
depth = 1
i = start
while i < len(full_source) and depth > 0:
if full_source[i] == "(":
depth += 1
elif full_source[i] == ")":
depth -= 1
i += 1
return full_source[i:].lstrip("\n")
def _extract_name(body: str) -> str | None:
"""Extract the person's full name from the = heading in the body."""
match = re.search(r"^=\s+(.+)$", body, re.MULTILINE)
return match.group(1).strip() if match else None
def _strip_imports(body: str) -> str:
"""Remove any #import or #show lines the LLM might accidentally include."""
lines = body.split("\n")
cleaned: list[str] = []
skip_show = False
depth = 0
for line in lines:
stripped = line.strip()
if stripped.startswith("#import"):
continue
if skip_show:
depth += stripped.count("(") - stripped.count(")")
if depth <= 0:
skip_show = False
continue
if stripped.startswith("#show:") and "rendercv" in stripped:
depth = stripped.count("(") - stripped.count(")")
if depth > 0:
skip_show = True
continue
cleaned.append(line)
result = "\n".join(cleaned).strip()
return result
def _build_llm_reference(template: dict[str, str]) -> str:
"""Build the LLM prompt reference from a template."""
return f"""\
You MUST output valid Typst content for a resume.
Do NOT include any #import or #show lines — those are handled automatically.
Start directly with the = Full Name heading.
{template["component_reference"]}
{template["rules"]}"""
# ─── Prompts ─────────────────────────────────────────────────────────────────
_RESUME_PROMPT = """\
You are an expert resume writer. Generate professional resume content as Typst markup.
{llm_reference}
**User Information:**
{user_info}
{user_instructions_section}
Generate the resume content now (starting with = Full Name):
"""
_REVISION_PROMPT = """\
You are an expert resume editor. Modify the existing resume according to the instructions.
Apply ONLY the requested changes do NOT rewrite sections that are not affected.
{llm_reference}
**Modification Instructions:** {user_instructions}
**EXISTING RESUME CONTENT:**
{previous_content}
---
Output the complete, updated resume content with the changes applied (starting with = Full Name):
"""
_FIX_COMPILE_PROMPT = """\
The resume content you generated failed to compile. Fix the error while preserving all content.
{llm_reference}
**Compilation Error:**
{error}
**Full Typst Source (for context error line numbers refer to this):**
{full_source}
**Your content starts after the template header. Output ONLY the content portion \
(starting with = Full Name), NOT the #import or #show rule:**
"""
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _strip_typst_fences(text: str) -> str:
"""Remove wrapping ```typst ... ``` fences that LLMs sometimes add."""
stripped = text.strip()
m = re.match(r"^(`{3,})(?:typst|typ)?\s*\n", stripped)
if m:
fence = m.group(1)
if stripped.endswith(fence):
stripped = stripped[m.end() :]
stripped = stripped[: -len(fence)].rstrip()
return stripped
def _compile_typst(source: str) -> bytes:
"""Compile Typst source to PDF bytes. Raises on failure."""
return typst.compile(source.encode("utf-8"))
# ─── Tool Factory ───────────────────────────────────────────────────────────
def create_generate_resume_tool(
search_space_id: int,
thread_id: int | None = None,
):
"""
Factory function to create the generate_resume tool.
Generates a Typst-based resume, validates it via compilation,
and stores the source in the Report table with content_type='typst'.
The LLM generates only the content body; the template header is
prepended by the backend.
"""
@tool
async def generate_resume(
user_info: str,
user_instructions: str | None = None,
parent_report_id: int | None = None,
) -> dict[str, Any]:
"""
Generate a professional resume as a Typst document.
Use this tool when the user asks to create, build, generate, write,
or draft a resume or CV. Also use it when the user wants to modify,
update, or revise an existing resume generated in this conversation.
Trigger phrases include:
- "build me a resume", "create my resume", "generate a CV"
- "update my resume", "change my title", "add my new job"
- "make my resume more concise", "reformat my resume"
Do NOT use this tool for:
- General questions about resumes or career advice
- Reviewing or critiquing a resume without changes
- Cover letters (use generate_report instead)
VERSIONING parent_report_id:
- Set parent_report_id when the user wants to MODIFY an existing
resume that was already generated in this conversation.
- Leave as None for new resumes.
Args:
user_info: The user's resume content — work experience,
education, skills, contact info, etc. Can be structured
or unstructured text.
user_instructions: Optional style or content preferences
(e.g. "emphasize leadership", "keep it to one page",
"use a modern style"). For revisions, describe what to change.
parent_report_id: ID of a previous resume to revise (creates
new version in the same version group).
Returns:
Dict with status, report_id, title, and content_type.
"""
report_group_id: int | None = None
parent_content: str | None = None
template = _get_template()
llm_reference = _build_llm_reference(template)
async def _save_failed_report(error_msg: str) -> int | None:
try:
async with shielded_async_session() as session:
failed = Report(
title="Resume",
content=None,
content_type="typst",
report_metadata={
"status": "failed",
"error_message": error_msg,
},
report_style="resume",
search_space_id=search_space_id,
thread_id=thread_id,
report_group_id=report_group_id,
)
session.add(failed)
await session.commit()
await session.refresh(failed)
if not failed.report_group_id:
failed.report_group_id = failed.id
await session.commit()
logger.info(
f"[generate_resume] Saved failed report {failed.id}: {error_msg}"
)
return failed.id
except Exception:
logger.exception(
"[generate_resume] Could not persist failed report row"
)
return None
try:
# ── Phase 1: READ ─────────────────────────────────────────────
async with shielded_async_session() as read_session:
if parent_report_id:
parent_report = await read_session.get(Report, parent_report_id)
if parent_report:
report_group_id = parent_report.report_group_id
parent_content = parent_report.content
logger.info(
f"[generate_resume] Revising from parent {parent_report_id} "
f"(group {report_group_id})"
)
llm = await get_document_summary_llm(read_session, search_space_id)
if not llm:
error_msg = (
"No LLM configured. Please configure a language model in Settings."
)
report_id = await _save_failed_report(error_msg)
return {
"status": "failed",
"error": error_msg,
"report_id": report_id,
"title": "Resume",
"content_type": "typst",
}
# ── Phase 2: LLM GENERATION ───────────────────────────────────
user_instructions_section = ""
if user_instructions:
user_instructions_section = (
f"**Additional Instructions:** {user_instructions}"
)
if parent_content:
dispatch_custom_event(
"report_progress",
{"phase": "writing", "message": "Updating your resume"},
)
parent_body = _strip_header(parent_content)
prompt = _REVISION_PROMPT.format(
llm_reference=llm_reference,
user_instructions=user_instructions
or "Improve and refine the resume.",
previous_content=parent_body,
)
else:
dispatch_custom_event(
"report_progress",
{"phase": "writing", "message": "Building your resume"},
)
prompt = _RESUME_PROMPT.format(
llm_reference=llm_reference,
user_info=user_info,
user_instructions_section=user_instructions_section,
)
response = await llm.ainvoke([HumanMessage(content=prompt)])
body = response.content
if not body or not isinstance(body, str):
error_msg = "LLM returned empty or invalid content"
report_id = await _save_failed_report(error_msg)
return {
"status": "failed",
"error": error_msg,
"report_id": report_id,
"title": "Resume",
"content_type": "typst",
}
body = _strip_typst_fences(body)
body = _strip_imports(body)
# ── Phase 3: ASSEMBLE + COMPILE ───────────────────────────────
dispatch_custom_event(
"report_progress",
{"phase": "compiling", "message": "Compiling resume..."},
)
name = _extract_name(body) or "Resume"
header = _build_header(template, name)
typst_source = header + body
compile_error: str | None = None
for attempt in range(2):
try:
_compile_typst(typst_source)
compile_error = None
break
except Exception as e:
compile_error = str(e)
logger.warning(
f"[generate_resume] Compile attempt {attempt + 1} failed: {compile_error}"
)
if attempt == 0:
dispatch_custom_event(
"report_progress",
{
"phase": "fixing",
"message": "Fixing compilation issue...",
},
)
fix_prompt = _FIX_COMPILE_PROMPT.format(
llm_reference=llm_reference,
error=compile_error,
full_source=typst_source,
)
fix_response = await llm.ainvoke(
[HumanMessage(content=fix_prompt)]
)
if fix_response.content and isinstance(
fix_response.content, str
):
body = _strip_typst_fences(fix_response.content)
body = _strip_imports(body)
name = _extract_name(body) or name
header = _build_header(template, name)
typst_source = header + body
if compile_error:
error_msg = (
f"Typst compilation failed after 2 attempts: {compile_error}"
)
report_id = await _save_failed_report(error_msg)
return {
"status": "failed",
"error": error_msg,
"report_id": report_id,
"title": "Resume",
"content_type": "typst",
}
# ── Phase 4: SAVE ─────────────────────────────────────────────
dispatch_custom_event(
"report_progress",
{"phase": "saving", "message": "Saving your resume"},
)
resume_title = f"{name} - Resume" if name != "Resume" else "Resume"
metadata: dict[str, Any] = {
"status": "ready",
"word_count": len(typst_source.split()),
"char_count": len(typst_source),
}
async with shielded_async_session() as write_session:
report = Report(
title=resume_title,
content=typst_source,
content_type="typst",
report_metadata=metadata,
report_style="resume",
search_space_id=search_space_id,
thread_id=thread_id,
report_group_id=report_group_id,
)
write_session.add(report)
await write_session.commit()
await write_session.refresh(report)
if not report.report_group_id:
report.report_group_id = report.id
await write_session.commit()
saved_id = report.id
logger.info(f"[generate_resume] Created resume {saved_id}: {resume_title}")
return {
"status": "ready",
"report_id": saved_id,
"title": resume_title,
"content_type": "typst",
"is_revision": bool(parent_content),
"message": f"Resume generated successfully: {resume_title}",
}
except Exception as e:
error_message = str(e)
logger.exception(f"[generate_resume] Error: {error_message}")
report_id = await _save_failed_report(error_message)
return {
"status": "failed",
"error": error_message,
"report_id": report_id,
"title": "Resume",
"content_type": "typst",
}
return generate_resume

View file

@ -1198,12 +1198,13 @@ class VideoPresentation(BaseModel, TimestampMixin):
class Report(BaseModel, TimestampMixin):
"""Report model for storing generated Markdown reports."""
"""Report model for storing generated reports (Markdown or Typst)."""
__tablename__ = "reports"
title = Column(String(500), nullable=False)
content = Column(Text, nullable=True) # Markdown body
content = Column(Text, nullable=True)
content_type = Column(String(20), nullable=False, server_default="markdown")
report_metadata = Column(JSONB, nullable=True) # section headings, word count, etc.
report_style = Column(
String(100), nullable=True

View file

@ -71,4 +71,11 @@ SYSTEM_PROMPT_DEFAULTS: list[dict] = [
"prompt": "Search the web for information about:\n\n{selection}",
"mode": "explore",
},
{
"slug": "build-resume",
"version": 1,
"name": "Build Resume",
"prompt": "Build me a professional resume. Here is my information:\n\n{selection}",
"mode": "explore",
},
]

View file

@ -231,6 +231,57 @@ def _replace_audio_paths_with_public_urls(
return result
@router.get("/{share_token}/reports/{report_id}/preview")
async def preview_public_report_pdf(
share_token: str,
report_id: int,
session: AsyncSession = Depends(get_async_session),
):
"""
Return a compiled PDF preview for a Typst-based report in a public snapshot.
No authentication required - the share_token provides access.
"""
import asyncio
import io
import re
import typst as typst_compiler
report_info = await get_snapshot_report(session, share_token, report_id)
if not report_info:
raise HTTPException(status_code=404, detail="Report not found")
content = report_info.get("content")
content_type = report_info.get("content_type", "markdown")
if not content:
raise HTTPException(status_code=400, detail="Report has no content to preview")
if content_type != "typst":
raise HTTPException(
status_code=400,
detail="Preview is only available for Typst-based reports",
)
def _compile() -> bytes:
return typst_compiler.compile(content.encode("utf-8"))
pdf_bytes = await asyncio.to_thread(_compile)
safe_title = re.sub(r"[^\w\s-]", "", report_info.get("title") or "Resume").strip()
filename = f"{safe_title}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{filename}"',
},
)
@router.get("/{share_token}/reports/{report_id}/content")
async def get_public_report_content(
share_token: str,
@ -259,6 +310,7 @@ async def get_public_report_content(
"id": report_info.get("original_id"),
"title": report_info.get("title"),
"content": report_info.get("content"),
"content_type": report_info.get("content_type", "markdown"),
"report_metadata": report_info.get("report_metadata"),
"report_group_id": report_info.get("report_group_id"),
"versions": versions,

View file

@ -279,6 +279,7 @@ async def read_report_content(
id=report.id,
title=report.title,
content=report.content,
content_type=report.content_type,
report_metadata=report.report_metadata,
report_group_id=report.report_group_id,
versions=versions,
@ -319,6 +320,7 @@ async def update_report_content(
id=report.id,
title=report.title,
content=report.content,
content_type=report.content_type,
report_metadata=report.report_metadata,
report_group_id=report.report_group_id,
versions=versions,
@ -333,6 +335,57 @@ async def update_report_content(
) from None
@router.get("/reports/{report_id}/preview")
async def preview_report_pdf(
report_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Return a compiled PDF preview for Typst-based reports (resumes).
Reads the Typst source from the database and compiles it to PDF bytes
on-the-fly. Only works for reports with content_type='typst'.
"""
try:
report = await _get_report_with_access(report_id, session, user)
if not report.content:
raise HTTPException(
status_code=400, detail="Report has no content to preview"
)
if report.content_type != "typst":
raise HTTPException(
status_code=400,
detail="Preview is only available for Typst-based reports",
)
def _compile() -> bytes:
return typst.compile(report.content.encode("utf-8"))
pdf_bytes = await asyncio.to_thread(_compile)
safe_title = re.sub(r"[^\w\s-]", "", report.title or "Resume").strip()
filename = f"{safe_title}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{filename}"',
},
)
except HTTPException:
raise
except Exception:
logger.exception("Failed to compile Typst preview for report %d", report_id)
raise HTTPException(
status_code=500,
detail="Failed to compile resume preview",
) from None
@router.get("/reports/{report_id}/export")
async def export_report(
report_id: int,
@ -354,6 +407,27 @@ async def export_report(
status_code=400, detail="Report has no content to export"
)
# Typst-based reports (resumes): compile directly without Pandoc
if report.content_type == "typst":
if format != ExportFormat.PDF:
raise HTTPException(
status_code=400,
detail="Typst-based reports currently only support PDF export",
)
def _compile_typst() -> bytes:
return typst.compile(report.content.encode("utf-8"))
pdf_bytes = await asyncio.to_thread(_compile_typst)
safe_title = re.sub(r"[^\w\s-]", "", report.title or "Resume").strip()
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{safe_title}.pdf"',
},
)
# Strip wrapping code fences that LLMs sometimes add around Markdown.
# Without this, pandoc treats the entire content as a code block.
markdown_content = _strip_wrapping_code_fences(report.content)

View file

@ -23,6 +23,7 @@ class ReportRead(BaseModel):
report_style: str | None = None
report_metadata: dict[str, Any] | None = None
report_group_id: int | None = None
content_type: str = "markdown"
created_at: datetime
class Config:
@ -40,11 +41,12 @@ class ReportVersionInfo(BaseModel):
class ReportContentRead(BaseModel):
"""Schema for reading a report with full Markdown content."""
"""Schema for reading a report with full content (Markdown or Typst)."""
id: int
title: str
content: str | None = None
content_type: str = "markdown"
report_metadata: dict[str, Any] | None = None
report_group_id: int | None = None
versions: list[ReportVersionInfo] = []

View file

@ -41,6 +41,7 @@ UI_TOOLS = {
"generate_image",
"generate_podcast",
"generate_report",
"generate_resume",
"generate_video_presentation",
}
@ -239,7 +240,7 @@ async def create_snapshot(
video_presentation_ids_seen.add(vp_id)
part["result"] = {**result_data, "status": "ready"}
elif tool_name == "generate_report":
elif tool_name in ("generate_report", "generate_resume"):
result_data = part.get("result", {})
report_id = result_data.get("report_id")
if report_id and report_id not in report_ids_seen:
@ -247,7 +248,6 @@ async def create_snapshot(
if report_info:
reports_data.append(report_info)
report_ids_seen.add(report_id)
# Update status to "ready" so frontend renders ReportCard
part["result"] = {**result_data, "status": "ready"}
messages_data.append(
@ -377,6 +377,7 @@ async def _get_report_for_snapshot(
"original_id": report.id,
"title": report.title,
"content": report.content,
"content_type": report.content_type,
"report_metadata": report.report_metadata,
"report_group_id": report.report_group_id,
"created_at": report.created_at.isoformat() if report.created_at else None,

View file

@ -953,6 +953,31 @@ async def _stream_agent_events(
f"Report generation failed: {error_msg}",
"error",
)
elif tool_name == "generate_resume":
yield streaming_service.format_tool_output_available(
tool_call_id,
tool_output
if isinstance(tool_output, dict)
else {"result": tool_output},
)
if (
isinstance(tool_output, dict)
and tool_output.get("status") == "ready"
):
yield streaming_service.format_terminal_info(
f"Resume generated: {tool_output.get('title', 'Resume')}",
"success",
)
else:
error_msg = (
tool_output.get("error", "Unknown error")
if isinstance(tool_output, dict)
else "Unknown error"
)
yield streaming_service.format_terminal_info(
f"Resume generation failed: {error_msg}",
"error",
)
elif tool_name in (
"create_notion_page",
"update_notion_page",