Skip to content
Open
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
14 changes: 14 additions & 0 deletions 4_langgraph/community_contributions/sammyloto/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Required
OPENAI_API_KEY=

# Optional: defaults to gpt-4o-mini
OPENAI_MODEL=gpt-4o-mini

# Optional: better web search than the free fallback (DuckDuckGo)
# Get a key at https://serper.dev
SERPER_API_KEY=

# LangSmith (optional, Week 4 observability)
# LANGCHAIN_TRACING_V2=true
# LANGCHAIN_API_KEY=
# LANGCHAIN_PROJECT=sammyloto-career-sprint
7 changes: 7 additions & 0 deletions 4_langgraph/community_contributions/sammyloto/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.env
checkpoints/
*.db
sandbox/*
!sandbox/.gitkeep
60 changes: 60 additions & 0 deletions 4_langgraph/community_contributions/sammyloto/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Career Sprint Sidekick (Week 4 community contribution)

A **bounded career task** assistant: research a company and role, align with a local profile, and produce talking points plus a short outreach draft - validated by a **worker -> tools -> evaluator** loop and **SQLite checkpointing**. Built for *Master AI Agents in 30 days* (LangGraph week).

## What this project demonstrates


| Week 4 theme | Here |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| **Days 1-2 - Graph + state** | `CareerSprintState` (`TypedDict`) with `add_messages` reducer; explicit nodes and edges. |
| **Day 3 - Persistence** | `AsyncSqliteSaver` -> `checkpoints/career_sprint.db` (WAL mode). |
| **Day 3 - Conditional routing** | Worker routes to `ToolNode` or evaluator; evaluator routes to `END` or back to worker. |
| **Day 4 - Structured outputs** | `SprintRubricAssessment` (Pydantic) for the evaluator; Gradio UI. |
| **Day 5 - Tools** | Web search (Serper or DuckDuckGo fallback), `read_career_profile`, Wikipedia, Python REPL, sandbox file tools. |


**Not included:** Playwright browser automation (optional in the course). Search + file + profile read cover the "tooling" learning goals without a browser install.

## Layout

- `graph.py` - LangGraph definition, default sprint rubric, `CareerSprintGraph.run_turn`.
- `tools.py` - Tool wiring; sandbox root is `sandbox/`; optional profile is `me/summary.txt`.
- `app.py` - Gradio app: **one `thread_id` per UI session** (reset starts a new thread and new compiled app instance).
- `checkpoints/` - Created at runtime (gitignored) for SQLite checkpoints.

## Setup

```bash
cd 4_langgraph/community_contributions/sammyloto_career_sprint
python3 -m venv .venv && source .venv/bin/activate # optional
pip install -r requirements.txt
cp .env.example .env
# Set OPENAI_API_KEY; optionally SERPER_API_KEY for stronger search
```

## Run

```bash
python app.py
```

Open the URL shown (default `http://127.0.0.1:7860`). Fill **company** and **role**, adjust **success criteria** if you like, then send a sprint request.

## Optional: personal profile (continuity with Week 1 style RAG)

Add a short bio under `me/summary.txt` (skills, goals, voice). The `read_career_profile` tool lets the worker tailor pitches without bundling Chroma in this submission.

## Optional: LangSmith

To trace runs in LangSmith, set in `.env`:

```env
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=your_key
LANGCHAIN_PROJECT=sammyloto-career-sprint
```

## Attribution

Patterns are **inspired by** the course Sidekick lab (`4_lab4` / `sidekick.py`)
165 changes: 165 additions & 0 deletions 4_langgraph/community_contributions/sammyloto/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Gradio UI: isolated thread IDs + SQLite-backed LangGraph runs."""

from __future__ import annotations

import os
import sys
from typing import Optional

import gradio as gr
from dotenv import load_dotenv
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage

# Ensure this folder is on path when executed from repo root
_ROOT = os.path.dirname(os.path.abspath(__file__))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)

from graph import CareerSprintGraph, DEFAULT_SPRINT_CRITERIA, new_thread_id

load_dotenv(override=True)


def messages_to_chat_ui(messages: list[BaseMessage]) -> list[dict]:
"""Flatten LangChain messages into Gradio chatbot dicts."""
out: list[dict] = []
for m in messages:
if isinstance(m, HumanMessage):
out.append({"role": "user", "content": m.content or ""})
elif isinstance(m, AIMessage):
text = (m.content or "").strip() or "[model used tools]"
out.append({"role": "assistant", "content": text})
elif isinstance(m, ToolMessage):
snippet = (m.content or "")[:1200]
out.append({"role": "assistant", "content": f"_(tool result)_\n{snippet}"})
return out


async def setup_app() -> tuple[CareerSprintGraph, str, list]:
app = CareerSprintGraph()
await app.setup()
tid = new_thread_id()
return app, tid, []


async def on_message(
app: CareerSprintGraph,
thread_id: str,
lc_history: list[BaseMessage],
user_text: str,
success_criteria: str,
company: str,
role: str,
):
if app is None:
err = [
{
"role": "assistant",
"content": "App is still loading. Wait until startup finishes, then try again.",
}
]
return err, lc_history, thread_id, app
if not user_text or not str(user_text).strip():
return gr.update(), lc_history, thread_id, app

result = await app.run_turn(
user_text.strip(),
success_criteria,
company,
role,
lc_history,
thread_id,
)
new_lc: list[BaseMessage] = list(result["messages"])
chat = messages_to_chat_ui(new_lc)
return chat, new_lc, thread_id, app


async def on_reset(_app: Optional[CareerSprintGraph]):
"""New graph instance + thread so prior checkpoints do not leak into the next demo."""
new_app = CareerSprintGraph()
await new_app.setup()
tid = new_thread_id()
if _app is not None:
await _app.aclose()
return (
new_app,
tid,
[],
[],
gr.update(value=""),
gr.update(value=""),
gr.update(value=""),
gr.update(value=DEFAULT_SPRINT_CRITERIA),
)


def main() -> None:
with gr.Blocks(
title="Career Sprint Sidekick",
theme=gr.themes.Default(primary_hue="teal"),
) as demo:
gr.Markdown(
"## Career Sprint Sidekick\n"
"LangGraph + tools + rubric loop + SQLite checkpoints. "
"Optional **me/summary.txt** personalizes output. Writes packs under **sandbox/**."
)
app_state = gr.State()
thread_state = gr.State()
lc_state = gr.State([])

with gr.Row():
company = gr.Textbox(label="Target company", placeholder="e.g. Acme Labs")
role = gr.Textbox(label="Target role", placeholder="e.g. Backend Engineer")
success_criteria = gr.Textbox(
label="Success criteria (optional)",
lines=4,
value=DEFAULT_SPRINT_CRITERIA,
)
chat = gr.Chatbot(label="Sprint thread", height=420)
user_in = gr.Textbox(
label="Your message",
placeholder="Describe the sprint, e.g. research + talking points + outreach note...",
lines=2,
)
with gr.Row():
go = gr.Button("Run sprint turn", variant="primary")
reset = gr.Button("New session", variant="secondary")

demo.load(
setup_app,
[],
[app_state, thread_state, lc_state],
time_limit=180,
)

go.click(
on_message,
[app_state, thread_state, lc_state, user_in, success_criteria, company, role],
[chat, lc_state, thread_state, app_state],
time_limit=600,
show_progress="minimal",
)
user_in.submit(
on_message,
[app_state, thread_state, lc_state, user_in, success_criteria, company, role],
[chat, lc_state, thread_state, app_state],
time_limit=600,
show_progress="minimal",
)
reset.click(
on_reset,
[app_state],
[app_state, thread_state, lc_state, chat, user_in, company, role, success_criteria],
time_limit=180,
)

demo.queue()

host = os.getenv("GRADIO_SERVER_NAME", "127.0.0.1")
port = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
demo.launch(server_name=host, server_port=port, share=False)


if __name__ == "__main__":
main()
Loading