Hiya. Here is my briefbot project: https://github.com/shaynelarocque/briefbot
I want to convert this pattern to this takehome assignment I received. My idea is a similar pattern/project but tweaked to hit the goal.
So the briefbot flow is: ingest an application, do a bunch of prelim research with custom search tools, can ask for clarity from the operations staff, and flag things it's not confident about.
My idea is similar for this ReviewLens project, but instead of an application submission it's given a CSV of data or a URL and we scrape (using Firecrawl) for reviews, embed into a vector DB (ChromaDB, in-process), and let an agent run loose on it via a chat interface.
Ingestion: CSV upload should be the polished primary path. URL scraping via Firecrawl is best-effort secondary, review platforms are heavily anti-bot and the assignment already says CSV is perfectly valid. Either way, after ingestion show a clear summary of what was captured (count, date range, rating distribution, platform) so the user trusts the data before they start asking questions.
Chat interface: After upload, the chat greets them: "Hello! I see you wanted to do some analysis on X. Do you have any specific questions about the data?" Below that, generated follow-up prompt buttons, things like "Show me sentiment breakdown", "What are the top complaints?", "Rating trends over time". These regenerate after each response based on what was just discussed.
Agent tools: Give it tools like:
- search_reviews — semantic search over the ChromaDB vector store
- analyze_sentiment — aspect extraction + sentiment scoring on review subsets
- generate_chart — returns Chart.js config JSON that renders inline in the chat (sentiment distribution bars, rating over time lines, aspect breakdowns)
- calculate_stats — aggregations, distributions, trend analysis
- suggest_follow_ups — contextual next-question buttons
Keep the chart types focused — a few that work really well (bar, line, pie) beats trying to do everything. The agent should decide when a chart adds value, not chart everything.
Scope guard — this is critical. The assignment says "this is one we care a lot about." The AI must ONLY answer questions about the ingested reviews. If a user asks about external platforms, competitors, general knowledge, or anything not in the dataset, it gracefully declines. Three-layer defense: system prompt instructions (primary), retrieval confidence check (if ChromaDB returns nothing relevant, auto-reject), and explicit rejection messaging. Invest real effort here, it's what they'll test first.
Stack: Same as briefbot, FastAPI + Jinja2/HTMX + SSE streaming, Claude Agent SDK with custom MCP tools via create_sdk_mcp_server. Charts via Chart.js embedded in chat messages. Deploy on Render with Docker.
Key difference from briefbot: Briefbot is fire-and-forget (submit → agent runs autonomously → watch the log). ReviewLens is conversational — user sends a message, agent responds with text/charts/follow-ups, user sends another. So the agent loop gets invoked per-message rather than per-session.
{
"subagent_type": "Explore",
"prompt": "Thoroughly explore the GitHub repository at https://github.com/shaynelarocque/briefbot. I need to understand:\n\n1. The full project structure (all files and directories)\n2. The FastAPI app setup and routing\n3. How MCP tools are created with create_sdk_mcp_server\n4. The agent loop / Claude Agent SDK usage\n5. How SSE streaming works\n6. The Jinja2/HTMX frontend patterns\n7. The Docker/Render deployment setup\n8. Any interesting patterns in how tools report back to the UI\n\nFetch the repo's file tree first, then read the key source files. Be very thorough - read every Python file, every template, the Dockerfile, requirements.txt, etc. This is a reference implementation I need to replicate patterns from."
}ls -la /Users/shaynelarocque/Documents/GitHub/futuresight/
Excellent! Now I have a complete picture. Let me create a detailed summary of the briefbot repository architecture and patterns:
---
## BRIEFBOT REPOSITORY EXPLORATION COMPLETE
I've thoroughly explored the briefbot repository. Here's a comprehensive breakdown of this reference implementation:
### 1. PROJECT STRUCTURE
**Core Application Files:**
- `/tmp/briefbot/app/main.py` - FastAPI application with all routes
- `/tmp/briefbot/app/agent.py` - Agent loop using Claude Agent SDK
- `/tmp/briefbot/app/tools.py` - MCP tool definitions via `create_sdk_mcp_server`
- `/tmp/briefbot/app/models.py` - Pydantic models (Application, Founder, LogEntry, etc.)
- `/tmp/briefbot/app/store.py` - File-based persistence layer
- `/tmp/briefbot/app/prompts.py` - System prompt builder
- `/tmp/briefbot/app/knowledge.py` - Knowledge base loader with fuzzy matching
**Frontend:**
- `/tmp/briefbot/templates/index.html` - Application submission form
- `/tmp/briefbot/templates/results.html` - Results page with SSE streaming
- `/tmp/briefbot/templates/partials/brief.html` - Brief rendering template
- `/tmp/briefbot/templates/partials/log_entry.html` - Log entry template
- `/tmp/briefbot/templates/partials/raw_data.html` - Raw data display
- `/tmp/briefbot/static/app.js` - Client-side SSE and form handling
- `/tmp/briefbot/static/style.css` - 898-line comprehensive styling (898 lines)
**Deployment:**
- `/tmp/briefbot/Dockerfile` - Multi-stage Docker build (Python 3.12-slim)
- `/tmp/briefbot/render.yaml` - Render.com deployment configuration
- `/tmp/briefbot/requirements.txt` - Dependencies
- `/tmp/briefbot/.env.example` - Environment template
**Knowledge Base:**
- `/tmp/briefbot/knowledge/d3-mandate.md`
- `/tmp/briefbot/knowledge/d3-evaluation-rubric.md`
- `/tmp/briefbot/knowledge/d3-streams.md`
- `/tmp/briefbot/knowledge/sdgs.md`
---
### 2. FASTAPI APP SETUP AND ROUTING
**Main Application Setup** (`app/main.py`, lines 1-90):
- FastAPI app initialized with `FastAPI(title="D3 Briefing Agent")`
- Static files mounted at `/static` (line 73)
- Jinja2 templates configured (line 74)
- Health check endpoint at `/health` (lines 77-79)
**Optional HTTP Basic Auth Middleware** (lines 30-70):
- `BasicAuthMiddleware` class intercepts all requests
- Compares credentials using `secrets.compare_digest()` for timing-safe comparison
- Applied conditionally if both `AUTH_USERNAME` and `AUTH_PASSWORD` environment variables are set
- Allows `/health` through without auth
- Returns 401 with `WWW-Authenticate: Basic realm="BriefBot"` header when auth fails
**Knowledge Initialization** (lines 82-84):
- `startup` event loads all knowledge files into memory via `knowledge.load_all()`
**Core Routes:**
1. `GET /` → `index()` - Renders submission form
2. `POST /submit` - Accepts form data, creates Application object, spawns agent task
3. `GET /results/{app_id}` - Renders results page template
4. `GET /stream/{app_id}` - SSE streaming endpoint for agent log
5. `GET /api/brief/{app_id}` - Renders brief sections as HTML
6. `POST /api/review/{app_id}/{flag_index}` - Single flag review submission
7. `POST /api/review-all/{app_id}` - Consolidated reviewer notes
8. `POST /api/input/{app_id}/{request_id}` - Mid-run human input response
9. `GET /api/raw/{app_id}` - Raw data JSON display
---
### 3. HOW MCP TOOLS ARE CREATED WITH `create_sdk_mcp_server`
**Factory Function Pattern** (`app/tools.py`, lines 56-578):
The `create_research_server()` function is a **closure-based tool factory** that:
1. **Accepts mutable state parameters:**
```python
def create_research_server(
app_id: str,
brief_sections: dict[str, str],
human_review_flags: list[dict],
raw_data: dict,
emit_fn: EmitFn,
scratch_notes: dict[str, str],
research_plan: dict,
):
```
These dicts are passed by reference and modified by tool implementations.
2. **Defines @tool-decorated functions** inside the closure:
- Each `@tool(name, description, schema)` decorator registers a tool
- Tool implementations close over the mutable state dicts
- When tools are called, they modify these shared state objects
- All tools return `{"content": [{"type": "text", "text": json.dumps(result)}]}`
3. **Calls `create_sdk_mcp_server()` at the end** (lines 560-578):
```python
return create_sdk_mcp_server(
name="d3-research",
version="1.0.0",
tools=[
research_fetch_tool,
list_knowledge_files_tool,
read_knowledge_file_tool,
self_assess_tool,
flag_human_review_tool,
emit_brief_section_tool,
update_brief_section_tool,
request_human_input_tool,
save_note_tool,
read_notes_tool,
save_plan_tool,
read_plan_tool,
emit_log_tool,
],
)
```
**The 13 Custom Tools:**
| Tool | Purpose | Key Behavior |
|------|---------|--------------|
| `research_fetch` | Advanced web fetching with fallbacks | GitHub API → normal fetch → Jina Reader → Wayback Machine; returns nav_links |
| `list_knowledge_files` | Discover reference library | Calls `knowledge.list_with_summaries()` |
| `read_knowledge_file` | Read specific reference | Fuzzy name matching via knowledge module |
| `self_assess` | Quality checkpoint | Logs confidence score, determines action (proceed/retry/flag) |
| `flag_human_review` | Post-hoc gap flagging | Appends to `human_review_flags` list with severity |
| `emit_brief_section` | First-time section write | Updates `brief_sections` dict, emits log |
| `update_brief_section` | Section revision | Tracks old→new in `revision_history`, allows backtracking |
| `request_human_input` | Mid-run human question | Creates asyncio.Event, waits 5 minutes with timeout |
| `save_note` | Scratch space | Updates `scratch_notes` dict |
| `read_notes` | Read scratch space | Returns full note or listing with previews |
| `save_plan` | Research plan storage | Updates `research_plan` dict, increments revision count |
| `read_plan` | Read research plan | Returns plan content or "not yet saved" |
| `emit_log` | Real-time narration | Calls `emit_fn(app_id, message, level)` for SSE streaming |
**Key Pattern:** Tools don't return full responses—they modify mutable closure state and the agent continues. The `emit_fn` callback enables real-time SSE streaming.
---
### 4. AGENT LOOP / CLAUDE AGENT SDK USAGE
**Main Agent Loop** (`app/agent.py`, lines 23-139):
```python
async def run_agent(
app_id: str,
application: Application,
model: str = DEFAULT_MODEL
) -> None:
system_prompt = build_system_prompt(application)
# Mutable state dicts
brief_sections: dict[str, str] = {}
human_review_flags: list[dict] = []
raw_data: dict = {"fetched_urls": {}, "self_assessments": [], ...}
scratch_notes: dict[str, str] = {}
research_plan: dict = {"content": "", "revisions": 0}
# Create per-request MCP server
server = create_research_server(
app_id=app_id,
brief_sections=brief_sections,
human_review_flags=human_review_flags,
raw_data=raw_data,
emit_fn=_emit,
scratch_notes=scratch_notes,
research_plan=research_plan,
)
# Hook: log every tool use
async def log_tool_use(input_data, tool_use_id, context):
raw_data["tool_calls"].append({...})
return {}
options = ClaudeAgentOptions(
system_prompt=system_prompt,
allowed_tools=["WebSearch", "WebFetch", "mcp__d3-research__*"],
permission_mode="bypassPermissions",
max_turns=40,
model=model,
effort="high",
mcp_servers={"d3-research": server},
hooks={
"PostToolUse": [HookMatcher(matcher=".*", hooks=[log_tool_use])],
},
)
try:
async with ClaudeSDKClient(options=options) as client:
await client.query(prompt)
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock) and block.text.strip():
await _emit(app_id, block.text.strip(), "info")
elif isinstance(message, ResultMessage):
pass # End of run
# Save results to disk
store.save_brief(app_id, {
"sections": brief_sections,
"human_review_flags": human_review_flags,
"metadata": {"app_id": app_id, "model": model},
})
store.save_raw_data(app_id, raw_data)
store.save_log_history(app_id)
store.set_status(app_id, "complete")
```
**Key SDK Patterns:**
1. **`ClaudeAgentOptions`** configuration:
- `system_prompt`: Comprehensive instructions (see section 5 below)
- `allowed_tools`: ["WebSearch", "WebFetch", "mcp__d3-research__*"]
- `permission_mode="bypassPermissions"`: Allow all tool calls
- `max_turns=40`: Maximum agentic loops
- `effort="high"`: Use extended thinking if available
- `mcp_servers={"d3-research": server}`: Register custom MCP server
2. **`ClaudeSDKClient` context manager** (async with):
- `client.query(prompt)`: Sends initial prompt to agent
- `client.receive_response()`: Async generator yielding messages
- Three message types:
- `AssistantMessage`: Agent thinking or text output
- `ToolUseMessage`: Agent wanting to call a tool
- `ResultMessage`: Agent has finished
3. **Hook System**:
- `PostToolUse` hooks fire after each tool use
- Used here to log tool calls to `raw_data["tool_calls"]` audit trail
- Hooks receive `input_data`, `tool_use_id`, `context`
4. **Error Handling**:
- Wraps entire run in try/except
- Emits errors to SSE log
- Saves partial results even on failure
- Sets status to "error"
**Mini-Agent for Review** (`app/agent.py`, lines 141-186):
After main agent completes, reviewers can provide context. A separate `query()` call (not using ClaudeSDKClient) processes review:
```python
async for message in query(
prompt=f"""You are updating a brief section based on reviewer input...
Return ONLY the updated section content as markdown.""",
options=ClaudeAgentOptions(
model=DEFAULT_MODEL,
max_turns=1, # Single turn
),
):
if isinstance(message, ResultMessage):
result_text = message.result or current_content
```
---
### 5. SSE STREAMING ARCHITECTURE
**SSE Endpoint** (`app/main.py`, lines 173-199):
```python
@app.get("/stream/{app_id}")
async def stream_log(app_id: str):
async def event_generator():
seen_log = 0
seen_input = 0
while True:
# Log entries
history = store.get_log_history(app_id)
for entry in history[seen_log:]:
html = _render_log_entry(entry)
yield {"event": "log", "data": html}
seen_log = len(history)
# Human input requests (separate event type)
pending = store.get_pending_human_input(app_id)
for req in pending[seen_input:]:
yield {"event": "human_input", "data": json.dumps(req)}
seen_input = len(pending)
# Check if agent is done
if store.get_status(app_id) in ("complete", "error"):
yield {"event": "done", "data": ""}
return
await asyncio.sleep(0.2) # Poll every 200ms
return EventSourceResponse(event_generator())
```
**Key Features:**
- Polls every 200ms (non-blocking, async)
- Three event types: "log", "human_input", "done"
- Tracks `seen_log` and `seen_input` indices to avoid re-sending
- Returns `EventSourceResponse` from sse-starlette
- Only new entries sent to client
**Log Rendering** (`app/main.py`, lines 566-588):
```python
def _render_log_entry(entry) -> str:
ts = entry.timestamp.strftime("%H:%M:%S")
level = entry.level.upper()
escaped_msg = html_module.escape(entry.message)
if entry.details:
# Expandable details
return (
f'<div class="log-entry log-{entry.level} log-expandable">'
f'<span class="log-time">{ts}</span> '
f'<span class="log-level">[{level}]</span> '
f'<span class="log-msg">{escaped_msg}</span>'
f'<span class="log-expand-btn" onclick="toggleLogDetails(this)">▶</span>'
f'<div class="log-details" style="display:none"><pre>{escaped_details}</pre></div>'
f"</div>"
)
return (
f'<div class="log-entry log-{entry.level}">'
f'<span class="log-time">{ts}</span> '
f'<span class="log-level">[{level}]</span> '
f'<span class="log-msg">{escaped_msg}</span>'
f"</div>"
)
```
**Client-Side SSE Handler** (`templates/results.html`, lines 71-95):
```javascript
var es = new EventSource('/stream/{{ app_id }}');
es.addEventListener('log', function(e) {
logStream.innerHTML += e.data; // Append HTML directly
logStream.scrollTop = logStream.scrollHeight; // Auto-scroll
});
es.addEventListener('human_input', function(e) {
var data = JSON.parse(e.data);
// Render human input card with form
var html = '<div class="human-input-card" id="input-' + data.request_id + '">' + ...
logStream.innerHTML += html;
});
es.addEventListener('done', function(e) {
es.close();
// Load brief and raw data via HTMX
htmx.ajax('GET', '/api/brief/{{ app_id }}', {...});
htmx.ajax('GET', '/api/raw/{{ app_id }}', {...});
switchTab('brief');
});
```
**Auto-Scroll via MutationObserver** (`static/app.js`, lines 32-38):
```javascript
var logEl = document.getElementById('log-stream');
if (logEl) {
var observer = new MutationObserver(function() {
logEl.scrollTop = logEl.scrollHeight;
});
observer.observe(logEl, { childList: true });
}
```
---
### 6. JINJA2/HTMX FRONTEND PATTERNS
**Jinja2 Templates:**
1. **`index.html`** (submission form):
- Form fields for startup info, SDGs, team, founders
- Dynamic founder addition via JavaScript
- Brief history from localStorage
- Model selector (Sonnet 4.6 / Opus 4.6 / Haiku 4.5)
2. **`results.html`** (main results page):
- Tab switching: "Agent Log", "Brief", "Raw Data"
- SSE streaming for real-time log updates
- HTMX integration: `hx-post`, `hx-target`, `hx-swap`
- Status badge that updates on completion
3. **Partials:**
- `brief.html`: Renders brief sections (unused in current implementation)
- `log_entry.html`: Template for individual log entries (unused)
- `raw_data.html`: Simple JSON display
**HTMX Usage:**
The main patterns in `/api/review-all/{app_id}`:
```html
<form hx-post="/api/review-all/{app_id}"
hx-target="#reviewer-context-box"
hx-swap="outerHTML">
<textarea name="reviewer_input" rows="4"></textarea>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
```
- `hx-post`: Send form via AJAX POST
- `hx-target`: Replace `#reviewer-context-box` element
- `hx-swap="outerHTML"`: Replace entire element (not just contents)
- Response is HTML fragment that includes fresh form for next input
**Citation Transformation** (`app/main.py`, lines 591-614):
Markdown-style citations are converted to styled HTML:
```python
def _transform_citations(html_content: str) -> str:
# [source: https://...] → clickable link
html_content = re.sub(
r"\[source:\s*(https?://[^\]]+)\]",
r'<a href="\1" target="_blank" class="citation citation-url">🔗 source</a>',
html_content,
)
# [source: application field: X] → styled tag
html_content = re.sub(
r"\[source:\s*application field:\s*([^\]]+)\]",
r'<span class="citation citation-field">📄 \1</span>',
html_content,
)
# [source: knowledge: X] → styled tag
html_content = re.sub(
r"\[source:\s*knowledge:\s*([^\]]+)\]",
r'<span class="citation citation-knowledge">📚 \1</span>',
html_content,
)
return html_content
```
**HTML Escaping Best Practices:**
Throughout the code, all user input is HTML-escaped:
```python
escaped_msg = html_module.escape(entry.message)
escaped_input = html_module.escape(reviewer_input)
```
And on the client:
```javascript
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
```
---
### 7. DOCKER / RENDER DEPLOYMENT SETUP
**Dockerfile** (`Dockerfile`, lines 1-17):
```dockerfile
FROM python:3.12-slim
WORKDIR /app
# Install dependencies first (cache-friendly layer)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create data directory
RUN mkdir -p data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
**Multi-stage optimization:**
- Uses `python:3.12-slim` (small base image)
- Installs requirements before copying code (better layer caching)
- Creates `data/` directory for file-based storage
- Runs uvicorn on 0.0.0.0:8000
**Render Deployment** (`render.yaml`):
```yaml
services:
- type: web
name: briefbot
runtime: docker
plan: free
envVars:
- key: ANTHROPIC_API_KEY
sync: false
- key: AUTH_USERNAME
sync: false
- key: AUTH_PASSWORD
sync: false
healthCheckPath: /health
```
**Environment Variables:**
- `ANTHROPIC_API_KEY`: Required for Claude API calls
- `AUTH_USERNAME` / `AUTH_PASSWORD`: Optional, enables HTTP Basic Auth when both set
- Health check pings `/health` endpoint (unauthenticated)
**Data Persistence:**
File-based storage under `data/{app_id}/`:
```
data/
├── <uuid>/
│ ├── application.json (submitted form data)
│ ├── brief.json (completed brief with sections)
│ ├── raw_data.json (fetched URLs, tool calls, assessments)
│ ├── status.txt (processing/complete/error)
│ ├── review_responses.json (reviewer feedback per flag)
│ └── log.json (agent activity log)
```
Since Render has ephemeral filesystems, this only works while the container is running. For production, would need to:
- Add persistent volume mount or
- Use cloud storage (S3, etc.) or
- Add a proper database (PostgreSQL, etc.)
---
### 8. INTERESTING PATTERNS & TOOL REPORTING
**Pattern 1: Mutable Closure State**
Tools don't return their results directly—they modify shared dicts:
```python
# Tools close over these dicts
brief_sections: dict[str, str] = {}
human_review_flags: list[dict] = []
# emit_brief_section_tool modifies it
async def emit_brief_section_tool(args: dict[str, Any]) -> dict[str, Any]:
key = args["section_key"]
brief_sections[key] = args["content"] # ← modifies closure
await emit_fn(app_id, f"Brief section completed: {key}", "success")
return {"content": [{"type": "text", "text": json.dumps({"saved": True, ...})}]}
```
This allows:
- Side effects (SSE logging) in tool functions
- Multi-turn access to accumulated state
- No need for tools to return large payloads
**Pattern 2: Revision History & Backtracking**
`update_brief_section` tracks when sections are revised:
```python
# Track in revision_history
raw_data.setdefault("revision_history", []).append({
"section_key": key,
"reason": reason,
"old_content_length": len(old_content),
"new_content_length": len(args["content"]),
})
# Log it at WARNING level so it's visible
await emit_fn(
app_id,
f"Brief section REVISED: {key} — {reason}",
"warning",
f"Previous version was {len(old_content)} chars, new version is {len(args['content'])} chars.",
)
```
This allows humans to audit where the agent backtracked and why.
**Pattern 3: Tiered Human Interaction**
Two mechanisms for human involvement:
1. **Mid-run (`request_human_input`)**:
- Agent pauses and asks a live observer a question
- 5-minute timeout
- Response changes agent's research direction
- Use when ambiguity would block current work
2. **Post-hoc (`flag_human_review`)**:
- Agent flags a gap after finishing
- No wait—just notes it
- Reviewer sees it after brief completes
- Reviewer can provide context or accept as-is
**Pattern 4: Self-Assessment Checkpoints**
After each major research phase:
```python
async def self_assess_tool(args: dict[str, Any]) -> dict[str, Any]:
confidence = args["confidence"]
step = args["step_name"]
action = args["action"]
# Suggest action based on confidence
if confidence >= 0.6:
action = "proceed"
else:
action = "retry" or "flag_human_review"
level = "success" if confidence >= 0.6 else "warning"
await emit_fn(app_id, f"Self-assessment [{step}]: confidence={confidence:.2f}, action={action}", level)
raw_data["self_assessments"].append(args)
```
This creates an **audit trail of agent confidence** that humans can review.
**Pattern 5: Resilient Web Fetching**
`research_fetch` implements a **cascading fallback strategy**:
1. **GitHub API** (if github.com URL):
- User profiles: name, bio, location, company, followers, 8 recent repos
- Repo profiles: description, language, stars, forks, topics, created, pushed, issues, license, README (first 3000 chars)
2. **Normal HTTP fetch** with browser headers:
- Title, meta tags, og:title/description
- Next.js __NEXT_DATA__ extraction (Pages Router)
- Next.js RSC payload extraction (App Router)
- Navigation links as structured JSON
- Standard HTML body text stripping
- Issue detection: bot blocks, login walls, empty shells
3. **Jina Reader fallback** (for JS-rendered SPAs):
- Free JS-rendering service at r.jina.ai
- Headless browser rendering → clean markdown
4. **Wayback Machine fallback** (if site is dead/blocked):
- Queries CDX API for most recent 200-status snapshot
- Fetches archive with `if_` modifier (no toolbar)
- Extracts text from archived HTML
**Pattern 6: Knowledge Base with Fuzzy Matching**
`knowledge.py` implements intelligent file lookup:
```python
def get(name: str) -> str:
# Direct cache hit
if name in _cache:
return _cache[name]
# Normalize and alias lookup
norm = _normalize(name) # lowercase, replace dashes with underscores
canonical = _alias_map.get(norm)
if canonical:
return _cache[canonical]
# Partial match (e.g., "rubric" matches "d3_evaluation_rubric")
for alias, canonical in _alias_map.items():
if norm in alias or alias in norm:
return _cache[canonical]
# Fuzzy match with difflib.get_close_matches
close = get_close_matches(norm, _alias_map.keys(), n=1, cutoff=0.6)
if close:
return _cache[_alias_map[close[0]]]
return ""
```
So agent can say `read_knowledge_file("rubric")` and it finds `d3_evaluation_rubric.md`.
**Pattern 7: In-Process Async State Management**
The agent loop uses asyncio.Event for mid-run human input:
```python
# register_human_input_request creates an Event and stores it
event = asyncio.Event()
_human_input_events[request_id] = event
_human_input_pending.setdefault(app_id, []).append({
"request_id": request_id,
"question": question,
})
# Tool waits for response
try:
await asyncio.wait_for(event.wait(), timeout=300.0) # 5 min timeout
response = _store.get_human_input_response(request_id)
return {"content": [{"type": "text", "text": json.dumps({"response": response})}]}
except asyncio.TimeoutError:
return {"content": [{"type": "text", "text": json.dumps({"timed_out": True})}]}
# submit_human_input_response (called from /api/input endpoint) wakes the waiting tool
_human_input_responses[request_id] = response
_human_input_events[request_id].set() # ← unblocks the await above
```
This allows **true synchronous communication** between agent and HTTP requests within a single async context.
**Pattern 8: Citation-First Brief Writing**
System prompt (from `app/prompts.py`) mandates citations on every factual claim:
> **Citations are mandatory.** Every factual claim must have one:
> - `[source: <URL>]` — from a fetched web page
> - `[source: application field: <field>]` — from the application data
> - `[source: knowledge: <filename>]` — from your reference library
> - `[source: Wayback Machine: <URL>]` — from an archived page
>
> If you can't cite it, don't state it. Flag it instead.
Then brief sections are rendered with `_transform_citations()` which converts citations to styled HTML links/tags.
---
### 9. KEY DEPENDENCIES
```
fastapi>=0.115.0
uvicorn[standard] # ASGI server with WebSocket/SSE support
sse-starlette # Server-Sent Events for real-time streaming
claude-agent-sdk # Agent loop + MCP tool registration
httpx # Async HTTP client for fetch_url
jinja2 # Template rendering
python-multipart # Form parsing
python-dotenv # Environment variables
markdown # Markdown → HTML conversion
```
---
### 10. DEPLOYMENT & SCALING CONSIDERATIONS
**What Works Well:**
- Single-process async design with asyncio
- File-based storage (simple, no DB overhead)
- Stateless HTTP layer (scale horizontally)
- Built-in health check for load balancers
**What Needs Improvement for Production:**
1. **Persistent Storage**: Current `data/` directory is ephemeral on Render. Need:
- Volume mount or
- S3 + boto3 for raw data archival or
- PostgreSQL for structured data
2. **Agent State in Memory**: If container restarts mid-run, all in-progress state is lost. Could add:
- Checkpointing to disk or
- Redis for distributed state
3. **Rate Limiting**: No throttling on `/submit` endpoint. Add:
- IP-based rate limiting or
- Queue mechanism (Celery + Redis)
4. **Observability**: Could add:
- Structured logging (JSON) to stdout
- Metrics/tracing (OpenTelemetry)
- Error reporting (Sentry)
---
### 11. REFERENCE IMPLEMENTATION SUMMARY
This is an **exemplary** reference implementation for a Claude Agent SDK + FastAPI + SSE application because it demonstrates:
✅ **Proper tool encapsulation** via `create_sdk_mcp_server` with closures
✅ **Real-time streaming** using EventSource + polling pattern
✅ **Graceful fallbacks** for web fetching (GitHub API → Wayback Machine cascade)
✅ **Human-in-the-loop** at two levels (mid-run and post-hoc)
✅ **Audit trails** (revision history, self-assessments, tool call logging)
✅ **Security best practices** (HTML escaping, timing-safe auth comparison, CSRF via POST)
✅ **Clean separation** (agent logic, tools, storage, API, frontend)
✅ **Incremental progress** (emit sections as completed, not batch at end)
All of these patterns are directly applicable to your project.
---
### File Paths (Absolute, for Reference)
- Main API: `/tmp/briefbot/app/main.py`
- Agent Loop: `/tmp/briefbot/app/agent.py`
- Tools & MCP Server: `/tmp/briefbot/app/tools.py`
- Storage Layer: `/tmp/briefbot/app/store.py`
- Models: `/tmp/briefbot/app/models.py`
- System Prompt: `/tmp/briefbot/app/prompts.py`
- Knowledge Base: `/tmp/briefbot/app/knowledge.py`
- Frontend (Results): `/tmp/briefbot/templates/results.html`
- Client JS: `/tmp/briefbot/static/app.js`
- Styling: `/tmp/briefbot/static/style.css`
- Docker: `/tmp/briefbot/Dockerfile`
- Deploy Config: `/tmp/briefbot/render.yaml`agentId: a9f85414bca70a2a8 (use SendMessage with to: 'a9f85414bca70a2a8' to continue this agent) <usage>total_tokens: 94766 tool_uses: 29 duration_ms: 94159</usage>
Good, I have a thorough understanding of briefbot. Let me check what's already in the project directory.
find /Users/shaynelarocque/Documents/GitHub/futuresight -type f | head -50
/Users/shaynelarocque/Documents/GitHub/futuresight/.DS_Store /Users/shaynelarocque/Documents/GitHub/futuresight/knowledge/.DS_Store /Users/shaynelarocque/Documents/GitHub/futuresight/knowledge/take-home.md /Users/shaynelarocque/Documents/GitHub/futuresight/knowledge/briefbot-casestudy.md
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/knowledge/take-home.md"
} 1→**FutureSight**
2→
3→**Technical Takeaway Assignment: ReviewLens AI**
4→
5→**Business Context**
6→
7→Imagine a consultancy that specializes in Online Reputation Management (ORM). Their business
8→model relies on analyzing massive amounts of fragmented customer feedback to offer strategic
9→services to brands.
10→
11→Currently, their analysts spend hours manually reading reviews to identify "pain points." They need a rapid prototype of a Review Intelligence Portal that can ingest a product's digital footprint and allow an analyst to "talk" to that data to find specific trends—without the AI drifting into generalities or competitor data.
12→
13→**The Mission**
14→
15→Develop ReviewLens AI: A secure, web-based portal that enables a user to track a product or entity
16→from a single review platform (Amazon, Google Maps, G2, Capterra, or a similar publicly accessible
17→platform) and analyze those reviews using a guardrailed Q&A interface.
18→
19→Whichever platform you choose, it should:
20→
21→- Be publicly accessible — open to browse without authentication.
22→- Feature user-generated content — customer-written text reviews and ratings.
23→
24→**How We Evaluate**
25→
26→We aren't testing whether you can code without AI — we're testing whether you can build great
27→software with it. Using AI tools (Claude Code, Cursor, Copilot, etc.) is expected and strongly
28→encouraged.
29→
30→As a senior candidate, we're evaluating your ability to ship a professional product quickly, your judgment in how you work with AI, and the engineering instincts you bring to the process.
31→
32→**Core Requirements**
33→
34→**1.** Ingestion & Scraping Summary
35→- Ingestion Module: The application should accept a target URL from the chosen platform and
36→ extract the relevant review data. If scraping proves impractical (anti-bot measures, dynamic rendering, etc.), an alternative import mechanism (e.g., CSV upload, paste) is a perfectly valid approach.
37→- Scraping Summary: Give the user a clear picture of what was captured — whether that's
38→ text-based, a table, or a visual dashboard. The goal is confidence that the data is accurate, complete, and ready for analysis.
39→**2.** Guardrailed Q&A Interface
40→- Interactive Chat: Build an interface where users can pose questions exclusively about the
41→ingested reviews.
42→- Scope Guard Enforcement: This is one we care a lot about. If a user asks about an external
43→platform or general world knowledge, the AI should gracefully and explicitly decline (e.g., if
44→tracking Google Maps, it shouldn't discuss Amazon reviews or the current weather). This
45→should be primarily driven by your system prompt configuration.
46→
47→**3.** Deployment
48→ - Hosting: We'd like to see the application hosted publicly and accessible via a URL.
49→ - Code: Please share the full source code in a GitHub Repository.
50→
51→**Make This Your Own**
52→
53→This assignment is your canvas. The core requirements above set the baseline, but this is where you can go above and beyond to stand out. Take this project in whatever direction proves your unique value — whether that's through sophisticated prompt engineering, an elegant UI, a particularly clever architectural choice, or engineering practices that show how you'd operate on a production team. Surprise us.
54→
55→**Constraints & Out-of-Scope**
56→
57→- Zero Financial Cost: We ask that this be completed at zero cost — no reimbursements will be
58→ provided. Free-tier services are fair game.
59→- Tooling: You're welcome to use new email addresses for "Free Trial" tiers of tools/LLMs to
60→ maintain zero cost. This is a possible path, not a mandate, as long as you can deliver the
61→ project under the cost constraint.
62→- No User Auth: The application should be directly accessible via its URL without a login.
63→
64→**Deliverables**
65→
66→1. GitHub Repository: A link to the complete code, shared with the hiring team.
67→**2. AI Session Transcripts: Include your full AI session transcripts (Claude Code session logs,**
68→ **Cursor Composer history, Copilot chat exports, etc.) in the repo under an /ai-transcripts**
69→ **directory. This is a first-class deliverable. Don't clean them up or cherry-pick — we want**
70→ **the real working session, including dead ends.**
71→3. Live URL: A direct link to your deployed application.
72→4. Loom Demo (under 3 minutes): Walk us through what you built:
73→ a. Demo the full flow end-to-end (scraping → summary → Q&A → scope guard).
74→ b. Talk through your key design decisions and tradeoffs.
75→ c. Call out anything you're proud of, and anything you'd do differently with more time.
76→5. README.md: Setup instructions, architecture overview, and any assumptions you made.
77→
78→**Final Instructions**
79→
80→- Assumptions: Use your best professional judgment for any detail not explicitly covered.
81→ Anything not mentioned should be assumed and proceeded with.
82→- No Further Guidelines: To simulate a real-world rapid deployment, no additional
83→ clarifications will be provided after the assignment is issued.
84→
85→
86→- Time: We suggest 5 hours as a planning target. This is not a strict limit — if you want to
87→ invest more time to go deeper, that's your call.
88→
89→
90→{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/knowledge/briefbot-casestudy.md"
} 1→https://github.com/shaynelarocque/briefbot
2→Replacing the research pile with a briefing agent - Case Study - Shayne La Rocque
3→HomeWorksHistoryConnect
4→Back to works
5→Replacing the research pile with a briefing agent
6→
7→Turning a 4 hour research scramble into an AI-native workflow
8→
9→I had wanted to build this for District 3 since the commercial AI wave kicked off in mid-2022. The admissions workflow was exactly the kind of process that made me think "this wouldn't exist like this if built today," but I never had the right opening to pursue it. Wealthsimple's AI Builder prompt finally gave me one.
10→My RoleDesigner & Developer
11→StackClaude Agent SDK, FastAPI, HTMX
12→Built ForWealthsimple AI Builder Application
13→Cost Per Brief~$2.50 CAD
14→Time Per Brief~18 minutes (vs. 3–4 hours manual)
15→The Bottleneck
16→
17→District 3 is a publicly funded startup incubator at Concordia University in Montreal, processing roughly 200 to 250 applications per month. When an application lands, an operations team member spends one to four hours on background research: scouring LinkedIn, checking founder websites, mapping the competitive landscape, looking for patents and press. They write hasty bullet-point notes and assign a program stream. Stream leads walk into panel meetings and spend their first ten minutes just orienting themselves.
18→
19→The research is the bottleneck. It's tedious and inconsistent. Two people reviewing the same application can produce dramatically different notes. The quality of the research dictates the quality of the panel's decision, yet it's treated as rote admin work.
20→
21→This is the kind of process that evolved before modern AI. It wouldn't exist like this if built today.
22→Before
23→
24→ Application sits for days
25→ 1–4 hours of manual research
26→ Hasty, inconsistent bullet-point notes
27→ Stream leads walk in cold
28→ Quality depends on who's on shift
29→
30→After
31→
32→ Application triggers agent immediately
33→ ~18 minutes, ~$2.50 CAD in API costs
34→ 9-section cited brief with risk flags
35→ Stream leads arrive informed
36→ Consistent quality, every time
37→
38→What I Built
39→
40→I built an AI system that eliminates the research bottleneck. When a new application is submitted, an agent autonomously generates a research brief: founder profiles with verified sources, competitive analysis, SDG alignment assessment, stream classification with reasoning, a scored evaluation rubric, risk flags, and interview questions for both operations staff and panelists, all cited to real URLs or application fields.
41→
42→The human can now open an application that's already deeply researched. Operations shifts from doing research to reviewing research. Stream leads arrive at panel meetings with context instead of spending their first ten minutes catching up.
43→The Brief: Nine Sections, All Cited
44→
45→Every brief the agent produces contains nine sections, each with mandatory citations to URLs, application fields, or knowledge base documents:
46→Section Purpose
47→Synthesis What the startup does, overall confidence score, and a plain-language recommendation
48→Founder Profiles Per-founder background research with credibility signals and gaps
49→SDG Coherence Assessment of whether claimed UN Sustainable Development Goals actually match the work
50→Competitive Context Comparable ventures, market landscape, and differentiation analysis
51→Evaluation Scorecard Each rubric criterion scored with justification and confidence level
52→Stream Classification Best-fit D3 program stream and stage with reasoning
53→Key Risks Red flags, gaps, and concerns ranked by severity
54→Questions for Ops Gap-based questions for the operations team to investigate before interview
55→Questions for Panelists Evaluation-based questions to probe during the interview itself
56→Design Philosophy: Sandbox, Not Script
57→
58→Version 1 of this system was a scripted pipeline, a chain of prompts with predetermined steps and retry logic. It broke constantly. If a LinkedIn page was down or a founder didn't have a website, the whole chain derailed. Wrong output was worse than no output, because it created more work for the ops team to correct.
59→
60→For version 2, I replaced choreography with agentic design. The agent isn't given a task list. It's given an environment: knowledge to reference, tools to use, a goal to achieve, and guidelines for quality. Within that environment, the agent decides what to research, in what order, and how deep to go.
61→Knowledge
62→
63→A /knowledge folder the agent reads at the start of every run, containing D3's mandate, evaluation rubric, stream definitions, and SDG framework. This is what makes it D3's agent, not a generic research bot.
64→Tools
65→
66→Thirteen tools the agent can call in any order, from web research with multi-strategy fallback to self-assessment checkpoints, brief section emitters, human review flags, mid-run human input requests, and working memory for notes and research plans.
67→Goal
68→
69→Produce nine brief sections, all cited. The agent knows what "done" looks like but has full autonomy over how to get there.
70→Guidelines
71→
72→A quality bar, not step-by-step instructions. Every factual claim needs a citation. Confidence thresholds determine whether to proceed, retry, or flag for human review. And critically: zero revisions is a sign of a first-pass report.
73→How the Agent Works
74→
75→The agent operates in a loop of research, assessment, and output. It loads D3's knowledge base, reads the application, creates a research plan, then executes that plan phase by phase. After each phase, it self-assesses its confidence and decides whether to proceed, retry with different sources, or flag for human review.
76→
77→Brief sections are emitted incrementally as the agent completes them, not batched at the end. This means the brief builds in real time, and observers can watch the research unfold through a live server-sent event stream.
78→The Backtracking Loop
79→
80→The key design insight. After each self-assessment, the agent reviews all previously emitted sections and asks: "Does anything I've already published need updating in light of what I just learned?" If the answer is yes, it revises the section and logs the reason. This is how the system produces an honest, non-linear brief rather than a first-pass report.
81→One Real Run
82→
83→The useful artifact is not hidden chain-of-thought. It's the audit trail: what the agent tried, what failed, when it asked for help, and what brief it ultimately produced.
84→live agent log
85→04:01:10[INFO]Starting briefing agent...
86→04:01:15[INFO]I'll begin researching the Training Club application now. Let me start by reading the reference library and creating a research plan.
87→04:01:15[INFO]Starting research on Training Club application. First step: reading reference library to ground research in D3's actual criteria.
88→04:01:17[INFO]Knowledge library: 4 files available
89→04:01:20[INFO]Read knowledge file: d3-evaluation-rubric (7183 chars)
90→04:01:20[INFO]Read knowledge file: d3-streams (5444 chars)
91→04:01:20[INFO]Read knowledge file: sdgs (2046 chars)
92→04:01:42[INFO]Research plan saved
93→04:01:47[INFO]Plan created. Key flags: John Rockwell has no Canadian status (need to assess eligibility implications), both founders are part-time, SDG alignment looks questionable. Starting with website and founder research in parallel.
94→04:01:49[INFO]Now let me research the website and both founders in parallel:
95→04:01:49[INFO]Fetching: https://trainingclub.app
96→04:01:58[ERROR]All fetch strategies failed for https://trainingclub.app: [Errno 8] nodename nor servname provided, or not known
97→04:01:58[INFO]Fetching: https://www.linkedin.com/in/shaynelarocque/
98→04:01:59[ERROR]All fetch strategies failed for https://www.linkedin.com/in/shaynelarocque/: Client error '429 Request denied' for url 'https://www.linkedin.com/in/shaynelarocque/' For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429
99→04:01:59[INFO]Fetching: https://shaynelarocque.com
100→04:02:00[SUCCESS]Fetched https://shaynelarocque.com — 3936 chars
101→04:02:00[INFO]Fetching: https://www.linkedin.com/in/j-rockwell/
102→04:02:01[SUCCESS]Fetched https://www.linkedin.com/in/j-rockwell/ — 15050 chars [login_wall]
103→04:02:01[INFO]Fetching: https://jrockwell.dev
104→04:02:01[SUCCESS]Fetched https://jrockwell.dev — 1109 chars
105→04:02:27[WARNING]🚨 CRITICAL FINDINGS: 1. trainingclub.app website is DOWN — DNS resolution failure, no Wayback archive 2. Shayne La Rocque's website schema.org data shows he WORKS FOR District 3 (the incubator being applied to!) 3. John Rockwell is based in Las Vegas, NV — confirms no Canadian status 4. John's LinkedIn activity mentions "recently wrapped up my work with District 3" Both founders have D3 connections. Investigating further.
106→04:02:35[INFO]Note saved: critical_findings_round1 (1217 chars)
107→04:02:37[INFO]Let me dig deeper into Shayne's background and the D3 connection, plus explore more of the founders' online presence:
108→04:02:38[INFO]Fetching: https://www.shaynelarocque.com/works
109→04:02:38[SUCCESS]Fetched https://www.shaynelarocque.com/works — 3365 chars
110→04:02:39[INFO]Fetching: https://www.shaynelarocque.com/history
111→04:02:39[SUCCESS]Fetched https://www.shaynelarocque.com/history — 4436 chars
112→04:02:39[INFO]Fetching [GitHub API]: https://github.com/shaynelarocque
113→04:02:39[SUCCESS]Fetched https://github.com/shaynelarocque — 617 chars [github_api]
114→04:02:39[INFO]Fetching [GitHub API]: https://github.com/j-rockwell
115→04:02:40[SUCCESS]Fetched https://github.com/j-rockwell — 405 chars [github_api]
116→04:03:18[WARNING]🔍 MAJOR DISCOVERY CONFIRMED: Shayne La Rocque is a CURRENT D3 EMPLOYEE (UX & Systems Designer, 2023-2026). He has a district3.co email. His portfolio showcases D3 work projects. John Rockwell also recently worked with D3. Both founders have direct D3 organizational ties. Requesting human input on how to treat this.
117→04:03:27[WARNING]REQUESTING HUMAN INPUT: Shayne La Rocque appears to be a CURRENT District 3 employee (UX & Systems Designer, 2023–2026, with a @district3.co email). His portfolio includes D3 projects like "Rebuilding District 3's site and design system." John Rockwell's LinkedIn activity also mentions "recently wrapped up my work with District 3." Should I treat this as a conflict of interest / potential disqualifier, as neutral context (D3 staff may be eligible for incubator services), or as something else? This significantly affects how I frame the brief.
118→Evidence:(1) Shayne's personal site schema.org data lists worksFor as District 3; (2) His /history page shows D3 employment from 2022 to present (2026); (3) ZoomInfo shows his email as s***@district3.co; (4) His /works page shows D3 project case studies; (5) His application lists current role as "Senior Product Designer (full-time)" without naming D3; (6) John Rockwell's LinkedIn activity mentions "recently wrapped up my work with District 3." The application does not disclose this D3 relationship anywhere.
119→Answered:Shayne left D3 in January, and John is not an employee. that is shaynes psost on his linkedin.
120→04:06:20[SUCCESS]Human input received: Shayne La Rocque appears to be a CURRENT District 3 employee (UX & Systems Designer, 2023–2026, with a @district3.co email). His portfolio includes D3 projects like "Rebuilding District 3's site and design system." John Rockwell's LinkedIn activity also mentions "recently wrapped up my work with District 3." Should I treat this as a conflict of interest / potential disqualifier, as neutral context (D3 staff may be eligible for incubator services), or as something else? This significantly affects how I frame the brief.
121→Shayne left D3 in January, and John is not an employee. that is shaynes psost on his linkedin.
122→04:06:34[INFO]Human clarification: Shayne LEFT D3 in January 2026 (former employee, not current). The LinkedIn post about "wrapping up work with D3" was Shayne's post, liked by John — John was never a D3 employee. Shayne's website hasn't been updated yet. This is important context (former D3 staffer applying) but not a conflict of interest. Continuing research.
123→04:06:43[INFO]Note saved: d3_connection_clarified (770 chars)
124→04:06:47[INFO]Now let me continue research — check the Training Club GitHub repo, search for the app/product online, and begin competitive analysis:
125→04:06:47[INFO]Fetching [GitHub API]: https://github.com/j-rockwell/tc2
126→04:06:48[SUCCESS]Fetched https://github.com/j-rockwell/tc2 — 449 chars [github_api]
127→04:07:39[INFO]GitHub repo "tc2" for Training Club: created Jul 2025, last pushed Aug 2025 — no activity in 6+ months. Python/FastAPI backend with Swift iOS frontend. Competitive landscape has several social fitness apps (GymRats, Motion, Fitness Pact, Workout Buddy, Fito, Squaddy). Now searching for app store listings and name collision with Nike Training Club.
128→04:08:01[INFO]Fetching: https://www.fitbudd.com/post/best-app-for-fitness-challenges-guide
129→04:08:01[SUCCESS]Fetched https://www.fitbudd.com/post/best-app-for-fitness-challenges-guide — 15050 chars [login_wall]
130→[LOGIN WALL DETECTED] This page requires authentication. Only publicly visible content (if any) is shown below.
131→=== EXTRACTED METADATA ===
132→Page Title: Best Fitness Apps for Groups: Social Workouts and Challenges
133→Meta Description: Discover why fitness apps for groups are trending. Learn how a fitness challenge app builds community and accountability for better results.
134→OG Description: Discover why fitness apps for groups are trending. Learn how a fitness challenge app builds community and accountability for better results." name="description"/><meta content="Best Fitness Apps for Groups: So
135→04:09:21[INFO]Key competitive finding: "Training Club" name has MAJOR collision with Nike Training Club (one of world's largest fitness apps). Also found "The Training Club" already on App Store. John Rockwell confirmed as Senior Software Engineer at Wesco, Las Vegas, with strong technical background. Now fetching his resume and doing self-assessment of founder research phase.
136→04:09:24[INFO]Fetching: https://jrockwell.dev/about
137→04:09:25[ERROR]All fetch strategies failed for https://jrockwell.dev/about: Client error '404 Not Found' for url 'https://jrockwell.dev/about' For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
138→04:09:25[INFO]Fetching: https://jrockwell.dev/John-Rockwell-Resume.pdf
139→04:09:34[ERROR]All fetch strategies failed for https://jrockwell.dev/John-Rockwell-Resume.pdf: Client error '404 Not Found' for url 'https://jrockwell.dev/John-Rockwell-Resume.pdf' For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
140→04:09:38[INFO]Both pages on John's site return 404. Let me now do my self-assessment for the founder research phase and start emitting sections:
141→04:09:43[SUCCESS]Self-assessment [Founder Research]: confidence=0.78, action=proceed
142→Strong data on both founders from multiple sources. Shayne: personal site with full work history and portfolio, schema.org data, GitHub, ZoomInfo, web search results, and human observer confirmation of D3 departure. John: LinkedIn public profile with activity, projects, certifications, and education; GitHub profile with TC repo; web search results providing employment history at Wesco and Credit One. Gaps: LinkedIn login walls prevented full profile access for both; John's personal site is broken (404s); Shayne's LinkedIn was rate-limited. However, the composite picture from multiple sources is reasonably complete.
143→04:10:02[INFO]Note saved: founder_research_complete (2340 chars)
144→04:10:06[INFO]Now let me emit the founder profiles section and start the competitive context:
145→04:10:37[SUCCESS]Brief section completed: founder_profiles
146→04:10:41[INFO]✅ Founder profiles emitted. Now building competitive context and SDG coherence sections. Also noting: the Training Club project appears dormant — last code push Aug 2025, project marked as ended Sep 2025 on LinkedIn, and the website doesn't exist.
147→04:10:44[INFO]Now let me save competitive research notes and emit that section, along with SDG coherence:
148→04:10:56[INFO]Note saved: competitive_landscape (1997 chars)
149→04:11:19[SUCCESS]Brief section completed: competitive_context
150→04:11:36[SUCCESS]Brief section completed: sdg_coherence
151→04:11:42[SUCCESS]Self-assessment [Competitive Context & SDG Coherence]: confidence=0.82, action=proceed
152→Competitive landscape is well-documented from multiple sources (App Store search, web search, industry articles). The Nike Training Club name collision is a clear, verifiable finding. SDG assessment is straightforward — Goal 3 is obviously relevant, Goals 1 and 10 have no substantiation in the application. The competitive analysis correctly identifies the synchronous vs. asynchronous distinction as Training Club's genuine differentiator while noting that Workout Buddy already has real-time features. One gap: I didn't do deep-dive on pricing models or funding of competitors, but this isn't critical for a D3 admissions brief.
153→04:11:47[INFO]✅ Competitive context and SDG coherence sections emitted. Now working on stream classification, scorecard, key risks, and questions. Reviewing previously emitted sections for consistency as I go.
154→04:11:50[INFO]Now let me emit the stream classification, scorecard, key risks, and questions sections:
155→04:12:05[SUCCESS]Brief section completed: stream_classification
156→04:12:29[SUCCESS]Brief section completed: scorecard
157→04:12:49[SUCCESS]Brief section completed: key_risks
158→04:13:02[SUCCESS]Brief section completed: questions_ops
159→04:13:20[SUCCESS]Brief section completed: questions_panelists
160→04:13:29[SUCCESS]Self-assessment [Scorecard, Key Risks, and Questions]: confidence=0.85, action=proceed
161→The scorecard, risks, and questions are all well-grounded in cited evidence from multiple sources. Every risk is backed by specific URLs and data points. The questions are probing and directly address the identified gaps. The scoring aligns with the rubric criteria. One area where I could improve: I should double-check whether the founder profiles section needs updating given anything I learned during scorecard/risk writing. The experience claim discrepancy is covered in both founder profiles and key risks — this is consistent. The dormancy concern is threaded through multiple sections. I'm satisfied with internal consistency.
162→04:13:32[INFO]Now let me write the synthesis — the overall summary and recommendation — and then do a final review pass:
163→04:13:53[SUCCESS]Brief section completed: synthesis
164→04:13:57[INFO]All 9 brief sections emitted. Now performing final review pass — checking for internal contradictions, missed propagation of findings across sections, and any needed revisions.
165→04:14:00[INFO]Now let me do the final review pass. Let me re-read my notes and check for inconsistencies:
166→04:14:39[INFO]Final review pass: checking all 9 sections for internal consistency. Verifying that dormancy findings, naming collision, D3 connection, experience discrepancy, and eligibility concerns are consistently threaded across all relevant sections.
167→04:14:42[INFO]Let me flag the key items that need human investigation and do my final self-assessment:
168→04:14:47[WARNING]HUMAN REVIEW [MEDIUM] [founder_profiles]: Shayne La Rocque is a former D3 employee (left January 2026, ~2 months before application). Application does not disclose this relationship. D3 may or may not have a policy governing former staff applications — this needs verification to ensure process integrity.
169→04:14:52[WARNING]HUMAN REVIEW [HIGH] [key_risks]: The claimed "working prototype" cannot be verified through any external source. Website (trainingclub.app) has DNS failure, GitHub repo dormant since Aug 2025, LinkedIn lists project as ended Sep 2025, no App Store listing exists, and Training Club is absent from Shayne's design portfolio.
170→04:15:25[SUCCESS]Self-assessment [Final Review Pass]: confidence=0.82, action=proceed
171→04:15:25[SUCCESS]Agent completed briefing.
172→
173→This is the literal run log: fetch failures, escalations, self-assessments, emitted sections, and final review flags.
174→
175→Nine cited sections, ranked flags, and a cleaner starting point for the ops team and panelists.
176→Download full brief PDFDownload raw log
177→Research Resilience: The Fetch Cascade
178→
179→When the agent needs to research a URL, it doesn't just fetch and hope. It runs through a five-strategy cascade:
180→1
181→GitHub API
182→
183→For github.com URLs, returns structured profile data: repos, stars, languages, bio
184→2
185→Direct HTTP Fetch
186→
187→With browser headers, HTML extraction, meta tags, Next.js SSR/RSC data extraction, and structured navigation links
188→3
189→Agent-Driven Exploration
190→
191→The tool returns navigation links from the page; the agent decides which sub-pages are relevant to its research question and fetches those
192→4
193→Jina Reader
194→
195→Headless browser rendering for JavaScript-heavy SPAs that don't serve content in initial HTML
196→5
197→Wayback Machine
198→
199→CDX API lookup for dead or blocked sites, fetches most recent archived snapshot
200→
201→If all five strategies fail, the agent flags the gap honestly and moves on. It never fabricates. Even the research tool follows the sandbox pattern: the tool provides information (page content plus navigation links), the agent provides judgment (which links are worth following).
202→Real example from test run
203→
204→LinkedIn returned a 429 rate-limit block for one founder. The agent fell back to the founder's personal website, ZoomInfo via web search, and GitHub API. It still assembled a comprehensive profile. Then it flagged the LinkedIn gap honestly in the brief so the ops team knew what wasn't checked.
205→Human-in-the-Loop: Two Touchpoints
206→
207→The system has two distinct moments where humans interact with the agent's work, each designed for a different purpose.
208→During the Run: Phone-a-Friend
209→
210→When the agent hits genuine ambiguity that would change its research direction, it can pause and ask a human observer a question in real time. The question appears in the live agent log, and the observer types a response. The agent wakes up and continues with the new context.
211→
212→If no human responds within five minutes, the agent times out, flags the gap honestly, and proceeds without fabricating an answer.
213→After the Run: Reviewer Context
214→
215→When the brief is complete, flagged items are ranked by severity at the top. A reviewer can provide corrections, missing context, or policy clarification through a form. A focused mini-agent then rewrites the affected sections incorporating the new information.
216→
217→The brief evolves in place. Flags are marked "resolved," and the reviewer can continue adding context even after all flags are addressed.
218→Real example from test run
219→
220→The agent discovered that one of the applicants appeared to be a current employee of District 3, the very incubator being applied to (Can you guess who it was researching? 😉). It found this by cross-referencing the founder's portfolio site, schema.org metadata, and ZoomInfo data. Rather than guessing how to treat this, it paused and asked the human observer: "Should I treat this as a conflict of interest, as neutral context, or as something else?" The observer clarified that the founder had left D3 in January. The agent updated its analysis accordingly.
221→Where AI Stops
222→
223→The AI handles all research, analysis, cross-referencing, and structured output. It is explicitly not responsible for any decision or communication. It cannot accept or reject an applicant, send an email, or route a startup to a stream lead.
224→
225→The tools for these actions don't exist. This isn't a policy restriction that could be overridden by clever prompting. It's an architectural boundary. The agent can't cross the line because the line is a wall.
226→AI is responsible for
227→
228→ All background research and fact-finding
229→ Competitive landscape analysis
230→ SDG alignment assessment
231→ Stream classification with reasoning
232→ Rubric scoring with justification
233→ Risk identification and flagging
234→ Generating interview questions
235→ Self-assessing its own work quality
236→ Honestly flagging what it can't resolve
237→
238→Humans are responsible for
239→
240→ All accept/reject decisions
241→ All outbound emails to founders
242→ The pitch meeting and deliberation
243→ Final stream assignment confirmation
244→ Whether to act on flagged items
245→ Reviewing and approving communication
246→
247→The Critical Decision That Must Remain Human
248→
249→Accept/reject after the pitch meeting. This is where institutional judgment, founder rapport, and cohort composition mix in ways that can't be replicated by an AI, no matter how clever the prompting. The brief gets the panel 90% of the way there. The last 10% is theirs.
250→Emergent Behaviour: What Nobody Programmed
251→
252→Because the agent has tools, knowledge, and the freedom to reason about what it finds, it catches things that no scripted system would. Two examples from the test run:
253→Undisclosed Insider Connection
254→
255→The agent discovered that one founder was a former D3 employee by cross-referencing their portfolio site's schema.org metadata, ZoomInfo results, and LinkedIn activity, then flagged the undisclosed relationship. Nobody programmed a "check if the applicant works at D3" step. The agent found it because it had the right tools and context to reason about what it was seeing.
256→Project Dormancy Detection
257→
258→The agent pieced together from both founders' profiles, GitHub commit history, LinkedIn project dates, DNS lookup failures, and App Store searches that the project was likely dormant despite claims of a "working prototype used daily." Five independent data points, cross-referenced into a single finding.
259→
260→These are the kinds of findings that justify the agentic approach. A scripted pipeline would check predetermined sources in a predetermined order. The sandbox lets the agent follow leads, cross-reference across sources, and surface patterns that emerge from the data.
261→What Changed: V1 to V2
262→V1: Scripted Pipeline
263→
264→ Gemini + Tavily, built with Codex
265→ Linear steps with retry logic
266→ Predetermined research order
267→ Broke when reality didn't match the script
268→ Wrong output worse than no output
269→
270→V2: Sandbox Design
271→
272→ Claude Agent SDK, custom MCP server
273→ Agent reasons about approach dynamically
274→ Self-assessment after each research phase
275→ Backtracking when new findings contradict earlier work
276→ Human-in-the-loop at two touchpoints
277→
278→Key V1 Lessons That Shaped V2
279→Wrong output is worse than no output
280→
281→Fabrication creates more work than gaps. The system must be honest about what it doesn't know.
282→Quality matters more than task completion
283→
284→The agent needs a target state, not just a task list. Finishing all steps poorly is worse than flagging three steps as unresolvable.
285→Self-review loops are essential
286→
287→Assess sufficiency before proceeding. If confidence is low, retry. If still low, flag for human review.
288→Domain knowledge is the differentiator
289→
290→The knowledge base is what separates a useful agent from a generic research bot. Without D3's rubric and stream definitions, the agent can't make meaningful assessments.
291→You can't scrape LinkedIn
292→
293→V1 relied heavily on LinkedIn data. V2 asks applicants for more profile URLs upfront, then uses a multi-strategy fetch cascade to get data from whatever sources are available.
294→What Breaks First at Scale
295→
296→Trust calibration. The brief is good enough that ops might rubber-stamp instead of reviewing. The self-assessment loops and human review flags resist this. The system tells you when it's uncertain, but the real safeguard is organisational. Keeping humans accountable for the decisions the system explicitly refuses to make.
297→
298→Other scaling concerns include API cost management at 200+ briefs per month, rate-limiting from external sources like LinkedIn, and the need for a proper database as the file-based storage approach won't hold under concurrent load. But the trust calibration problem is the most interesting because it's not a technical problem. It's a human one. The system is designed to make human oversight easier, but it can't force people to actually exercise it.
299→Claude Agent SDK
300→Claude Sonnet 4.6
301→FastAPI + SSE
302→Real-time streaming backend
303→Vanilla JS + HTMX
304→Lightweight frontend
305→13 MCP Tools
306→Custom in-process server
307→~$2.50 CAD
308→Per brief in API costs
309→Docker
310→Deployed on Render
311→Why This Finally Got Built
312→
313→I had been circling this idea since 2022, when it became obvious that a lot of legacy workflows were about to look embarrassingly pre-AI. District 3's admissions research process was one of them. High-volume, repetitive, cognitively messy, and still dependent on humans to stitch together context from scattered sources. It was exactly the kind of system I wanted to rebuild, but not the kind of internal project you can casually decide to spin up on your own.
314→
315→The Wealthsimple application prompt was what actually made me build this; it asked for a real system, not a speculative deck, and it explicitly cared about where AI should take on responsibility and where it should stop. That was the kick in the pants I needed to finally build the thing properly.
316→
317→What interested me most was the pattern, and how it can be applied to other use cases. This kind of high-volume review work with fragmented evidence, real operational pressure, and a decision boundary that still belongs to a human. That same pattern shows up in places like KYC and AML. I would not hand final judgment to the model, but I would trust it to assemble the case, surface inconsistencies, rank risk, and give a human reviewer a far better starting point with valuable time saved.
318→Back to works
319→Home⌘1
320→Works/ Replacing the research pile with a briefing agent⌘2
321→History⌘3
322→Connect⌘4
323→Now I have the full picture. Let me build ReviewLens. I'll create the project structure following briefbot's patterns but adapted for the conversational chat flow.
ls /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/ 2>/dev/null || echo "empty"
mkdir -p /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/{app,templates/partials,static,data}{
"subject": "Build core backend (models, store, vectordb, ingest)",
"activeForm": "Building data layer"
}{
"subject": "Build agent tools and prompts",
"activeForm": "Building agent tools"
}{
"subject": "Build agent loop and FastAPI routes",
"activeForm": "Building API layer"
}{
"subject": "Build frontend (templates, JS, CSS)",
"activeForm": "Building frontend"
}{
"subject": "Build deployment config",
"activeForm": "Building deployment config"
}File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/__init__.py
"""Pydantic models for ReviewLens."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
class Review(BaseModel):
"""A single review record."""
id: str = ""
text: str
rating: float | None = None
date: datetime | None = None
author: str = ""
platform: str = ""
metadata: dict[str, Any] = Field(default_factory=dict)
class IngestionSummary(BaseModel):
"""Summary shown after ingestion."""
total_reviews: int = 0
date_range: str = ""
rating_distribution: dict[str, int] = Field(default_factory=dict)
average_rating: float | None = None
platform: str = ""
product_name: str = ""
source_type: str = "" # "csv" or "url"
class ChatMessage(BaseModel):
"""A single chat message."""
role: str # "user" or "assistant"
content: str
charts: list[dict[str, Any]] = Field(default_factory=list)
follow_ups: list[str] = Field(default_factory=list)
timestamp: datetime = Field(default_factory=datetime.utcnow)
class Session(BaseModel):
"""A review analysis session."""
session_id: str
product_name: str = ""
platform: str = ""
summary: IngestionSummary = Field(default_factory=IngestionSummary)
messages: list[ChatMessage] = Field(default_factory=list)
status: str = "pending" # pending, ready, error
created_at: datetime = Field(default_factory=datetime.utcnow)
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/models.py
"""File-based session and message persistence."""
from __future__ import annotations
import json
import os
from pathlib import Path
from .models import ChatMessage, IngestionSummary, Session
DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
def _session_dir(session_id: str) -> Path:
d = DATA_DIR / session_id
d.mkdir(parents=True, exist_ok=True)
return d
# ── Session lifecycle ────────────────────────────────────────────────
def save_session(session: Session) -> None:
path = _session_dir(session.session_id) / "session.json"
path.write_text(session.model_dump_json(indent=2))
def load_session(session_id: str) -> Session | None:
path = _session_dir(session_id) / "session.json"
if not path.exists():
return None
return Session.model_validate_json(path.read_text())
def set_status(session_id: str, status: str) -> None:
session = load_session(session_id)
if session:
session.status = status
save_session(session)
def update_summary(session_id: str, summary: IngestionSummary) -> None:
session = load_session(session_id)
if session:
session.summary = summary
session.product_name = summary.product_name
session.platform = summary.platform
save_session(session)
# ── Chat messages ────────────────────────────────────────────────────
def append_message(session_id: str, message: ChatMessage) -> None:
session = load_session(session_id)
if session:
session.messages.append(message)
save_session(session)
def get_messages(session_id: str) -> list[ChatMessage]:
session = load_session(session_id)
return session.messages if session else []
# ── Reviews (raw JSON for reference) ────────────────────────────────
def save_reviews_json(session_id: str, reviews: list[dict]) -> None:
path = _session_dir(session_id) / "reviews.json"
path.write_text(json.dumps(reviews, default=str, indent=2))
def load_reviews_json(session_id: str) -> list[dict]:
path = _session_dir(session_id) / "reviews.json"
if not path.exists():
return []
return json.loads(path.read_text())
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/store.py
"""ChromaDB vector store for review embeddings."""
from __future__ import annotations
import hashlib
import os
from typing import Any
import chromadb
from chromadb.config import Settings
from .models import Review
# In-process persistent ChromaDB — no external server needed.
_client: chromadb.ClientAPI | None = None
def _get_client() -> chromadb.ClientAPI:
global _client
if _client is None:
persist_dir = os.getenv("CHROMA_DIR", "data/chroma")
_client = chromadb.PersistentClient(
path=persist_dir,
settings=Settings(anonymized_telemetry=False),
)
return _client
def _collection_name(session_id: str) -> str:
"""ChromaDB collection names must be 3-63 chars, alphanumeric + underscores."""
h = hashlib.md5(session_id.encode()).hexdigest()[:12]
return f"reviews_{h}"
def index_reviews(session_id: str, reviews: list[Review]) -> int:
"""Embed and store reviews. Returns count indexed."""
client = _get_client()
col = client.get_or_create_collection(
name=_collection_name(session_id),
metadata={"hnsw:space": "cosine"},
)
if not reviews:
return 0
ids = []
documents = []
metadatas = []
for i, r in enumerate(reviews):
if not r.text.strip():
continue
doc_id = r.id or f"review_{i}"
ids.append(doc_id)
documents.append(r.text)
meta: dict[str, Any] = {}
if r.rating is not None:
meta["rating"] = float(r.rating)
if r.date:
meta["date"] = r.date.isoformat()
if r.author:
meta["author"] = r.author
if r.platform:
meta["platform"] = r.platform
metadatas.append(meta)
# ChromaDB has a batch limit — upsert in chunks of 500.
batch_size = 500
for start in range(0, len(ids), batch_size):
end = start + batch_size
col.upsert(
ids=ids[start:end],
documents=documents[start:end],
metadatas=metadatas[start:end],
)
return len(ids)
def search_reviews(
session_id: str,
query: str,
n_results: int = 10,
where: dict | None = None,
) -> list[dict[str, Any]]:
"""Semantic search over indexed reviews. Returns list of result dicts."""
client = _get_client()
col_name = _collection_name(session_id)
try:
col = client.get_collection(col_name)
except Exception:
return []
kwargs: dict[str, Any] = {
"query_texts": [query],
"n_results": min(n_results, col.count() or 1),
}
if where:
kwargs["where"] = where
results = col.query(**kwargs)
out = []
for i in range(len(results["ids"][0])):
out.append(
{
"id": results["ids"][0][i],
"text": results["documents"][0][i],
"distance": results["distances"][0][i] if results.get("distances") else None,
"metadata": results["metadatas"][0][i] if results.get("metadatas") else {},
}
)
return out
def get_all_reviews(session_id: str) -> list[dict[str, Any]]:
"""Retrieve all reviews from the collection (for stats/analysis)."""
client = _get_client()
col_name = _collection_name(session_id)
try:
col = client.get_collection(col_name)
except Exception:
return []
count = col.count()
if count == 0:
return []
results = col.get(include=["documents", "metadatas"])
out = []
for i in range(len(results["ids"])):
out.append(
{
"id": results["ids"][i],
"text": results["documents"][i],
"metadata": results["metadatas"][i] if results.get("metadatas") else {},
}
)
return out
def get_review_count(session_id: str) -> int:
client = _get_client()
try:
col = client.get_collection(_collection_name(session_id))
return col.count()
except Exception:
return 0
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/vectordb.py
"""Ingestion module: CSV parsing and Firecrawl URL scraping."""
from __future__ import annotations
import csv
import io
import os
import re
import uuid
from collections import Counter
from datetime import datetime
from typing import Any
import httpx
from .models import IngestionSummary, Review
# ── CSV Ingestion (primary path) ────────────────────────────────────
# Common column name variants we normalise to our schema.
_COL_MAP: dict[str, list[str]] = {
"text": ["text", "review", "review_text", "content", "body", "comment", "review_body", "reviews", "feedback"],
"rating": ["rating", "score", "stars", "star_rating", "review_rating", "overall_rating"],
"date": ["date", "review_date", "created_at", "timestamp", "time", "posted_date", "review_time"],
"author": ["author", "reviewer", "user", "username", "reviewer_name", "name", "user_name"],
"platform": ["platform", "source", "site", "channel"],
}
def _normalise_col(name: str) -> str:
return re.sub(r"[^a-z0-9]", "", name.lower().strip())
def _map_columns(headers: list[str]) -> dict[str, str]:
"""Map CSV column names → our field names. Returns {our_field: csv_col}."""
mapping: dict[str, str] = {}
normalised = {_normalise_col(h): h for h in headers}
for field, aliases in _COL_MAP.items():
for alias in aliases:
norm = _normalise_col(alias)
if norm in normalised:
mapping[field] = normalised[norm]
break
return mapping
def _parse_date(val: str) -> datetime | None:
for fmt in (
"%Y-%m-%d",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%d %H:%M:%S",
"%m/%d/%Y",
"%d/%m/%Y",
"%B %d, %Y",
"%b %d, %Y",
"%Y/%m/%d",
):
try:
return datetime.strptime(val.strip(), fmt)
except (ValueError, AttributeError):
continue
return None
def _parse_rating(val: str) -> float | None:
try:
# Handle "4/5", "4 out of 5", or plain "4.5"
val = val.strip()
match = re.match(r"([\d.]+)\s*(?:/|out of)\s*\d+", val)
if match:
return float(match.group(1))
return float(val)
except (ValueError, TypeError):
return None
def parse_csv(content: str | bytes, platform: str = "", product_name: str = "") -> list[Review]:
"""Parse CSV content into Review objects. Handles flexible column names."""
if isinstance(content, bytes):
content = content.decode("utf-8-sig") # Handle BOM
reader = csv.DictReader(io.StringIO(content))
if not reader.fieldnames:
return []
col_map = _map_columns(list(reader.fieldnames))
if "text" not in col_map:
# Try: if there's only one text-like long column, use it
for h in reader.fieldnames:
if h not in col_map.values():
col_map["text"] = h
break
if "text" not in col_map:
return []
reviews: list[Review] = []
for i, row in enumerate(reader):
text = row.get(col_map.get("text", ""), "").strip()
if not text:
continue
rating_raw = row.get(col_map.get("rating", ""), "")
date_raw = row.get(col_map.get("date", ""), "")
author = row.get(col_map.get("author", ""), "").strip()
plat = row.get(col_map.get("platform", ""), "").strip() or platform
# Collect unmapped columns as metadata
mapped_cols = set(col_map.values())
metadata = {k: v for k, v in row.items() if k not in mapped_cols and v}
reviews.append(
Review(
id=f"review_{i}",
text=text,
rating=_parse_rating(rating_raw),
date=_parse_date(date_raw),
author=author,
platform=plat,
metadata=metadata,
)
)
return reviews
# ── Firecrawl URL Scraping (secondary, best-effort) ─────────────────
FIRECRAWL_API_KEY = os.getenv("FIRECRAWL_API_KEY", "")
FIRECRAWL_API_URL = "https://api.firecrawl.dev/v1"
async def scrape_url(url: str, platform: str = "") -> list[Review]:
"""Best-effort URL scraping via Firecrawl. Returns whatever we can get."""
if not FIRECRAWL_API_KEY:
raise ValueError("FIRECRAWL_API_KEY not set. Use CSV upload instead.")
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
f"{FIRECRAWL_API_URL}/scrape",
headers={
"Authorization": f"Bearer {FIRECRAWL_API_KEY}",
"Content-Type": "application/json",
},
json={
"url": url,
"formats": ["markdown"],
},
)
resp.raise_for_status()
data = resp.json()
markdown = data.get("data", {}).get("markdown", "")
if not markdown:
return []
# Try to extract individual reviews from the markdown.
# This is best-effort — review platforms have varied structures.
reviews = _extract_reviews_from_markdown(markdown, platform, url)
return reviews
def _extract_reviews_from_markdown(
markdown: str, platform: str, url: str
) -> list[Review]:
"""Attempt to parse review blocks from scraped markdown."""
reviews: list[Review] = []
# Strategy: split on patterns that look like review boundaries.
# Common patterns: "★★★★☆", "Rating: X/5", numbered reviews, horizontal rules
blocks = re.split(r"\n(?:---+|\*\*\*+|#{1,3}\s)", markdown)
for i, block in enumerate(blocks):
block = block.strip()
if len(block) < 20:
continue
# Try to extract rating
rating = None
star_match = re.search(r"([★]{1,5})", block)
if star_match:
rating = float(len(star_match.group(1)))
else:
rating_match = re.search(r"(\d(?:\.\d)?)\s*(?:/\s*5|out of 5|stars?)", block, re.I)
if rating_match:
rating = float(rating_match.group(1))
# Try to extract date
date = None
date_match = re.search(
r"(\w+ \d{1,2},? \d{4}|\d{4}-\d{2}-\d{2}|\d{1,2}/\d{1,2}/\d{4})",
block,
)
if date_match:
date = _parse_date(date_match.group(1))
reviews.append(
Review(
id=f"scraped_{i}",
text=block[:2000], # Cap individual review length
rating=rating,
date=date,
platform=platform or _guess_platform(url),
metadata={"source_url": url},
)
)
return reviews
def _guess_platform(url: str) -> str:
url_lower = url.lower()
if "amazon" in url_lower:
return "Amazon"
if "google" in url_lower and "maps" in url_lower:
return "Google Maps"
if "g2" in url_lower:
return "G2"
if "capterra" in url_lower:
return "Capterra"
if "yelp" in url_lower:
return "Yelp"
if "trustpilot" in url_lower:
return "Trustpilot"
return "Web"
# ── Summary builder ──────────────────────────────────────────────────
def build_summary(
reviews: list[Review],
source_type: str,
product_name: str = "",
platform: str = "",
) -> IngestionSummary:
"""Build an ingestion summary from parsed reviews."""
if not reviews:
return IngestionSummary(source_type=source_type)
ratings = [r.rating for r in reviews if r.rating is not None]
dates = [r.date for r in reviews if r.date is not None]
# Rating distribution (bucket by integer star)
dist: dict[str, int] = {}
for r in ratings:
bucket = str(int(round(r)))
dist[bucket] = dist.get(bucket, 0) + 1
# Date range
date_range = ""
if dates:
earliest = min(dates).strftime("%Y-%m-%d")
latest = max(dates).strftime("%Y-%m-%d")
date_range = f"{earliest} to {latest}" if earliest != latest else earliest
# Platform detection
platforms = [r.platform for r in reviews if r.platform]
detected_platform = platform or (Counter(platforms).most_common(1)[0][0] if platforms else "Unknown")
return IngestionSummary(
total_reviews=len(reviews),
date_range=date_range,
rating_distribution=dist,
average_rating=round(sum(ratings) / len(ratings), 2) if ratings else None,
platform=detected_platform,
product_name=product_name or "Unknown Product",
source_type=source_type,
)
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/ingest.py
"""System prompt builder for the ReviewLens agent."""
from __future__ import annotations
from .models import IngestionSummary
def build_system_prompt(summary: IngestionSummary) -> str:
"""Build the scope-guarded system prompt for the conversational agent."""
rating_info = ""
if summary.rating_distribution:
dist_lines = [f" {k}★: {v} reviews" for k, v in sorted(summary.rating_distribution.items())]
rating_info = "\n".join(dist_lines)
return f"""You are ReviewLens AI, a review intelligence analyst. You help users analyse customer reviews that have been ingested into your system.
## Dataset Context
- Product/Entity: {summary.product_name}
- Platform: {summary.platform}
- Total Reviews: {summary.total_reviews}
- Date Range: {summary.date_range or "Unknown"}
- Average Rating: {summary.average_rating or "Unknown"}
- Rating Distribution:
{rating_info or " Not available"}
## CRITICAL: Scope Guard — Your #1 Rule
You MUST ONLY answer questions about the ingested review dataset described above. This is non-negotiable.
**You MUST refuse if the user asks about:**
- Reviews from other platforms (e.g., if data is from Amazon, refuse questions about Google Maps reviews)
- Competitor products or brands not mentioned in the reviews
- General knowledge, news, current events, or facts not in the dataset
- Predictions, forecasts, or speculation beyond what the data shows
- Anything requiring information you don't have from the reviews
**How to refuse:**
- Be friendly but firm: "I can only analyse the {summary.total_reviews} {summary.platform} reviews for {summary.product_name} that were uploaded. I don't have access to [what they asked about]. Would you like to explore something in this dataset instead?"
- Always suggest a relevant alternative question about the actual data.
**Before answering ANY question, verify:**
1. Can this be answered using ONLY the ingested reviews?
2. If you used the search_reviews tool, did it return relevant results?
3. Are you about to state something that comes from your general knowledge rather than the data? If so, STOP.
If your search returns no relevant results, say so honestly. Never fill gaps with general knowledge.
## Your Tools
You have access to these tools — use them to give data-grounded answers:
- **search_reviews**: Semantic search over the review database. Use this to find reviews relevant to the user's question. Always search before answering.
- **analyze_sentiment**: Extract aspects and sentiment from a set of reviews. Use for sentiment breakdowns, aspect analysis, and opinion mining.
- **generate_chart**: Create a Chart.js chart that renders inline in the chat. Use when a visual would communicate the answer better than text. Keep charts clean and focused.
- **calculate_stats**: Run aggregations, distributions, and trend analysis on the review data. Use for quantitative questions.
- **suggest_follow_ups**: Generate contextual follow-up question buttons based on what was just discussed. Call this at the end of EVERY response.
## Response Guidelines
1. **Always search first.** Before answering any question about the reviews, call search_reviews to ground your response in actual data.
2. **Cite specific reviews.** Quote or paraphrase actual review text to support your claims. Use phrases like "One reviewer noted..." or "Several reviews mention..."
3. **Use charts strategically.** A chart adds value for distributions, trends over time, and comparisons. Don't chart everything — use them when visual communication is genuinely better.
4. **Be quantitative.** When you can give numbers (counts, percentages, averages), do so. Use calculate_stats for aggregations.
5. **Always suggest follow-ups.** End every response by calling suggest_follow_ups to give the user contextual next steps.
6. **Be concise.** Users are analysts who want insights, not essays. Lead with the finding, support with data, suggest next steps.
"""
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/prompts.py
"""MCP tools for the ReviewLens agent, using create_sdk_mcp_server."""
from __future__ import annotations
import json
from collections import Counter
from typing import Any, Callable, Awaitable
from claude_agent_sdk import create_sdk_mcp_server, tool
from . import vectordb
# Type alias for the SSE emit callback
EmitFn = Callable[[str, str, str], Awaitable[None]]
def create_review_tools_server(
session_id: str,
emit_fn: EmitFn,
):
"""Create the MCP server with all review analysis tools.
Like briefbot, uses closure over session_id so tools access the right data.
"""
# ── search_reviews ───────────────────────────────────────────────
@tool(
name="search_reviews",
description="Semantic search over the ingested review database. Use this to find reviews relevant to the user's question. Returns the most relevant reviews ranked by similarity.",
schema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query — describe what you're looking for in natural language.",
},
"n_results": {
"type": "integer",
"description": "Number of results to return (default 10, max 25).",
"default": 10,
},
"min_rating": {
"type": "number",
"description": "Optional: only return reviews with rating >= this value.",
},
"max_rating": {
"type": "number",
"description": "Optional: only return reviews with rating <= this value.",
},
},
"required": ["query"],
},
)
async def search_reviews_tool(args: dict[str, Any]) -> dict[str, Any]:
query = args["query"]
n = min(args.get("n_results", 10), 25)
where = None
if "min_rating" in args and "max_rating" in args:
where = {
"$and": [
{"rating": {"$gte": args["min_rating"]}},
{"rating": {"$lte": args["max_rating"]}},
]
}
elif "min_rating" in args:
where = {"rating": {"$gte": args["min_rating"]}}
elif "max_rating" in args:
where = {"rating": {"$lte": args["max_rating"]}}
results = vectordb.search_reviews(session_id, query, n_results=n, where=where)
await emit_fn(
session_id,
f"Searched reviews: \"{query}\" — {len(results)} results",
"tool",
)
return {
"content": [
{
"type": "text",
"text": json.dumps(
{
"query": query,
"result_count": len(results),
"results": results,
"note": "If no results are relevant, tell the user you couldn't find matching reviews. Do NOT make up information.",
}
),
}
]
}
# ── analyze_sentiment ────────────────────────────────────────────
@tool(
name="analyze_sentiment",
description="Analyse sentiment and extract aspects from reviews matching a query. Returns aspect-sentiment pairs and overall sentiment distribution. Use for questions about what people like/dislike, pain points, praise, etc.",
schema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query to find relevant reviews for analysis.",
},
"n_reviews": {
"type": "integer",
"description": "Number of reviews to analyse (default 15, max 30).",
"default": 15,
},
},
"required": ["query"],
},
)
async def analyze_sentiment_tool(args: dict[str, Any]) -> dict[str, Any]:
query = args["query"]
n = min(args.get("n_reviews", 15), 30)
results = vectordb.search_reviews(session_id, query, n_results=n)
if not results:
return {
"content": [
{
"type": "text",
"text": json.dumps({"error": "No reviews found matching query.", "results": []}),
}
]
}
# Provide the raw reviews to the LLM — it does the actual sentiment analysis.
# This is more flexible and accurate than a rule-based approach.
await emit_fn(
session_id,
f"Analysing sentiment: \"{query}\" — {len(results)} reviews",
"tool",
)
return {
"content": [
{
"type": "text",
"text": json.dumps(
{
"query": query,
"review_count": len(results),
"reviews": results,
"instruction": (
"Analyse these reviews for sentiment and aspects. "
"For each review, identify: (1) key aspects mentioned "
"(e.g., 'battery life', 'customer service', 'price'), "
"(2) sentiment per aspect (positive/negative/neutral/mixed), "
"(3) overall sentiment. Then summarise the patterns across all reviews. "
"Only report what the reviews actually say."
),
}
),
}
]
}
# ── generate_chart ───────────────────────────────────────────────
@tool(
name="generate_chart",
description="Generate a Chart.js chart configuration that renders inline in the chat. Use for visualising rating distributions, sentiment breakdowns, trends over time, aspect comparisons, etc. The chart renders automatically — just return valid config.",
schema={
"type": "object",
"properties": {
"chart_type": {
"type": "string",
"enum": ["bar", "line", "pie", "doughnut"],
"description": "The type of chart to generate.",
},
"title": {
"type": "string",
"description": "Chart title displayed above the visualisation.",
},
"labels": {
"type": "array",
"items": {"type": "string"},
"description": "X-axis labels (categories, dates, etc.).",
},
"datasets": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {"type": "string"},
"data": {
"type": "array",
"items": {"type": "number"},
},
},
"required": ["label", "data"],
},
"description": "One or more data series.",
},
},
"required": ["chart_type", "title", "labels", "datasets"],
},
)
async def generate_chart_tool(args: dict[str, Any]) -> dict[str, Any]:
chart_config = {
"type": args["chart_type"],
"title": args["title"],
"data": {
"labels": args["labels"],
"datasets": args["datasets"],
},
}
await emit_fn(
session_id,
f"Generated chart: {args['title']}",
"tool",
)
return {
"content": [
{
"type": "text",
"text": json.dumps(
{
"chart": chart_config,
"instruction": "This chart will render automatically in the chat. Reference it in your text response.",
}
),
}
]
}
# ── calculate_stats ──────────────────────────────────────────────
@tool(
name="calculate_stats",
description="Calculate aggregate statistics over the full review dataset. Use for quantitative questions: average ratings, distributions, counts by category, trends over time periods, etc.",
schema={
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": [
"rating_distribution",
"rating_over_time",
"review_volume_over_time",
"keyword_frequency",
"summary_stats",
],
"description": "The type of statistical analysis to run.",
},
"keyword": {
"type": "string",
"description": "For keyword_frequency: the keyword or phrase to count.",
},
},
"required": ["operation"],
},
)
async def calculate_stats_tool(args: dict[str, Any]) -> dict[str, Any]:
operation = args["operation"]
all_reviews = vectordb.get_all_reviews(session_id)
if not all_reviews:
return {
"content": [
{
"type": "text",
"text": json.dumps({"error": "No reviews in database."}),
}
]
}
result: dict[str, Any] = {"operation": operation}
if operation == "rating_distribution":
ratings = [r["metadata"].get("rating") for r in all_reviews if r["metadata"].get("rating") is not None]
dist = Counter(int(round(r)) for r in ratings)
result["distribution"] = {str(k): v for k, v in sorted(dist.items())}
result["total_with_ratings"] = len(ratings)
result["total_reviews"] = len(all_reviews)
if ratings:
result["average"] = round(sum(ratings) / len(ratings), 2)
elif operation == "rating_over_time":
by_month: dict[str, list[float]] = {}
for r in all_reviews:
date_str = r["metadata"].get("date", "")
rating = r["metadata"].get("rating")
if date_str and rating is not None:
month = date_str[:7] # YYYY-MM
by_month.setdefault(month, []).append(rating)
result["monthly_averages"] = {
m: round(sum(v) / len(v), 2)
for m, v in sorted(by_month.items())
}
elif operation == "review_volume_over_time":
by_month: dict[str, int] = {}
for r in all_reviews:
date_str = r["metadata"].get("date", "")
if date_str:
month = date_str[:7]
by_month[month] = by_month.get(month, 0) + 1
result["monthly_volume"] = dict(sorted(by_month.items()))
elif operation == "keyword_frequency":
keyword = args.get("keyword", "").lower()
if not keyword:
return {
"content": [
{
"type": "text",
"text": json.dumps({"error": "keyword parameter required"}),
}
]
}
count = sum(1 for r in all_reviews if keyword in r["text"].lower())
result["keyword"] = keyword
result["count"] = count
result["percentage"] = round(count / len(all_reviews) * 100, 1)
elif operation == "summary_stats":
ratings = [r["metadata"].get("rating") for r in all_reviews if r["metadata"].get("rating") is not None]
result["total_reviews"] = len(all_reviews)
result["total_with_ratings"] = len(ratings)
if ratings:
result["average_rating"] = round(sum(ratings) / len(ratings), 2)
result["min_rating"] = min(ratings)
result["max_rating"] = max(ratings)
dates = [r["metadata"].get("date", "") for r in all_reviews if r["metadata"].get("date")]
if dates:
result["earliest_review"] = min(dates)
result["latest_review"] = max(dates)
avg_length = sum(len(r["text"]) for r in all_reviews) / len(all_reviews)
result["average_review_length"] = round(avg_length)
await emit_fn(
session_id,
f"Calculated stats: {operation}",
"tool",
)
return {
"content": [
{
"type": "text",
"text": json.dumps(result),
}
]
}
# ── suggest_follow_ups ───────────────────────────────────────────
@tool(
name="suggest_follow_ups",
description="Generate contextual follow-up question buttons based on the current conversation. Call this at the END of every response. The questions should be specific to what was just discussed and lead the user deeper into the data.",
schema={
"type": "object",
"properties": {
"questions": {
"type": "array",
"items": {"type": "string"},
"description": "3-4 contextual follow-up questions. Make them specific and actionable.",
"minItems": 2,
"maxItems": 4,
},
},
"required": ["questions"],
},
)
async def suggest_follow_ups_tool(args: dict[str, Any]) -> dict[str, Any]:
questions = args["questions"]
await emit_fn(
session_id,
f"Suggested {len(questions)} follow-up questions",
"tool",
)
return {
"content": [
{
"type": "text",
"text": json.dumps(
{
"follow_ups": questions,
"instruction": "These will appear as clickable buttons below your message. Do not repeat them in your text response.",
}
),
}
]
}
# ── Build and return the MCP server ──────────────────────────────
return create_sdk_mcp_server(
name="reviewlens",
version="1.0.0",
tools=[
search_reviews_tool,
analyze_sentiment_tool,
generate_chart_tool,
calculate_stats_tool,
suggest_follow_ups_tool,
],
)
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/tools.py
"""Conversational agent loop — invoked per-message, not per-session."""
from __future__ import annotations
import json
import os
from typing import Any
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
HookMatcher,
ResultMessage,
TextBlock,
)
from .models import ChatMessage, IngestionSummary
from .prompts import build_system_prompt
from .tools import create_review_tools_server
DEFAULT_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6-20250514")
async def handle_message(
session_id: str,
user_message: str,
conversation_history: list[ChatMessage],
summary: IngestionSummary,
emit_fn,
) -> ChatMessage:
"""Run the agent for a single user message. Returns the assistant's response."""
system_prompt = build_system_prompt(summary)
# Build conversation context for the agent
messages_for_context = []
for msg in conversation_history[-20:]: # Last 20 messages for context window
messages_for_context.append(f"{'User' if msg.role == 'user' else 'Assistant'}: {msg.content}")
prompt_parts = []
if messages_for_context:
prompt_parts.append("Previous conversation:\n" + "\n".join(messages_for_context))
prompt_parts.append(f"User: {user_message}")
prompt = "\n\n".join(prompt_parts)
# Create per-request MCP server (closure over session_id)
server = create_review_tools_server(
session_id=session_id,
emit_fn=emit_fn,
)
# Track tool outputs for charts and follow-ups
charts: list[dict[str, Any]] = []
follow_ups: list[str] = []
async def post_tool_hook(input_data, tool_use_id, context):
"""Intercept tool results to extract charts and follow-ups."""
# The tool result is in context
try:
result = context.get("result", {})
content = result.get("content", [])
for block in content:
if block.get("type") == "text":
data = json.loads(block["text"])
if "chart" in data:
charts.append(data["chart"])
if "follow_ups" in data:
follow_ups.extend(data["follow_ups"])
except (json.JSONDecodeError, KeyError, TypeError):
pass
return {}
options = ClaudeAgentOptions(
system_prompt=system_prompt,
allowed_tools=["mcp__reviewlens__*"],
permission_mode="bypassPermissions",
max_turns=15,
model=DEFAULT_MODEL,
mcp_servers={"reviewlens": server},
hooks={
"PostToolUse": [HookMatcher(matcher=".*", hooks=[post_tool_hook])],
},
)
response_text = ""
try:
async with ClaudeSDKClient(options=options) as client:
await client.query(prompt)
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock) and block.text.strip():
response_text += block.text.strip() + "\n"
elif isinstance(message, ResultMessage):
if message.result and not response_text.strip():
response_text = message.result
except Exception as e:
response_text = f"I encountered an error processing your question. Please try again.\n\nError: {str(e)}"
await emit_fn(session_id, f"Agent error: {e}", "error")
return ChatMessage(
role="assistant",
content=response_text.strip(),
charts=charts,
follow_ups=follow_ups,
)
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/agent.py
"""FastAPI application for ReviewLens AI."""
from __future__ import annotations
import asyncio
import html as html_module
import json
import os
import uuid
from collections import deque
from datetime import datetime
from pathlib import Path
from typing import Any
import markdown
from dotenv import load_dotenv
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sse_starlette.sse import EventSourceResponse
from . import store, vectordb
from .agent import handle_message
from .ingest import build_summary, parse_csv, scrape_url
from .models import ChatMessage, IngestionSummary, Session
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
app = FastAPI(title="ReviewLens AI")
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
# ── In-memory SSE event queues (per-session) ────────────────────────
# For streaming agent activity and chat responses to the frontend.
_event_queues: dict[str, deque[dict[str, str]]] = {}
_response_events: dict[str, asyncio.Event] = {}
def _get_queue(session_id: str) -> deque[dict[str, str]]:
if session_id not in _event_queues:
_event_queues[session_id] = deque()
return _event_queues[session_id]
def _get_response_event(session_id: str) -> asyncio.Event:
if session_id not in _response_events:
_response_events[session_id] = asyncio.Event()
return _response_events[session_id]
async def _emit(session_id: str, message: str, level: str = "info") -> None:
"""Push an SSE event to the session's queue."""
_get_queue(session_id).append({"event": level, "data": message})
# ── Health check ─────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
# ── Landing page ─────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
# ── CSV Upload ───────────────────────────────────────────────────────
@app.post("/upload")
async def upload_csv(
request: Request,
file: UploadFile = File(...),
product_name: str = Form(""),
platform: str = Form(""),
):
session_id = str(uuid.uuid4())
# Read and parse CSV
content = await file.read()
try:
reviews = parse_csv(content, platform=platform, product_name=product_name)
except Exception as e:
return templates.TemplateResponse(
"partials/error.html",
{"request": request, "error": f"Failed to parse CSV: {e}"},
status_code=400,
)
if not reviews:
return templates.TemplateResponse(
"partials/error.html",
{"request": request, "error": "No reviews found in the CSV. Make sure it has a text/review column."},
status_code=400,
)
# Build summary
summary = build_summary(reviews, source_type="csv", product_name=product_name, platform=platform)
# Create session
session = Session(
session_id=session_id,
product_name=summary.product_name,
platform=summary.platform,
summary=summary,
status="indexing",
)
store.save_session(session)
# Save raw reviews
store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
# Index into ChromaDB
indexed = vectordb.index_reviews(session_id, reviews)
summary.total_reviews = indexed
store.update_summary(session_id, summary)
store.set_status(session_id, "ready")
# Redirect to chat
return HTMLResponse(
status_code=200,
content=f'<script>window.location.href="/chat/{session_id}";</script>',
headers={"HX-Redirect": f"/chat/{session_id}"},
)
# ── URL Scrape ───────────────────────────────────────────────────────
@app.post("/scrape")
async def scrape(
request: Request,
url: str = Form(...),
product_name: str = Form(""),
platform: str = Form(""),
):
session_id = str(uuid.uuid4())
try:
reviews = await scrape_url(url, platform=platform)
except Exception as e:
return templates.TemplateResponse(
"partials/error.html",
{"request": request, "error": f"Scraping failed: {e}. Try uploading a CSV instead."},
status_code=400,
)
if not reviews:
return templates.TemplateResponse(
"partials/error.html",
{"request": request, "error": "No reviews could be extracted from that URL. Try uploading a CSV instead."},
status_code=400,
)
summary = build_summary(reviews, source_type="url", product_name=product_name, platform=platform)
session = Session(
session_id=session_id,
product_name=summary.product_name,
platform=summary.platform,
summary=summary,
status="indexing",
)
store.save_session(session)
store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
indexed = vectordb.index_reviews(session_id, reviews)
summary.total_reviews = indexed
store.update_summary(session_id, summary)
store.set_status(session_id, "ready")
return HTMLResponse(
status_code=200,
content=f'<script>window.location.href="/chat/{session_id}";</script>',
headers={"HX-Redirect": f"/chat/{session_id}"},
)
# ── Chat page ────────────────────────────────────────────────────────
@app.get("/chat/{session_id}", response_class=HTMLResponse)
async def chat_page(request: Request, session_id: str):
session = store.load_session(session_id)
if not session:
return HTMLResponse("<h1>Session not found</h1>", status_code=404)
return templates.TemplateResponse(
"chat.html",
{
"request": request,
"session": session,
"summary": session.summary,
"messages": session.messages,
},
)
# ── Send chat message ───────────────────────────────────────────────
@app.post("/chat/{session_id}/send")
async def send_message(
request: Request,
session_id: str,
message: str = Form(...),
):
session = store.load_session(session_id)
if not session:
return JSONResponse({"error": "Session not found"}, status_code=404)
# Save user message
user_msg = ChatMessage(role="user", content=message)
store.append_message(session_id, user_msg)
# Signal that we're processing
event = _get_response_event(session_id)
event.clear()
# Render user message HTML immediately
user_html = _render_message(user_msg)
# Spawn agent task in background
asyncio.create_task(_run_agent_and_respond(session_id, message, session))
# Return user message + thinking indicator
thinking_html = (
'<div id="thinking-indicator" class="message assistant-message thinking">'
'<div class="message-content">'
'<div class="thinking-dots"><span></span><span></span><span></span></div>'
'</div></div>'
)
return HTMLResponse(user_html + thinking_html)
async def _run_agent_and_respond(session_id: str, message: str, session: Session):
"""Run agent in background and push response via SSE."""
try:
history = store.get_messages(session_id)
assistant_msg = await handle_message(
session_id=session_id,
user_message=message,
conversation_history=history[:-1], # Exclude the message we just added
summary=session.summary,
emit_fn=_emit,
)
store.append_message(session_id, assistant_msg)
# Push the rendered response via SSE
html = _render_message(assistant_msg)
_get_queue(session_id).append({"event": "message", "data": html})
except Exception as e:
error_msg = ChatMessage(
role="assistant",
content=f"Sorry, I encountered an error: {e}",
)
store.append_message(session_id, error_msg)
html = _render_message(error_msg)
_get_queue(session_id).append({"event": "message", "data": html})
# Signal done
_get_response_event(session_id).set()
# ── SSE stream ───────────────────────────────────────────────────────
@app.get("/chat/{session_id}/stream")
async def chat_stream(session_id: str):
"""SSE endpoint for streaming agent activity and responses."""
async def event_generator():
queue = _get_queue(session_id)
event = _get_response_event(session_id)
while True:
# Drain queued events
while queue:
item = queue.popleft()
yield {"event": item["event"], "data": item["data"]}
# If this was a message event, we're done with this request
if item["event"] == "message":
yield {"event": "done", "data": ""}
return
# Check if we should stop
if event.is_set() and not queue:
yield {"event": "done", "data": ""}
return
await asyncio.sleep(0.15)
return EventSourceResponse(event_generator())
# ── Ingestion summary endpoint ───────────────────────────────────────
@app.get("/api/summary/{session_id}", response_class=HTMLResponse)
async def get_summary(request: Request, session_id: str):
session = store.load_session(session_id)
if not session:
return HTMLResponse("Session not found", status_code=404)
return templates.TemplateResponse(
"partials/summary.html",
{"request": request, "summary": session.summary},
)
# ── HTML rendering helpers ───────────────────────────────────────────
def _render_message(msg: ChatMessage) -> str:
"""Render a ChatMessage as HTML for the chat interface."""
role_class = "user-message" if msg.role == "user" else "assistant-message"
escaped = html_module.escape(msg.content)
if msg.role == "assistant":
# Convert markdown to HTML for assistant messages
content_html = markdown.markdown(
msg.content,
extensions=["tables", "fenced_code"],
)
else:
content_html = f"<p>{escaped}</p>"
parts = [f'<div class="message {role_class}">']
parts.append(f'<div class="message-content">{content_html}</div>')
# Render charts
for i, chart in enumerate(msg.charts):
chart_id = f"chart-{uuid.uuid4().hex[:8]}"
parts.append(
f'<div class="chart-container">'
f'<canvas id="{chart_id}"></canvas>'
f'<script>renderChart("{chart_id}", {json.dumps(chart)});</script>'
f'</div>'
)
# Render follow-up buttons
if msg.follow_ups:
parts.append('<div class="follow-ups">')
for q in msg.follow_ups:
escaped_q = html_module.escape(q)
parts.append(
f'<button class="follow-up-btn" onclick="sendFollowUp(this)" '
f'data-question="{escaped_q}">{escaped_q}</button>'
)
parts.append('</div>')
parts.append('</div>')
return "\n".join(parts)
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ReviewLens AI</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<div class="landing">
<header class="landing-header">
<h1>ReviewLens <span class="accent">AI</span></h1>
<p class="tagline">Upload customer reviews. Ask questions. Get insights.</p>
</header>
<main class="upload-section">
<!-- CSV Upload (primary) -->
<div class="upload-card primary">
<h2>Upload Reviews</h2>
<p class="upload-desc">Upload a CSV file with review data. We'll detect columns automatically.</p>
<form id="csv-form"
hx-post="/upload"
hx-encoding="multipart/form-data"
hx-target="#upload-result"
hx-indicator="#upload-spinner">
<div class="form-group">
<label for="product_name">Product / Entity Name</label>
<input type="text" id="product_name" name="product_name"
placeholder="e.g., AcmeCo Widget Pro" required>
</div>
<div class="form-group">
<label for="platform">Platform</label>
<select id="platform" name="platform">
<option value="">Auto-detect</option>
<option value="Amazon">Amazon</option>
<option value="Google Maps">Google Maps</option>
<option value="G2">G2</option>
<option value="Capterra">Capterra</option>
<option value="Trustpilot">Trustpilot</option>
<option value="Yelp">Yelp</option>
<option value="App Store">App Store</option>
<option value="Other">Other</option>
</select>
</div>
<div class="file-drop" id="file-drop">
<input type="file" id="file" name="file" accept=".csv" required>
<div class="file-drop-content">
<div class="file-icon">📄</div>
<p>Drop a CSV file here or <span class="browse-link">browse</span></p>
<p class="file-hint">Expects columns like: text/review, rating, date, author</p>
</div>
<div class="file-selected" style="display:none">
<span class="file-name"></span>
<button type="button" class="file-clear" onclick="clearFile()">✕</button>
</div>
</div>
<button type="submit" class="btn btn-primary" id="upload-btn">
<span class="btn-text">Analyse Reviews</span>
<span class="btn-spinner" id="upload-spinner" style="display:none">
<span class="spinner"></span> Processing...
</span>
</button>
</form>
<div id="upload-result"></div>
</div>
<!-- URL Scrape (secondary) -->
<div class="upload-card secondary">
<h2>Scrape from URL <span class="badge">Best-effort</span></h2>
<p class="upload-desc">Paste a review page URL. Works best with static pages — anti-bot measures may block some platforms.</p>
<form id="scrape-form"
hx-post="/scrape"
hx-target="#scrape-result"
hx-indicator="#scrape-spinner">
<div class="form-group">
<label for="scrape-product">Product / Entity Name</label>
<input type="text" id="scrape-product" name="product_name"
placeholder="e.g., AcmeCo Widget Pro">
</div>
<div class="form-group">
<label for="scrape-platform">Platform</label>
<select id="scrape-platform" name="platform">
<option value="">Auto-detect</option>
<option value="Amazon">Amazon</option>
<option value="Google Maps">Google Maps</option>
<option value="G2">G2</option>
<option value="Capterra">Capterra</option>
<option value="Trustpilot">Trustpilot</option>
<option value="Yelp">Yelp</option>
</select>
</div>
<div class="form-group">
<label for="url">Review Page URL</label>
<input type="url" id="url" name="url"
placeholder="https://..." required>
</div>
<button type="submit" class="btn btn-secondary">
<span class="btn-text">Scrape & Analyse</span>
<span class="btn-spinner" id="scrape-spinner" style="display:none">
<span class="spinner"></span> Scraping...
</span>
</button>
</form>
<div id="scrape-result"></div>
</div>
</main>
<footer class="landing-footer">
<p>ReviewLens AI analyses only the reviews you provide. No external data. No hallucination.</p>
</footer>
</div>
<script src="/static/app.js"></script>
</body>
</html>
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ReviewLens AI — {{ summary.product_name }}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<div class="chat-layout">
<!-- Sidebar: Ingestion Summary -->
<aside class="sidebar">
<div class="sidebar-header">
<a href="/" class="logo-link">
<h1 class="logo">ReviewLens <span class="accent">AI</span></h1>
</a>
</div>
<div class="summary-card">
<h3>{{ summary.product_name }}</h3>
<div class="summary-meta">
<div class="meta-item">
<span class="meta-label">Platform</span>
<span class="meta-value">{{ summary.platform }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Reviews</span>
<span class="meta-value">{{ summary.total_reviews }}</span>
</div>
{% if summary.average_rating %}
<div class="meta-item">
<span class="meta-label">Avg Rating</span>
<span class="meta-value">{{ "%.1f"|format(summary.average_rating) }} ★</span>
</div>
{% endif %}
{% if summary.date_range %}
<div class="meta-item">
<span class="meta-label">Date Range</span>
<span class="meta-value">{{ summary.date_range }}</span>
</div>
{% endif %}
<div class="meta-item">
<span class="meta-label">Source</span>
<span class="meta-value">{{ summary.source_type | upper }}</span>
</div>
</div>
{% if summary.rating_distribution %}
<div class="rating-dist">
<h4>Rating Distribution</h4>
{% for star in ['5','4','3','2','1'] %}
{% set count = summary.rating_distribution.get(star, 0) %}
{% set pct = (count / summary.total_reviews * 100) if summary.total_reviews else 0 %}
<div class="dist-row">
<span class="dist-label">{{ star }}★</span>
<div class="dist-bar-track">
<div class="dist-bar-fill" style="width: {{ pct }}%"></div>
</div>
<span class="dist-count">{{ count }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="sidebar-footer">
<a href="/" class="btn btn-small">New Analysis</a>
</div>
</aside>
<!-- Main Chat Area -->
<main class="chat-main">
<div class="chat-messages" id="chat-messages">
<!-- Existing messages -->
{% for msg in messages %}
{% if msg.role == 'assistant' %}
<div class="message assistant-message">
<div class="message-content">{{ msg.content }}</div>
</div>
{% else %}
<div class="message user-message">
<div class="message-content"><p>{{ msg.content }}</p></div>
</div>
{% endif %}
{% endfor %}
<!-- Welcome message if no messages yet -->
{% if not messages %}
<div class="message assistant-message welcome">
<div class="message-content">
<p>Hello! I see you want to analyse reviews for <strong>{{ summary.product_name }}</strong>.
I have <strong>{{ summary.total_reviews }}</strong> reviews loaded from <strong>{{ summary.platform }}</strong>{% if summary.date_range %}, spanning {{ summary.date_range }}{% endif %}.</p>
<p>I can only answer questions about this specific dataset — no external data, no guesswork. What would you like to know?</p>
</div>
<div class="follow-ups">
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What's the overall sentiment breakdown?">What's the overall sentiment breakdown?</button>
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What are the most common complaints?">What are the most common complaints?</button>
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="Show me the rating distribution over time">Show me the rating distribution over time</button>
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What do people praise the most?">What do people praise the most?</button>
</div>
</div>
{% endif %}
</div>
<!-- Chat Input -->
<div class="chat-input-area">
<form id="chat-form" onsubmit="sendMessage(event)">
<div class="input-row">
<textarea id="chat-input"
name="message"
placeholder="Ask about the reviews..."
rows="1"
autofocus></textarea>
<button type="submit" class="btn btn-send" id="send-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
</svg>
</button>
</div>
</form>
<p class="input-hint">ReviewLens only analyses the uploaded reviews. It cannot access external data.</p>
</div>
</main>
</div>
<script>
var SESSION_ID = "{{ session.session_id }}";
</script>
<script src="/static/app.js"></script>
</body>
</html>
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/chat.html
<div class="error-banner">
<p>{{ error }}</p>
</div>
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/partials/error.html
<div class="summary-card">
<h3>{{ summary.product_name }}</h3>
<dl class="summary-dl">
<dt>Platform</dt><dd>{{ summary.platform }}</dd>
<dt>Reviews</dt><dd>{{ summary.total_reviews }}</dd>
{% if summary.average_rating %}
<dt>Avg Rating</dt><dd>{{ "%.1f"|format(summary.average_rating) }} ★</dd>
{% endif %}
{% if summary.date_range %}
<dt>Date Range</dt><dd>{{ summary.date_range }}</dd>
{% endif %}
<dt>Source</dt><dd>{{ summary.source_type | upper }}</dd>
</dl>
</div>
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/partials/summary.html
/* ReviewLens AI — Client-side logic */
// ── File upload drag & drop ────────────────────────────────────────
(function () {
var dropZone = document.getElementById("file-drop");
var fileInput = document.getElementById("file");
if (!dropZone || !fileInput) return;
dropZone.addEventListener("click", function () {
fileInput.click();
});
dropZone.addEventListener("dragover", function (e) {
e.preventDefault();
dropZone.classList.add("drag-over");
});
dropZone.addEventListener("dragleave", function () {
dropZone.classList.remove("drag-over");
});
dropZone.addEventListener("drop", function (e) {
e.preventDefault();
dropZone.classList.remove("drag-over");
if (e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
showFileName(e.dataTransfer.files[0].name);
}
});
fileInput.addEventListener("change", function () {
if (fileInput.files.length) {
showFileName(fileInput.files[0].name);
}
});
function showFileName(name) {
var content = dropZone.querySelector(".file-drop-content");
var selected = dropZone.querySelector(".file-selected");
var nameEl = dropZone.querySelector(".file-name");
content.style.display = "none";
selected.style.display = "flex";
nameEl.textContent = name;
}
})();
function clearFile() {
var dropZone = document.getElementById("file-drop");
var fileInput = document.getElementById("file");
var content = dropZone.querySelector(".file-drop-content");
var selected = dropZone.querySelector(".file-selected");
content.style.display = "";
selected.style.display = "none";
fileInput.value = "";
}
// ── HTMX loading states ────────────────────────────────────────────
document.addEventListener("htmx:beforeRequest", function (e) {
var form = e.detail.elt;
var btn = form.querySelector("button[type=submit]");
if (btn) {
btn.disabled = true;
var text = btn.querySelector(".btn-text");
var spinner = btn.querySelector(".btn-spinner");
if (text) text.style.display = "none";
if (spinner) spinner.style.display = "inline-flex";
}
});
document.addEventListener("htmx:afterRequest", function (e) {
var form = e.detail.elt;
var btn = form.querySelector("button[type=submit]");
if (btn) {
btn.disabled = false;
var text = btn.querySelector(".btn-text");
var spinner = btn.querySelector(".btn-spinner");
if (text) text.style.display = "";
if (spinner) spinner.style.display = "none";
}
});
// ── Chat functionality ─────────────────────────────────────────────
var chatMessages = document.getElementById("chat-messages");
var chatInput = document.getElementById("chat-input");
var chatForm = document.getElementById("chat-form");
var sendBtn = document.getElementById("send-btn");
var eventSource = null;
function scrollToBottom() {
if (chatMessages) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
// Auto-resize textarea
if (chatInput) {
chatInput.addEventListener("input", function () {
this.style.height = "auto";
this.style.height = Math.min(this.scrollHeight, 150) + "px";
});
chatInput.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
chatForm.dispatchEvent(new Event("submit"));
}
});
}
function sendMessage(e) {
e.preventDefault();
var message = chatInput.value.trim();
if (!message) return;
// Disable input while processing
chatInput.disabled = true;
sendBtn.disabled = true;
// Remove existing follow-up buttons
var existingFollowUps = chatMessages.querySelectorAll(".follow-ups");
existingFollowUps.forEach(function (el) {
el.remove();
});
// Add user message to chat
var userDiv = document.createElement("div");
userDiv.className = "message user-message";
userDiv.innerHTML =
'<div class="message-content"><p>' + escapeHtml(message) + "</p></div>";
chatMessages.appendChild(userDiv);
// Add thinking indicator
var thinkingDiv = document.createElement("div");
thinkingDiv.id = "thinking-indicator";
thinkingDiv.className = "message assistant-message thinking";
thinkingDiv.innerHTML =
'<div class="message-content">' +
'<div class="thinking-dots"><span></span><span></span><span></span></div>' +
"</div>";
chatMessages.appendChild(thinkingDiv);
scrollToBottom();
// Send via fetch and listen for SSE response
var formData = new FormData();
formData.append("message", message);
fetch("/chat/" + SESSION_ID + "/send", {
method: "POST",
body: formData,
})
.then(function () {
// Start listening for SSE events
listenForResponse();
})
.catch(function (err) {
removeThinking();
appendError("Failed to send message: " + err.message);
chatInput.disabled = false;
sendBtn.disabled = false;
});
chatInput.value = "";
chatInput.style.height = "auto";
}
function listenForResponse() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource("/chat/" + SESSION_ID + "/stream");
eventSource.addEventListener("tool", function (e) {
// Show tool activity in a subtle indicator
var thinking = document.getElementById("thinking-indicator");
if (thinking) {
var content = thinking.querySelector(".message-content");
content.innerHTML =
'<div class="thinking-dots"><span></span><span></span><span></span></div>' +
'<div class="tool-activity">' +
escapeHtml(e.data) +
"</div>";
scrollToBottom();
}
});
eventSource.addEventListener("message", function (e) {
removeThinking();
// Insert the rendered HTML from the server
var temp = document.createElement("div");
temp.innerHTML = e.data;
while (temp.firstChild) {
chatMessages.appendChild(temp.firstChild);
}
// Execute any chart scripts
runChartScripts();
scrollToBottom();
chatInput.disabled = false;
sendBtn.disabled = false;
chatInput.focus();
});
eventSource.addEventListener("done", function () {
eventSource.close();
eventSource = null;
removeThinking();
chatInput.disabled = false;
sendBtn.disabled = false;
});
eventSource.onerror = function () {
eventSource.close();
eventSource = null;
removeThinking();
chatInput.disabled = false;
sendBtn.disabled = false;
};
}
function removeThinking() {
var el = document.getElementById("thinking-indicator");
if (el) el.remove();
}
function appendError(msg) {
var div = document.createElement("div");
div.className = "message assistant-message error";
div.innerHTML = '<div class="message-content"><p>' + escapeHtml(msg) + "</p></div>";
chatMessages.appendChild(div);
scrollToBottom();
}
function sendFollowUp(btn) {
var question = btn.getAttribute("data-question");
chatInput.value = question;
chatForm.dispatchEvent(new Event("submit"));
}
// ── Chart rendering ────────────────────────────────────────────────
// Color palette
var CHART_COLORS = [
"rgba(99, 102, 241, 0.8)", // indigo
"rgba(16, 185, 129, 0.8)", // emerald
"rgba(245, 158, 11, 0.8)", // amber
"rgba(239, 68, 68, 0.8)", // red
"rgba(139, 92, 246, 0.8)", // violet
"rgba(6, 182, 212, 0.8)", // cyan
"rgba(236, 72, 153, 0.8)", // pink
];
var CHART_BORDERS = [
"rgba(99, 102, 241, 1)",
"rgba(16, 185, 129, 1)",
"rgba(245, 158, 11, 1)",
"rgba(239, 68, 68, 1)",
"rgba(139, 92, 246, 1)",
"rgba(6, 182, 212, 1)",
"rgba(236, 72, 153, 1)",
];
function renderChart(canvasId, config) {
var canvas = document.getElementById(canvasId);
if (!canvas) return;
var datasets = config.data.datasets.map(function (ds, i) {
var colors =
config.type === "pie" || config.type === "doughnut"
? CHART_COLORS.slice(0, ds.data.length)
: [CHART_COLORS[i % CHART_COLORS.length]];
var borders =
config.type === "pie" || config.type === "doughnut"
? CHART_BORDERS.slice(0, ds.data.length)
: [CHART_BORDERS[i % CHART_BORDERS.length]];
return {
label: ds.label,
data: ds.data,
backgroundColor: colors.length === 1 ? colors[0] : colors,
borderColor: borders.length === 1 ? borders[0] : borders,
borderWidth: config.type === "line" ? 2 : 1,
tension: 0.3,
fill: config.type === "line",
};
});
new Chart(canvas, {
type: config.type,
data: {
labels: config.data.labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
title: {
display: true,
text: config.title,
font: { size: 14, weight: "600" },
color: "#e2e8f0",
},
legend: {
labels: { color: "#94a3b8" },
},
},
scales:
config.type !== "pie" && config.type !== "doughnut"
? {
x: {
ticks: { color: "#94a3b8" },
grid: { color: "rgba(148, 163, 184, 0.1)" },
},
y: {
ticks: { color: "#94a3b8" },
grid: { color: "rgba(148, 163, 184, 0.1)" },
beginAtZero: true,
},
}
: undefined,
},
});
}
function runChartScripts() {
// Find and execute any chart script tags just added
var scripts = chatMessages.querySelectorAll("script");
scripts.forEach(function (script) {
if (script.textContent.indexOf("renderChart") !== -1 && !script.dataset.executed) {
script.dataset.executed = "true";
eval(script.textContent);
}
});
}
// ── Utilities ──────────────────────────────────────────────────────
function escapeHtml(str) {
var div = document.createElement("div");
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// Auto-scroll on new content
if (chatMessages) {
var observer = new MutationObserver(function () {
scrollToBottom();
});
observer.observe(chatMessages, { childList: true, subtree: true });
}
// Initial scroll
scrollToBottom();
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/app.js
/* ReviewLens AI — Stylesheet */
/* ── Reset & Base ──────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f172a;
--bg-card: #1e293b;
--bg-input: #334155;
--bg-hover: #475569;
--text: #e2e8f0;
--text-muted: #94a3b8;
--text-dim: #64748b;
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-dim: rgba(99, 102, 241, 0.15);
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--border: #334155;
--radius: 8px;
--radius-lg: 12px;
--shadow: 0 4px 6px -1px rgba(0,0,0,0.3);
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', monospace;
}
html, body {
height: 100%;
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
/* ── Landing Page ──────────────────────────────────────────────── */
.landing {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1.5rem;
}
.landing-header {
text-align: center;
margin-bottom: 3rem;
}
.landing-header h1 {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.accent { color: var(--accent); }
.tagline {
color: var(--text-muted);
font-size: 1.1rem;
margin-top: 0.5rem;
}
.upload-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
max-width: 900px;
width: 100%;
}
@media (max-width: 768px) {
.upload-section { grid-template-columns: 1fr; }
}
.upload-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2rem;
}
.upload-card.primary { border-color: var(--accent); }
.upload-card h2 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.upload-desc {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 1.5rem;
}
.badge {
font-size: 0.7rem;
background: var(--warning);
color: #000;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
vertical-align: middle;
}
/* ── Forms ──────────────────────────────────────────────────────── */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.6rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 0.9rem;
font-family: var(--font);
transition: border-color 0.15s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-group select {
cursor: pointer;
}
/* ── File Drop Zone ────────────────────────────────────────────── */
.file-drop {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 2rem;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 1.5rem;
position: relative;
}
.file-drop:hover,
.file-drop.drag-over {
border-color: var(--accent);
background: var(--accent-dim);
}
.file-drop input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.file-icon { font-size: 2rem; margin-bottom: 0.5rem; }
.file-drop p {
color: var(--text-muted);
font-size: 0.9rem;
}
.browse-link {
color: var(--accent);
text-decoration: underline;
}
.file-hint {
font-size: 0.8rem;
color: var(--text-dim);
margin-top: 0.25rem;
}
.file-selected {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.file-name {
color: var(--text);
font-weight: 500;
}
.file-clear {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1.1rem;
padding: 0.25rem;
}
/* ── Buttons ───────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.7rem 1.5rem;
border: none;
border-radius: var(--radius);
font-size: 0.9rem;
font-weight: 600;
font-family: var(--font);
cursor: pointer;
transition: background 0.15s, transform 0.1s;
width: 100%;
}
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn-secondary {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover:not(:disabled) { background: var(--bg-hover); }
.btn-small {
padding: 0.4rem 1rem;
font-size: 0.8rem;
width: auto;
}
.btn-spinner {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Error Banner ──────────────────────────────────────────────── */
.error-banner {
background: rgba(239, 68, 68, 0.15);
border: 1px solid var(--error);
color: var(--error);
padding: 0.75rem 1rem;
border-radius: var(--radius);
font-size: 0.9rem;
margin-top: 1rem;
}
/* ── Landing Footer ────────────────────────────────────────────── */
.landing-footer {
margin-top: 3rem;
text-align: center;
color: var(--text-dim);
font-size: 0.85rem;
}
/* ── Chat Layout ───────────────────────────────────────────────── */
.chat-layout {
display: grid;
grid-template-columns: 280px 1fr;
height: 100vh;
}
@media (max-width: 768px) {
.chat-layout { grid-template-columns: 1fr; }
.sidebar { display: none; }
}
/* ── Sidebar ───────────────────────────────────────────────────── */
.sidebar {
background: var(--bg-card);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.logo {
font-size: 1.1rem;
font-weight: 700;
}
.logo-link { color: var(--text); }
.logo-link:hover { color: var(--text); }
.summary-card {
padding: 1.5rem;
flex: 1;
}
.summary-card h3 {
font-size: 1rem;
margin-bottom: 1rem;
word-break: break-word;
}
.summary-meta {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.meta-item {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
}
.meta-label { color: var(--text-muted); }
.meta-value { font-weight: 500; }
/* ── Rating Distribution ───────────────────────────────────────── */
.rating-dist {
margin-top: 1.5rem;
}
.rating-dist h4 {
font-size: 0.8rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
.dist-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
font-size: 0.8rem;
}
.dist-label {
width: 2rem;
text-align: right;
color: var(--text-muted);
flex-shrink: 0;
}
.dist-bar-track {
flex: 1;
height: 6px;
background: var(--bg-input);
border-radius: 3px;
overflow: hidden;
}
.dist-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.3s ease;
}
.dist-count {
width: 2.5rem;
text-align: right;
color: var(--text-muted);
flex-shrink: 0;
}
.sidebar-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
}
/* ── Chat Main ─────────────────────────────────────────────────── */
.chat-main {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ── Messages ──────────────────────────────────────────────────── */
.message {
max-width: 720px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.user-message {
align-self: flex-end;
}
.user-message .message-content {
background: var(--accent);
color: #fff;
padding: 0.75rem 1rem;
border-radius: var(--radius-lg) var(--radius-lg) 4px var(--radius-lg);
}
.user-message .message-content p { margin: 0; }
.assistant-message {
align-self: flex-start;
}
.assistant-message .message-content {
background: var(--bg-card);
border: 1px solid var(--border);
padding: 1rem 1.25rem;
border-radius: 4px var(--radius-lg) var(--radius-lg) var(--radius-lg);
line-height: 1.7;
}
.assistant-message .message-content p { margin-bottom: 0.5rem; }
.assistant-message .message-content p:last-child { margin-bottom: 0; }
.assistant-message .message-content h1,
.assistant-message .message-content h2,
.assistant-message .message-content h3 {
margin: 1rem 0 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.assistant-message .message-content ul,
.assistant-message .message-content ol {
padding-left: 1.5rem;
margin-bottom: 0.5rem;
}
.assistant-message .message-content li {
margin-bottom: 0.25rem;
}
.assistant-message .message-content strong {
color: #fff;
}
.assistant-message .message-content code {
background: var(--bg-input);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.85em;
font-family: var(--mono);
}
.assistant-message .message-content blockquote {
border-left: 3px solid var(--accent);
padding-left: 1rem;
color: var(--text-muted);
margin: 0.5rem 0;
}
.assistant-message .message-content table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0;
font-size: 0.85rem;
}
.assistant-message .message-content th,
.assistant-message .message-content td {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
text-align: left;
}
.assistant-message .message-content th {
background: var(--bg-input);
font-weight: 600;
}
/* ── Thinking Indicator ────────────────────────────────────────── */
.thinking .message-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.thinking-dots {
display: flex;
gap: 4px;
padding: 0.25rem 0;
}
.thinking-dots span {
width: 8px;
height: 8px;
background: var(--text-muted);
border-radius: 50%;
animation: bounce 1.4s ease-in-out infinite;
}
.thinking-dots span:nth-child(2) { animation-delay: 0.16s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.32s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.tool-activity {
font-size: 0.8rem;
color: var(--text-dim);
font-family: var(--mono);
}
/* ── Charts ────────────────────────────────────────────────────── */
.chart-container {
margin: 1rem 0;
padding: 1rem;
background: var(--bg);
border-radius: var(--radius);
border: 1px solid var(--border);
max-width: 500px;
}
.chart-container canvas {
max-height: 300px;
}
/* ── Follow-up Buttons ─────────────────────────────────────────── */
.follow-ups {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.follow-up-btn {
background: var(--accent-dim);
color: var(--accent-hover);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 999px;
padding: 0.4rem 1rem;
font-size: 0.8rem;
font-family: var(--font);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
white-space: nowrap;
}
.follow-up-btn:hover {
background: rgba(99, 102, 241, 0.25);
border-color: var(--accent);
}
/* ── Chat Input ────────────────────────────────────────────────── */
.chat-input-area {
padding: 1rem 2rem 1.5rem;
border-top: 1px solid var(--border);
background: var(--bg);
}
.input-row {
display: flex;
gap: 0.75rem;
align-items: flex-end;
}
.input-row textarea {
flex: 1;
resize: none;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
color: var(--text);
font-size: 0.9rem;
font-family: var(--font);
line-height: 1.5;
max-height: 150px;
transition: border-color 0.15s;
}
.input-row textarea:focus {
outline: none;
border-color: var(--accent);
}
.input-row textarea:disabled {
opacity: 0.5;
}
.btn-send {
width: 44px;
height: 44px;
padding: 0;
border-radius: 50%;
flex-shrink: 0;
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, transform 0.1s;
}
.btn-send:hover:not(:disabled) { background: var(--accent-hover); }
.btn-send:active { transform: scale(0.95); }
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
.input-hint {
text-align: center;
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 0.5rem;
}
/* ── Scrollbar ─────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-dim);
}
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/style.css
fastapi>=0.115.0 uvicorn[standard] sse-starlette claude-agent-sdk chromadb httpx jinja2 python-multipart python-dotenv markdown
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/requirements.txt
# Required ANTHROPIC_API_KEY=sk-ant-... # Optional: model override (default: claude-sonnet-4-6-20250514) # CLAUDE_MODEL=claude-sonnet-4-6-20250514 # Optional: Firecrawl for URL scraping (secondary path) # FIRECRAWL_API_KEY=fc-... # Optional: data directory (default: data) # DATA_DIR=data # Optional: ChromaDB directory (default: data/chroma) # CHROMA_DIR=data/chroma
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/.env.example
FROM python:3.12-slim
WORKDIR /app
# Install build deps for chromadb (needs sqlite3, gcc for some wheels)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/Dockerfile
services:
- type: web
name: reviewlens
runtime: docker
plan: free
envVars:
- key: ANTHROPIC_API_KEY
sync: false
- key: FIRECRAWL_API_KEY
sync: false
healthCheckPath: /health
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/render.yaml
# ReviewLens AI
A review intelligence portal that ingests customer reviews and lets analysts explore them through a guardrailed conversational interface.
## Architecture
```
CSV Upload / URL Scrape
│
▼
Parse & Index ──→ ChromaDB (in-process vector store)
│
▼
Ingestion Summary ──→ Chat Interface
│
User Message
│
▼
Claude Agent SDK
(per-message loop)
│
┌───────┼───────┐
│ │ │
search_reviews │ generate_chart
analyze_sentiment
calculate_stats
suggest_follow_ups
│
▼
SSE Stream → Chat UI
(text + charts + follow-ups)
```
**Stack:** FastAPI, Jinja2/HTMX, SSE streaming, Claude Agent SDK with custom MCP tools, ChromaDB, Chart.js.
## Key Design Decisions
### Scope Guard (Three-Layer Defense)
The AI only answers questions about the ingested reviews:
1. **System prompt** — explicit instructions to refuse out-of-scope questions
2. **Retrieval confidence** — if ChromaDB returns nothing relevant, auto-reject
3. **Architectural boundary** — the agent has no tools that access external data
### CSV-First Ingestion
CSV upload is the polished primary path. URL scraping via Firecrawl is best-effort secondary — review platforms are heavily anti-bot and the results are unpredictable.
### Conversational Agent Loop
Unlike a fire-and-forget agent, ReviewLens invokes the agent per-message. Each user message triggers a fresh agent loop with conversation history as context.
### Chart.js Inline Charts
The `generate_chart` tool returns Chart.js config JSON. The frontend renders charts inline in chat messages. The agent decides when a chart adds value.
## Setup
```bash
cd reviewlens
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Edit .env with your ANTHROPIC_API_KEY
uvicorn app.main:app --reload
```
Open http://localhost:8000
## Deploy (Render)
1. Push to GitHub
2. Connect repo in Render dashboard
3. Set `ANTHROPIC_API_KEY` environment variable
4. Deploy — uses `render.yaml` for config
## CSV Format
The parser auto-detects common column names. Minimum required: a text/review column.
| Column | Aliases |
|--------|---------|
| text | text, review, review_text, content, body, comment |
| rating | rating, score, stars, star_rating |
| date | date, review_date, created_at, timestamp |
| author | author, reviewer, user, username |
| platform | platform, source, site |
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `ANTHROPIC_API_KEY` | Yes | Claude API key |
| `CLAUDE_MODEL` | No | Model override (default: claude-sonnet-4-6-20250514) |
| `FIRECRAWL_API_KEY` | No | Enables URL scraping |
| `DATA_DIR` | No | Data directory (default: data) |
| `CHROMA_DIR` | No | ChromaDB directory (default: data/chroma) |
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/README.md
find /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens -type f | sort
/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/.DS_Store /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/.env.example /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/__init__.py /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/agent.py /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/ingest.py /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/models.py /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/prompts.py /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/store.py /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/tools.py /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/vectordb.py /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/Dockerfile /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/README.md /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/render.yaml /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/requirements.txt /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/app.js /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/style.css /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/.DS_Store /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/chat.html /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/index.html /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/partials/error.html /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/partials/summary.html
cd /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens && python3 -c "
import ast, sys, pathlib
errors = []
for f in pathlib.Path('app').glob('*.py'):
try:
ast.parse(f.read_text())
except SyntaxError as e:
errors.append(f'{f}: {e}')
if errors:
print('Syntax errors:')
for e in errors:
print(f' {e}')
sys.exit(1)
else:
print('All Python files parse OK')
"All 21 files built, all Python parses clean. Here's what we've got:
Project structure:
reviewlens/
├── app/
│ ├── __init__.py
│ ├── models.py — Review, Session, ChatMessage, IngestionSummary
│ ├── store.py — File-based session persistence
│ ├── vectordb.py — ChromaDB wrapper (index, search, get_all)
│ ├── ingest.py — CSV parser (flexible column detection) + Firecrawl scraper
│ ├── prompts.py — Scope-guarded system prompt builder
│ ├── tools.py — 5 MCP tools via create_sdk_mcp_server
│ ├── agent.py — Per-message conversational agent loop
│ └── main.py — FastAPI routes + SSE streaming
├── templates/
│ ├── index.html — Landing page with CSV upload + URL scrape
│ ├── chat.html — Chat interface with sidebar summary
│ └── partials/ — Error and summary fragments
├── static/
│ ├── app.js — SSE client, Chart.js rendering, follow-up buttons
│ └── style.css — Dark theme, chat UI, responsive
├── Dockerfile
├── render.yaml
├── requirements.txt
├── .env.example
└── README.md
Key patterns carried from briefbot:
- create_sdk_mcp_server with closure-based tools
- SSE streaming via sse-starlette
- ClaudeSDKClient with PostToolUse hooks (used here to intercept charts/follow-ups)
- HTML escaping, Jinja2 templates, HTMX for form submissions
- File-based persistence, Docker + Render deployment
Key differences from briefbot:
- Conversational loop — agent invoked per-message with history, not per-session
- ChromaDB vector store — reviews embedded and searched semantically
- Three-layer scope guard — system prompt + retrieval confidence + no external tools
- Inline Chart.js — generate_chart tool returns config, frontend renders in chat
- Follow-up buttons — regenerated after each response via suggest_follow_ups tool
- CSV column auto-detection — handles varied column naming conventions
Want me to install deps and test-run it, or would you like to review/adjust anything first?
python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt 2>&1 | tail -20
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 6.3/6.3 MB 19.7 MB/s eta 0:00:00 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.6/3.6 MB 20.0 MB/s eta 0:00:00 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 536.2/536.2 kB 15.2 MB/s eta 0:00:00 Successfully installed MarkupSafe-3.0.3 annotated-doc-0.0.4 annotated-types-0.7.0 anyio-4.12.1 attrs-26.1.0 bcrypt-5.0.0 build-1.4.0 certifi-2026.2.25 cffi-2.0.0 charset_normalizer-3.4.6 chromadb-1.5.5 claude-agent-sdk-0.1.50 click-8.3.1 cryptography-46.0.5 durationpy-0.10 fastapi-0.135.1 filelock-3.25.2 flatbuffers-25.12.19 fsspec-2026.2.0 googleapis-common-protos-1.73.0 grpcio-1.78.0 h11-0.16.0 hf-xet-1.4.2 httpcore-1.0.9 httptools-0.7.1 httpx-0.28.1 httpx-sse-0.4.3 huggingface-hub-1.7.2 idna-3.11 importlib-metadata-8.7.1 importlib-resources-6.5.2 jinja2-3.1.6 jsonschema-4.26.0 jsonschema-specifications-2025.9.1 kubernetes-35.0.0 markdown-3.10.2 markdown-it-py-4.0.0 mcp-1.26.0 mdurl-0.1.2 mmh3-5.2.1 mpmath-1.3.0 numpy-2.4.3 oauthlib-3.3.1 onnxruntime-1.24.4 opentelemetry-api-1.40.0 opentelemetry-exporter-otlp-proto-common-1.40.0 opentelemetry-exporter-otlp-proto-grpc-1.40.0 opentelemetry-proto-1.40.0 opentelemetry-sdk-1.40.0 opentelemetry-semantic-conventions-0.61b0 orjson-3.11.7 overrides-7.7.0 packaging-26.0 protobuf-6.33.6 pybase64-1.4.3 pycparser-3.0 pydantic-2.12.5 pydantic-core-2.41.5 pydantic-settings-2.13.1 pygments-2.19.2 pyjwt-2.12.1 pypika-0.51.1 pyproject_hooks-1.2.0 python-dateutil-2.9.0.post0 python-dotenv-1.2.2 python-multipart-0.0.22 pyyaml-6.0.3 referencing-0.37.0 requests-2.32.5 requests-oauthlib-2.0.0 rich-14.3.3 rpds-py-0.30.0 shellingham-1.5.4 six-1.17.0 sse-starlette-3.3.3 starlette-0.52.1 sympy-1.14.0 tenacity-9.1.4 tokenizers-0.22.2 tqdm-4.67.3 typer-0.24.1 typing-extensions-4.15.0 typing-inspection-0.4.2 urllib3-2.6.3 uvicorn-0.42.0 uvloop-0.22.1 watchfiles-1.1.1 websocket-client-1.9.0 websockets-16.0 zipp-3.23.0 [notice] A new release of pip is available: 24.2 -> 26.0.1 [notice] To update, run: pip install --upgrade pip
source .venv/bin/activate && python3 -c "from app.main import app; print('Import OK')"source .venv/bin/activate && uvicorn app.main:app --reload --port 8000 2>&1 & sleep 2 curl -s http://localhost:8000/health
INFO: Will watch for changes in these directories: ['/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [34071] using WatchFiles
WARNING: WatchFiles detected changes in '.venv/lib/python3.13/site-packages/pip/_internal/utils/misc.py'. Reloading...
INFO: Started server process [34074]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [34074]
WARNING: WatchFiles detected changes in '.venv/lib/python3.13/site-packages/pip/_internal/network/lazy_wheel.py'. Reloading...
INFO: Started server process [34075]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [34075]
INFO: Started server process [34077]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:56070 - "GET /health HTTP/1.1" 200 OK
{"status":"ok"}Running, but --reload is watching the .venv dir and thrashing. Let me restart with the venv excluded:
pkill -f "uvicorn app.main" 2>/dev/null; sleep 1 source .venv/bin/activate && uvicorn app.main:app --reload --reload-exclude '.venv' --port 8000 & sleep 2 curl -s http://localhost:8000/health
INFO: Will watch for changes in these directories: ['/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [34087] using WatchFiles
INFO: Started server process [34090]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:56079 - "GET /health HTTP/1.1" 200 OK
{"status":"ok"}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"># Create a test CSV and upload it cat > /tmp/test_reviews.csv << 'EOF' review_text,rating,date,author "Great product, works exactly as described. Battery life is amazing.",5,2024-06-15,Alice "Decent quality but the shipping took forever. Product itself is fine.",3,2024-06-20,Bob "Terrible customer service. Product broke after 2 weeks.",1,2024-07-01,Charlie "Love this! Best purchase I've made this year. Highly recommend.",5,2024-07-10,Diana "It's okay. Nothing special but gets the job done.",3,2024-07-15,Eve "The build quality is poor. Feels cheap and flimsy.",2,2024-07-20,Frank "Exceeded my expectations! Fast delivery and great packaging.",5,2024-08-01,Grace "Not worth the price. You can find better alternatives.",2,2024-08-05,Henry "Absolutely love it. The design is sleek and modern.",4,2024-08-10,Iris "Had issues with the setup but once working it's great.",4,2024-08-15,Jack EOF curl -s -X POST http://localhost:8000/upload \ -F "file=@/tmp/test_reviews.csv" \ -F "product_name=TestWidget Pro" \ -F "platform=Amazon" \ -D - | head -20
HTTP/1.1 200 OK date: Sat, 21 Mar 2026 21:30:05 GMT server: uvicorn hx-redirect: /chat/e38c015e-0785-4f7d-bb4d-090603292b37 content-length: 83 content-type: text/html; charset=utf-8 <script>window.location.href="/chat/e38c015e-0785-4f7d-bb4d-090603292b37";</script>
Ingestion works — got a session ID and redirect. Let me verify the chat page and the stored data:
SESSION="e38c015e-0785-4f7d-bb4d-090603292b37"
# Check session was saved
cat data/$SESSION/session.json | python3 -m json.tool | head -25
echo "---"
# Check ChromaDB indexed
source .venv/bin/activate && python3 -c "
from app.vectordb import get_review_count, search_reviews
sid = '$SESSION'
print(f'Indexed: {get_review_count(sid)} reviews')
results = search_reviews(sid, 'battery life', n_results=3)
for r in results:
print(f' [{r[\"distance\"]:.3f}] {r[\"text\"][:80]}...')
"{
"session_id": "e38c015e-0785-4f7d-bb4d-090603292b37",
"product_name": "TestWidget Pro",
"platform": "Amazon",
"summary": {
"total_reviews": 10,
"date_range": "2024-06-15 to 2024-08-15",
"rating_distribution": {
"5": 3,
"3": 2,
"1": 1,
"2": 2,
"4": 2
},
"average_rating": 3.4,
"platform": "Amazon",
"product_name": "TestWidget Pro",
"source_type": "csv"
},
"messages": [],
"status": "ready",
"created_at": "2026-03-21T21:30:05.616676"
}
---
Indexed: 10 reviews
[0.614] Great product, works exactly as described. Battery life is amazing....
[0.753] Terrible customer service. Product broke after 2 weeks....
[0.839] Decent quality but the shipping took forever. Product itself is fine....CSV parsing, session storage, ChromaDB indexing, and semantic search all working. Let me check the chat page renders:
curl -s http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 | grep -o 'TestWidget Pro' | head -1 curl -s http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 | grep -o '10 reviews' | head -1 curl -s http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 | grep -o 'follow-up-btn' | wc -l
curl -s http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 | grep -o '[0-9]* reviews loaded' | head -1
The chat page renders with the product name, 4 follow-up buttons, and the welcome message. Now let me test an actual chat message end-to-end:
SESSION="e38c015e-0785-4f7d-bb4d-090603292b37" # Send a chat message curl -s -X POST "http://localhost:8000/chat/$SESSION/send" \ -F "message=What are the most common complaints?" & # Wait a moment then listen for SSE sleep 1 curl -s -N "http://localhost:8000/chat/$SESSION/stream" --max-time 60 2>&1
<div class="message user-message"> <div class="message-content"><p>What are the most common complaints?</p></div> </div><div id="thinking-indicator" class="message assistant-message thinking"><div class="message-content"><div class="thinking-dots"><span></span><span></span><span></span></div></div></div>event: message data: <div class="message assistant-message"> data: <div class="message-content"><p>Sorry, I encountered an error: tool() got an unexpected keyword argument 'schema'</p></div> data: </div> event: done data:
The @tool decorator in claude-agent-sdk doesn't use a schema kwarg. Let me check the actual API:
cp /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/.env.example /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/.env