Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Section } from '@/components/section'
import { FollowupPanel } from '@/components/followup-panel'
import { inquire, researcher, taskManager, querySuggestor, resolutionSearch, type DrawnFeature } from '@/lib/agents'
import { writer } from '@/lib/agents/writer'
import { saveChat, getSystemPrompt } from '@/lib/actions/chat'
import { saveChat, getSystemPrompt, generateReportContext } from '@/lib/actions/chat'
import { Chat, AIMessage } from '@/lib/types'
import { UserMessage } from '@/components/user-message'
import { BotMessage } from '@/components/message'
Expand Down Expand Up @@ -50,6 +50,20 @@ async function submit(formData?: FormData, skip?: boolean) {
console.error('Failed to parse drawnFeatures:', e);
}

if (action === 'generate_report_context') {
const messagesString = formData?.get('messages');
if (typeof messagesString !== 'string') {
return { title: 'QCX Intelligence Analysis', summary: 'Automated executive summary is currently unavailable.' };
}
try {
const messages = JSON.parse(messagesString) as AIMessage[];
return await generateReportContext(messages);
} catch (e) {
console.error('Failed to parse messages for report context:', e);
return { title: 'QCX Intelligence Analysis', summary: 'Automated executive summary is currently unavailable.' };
}
}

if (action === 'resolution_search') {
const file_mapbox = formData?.get('file_mapbox') as File;
const file_google = formData?.get('file_google') as File;
Expand Down
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 16 additions & 19 deletions components/download-report-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useMap } from './map/map-context'
import { useMapData } from './map/map-data-context'
import { generatePDFReport } from '@/lib/utils/report-generator'
import { AI } from '@/app/actions'
import { useActions } from 'ai/rsc'
import { toast } from 'sonner'
import { ReportTemplate } from './report-template'
import { createPortal } from 'react-dom'
Expand All @@ -16,10 +17,12 @@ export const DownloadReportButton = () => {
const [aiState] = useAIState<typeof AI>()
const { map } = useMap()
const { mapData } = useMapData()
const actions = useActions<typeof AI>()
const [isGenerating, setIsGenerating] = useState(false)
const [showTemplate, setShowTemplate] = useState(false)
const [mapSnapshot, setMapSnapshot] = useState<string | undefined>()
const [reportTitle, setReportTitle] = useState('QCX Analysis Report')
const [reportSummary, setReportSummary] = useState<string | undefined>()
const [isMounted, setIsMounted] = useState(false)

useEffect(() => {
Expand All @@ -39,33 +42,26 @@ export const DownloadReportButton = () => {
let snapshot: string | undefined
if (map) {
try {
// Use JPEG to keep the data URL smaller and potentially avoid context loss
snapshot = map.getCanvas().toDataURL('image/jpeg', 0.6)
// preserveDrawingBuffer is true, so we can capture the canvas
snapshot = map.getCanvas().toDataURL('image/png')
setMapSnapshot(snapshot)
} catch (e) {
console.warn('Failed to capture map snapshot', e)
}
}

// Extract title more robustly
let chatTitle = 'Untitled Chat'
if (aiState.messages.length > 0) {
const firstMessage = aiState.messages[0]
const content = typeof firstMessage.content === 'string'
? firstMessage.content
: Array.isArray(firstMessage.content)
? (firstMessage.content as any[]).map(p => p.type === 'text' ? p.text : '').join(' ')
: ''
// Generate AI summary and title
toast.loading('Synthesizing intelligence findings...', { id: toastId })

try {
const parsed = JSON.parse(content)
chatTitle = parsed.input || content
} catch (e) {
chatTitle = content
}
}
const finalTitle = (chatTitle || 'QCX Analysis Report').substring(0, 50)
const formData = new FormData();
formData.append('action', 'generate_report_context');
formData.append('messages', JSON.stringify(aiState.messages));

const { title, summary } = await (actions as any).submit(formData);

const finalTitle = title || 'QCX Intelligence Analysis'
setReportTitle(finalTitle)
setReportSummary(summary)

// Step 1: Render template in portal
setShowTemplate(true)
Expand Down Expand Up @@ -131,6 +127,7 @@ export const DownloadReportButton = () => {
drawnFeatures={mapData?.drawnFeatures}
mapSnapshot={mapSnapshot}
chatTitle={reportTitle}
aiSummary={reportSummary}
/>
</div>,
document.body
Expand Down
43 changes: 31 additions & 12 deletions components/report-template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ export interface ReportTemplateProps {
}>
mapSnapshot?: string
chatTitle: string
aiSummary?: string
}

export const ReportTemplate: React.FC<ReportTemplateProps> = ({
messages,
drawnFeatures,
mapSnapshot,
chatTitle
chatTitle,
aiSummary
}) => {
const renderMessageContent = (content: any): string => {
if (typeof content === 'string') {
Expand Down Expand Up @@ -50,9 +52,10 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
// and the response is relatively short (likely a transition/redundant summary), filter it out.
if (message.type === 'response' && index + 1 < rawFiltered.length) {
const nextMessage = rawFiltered[index + 1]
if (nextMessage.type === 'resolution_search_result') {
if (nextMessage.role === 'assistant' && (nextMessage.type === 'resolution_search_result' || nextMessage.type === 'response')) {
const content = renderMessageContent(message.content)
if (content.length < 300) {
// Filter out short transition messages or duplicated high-level summaries
if (content.length < 500) {
return false
}
}
Expand Down Expand Up @@ -103,6 +106,22 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({

{/* Main Content Container */}
<div className="p-16 space-y-20">
{aiSummary && (
<section className="break-inside-avoid">
<div className="flex items-baseline gap-4 mb-8 border-b border-slate-100 pb-4">
<span className="text-4xl font-black text-slate-200">00</span>
<h2 className="text-2xl font-black text-[#003366] uppercase tracking-tight">Intelligence Executive Summary</h2>
</div>
<div className="bg-slate-50 p-10 rounded-2xl border-l-8 border-[#003366] shadow-inner">
<div className="prose prose-slate prose-lg max-w-none text-slate-800 font-medium leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{aiSummary}
</ReactMarkdown>
</div>
</div>
</section>
)}

{mapSnapshot && (
<section className="break-inside-avoid">
<div className="flex items-baseline gap-4 mb-8 border-b border-slate-100 pb-4">
Expand Down Expand Up @@ -174,23 +193,23 @@ export const ReportTemplate: React.FC<ReportTemplateProps> = ({
{result.summary}
</div>
)}
<div className="grid grid-cols-2 gap-10">
<div className="flex flex-col items-center gap-12">
{result.mapboxImage && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="space-y-4 w-full max-w-2xl">
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 rounded-full bg-indigo-500"></div>
<p className="text-[10px] text-slate-400 font-black uppercase tracking-widest">Spectral RGB</p>
<p className="text-[10px] text-slate-400 font-black uppercase tracking-widest">Spectral RGB Analysis View</p>
</div>
<img src={result.mapboxImage} alt="Mapbox View" className="rounded-xl border-4 border-white shadow-xl w-full block" crossOrigin="anonymous" />
<img src={result.mapboxImage} alt="Mapbox View" className="rounded-xl border-4 border-white shadow-xl w-full block mx-auto" crossOrigin="anonymous" />
</div>
)}
{result.googleImage && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="space-y-4 w-full max-w-2xl">
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 rounded-full bg-orange-500"></div>
<p className="text-[10px] text-slate-400 font-black uppercase tracking-widest">High-Res Panchro</p>
<p className="text-[10px] text-slate-400 font-black uppercase tracking-widest">High-Resolution Panchromatic Satellite</p>
</div>
<img src={result.googleImage} alt="Google Satellite" className="rounded-xl border-4 border-white shadow-xl w-full block" crossOrigin="anonymous" />
<img src={result.googleImage} alt="Google Satellite" className="rounded-xl border-4 border-white shadow-xl w-full block mx-auto" crossOrigin="anonymous" />
</div>
)}
</div>
Expand Down
63 changes: 63 additions & 0 deletions lib/actions/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,69 @@ import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'
import { generateText } from 'ai'
import { getModel } from '../utils'

export async function generateReportContext(messages: AIMessage[]) {
try {
const model = await getModel()

const promptMessages = messages
.filter(msg => msg.role === 'user' || (msg.role === 'assistant' && msg.type === 'response'))
.map(msg => {
const role = msg.role === 'user' ? 'user' as const : 'assistant' as const
const rawContent =
typeof msg.content === 'string'
? msg.content
: Array.isArray(msg.content)
? msg.content.map(p => (p && typeof p === 'object' && 'type' in p && p.type === 'text') ? p.text : '').join('\n')
: JSON.stringify(msg.content)

// Sanitize: strip huge base64 images if any are still in there
const content = rawContent
.replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, '[image omitted]')
.trim()

return { role, content }
})
.filter(msg => msg.content.length > 0)

const { text } = await generateText({
model,
system: `You are a high-level geospatial intelligence analyst. Based on the provided conversation, generate:
1. A professional, concise report title (max 60 characters).
2. A 150-200 word executive summary that synthesizes the intelligence findings, observations, and spatial analysis discussed.

Format your response as a JSON object:
{
"title": "The Title Here",
"summary": "The executive summary here..."
}
Do not include any other text or markdown formatting in your response.`,
messages: promptMessages as any,
})

try {
return JSON.parse(text) as { title: string; summary: string }
} catch (e) {
console.error('Failed to parse AI response for report context', {
error: e instanceof Error ? e.message : String(e),
preview: text.slice(0, 200)
})
// Fallback
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return {
title: 'QCX Intelligence Analysis',
summary: 'Executive summary generation failed, but manual review of the intelligence assessment is recommended.'
}
}
} catch (error) {
console.error('Error generating report context:', error)
return {
title: 'QCX Intelligence Analysis',
summary: 'Automated executive summary is currently unavailable.'
}
}
}

export async function getChats(userId?: string | null): Promise<DrizzleChat[]> {
if (!userId) {
Expand Down
8 changes: 4 additions & 4 deletions lib/utils/report-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const generatePDFReport = async (elementId: string, fileName: string) =>
])

const canvas = await html2canvas(element, {
scale: 2, // Increase scale for higher DPI/sharpness
scale: 3, // Increased scale for ultra-sharp text and images
useCORS: true,
logging: false,
allowTaint: true,
Expand All @@ -49,7 +49,7 @@ export const generatePDFReport = async (elementId: string, fileName: string) =>
}
})

const imgData = canvas.toDataURL('image/jpeg', 1.0) // Maximum quality
const imgData = canvas.toDataURL('image/png') // Use PNG for lossless quality and sharper text

// A4 dimensions in px at 72 DPI are roughly 595 x 842
// But we use the internal pageSize values for flexibility
Expand All @@ -70,14 +70,14 @@ export const generatePDFReport = async (elementId: string, fileName: string) =>
let position = 0

// Add first page
pdf.addImage(imgData, 'JPEG', 0, position, pdfWidth, scaledHeight, undefined, 'FAST')
pdf.addImage(imgData, 'PNG', 0, position, pdfWidth, scaledHeight, undefined, 'FAST')
heightLeft -= pdfHeight

// Add subsequent pages if content overflows
while (heightLeft > 0) {
position = heightLeft - scaledHeight
pdf.addPage()
pdf.addImage(imgData, 'JPEG', 0, position, pdfWidth, scaledHeight, undefined, 'FAST')
pdf.addImage(imgData, 'PNG', 0, position, pdfWidth, scaledHeight, undefined, 'FAST')
heightLeft -= pdfHeight
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
"zustand": "^5.0.9"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@playwright/test": "^1.60.0",
"@types/cookie": "^0.6.0",
"@types/lodash": "^4.17.21",
"@types/mapbox-gl": "^3.4.1",
Expand Down
17 changes: 17 additions & 0 deletions tests/verify_report.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { test, expect } from '@playwright/test';

test('verify report template rendering', async ({ page }) => {
Comment on lines +1 to +3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Test will not be discovered by Playwright in current location.

playwright.config.ts sets testDir: './tests', but this spec is added at repo root, so it won’t execute in npm run test:e2e. Move it under tests/ (e.g., tests/verify_report.spec.ts) or update testDir.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@verify_report.spec.ts` around lines 1 - 3, The spec file
verify_report.spec.ts is placed at repo root so Playwright won't discover it
because playwright.config.ts sets testDir: './tests'; either move
verify_report.spec.ts into the tests/ directory (e.g.,
tests/verify_report.spec.ts) so the test('verify report template rendering',
...) is picked up, or update the playwright.config.ts testDir value to include
the repo root or the specific path; adjust imports if needed after moving and
re-run npm run test:e2e to verify discovery.

// Mocking AI state/messages for report generation
await page.goto('/');

// We need to simulate a chat to have messages to export
await page.fill('[data-testid="chat-input"]', 'Analyze the Eiffel Tower');
await page.click('[data-testid="chat-submit"]');

// Wait for some response to appear
await page.waitForSelector('[data-testid="bot-message"]', { timeout: 30000 });

// Now try to trigger the download button which should be in the header or settings
// The DownloadReportButton is used in the SettingsView or Header?
// Let's check where it is used.
});