From ca8961767b166e93b31af01688298de0e80a747f Mon Sep 17 00:00:00 2001 From: Alvin Cheung Date: Sun, 27 Jul 2025 00:08:10 -0500 Subject: [PATCH 1/3] Redesign semantic link analysis to use full post context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Complete rewrite of semantic link analyzer to analyze full post pairs instead of extracting phrases - Claude now makes intelligent decisions about single best semantic link per post pair - Added Claude's raw analysis output storage and display in UI - Added "Show Claude's Raw Output" feature to see exact JSON responses - Updated job processor to handle new single-link response format - Fixed API route to handle posts without links - Fixed React hooks order violation in AnalysisTab - Improved analysis summary display with collapsible sections This redesign moves from mechanical phrase extraction to intelligent full-context analysis, resulting in more relevant and valuable semantic links. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .claude/settings.local.json | 12 +- .../PRPs/semantic-link-analysis-redesign.md | 365 ++++++++++++++++++ package-lock.json | 230 +++++++++++ package.json | 1 + prisma/database/blog.db | Bin 454656 -> 503808 bytes prisma/schema.prisma | 10 + src/app/api/analysis/analyze-post/route.ts | 100 +++++ src/app/api/analysis/queue/route.ts | 145 +++++++ src/app/components/AnalysisTab.tsx | 351 +++++++++++++++++ src/app/components/EnhancedBlogViewer.tsx | 145 ++++++- src/app/components/LinkPreview.tsx | 37 ++ src/app/components/QueueProgress.tsx | 310 +++++++++++++++ src/lib/job-processor.ts | 357 +++++++++++++---- src/lib/job-queue.ts | 17 +- src/lib/prompts/semantic-link-prompt.ts | 75 ++++ src/lib/semantic-analyzer.ts | 151 -------- src/lib/semantic-link-analyzer.ts | 130 +++++++ 17 files changed, 2197 insertions(+), 239 deletions(-) create mode 100644 SPEC_PRP/PRPs/semantic-link-analysis-redesign.md create mode 100644 src/app/api/analysis/analyze-post/route.ts create mode 100644 src/app/api/analysis/queue/route.ts create mode 100644 src/app/components/AnalysisTab.tsx create mode 100644 src/app/components/LinkPreview.tsx create mode 100644 src/app/components/QueueProgress.tsx create mode 100644 src/lib/prompts/semantic-link-prompt.ts delete mode 100644 src/lib/semantic-analyzer.ts create mode 100644 src/lib/semantic-link-analyzer.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ef871e5..2ceddfc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -33,7 +33,17 @@ "Bash(npm run build:*)", "Bash(curl:*)", "mcp__sequential-thinking__sequentialthinking", - "Bash(gh repo set-default:*)" + "Bash(gh repo set-default:*)", + "Bash(cd:*)", + "Bash(cd:*)", + "Bash(cd:*)", + "Bash(cd:*)", + "Bash(cd:*)", + "Bash(cd:*)", + "Bash(pkill:*)", + "Bash(cd:*)", + "Bash(cd:*)", + "Bash(cd:*)" ] }, "enableAllProjectMcpServers": true, diff --git a/SPEC_PRP/PRPs/semantic-link-analysis-redesign.md b/SPEC_PRP/PRPs/semantic-link-analysis-redesign.md new file mode 100644 index 0000000..23e12f2 --- /dev/null +++ b/SPEC_PRP/PRPs/semantic-link-analysis-redesign.md @@ -0,0 +1,365 @@ +# PRP: Semantic Link Analysis System Redesign + +## Executive Summary +Transform the current analysis system to enable bi-directional semantic link discovery between blog posts using Claude AI. The system will analyze individual posts on-demand, finding both incoming link opportunities (from other posts) and outgoing link opportunities (to other posts), with queue-based batch processing, preview capabilities, and CSV export functionality. + +## Current State Assessment + +### State Documentation + +```yaml +current_state: + files: + - src/app/api/analysis/start/route.ts # Batch analysis for all posts + - src/app/api/analysis/status/route.ts # Global analysis status + - src/lib/job-processor.ts # Parallel job processing + - src/lib/semantic-analyzer.ts # Claude API integration + - src/app/components/LinkReviewPanel.tsx # Link review UI + + behavior: + - Analyzes all posts in database at once + - No single-post analysis capability + - Basic link review interface + - Limited control over analysis process + - No preview of link context + + issues: + - Cannot analyze individual posts on demand + - No bi-directional link discovery + - Missing context preview for suggested links + - No queue visibility during analysis + - Limited to analyzing all posts at once + +desired_state: + files: + - src/app/api/analysis/analyze-post/route.ts # Single post analysis + - src/app/api/analysis/queue/route.ts # Queue management + - src/lib/semantic-link-analyzer.ts # Enhanced Claude integration + - src/app/components/AnalysisTab.tsx # New analysis tab + - src/app/components/LinkPreview.tsx # Link context preview + + behavior: + - Analyze individual posts on-demand + - Bi-directional link discovery (incoming & outgoing) + - Queue-based batch processing with visibility + - Preview link placement in context + - Confidence scores and reasoning display + - Export approved links as HTML in CSV + + benefits: + - Targeted analysis of specific posts + - Better link quality with context preview + - Transparent queue progress + - Efficient batch processing + - Production-ready export format +``` + +## Hierarchical Objectives + +### 1. High-Level: Enable On-Demand Bi-Directional Link Analysis +Transform the analysis system to support individual post analysis with both incoming and outgoing link discovery, queue management, and enhanced review capabilities. + +### 2. Mid-Level Milestones + +#### 2.1 Create Single-Post Analysis Infrastructure +- Add analyze button to individual posts +- Implement bi-directional link discovery +- Queue management with batch processing + +#### 2.2 Enhance Link Review Experience +- Add Analysis tab to blog viewer +- Show link previews with context +- Display confidence scores and reasoning + +#### 2.3 Implement Export Functionality +- Generate HTML links with proper attributes +- Export in original CSV format +- Include only approved links + +### 3. Low-Level Tasks + +## Task Specifications + +### Task 1: Create Single Post Analysis Endpoint +```yaml +task_name: create_single_post_analysis_endpoint +action: CREATE +file: src/app/api/analysis/analyze-post/route.ts +changes: | + - Create POST endpoint accepting postId + - Queue analysis jobs for bi-directional links + - Find places in OTHER posts to link TO this post + - Find places in THIS post to link to OTHER posts + - Return job IDs for tracking +validation: + - command: "curl -X POST http://localhost:3000/api/analysis/analyze-post -d '{"postId": 1}'" + - expect: "Returns job queue information" +``` + +### Task 2: Implement Enhanced Semantic Analyzer +```yaml +task_name: create_enhanced_semantic_analyzer +action: CREATE +file: src/lib/semantic-link-analyzer.ts +changes: | + - Use Claude SDK (@anthropic-ai/sdk) + - Implement bi-directional analysis logic + - Extract link text (max 4 words) + - Calculate confidence scores + - Generate reasoning for each suggestion + - Handle rate limiting (max 20 concurrent) +validation: + - command: "npm run type-check" + - expect: "No type errors" +``` + +### Task 3: Create Analysis Queue Management +```yaml +task_name: create_queue_management_api +action: CREATE +file: src/app/api/analysis/queue/route.ts +changes: | + - GET endpoint for queue status + - Show queued, processing, completed jobs + - Real-time progress updates + - Support batch processing +validation: + - command: "curl http://localhost:3000/api/analysis/queue" + - expect: "Returns queue status with job details" +``` + +### Task 4: Add Analysis Tab Component +```yaml +task_name: create_analysis_tab_component +action: CREATE +file: src/app/components/AnalysisTab.tsx +changes: | + - Display incoming link suggestions + - Display outgoing link suggestions + - Show confidence scores and reasoning + - Approve/reject functionality + - Group by source/target post +validation: + - command: "npm run dev" + - expect: "Analysis tab displays link suggestions" +``` + +### Task 5: Implement Link Preview Component +```yaml +task_name: create_link_preview_component +action: CREATE +file: src/app/components/LinkPreview.tsx +changes: | + - Show surrounding context (before/after) + - Highlight proposed link text + - Display as it would appear with HTML + - Show target post title on hover +validation: + - command: "npm run dev" + - expect: "Link previews show context" +``` + +### Task 6: Update EnhancedBlogViewer +```yaml +task_name: update_blog_viewer_for_analysis +action: MODIFY +file: src/app/components/EnhancedBlogViewer.tsx +changes: | + - Add "Analyze" button to header + - Add "Analysis" tab to tab list + - Integrate AnalysisTab component + - Handle analysis state and progress +validation: + - command: "npm run type-check" + - expect: "No type errors" +``` + +### Task 7: Enhance Export with HTML Links +```yaml +task_name: enhance_csv_export +action: MODIFY +file: src/app/api/export/csv/route.ts +changes: | + - Apply approved links as HTML tags + - Include proper rel attributes + - Maintain original CSV format + - Only include approved links +validation: + - command: "npm run test-export" + - expect: "CSV contains HTML links" +``` + +### Task 8: Update Claude Prompt Strategy +```yaml +task_name: update_analysis_prompt +action: CREATE +file: src/lib/prompts/semantic-link-prompt.ts +changes: | + - Adapt prompt from example project + - Focus on 2-4 word link text + - Emphasize high-quality connections + - Return structured JSON response + - 70% confidence threshold +validation: + - command: "npm run type-check" + - expect: "Valid prompt structure" +``` + +### Task 9: Implement Queue Progress UI +```yaml +task_name: create_queue_progress_component +action: CREATE +file: src/app/components/QueueProgress.tsx +changes: | + - Real-time queue status display + - Show processing progress + - List active analysis jobs + - Cancel functionality +validation: + - command: "npm run dev" + - expect: "Queue progress displays correctly" +``` + +### Task 10: Add Database Schema Updates +```yaml +task_name: update_database_schema +action: MODIFY +file: prisma/schema.prisma +changes: | + - Add analysisType to AnalysisJob + - Add queuePosition field + - Add batchId for grouping + - Run migration +validation: + - command: "npx prisma migrate dev" + - expect: "Migration successful" +``` + +## Implementation Strategy + +### Dependencies Order +1. Database schema updates first (Task 10) +2. Core analysis infrastructure (Tasks 1, 2, 3) +3. UI components (Tasks 4, 5, 6, 9) +4. Prompt optimization (Task 8) +5. Export enhancement (Task 7) + +### Claude API Integration +```typescript +// Example structure for semantic analysis +interface AnalysisResult { + sourcePostId: number; + targetPostId: number; + linkText: string; // 2-4 words + linkPosition: number; + confidence: number; // 0-100 + reasoning: string; + contextBefore: string; + contextAfter: string; +} +``` + +### Rate Limiting Strategy +- Maximum 20 concurrent Claude API calls +- Queue-based processing with backpressure +- Retry failed analyses up to 3 times +- Exponential backoff for rate limits + +## Risk Assessment + +### Identified Risks +1. **API Costs**: Multiple analyses per post + - Mitigation: Efficient prompts, caching results + +2. **Performance**: Large-scale analysis load + - Mitigation: Queue management, batch processing + +3. **Link Quality**: False positives + - Mitigation: 70% confidence threshold, preview + +4. **User Experience**: Complex approval process + - Mitigation: Intuitive UI with bulk actions + +## User Interaction Points + +### Analysis Workflow +1. User clicks "Analyze" on a blog post +2. System queues bi-directional analysis +3. Progress shown in real-time +4. Results appear in Analysis tab +5. User reviews with preview capability +6. Approve/reject individual links +7. Export includes approved links + +### Bulk Operations +- Approve all high-confidence links (85%+) +- Filter by confidence level +- Sort by relevance score +- Group by source/target post + +## Success Criteria +- [ ] Individual posts can be analyzed on-demand +- [ ] Bi-directional links discovered accurately +- [ ] Queue progress visible in real-time +- [ ] Link previews show context clearly +- [ ] Confidence scores guide decisions +- [ ] Export produces valid HTML links +- [ ] Rate limiting prevents API overuse + +## Technical Specifications + +### Prompt Structure (Adapted) +```typescript +const ANALYSIS_PROMPT = ` +You are a conservative semantic link analyst... + +CRITICAL: Only suggest a link if ALL criteria are met: +1. STRONG SEMANTIC RELEVANCE +2. USER VALUE at specific point +3. NATURAL CONTEXT flow +4. SPECIFIC CONNECTION +5. CLEAR USER INTENT +6. NO EXISTING LINK +7. LINK TEXT LENGTH: 2-4 words only + +Respond with JSON: +{ + "shouldLink": boolean, + "linkText": "2-4 word phrase", + "confidence": 0-100, + "reasoning": "explanation" +} +`; +``` + +### Export Format +```csv +Content,"

Learn about service management best practices...

" +``` + +## Notes +- Leverage existing Prisma models and infrastructure +- Maintain compatibility with current link review system +- Focus on quality over quantity in suggestions +- Ensure single link per target post rule is enforced + +## Implementation Updates (Not in Original PRP) + +### Additional Changes Made During Implementation + +1. **Prisma Client Regeneration**: After database schema changes, must run `npx prisma generate` and restart the development server for changes to take effect. + +2. **Claude API Integration**: + - Attempted to use `@anthropic-ai/claude-code` package for authentication-free API access + - The package's `query` function returns an async generator of `SDKMessage` objects + - SDKMessage structure differs from standard Anthropic SDK response format + - Message content extraction requires checking multiple properties (`text`, `content`) with type guards + +3. **Actual Implementation Differences**: + - Created `semantic-link-analyzer.ts` as a new file rather than modifying existing `semantic-analyzer.ts` + - Implemented phrase extraction to analyze multiple 2-4 word segments per post + - Added proper type handling for different SDKMessage types from claude-code package + +4. **Authentication Consideration**: + - The `@anthropic-ai/claude-code` package is designed for CLI environments + - For web applications, may need alternative authentication approach or environment setup \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 97ae646..1d4bc2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "blog-viewer", "version": "0.1.0", "dependencies": { + "@anthropic-ai/claude-code": "^1.0.61", "@anthropic-ai/sdk": "^0.57.0", "@prisma/client": "^6.12.0", "class-variance-authority": "^0.7.1", @@ -68,6 +69,235 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/claude-code": { + "version": "1.0.61", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.61.tgz", + "integrity": "sha512-+gjKzY1hsWfHoH52SgKR6E0ujCDPyyRsjyRShtZfS0urKd8VQq3D/DF3hvT3P4JJeW0YuWp5Dep0aSRON+/FFA==", + "license": "SEE LICENSE IN README.md", + "bin": { + "claude": "cli.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + } + }, + "node_modules/@anthropic-ai/claude-code/node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@anthropic-ai/claude-code/node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@anthropic-ai/claude-code/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-code/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-code/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-code/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-code/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@anthropic-ai/claude-code/node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@anthropic-ai/claude-code/node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@anthropic-ai/claude-code/node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@anthropic-ai/claude-code/node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.57.0", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.57.0.tgz", diff --git a/package.json b/package.json index 721d3b6..9653705 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "import-csv": "tsx scripts/import-csv.ts" }, "dependencies": { + "@anthropic-ai/claude-code": "^1.0.61", "@anthropic-ai/sdk": "^0.57.0", "@prisma/client": "^6.12.0", "class-variance-authority": "^0.7.1", diff --git a/prisma/database/blog.db b/prisma/database/blog.db index 140c52b4db7a54493a221e296bbbf5f132f9aa83..c215ddc682b5d70d0b711ede7d8a84f3613bd8ef 100644 GIT binary patch delta 57780 zcmeHw349#YnWw5RNiB7kE?c(cqb*}(W6SPCSKAnDjKKzs0m}h%nd3CzCmn%nk_zz`WF(eh$LL&M$pzxm(r)7!(2mKQ&J z*RP9RP2SfV#GL;n{|4Vftv_mww7lQ@`g84V-}S2Vo147H=6&vXLxa0n9u9^>p?J7I zG!z`@A4tlB{UZ@6(JzHU$)Ul-h#Ve?4-O6v42I;`U??6Ph{xm6NF**t64FpGA;(98 zQcxO_2c-D$U@#aPiH-~o#PLum9!w^J$$|Lbu<)ygW)I#O4u%JV!{K2y7#xU%LMNm| z;)Ilu(j_IOoKT9BlTyBPqL4jtG8#K=G>J}@#8AJF?XM;Z)Fp&d8;eIJO5blo+VAQf497u#mV#&d1Sd|8op-2$38yg&uBXT?*ONOP9 za99o|gNZ~efvJo~B7?!9kw|0+(;t!tT}DFkKtzhh2SG6)nc#3F z7?pZGIvH2A)Hm&>FvPXl%Bg3&||1jvRe_(J31TYYd z_eVxzQYehS<4O6<7uH?j0*M_v^MBW^8^njv`Snirh-37K;h|xWMrfcv9v=+%4-Cm8 z{n11ObRHkV*YT)?@twJ0J$uCHnGdeNm>zM%e7}P|LSc(Lm`ucnps zIgc%CA>5eeR`EX*Umbg{HF!^x(EHq@BR4sObkUC@f9_kC{>(8Vb>N2E+i#pB|rctpHL{A2OY#Xk{$3oHFs;v3@6#eWc=7he`%62Bz=NAcsLCO#%U z{fYK~sJfiZ!bZ9T=(2$>>*=zNE^F!1LzgvlSxuK!bm^wcO1i9YI|ZS$i>_UrbnWP% zuiNQkdmCNdB3;}3bZzy~RcxiJzXjKQg3n8TIGX9|YND&Bk*-b;T^rqW^}6WVhb52j~IoDgO4 z1SaRGco+ol6jgd)*G9T_2I$(pfv#=q=_;BLKm-3?v-7uSgwi9Zov6Pv_ui!b_rhPVDT|38XPffVxoAA$&O^nX*lLJav|#GAg) zuljw?4MIFSnM=zBInh=2S9^QepZ@ZDy=9G!jb(qjTv!5ME}xCdijvB-IyW?1o$xoR ziB2$T{+mQcN7wkEf6uMmd&D3& zwo5vVT|&#+&bCP#+a@C0CVsX}d~BPvvTf2r+eGlPUDC{UNfX;8jck{A*e-FiUE*T9 z#K{v32C>m!Y!LVTRQ!AKRnW^f#4n1!Bc8?Xcmz9R3cKRCcv##g4vJgFbz-~d_W#WP zWB*_H|J45n{;&E!>;I(xL;ffH^Zp`eX`5edarOu~Ig?0b#x`=F1Kj5g+~@V&=XKoY zwcO_(?(-V%^J?z%D&waBb@ntHOTd_5tFuA;J>OsW#(g*X zqP~qj7gY8aTYszd(bmb?>~A&1bWpgA>(OTg6JT`#RSekx9; zGR1j~jYea7HK zHXE3ByRrK_t=sThP>NrR72UmuceGrLzYk!|Fq(uun!JqlE7E8Jc(HN3+1>x!YfP0R zFM~xZg@A6`rDSF6|7hNbM_{I1+S4ENbhmh4ZVKRwD5hT9`!}AIW4$jl8k6aq%qHY~ zCUD^BVQVt`Tpr_f8q=u>Ib8~*i5R-dD}*<8<92_0-(U=qalvj*{R#cztwi60VG z@ZM|1kl5}2KmNZ2xBXrJ2mE*YCI8j_et)O$XW+Mg;8RyShlQ9_h>xELod^vNMh1tY z;gR7;bfBwlOK07dj=C-Fbz0QuKwF)r=zv(a#b39@SGT3LR*TBswWU@QYw^}?X|CJS zRJWzEszo)Y+f&tKwz%uIxazhz>$W&nIft9=1sAy$hbZq{aJ?k_Li~+3sFnD$XTkLf z{_OQNcQ&-R{=C8cHSu9l@Xz@@KDT#$^WQelxNmg*x%f5lYhdr6aqK;vyQHyu+cx2` z?t&Cc%Ncp*#6H7|a!oc?@n`q`n-1(7JrEe(cg1xF0=?9#Z5t78ZZ+BWXelRafrzl$ zoh+u)eP?o>i_d)8IpoI9Ey~*EL&7Q_dyzwl-oWUATSm24Mua1q&0inYy*7H&c5eXx z^^&_mxeA<7n;L|Wd3i)=?_l5Ui)E+f(Q&Yh@obtk?g?CZ;HrJct{V*mw|l2{Gd!-Y`08XS5{MT*T&dY?A&3I@12JM?cVNHuU*sYmq< zCEf>*T-4aTd9(1iq`ept)(I=LS3|;gwO7N!KkW|m8ZMN5N$_6djjS)PkRb+S>s#~v zk1%J@!XHG0Ey70eYYmA-UqD;HAbPmB=?xsYKG18vYG%9kA&gjCJ0dh`LzfAn_A2go z3~tjN-y;mPtU8^*!f)CnJSJ+wh|nW+YwJb?`JJ(nY?-yOh_FYyJu0j_^MLD=bN=3~ z+I*XETzmC0;ggR17VX=+g@Vv`<{rmt?Zl_t*JZO4?4bpw&?rz1pq=Hi z5ZIt~{X4X$q3e(kr!5@F=5pD5p$KnODNx9=d(0OsNck}tDieOcW5*7!cN9tz|I9>6 zi5C?GlSb<3SiV;x0exun=;56t;-w5$fz+>TMhQ$y`Bb*3u({p4BS4Sh9#0R*XA?zQ z0JC>V!4m@VWK2#Z@K{VoQi|nMalDE)HR9|VF-KJKMl=EZOVKKzH)F{!xulnA3I}>G zxx`puDVM`)DA3;|eU4y81+vMiDP(FQo@OwWU0!dm_8-6Oyh7`E+SNs?aEsbYlSC9# zMPJYz@ot!4o(q-&7?ybo& zZwL^YlrMkTIk>8#Src!mL#>5A+cTam3~Z<_QyVau6qyGPVxi}5(DRR-LS5g}~|Mt=A*F6RnF z?zCm)S#*=iFg2UeKetD1Hdn#CEQ)RlfDMABN{LB#=%K*GxjzjmsH@d<=NHQbKo=fh{!Lt>_Zck6BOVV%cs8NEjL2VSH>o zI@-0vCC4UBeZv{x#ZoL=7-s?nE(KytnSdfgOa~OOe~^Shqhx!|jN@h5ThmrFw3bof*zoi+j zx+upjGgmntV>8~7k!3Spo(Whq=(RMRcLWjMaXyHUo2Uw2%}CO2hK@QHz@@YXm)R~{ zrYQnc6mcm~@F(Ab%M`_ZauoN;Qrss^%Ibtuck^v(5dRYH+uwxab^e)duQuUTmM_@7h6BldQa<#)_tv;UNY0n$lG(w4)jCv2q|jQR z)@V!_iNM>n!D<~WMT=<7Q)@Ci7al7nrq&y+YC1Ter039_qvqmJZfH!JoLy)AI5Isr zi`ERahGvS&!1VCcTB|i&h*N8tTIFOU9ZxG$J$CCzs({v$)LKF$TZx^Ft+85zb2HJP z8Vrw9b4nV_MdA}PtL9ku%ifOb)sN+~+E)-2!_zcdA!?n@1!pt)K{e8DH-`tN zAcN+p8D7_jT$&wfvs;5FPokC4$87XuDl(IeidO6NtPBbWG5Q#rNQRO##c99YIz3C$ zjnT(Qe0;JPo}2cuR+UVvX(b&;Gb4~fbSgAAUW&BZKhBJkhQjD0K9)((gl0l5c5521 z6bv!?h)&K#X2-H4UamDUiB?7*bD4=^XimvC+pXgz6~7KK0vS+FCguibGfj5$$j|^< z8GQ_8Q@b5p1Kk+*q|LlIH;c)AU zZ@0Qze$D%H@2`3H!%Os7^UkL4G=1mkee2ZI7kk?;7uIbMgkiV4;Sq!!-EP)HVYU#( zl(R{bbJ08qW!4J~Dhtp3m=*R*aIP!Pb4Az;^)Q){n}40TEasp(^d2XVGK2$j?OzGk zeju)ucDoKV_6C0R;I9OFNz;L)vp+2rp_EDFsG{GI20_*yq&8WMie+*#ThoNDFryPj z2ziqd>In+oqXinLs5{b$JP{d$hH~ zt3B&g(&=j3@ur?!L#eIMhG`n0n;5n#uQDk`awkBE(*rjJXv=W3lh$L{XDS(JxTBU9 zK})4fvZeausfi|z7#{ZV!~m~IknI9xxEPt58IhW*iJ_7Ct12DW!moB6n!WoL>SEKK zHZ^F!_jTt%e(4XN*QIC1_+Bh_G+1}B>#X%V&4g5!VM4NHB6e?AQda9Kel&;ueATp8~z)&l&T$sVBOryt_kX_ z^LI|KZRTkv%hSvVy3^%uDw;eg#ipC9X{L$msJx%Mv(!UJ*S=Yo)0CM5kQ(P&x)3t8xo+lre#u_T25|tS6{KIY*tZk z?P`ORTHPcJlX&Aux-bTcW{Qw9Re|P4@2Ge<_iTp2Qwq0sR6JWXH)8>sLy&1<>7m$ADn6XaX`~sGmWV>7R2$*_qg6 zeA-|Ba6NuiEx<739D8`Kb)w;5Z9;^h5M}{Q&D`jD0?xaMtSv#$b?H<%QD3Bv@3&S>27J{>x11{4dG6Da0Lm zH_cT%0KEj6R7(}|g& z48dv{YniD=Yq^l*TdSoU-&!rFj8>I(TrFw%A5&|&sNh?P*_ZQrzO|ak@~sj)MitDF zZ?5Jxm96DwGv8XR6Z5SWgHbIri*K%G418<#e&$;%_93=mWh=jZZkw|m6DbF|9-=)H8)7$WCq>7Hg!K!qHpiP{(}u$Mp5D;VkoIJc<2~AaKM-aYz8`R0A~pt6 zi9Nl&muY9e=jdM87UE?S4GYBjH8dn-=}SR#xWYW>?lGHdPHBx+Oc>< zBFh{XrEGCw_8G@xqOfJb^(=L@Rr}lTBBF8kH!#S{UUGbdb-Ig>V4L>JHyxYE_q-YZ@Bh5ZiP7xP_RcsqFKlgd{&!K>x!?-WmqG1c zu5>=V@PVN7N;bMn+2}6SM|T76aBs&GBA7d;K4*K_4SVz(?!yiKFm4z^SSnROV7Q>o zJDk~t_dex(NEC(_T+bLkyNvw|3kHIC;U#+RAbz$>6aK+@XyNcH&KE>sx7MC@T(q#| zHRos9Q1-H)>}5ZB-i2R8@r%o}zO-Gb{4{M&Mvq3j6+vR`Zeuw%vNH@ROEh3i4`ZrpVQ3q$|ju;2>1^(jBV`afX2 z2O8a1Vf;rie!6iK^i2OATX5Y+|Gr0?|5f*u3kRQaKh37`YWC}^^!AO$}uBVKLlOJ+6c36M-vz}>}FupMJyyxK- zN@{FK;T7ozC-I8L53XlFxSsvsgRePUUZ-$>gZEQSG2wpfvBJCfp?C2^@8XBv#SfW? zs|$Ei4)QOXQU>*=`#6NH zkqltr@(!_-LYjai5@>WRBo@Jh?;9X<1~E{^RhDGHApQ6OJ=sPfNSO@a7D$o+J_Fbb zJ-E-76SrZ;fWIve9y7^68T8vp${8TkIprhKEd(8AlS+t9{bTRIj=&8_jMUdc`(DP` zwUdyP6}U}1OQ4eEQW@!dlftm@W;eToh4RE78yf1e$mg zfY&R4m6u`4sv+&~Zmzesxz_C9=DY%R9^L;qxvJ;3-^?=nLJNC$N zgVCCxFB|h!C&jdq(H^2)l{5c*>D4zcinq_pl{&z`DKOHe#lc9W%^%+p&{7**z1qeN zu8S7qe&-|gXsMi)#Ua!+@nyAASVjhyjifGL-tCkT_HSx^2Nqf1`WuthdQC;MmFQv+ z#71rh`aD{LN7(BS-!JTKec1WB=NHY7do(CQmk1N5w>or465?vWG`8@Nr0P(G8CbJ1 zjr9U+vojP^Eu9rO5Sy7IbKyWqRhjVwU|bZKbZvE>m?3Ccj*g)Q)Fvtven3hFLl5>v z4sQ!+a|=R`+Gi(=RG<=}d*DH&5g0PGR!T^9dX6y$D=Se-Lz}X)5-AG=g4;@AQ}rCS zI(d7fOU)`&?yPjyd3uYBgQGpt$ge#`!1x%K9U`{Zo9%{0ln zq`)b}{X38XTor4r0QV@yV_ zl~qbaXE207DgF5O)YF|Fe);dQuUOBOEVvz;4wL3v>Bx z+*al8uh{&vih*=`u-R#nnFKB7(L+3)g(1mmqmXPivnUD)FEZ@0>Fmt2BXORb&M%Xx zQJsev&LX&cb%|W}vT_M>hI|Q1px^fP@E#EcIk4SAw)29KTT+Ts0=u3>Pc$+v!SkSf z;UY(y(-M%Tr-5805N%_GU|`&f^TpUSTEUHKygfWyQ$^&nklRG5r7%&4D1gGsJsg!) zy(|Rno0?9rSTU6@^n-cvEKMg_0-BYE8^>8jzy=bGuvIzubTv4~%m}avDRx5*mB^Nh zr~dBG(aEer`2hHQ?+fIa^fY|G!PnybSmXb7A9Fs_@cot-TaJ7G$@_%2ula2A*{7dg zP)}dkYSK3;>6i25T_2iik7@~MYU$4sHhJF+#zH^=xV>fz#N`i-YM;KzHF)7jo@EkL zRL~-t4{xRwtd)2)Rwo&yo~>^x$4jwOsxij#vW#~2?>(I=bavAZY7;JzkL^Mym#QfV zi&Tq)xF%etRLslfjmW2F{=p38XfrLIDoD_cY#UU19*tZGRnL^S7_z1L>-9(sx2)kv zglE>09#m+8Q5(ZI|C#(ixP_yl(>)w%v-GvvnBe%JSsOE5F~DIh<33LI1=lzCxg6Cl4JMQ6YRGWd<2)Gn*=zM^ak=S7Xlg&7%@ z-*7uQv?*OqpRWutf`!KOo9?&XEQv)+Ql0OoC3m}Sxo~@|rt{_97pvv_ZR$hbjEdHM{e4 zzL67okM{72Q#C%^G(c>{sWNedcRx_(Na^B_b0Y71_c`}nI2K+rV792)Ib5zLy&EHY zYcaCbx>msa*%yC;VF?^t8chc zwWIfi%8(YN^pDrZ4pS?%^(zjfff%ff}-QDj9kAcWM2BkaK5msPkyrg-1oI6xT zx>SWWO^&nrHT5WBPBnj*8KAF<&vQOCUjM#o-yw?8Q^`trlc>0Y8L2m_D{$5RDgc#@;2aw24VcyKU`|O@cnGMZ6nUkfJv@GTIY0EKy}8Y^#>EQUNo$>Yy@raxzH}1e8uj{b)Sk z5NX>0cck;E-+%(bz^Y(Xwn)?26qO}s(<0BN6c#RF2#Gv!A@~8C<5XcQo^dl<9>c3u ztLmOg_F3(+dy6=&tin*brf=;P7)biSO=L_MLMfj>Rjf%F03Bn{CJb{}(+CEkQlxHJ%aZ%7GuJjO_ zjxaWT`Y_n^RO#t{olVy$TvNMFb}gQ*E>YFg+Ljl{6DN#o&t8Pm$p7YDaHU}fnC_7b zRh$DO9cNBF6h+0^OM1!iJUo!_r|9Ja;XpxrBv-o?O&m6f(VKyhp?%(FcZgL4+;a80hQPq~R-lfcm7N(sc^ z`e*(TT|#Qo2c2E?Du?>81OzaI*Cn9en#&T5Ed^*}*{q-AY)Mb`Rt>IDT7>CVx)IcJ!Ta^5H!$(3bvpv7@#lAhbQz6-G73KF$ewu%E2r zw}U(=J^3Z=wsFUHZ51x(3RN>9&XJsrrMzXmQnM_NS};|@{9d*K1J%UGC(Of7Vm12m;(aouY43|687F0;64 zFp9C+2BKOp(KTiD(dh;FLUU$_)if&5j%hY+FlKSk6d>M!THslu%(E1jkV__^;e#Q) zbsV>0j;V%$N*&@&47`Z7E@fzO%o7dX*fy9Yb2J0GiNj2|WI8(otIdi=!SWb zMe#^K)(EKME1;tIdv{#e|uibwZMaDA;Bw(7V~u7{&xGZdf3l zhwO?;S)!u;I8??|r&C#N*AF}`s)a7)HGHf*-sEaFmgTjNk`4-mJgX>X$j{-;*1eJ zif>O6^GWsA05bQR3vO*HJf@d$$A%e*(b_-L-nSq$0-A*J)L@x3xC;Lf9%kx;Wv`?! zcbRa}budft)EcZV@Ks3s&<>YVNGlJq zX3ZOpg&|cA;0T~HSmpdlxuD{t62%*#5tO@N4T);xNnvN{8U;`TRoXIi7j|R}oCdnz z0X_#QUtn+~FOskDXniJ3w`FQ_66sj9SEtqGoftx!hP@3Y0-10{9U79&PbgmRK|mR> zG?R2JiGHLvHehX&xY{CCYn~or6A2v+4y~4xJa0)v*Jbtr4vq58<@Upib9-)fD&#Wp z{`zv6#6wjQs^gi4reAkF1Ihe;NAPi>g(UM4T{6cZndONmMO`x2sPL^H7udd|mNc$; z>WHDwnZP8=rZKfkjy-W@eC<62(g0mDrm|I zSMe7Yd@4UV!;Cu_b{L&ZMZhCdjpn?Gx|~0ZEj%V4$l#1kNLrkcl85e%2nv&8Y@17_ zHg58ImBKBCwaW{`GR1+}UNuBAlgP%IPtfY^V?xV1-;M#t(?zy?rZ@~7(oKO|RO7@> zgJhWLXpGd5;uM}Mlkz6faMYDHgv;+M0PFte)P0@Aag4I_7Cdzh&RE9ad>(m^md> z3c2)&=e3n<+?Tir4ypau)$YE{9D}1@@~XYgXAX@Xx=x$_UDt{OZ_|d+Q`ogvu5fm( zgw{Z(UNLRfc1mj3-0N5q2UB2FVb~+YW=gH|VYLuph3dIrbO zpA-5;wD7=rT43#4cRQOXB;{@yZfi#+-CtxO~o%G!6HG-^RSfH z{`9yfci}Kib*}#JO#;&u7$x2~U7z&0JLm>tLB4eTmItOEPlhTB)Jx${E zV9ZRwivbG`)|?&xsG+o07OowMKXBo-{VAo*%$WH9PdmE26~HSlJa_2?nJz z2M-OM9%iQfGD@d+L82ed(>4^mR6ejNq_y9Mbh1ERVx?Ym^em#XyXuYho76)Nyw`t(;5DDbb=uINqOlov0q?xt>)|I#PE%DO2~ zH=p=V^%KKT1X-<9>)(Exy7m4`XFFLpY|hShoj%dVZMR2?{C2A@m~6hzu#Et%Tc5vl z=SB1;ul~^9Z`W(Dz2DRGfd@Pn@~9R%f1c6}7fT7Nl@Vs?0N5$a4}1M}H`tO23FVaO zgVQI=!i-j~36o>w_aJpYr9x^~JKgLpSFyj)6Qm+GfmQbic(}CT`l^}Jh4TnW=5rfU zf(9g|`(dR4NKoOvGW3=pN939uk2#(! zLNQn`%_7H`EnCi596g#?O${I@Jtzy&V;D`G0c+C=$$2}G$`Dx~mp=WCRQ{FvtSBVs zfv6xh;Ehp{El{pDthRnOO6k*II5q9#sOsTOJXKZK+vlh%f@*F!eKxVgsV1dQ zaAc(o-0Hr7M?o!WSJcc1xJ%sbMQ*t=X)f~_)3^7_OVSh9fYriSJpxo(2b z_bS(W9`pIge=(^0d}rXl5GUT})cqGVj)ZKjl^h$sw3go@d;4PN9#=$rzy--rrD_AF z+hQq8^&OcUyPxS5Hp90RmSrZ#mi|oIp*?=awR7nhuC5*B*(up16G_l;5^afCE)z>& zS+(m1W{XFl*3^PFLFG!Je6-=>WnnlhrCvzU*eA@v!J?U_V}Afa$Tu7SX_}P)0r4~- zH!zntuQ-e)KxN?(A@8{EvNudIrUo)ZQH|Xg*ka4+YRSJ9ARs+RrM~qe?rYIKXQbOc zifFigwJ=FnZ!E_0?{pI!dpnzeo74(_E!!3q&6clr2j}p3yr-3TT1e+<6XcVXpN#7~ zt;RXFVzsW?{hdmVc1YJA&-}}!2QH~q;m=g@-c?zFA~Vi=|HeFv9EL;CzVwQ_ zrP+dtYpz$^o0jHv(Cz8q+3OLQE{jtwhwl+6T+)3|3`zqfL}B;UE=r!G{rX@haTM~5?AffxoQiz z>d|9QbTh6xQ{&)qw^5#~`(Pz2Wk*Gy;FUu}LRft=fRoH!bAii~A#soFHRd|CoV&}g z<_#yq5J!Yx-)m#r8(matb^H^%L5UV(FUc* z$E7SZFk&0v8v1SanN{^8XB3#CJ`8Mt3iu0x{*|S$d1Xss@Y*sZu&#P(@JlmTWmTr* z@=O`qHDJWOH*05q&Cv^W5IofM;^{Y-Fz+Rf;o8xw*N5gQ$6iMRpa7m*tB4(S#NGq| zYO1n=!PjAQ%j0wA+t!L$MXMtC#2J~DnNk3tHaOQWiRvqeUWM@h0$p}M;%!sCRkldy zOKPB(f(~Fzc6@6&R9=s()!#(7_uk%nnZAuUJu&#?Q9rS?Yju`(0xWG}_XlNNQJj@i z2rsasa>pi;q2x?)x=kZzcZc`qH96X%S(la*^E`OFYdgDDD-of>0eRlN-4!-1v&?d` zx5C@KhH$dd9BNEYPAE_$*eXtLaC5}S|C}@IxkOAF<@eh|8f{H+dwze5jC6>qXSR9eV^4LIcn=IK&6S!ODkk|m0+IUB6 z0gS!Ekd&}!7%dL0IdstzV-0fxvQ#TRy@IJw~ zN5KcU9gBG}AIX>>VXd;PcII|g=7OcIq~he?bvC@&(EN$Us~g?~z~d*Qk00o;ogw$j zq_!XV6-igyM~dlSFwD*>juqxY(M0BC2Py!j;;9^F2>LN0N)_*iYs$M?UXVrXFendL zR2?H$ZVDrUTh=KO^YVJnz`7%Z%9vsa@i$6$BQ(970Jq#Xo{F=Wp%Fedzzn)dONjiM zaXvW1=eob8L)NsA?vJlb;tZVk0{jIaej-YP?DeXfFNddr=nXT-(R402o5>GGT=q6mCIHV;fF5vEGR!kCMpn^T18OZOuW2J!lv^@I z3Fe!H#VE#$1loMoOw=V03Gimbrui`-H;3pG<&eO}fh@Om3aN0Hv2p5UC|g30>4G^q zP6=UV7BGQFQD7JhsU~M-zE$lXUv~Ehi8D=+4f|`!FkL=i<~i|23`j~O@S4=UF6hZC zGS~5A?e5PBJGEV(6L#G4_^oZ+0cj#uWnP$H_v9V=!h{E=V64p*OB1ESxDt^|vqSB* zuz&uuwM1FpqNbbWRxllo2Ce`ciA+~ohJVl4Kat1&m45n%+UvVLyR@fvdk!w`wzvJ% zWmrhc+XQ?no*m1ul=HVJLseyw_~nw}OC98*I|0iu<_yA3N2}GWjK&>#hO;+NkKkrX zNK$0zZ&qA@JX*c@juE+GU4i8r2`Wrm1-7y>BEZ54ly#p-Q6dWEq)LTs9>TDWP*|oL zzSchA*r)A?KL=BY?Flz|kN*GM6us7_;AXPikwsf%xSu(mE;VXCrxJVE-xa^HEh22K z-l5xjPsGxI+uq(A7{@&0C*y?z@|<_>oZ*ZmU-Q=+T&{;2TK`w$EzRF?Jp>>9j7vRz zeHVw~dE_XM;;9eHwGo6%fO6-0|G~zF9n0{@Cx-LWKKeON*?~Ie_bdWXG7G~){+OP` z#p|+$=?jM_tf3TFLI^7mgeut<%Ry?KS9{jd zgXv5qC~(WRXJ-|{md|y68@7LY8BqSgwVm85KAPoMu|9U5E3m%=cz*7oKjfD2qtBP) z%+K%MHEz=h*5H5IHy6C&PCGOCeew9(S3 zKq5L*EB*9a46!Oa*Wmqv@EmlU-xQAiild7hnmbw0?!kUof;Zmy;T5_aSS-!JKxgL! z4vpvX%3N`*vx;-9`AjV%aM@K;Z9m9)ih)bTEw6?mW}UO|c( znSBQ9V_tr+IWi8>D_>5O(TL?Ngb!sG{C?p5XI|g45w2#EBc>!MlO&dIkuGgV<=IRE zeisOgvUn;d29zSJonlG>l0_BE2|Lm%DP-t=DpN0+5aN&eRu~31ZQH9@4#T$93$#K= zF?YOKrImctXL+X^j+TG z1(1PP(37OmU<**Qt{l^zjjhTQXDH}(XRlI-)|fv0oW5oHxUL(bj*&iryiSrs(ieyT z8UWUu4#3ns66Tg%=pTVOCI=dh_jf&&Vkeu21_Q!71MQ1ED0ny10CL9iD2Z~BG z(^bP--F&Q;@a$V6*TXF5V`oqQe{693Y=e8Q_wTeP|Hc#2R{o7gFS!73fbBKqurTG7 zemEo_Yz;F;W);ZeSgr2PAhuOIMre1Y`y;is?VjfIQQ4u&Q&W=%)lQap*KI?}$l`9B zfI&rdxr0qp7x6Szalh?+Bs$!{aSPJCJ;HYze6P2BqMI~i*QVWl!>+IM%hH&^lw?N@Jd9jqA@ zvycS5T18~c1)y1Ij4uK$k;w;a5zQW#9tA6zr7acHQXcG@o{~=?#};>CY+HNvE z3U4J5ueCqzTCqQ{#rSey>yE%a!}zT%#%EgsW`G?vjWY@b>Pf>xofPB1<1q;$xhhGM z6*sn_B&6Tb&|*MIz_W@uOBCTa#(3=9;)!*`repnb?N zxHlZS_qI>c?ecUsJxzE2=)T{8os~-^SeRXFe^%%|dg`Xtw4Ncy{2j)6MlaXbGdMRB z#d?m@dZwhoTqHg*9hkoZfT-n_TM@xnhN}91=$!~Yrp7r ztal=At_fYPRa=-5K~!aQnPa(*V{|O5IefNG+KaC`dp%gl_x{TtX&qk?ZfOF~k`*Kk zX=hhER%^fiWns_tr>^X#eTxMoK0Hi(_<)X7n9Ix}X*-;t z^Wb%hqPmu`WwCx>494<2@mucITech;-5xl2)Aqns`f<&$fdR3S$E5UDY+}1`f;Rht zb3Gt*qu5bw1L(0A!)a-64h(_?YIKwm9nkfQ4>(sJfDJ{k2cW|!aGGYzZydH!Mg?8>daRQH90!Fh&oIjkf7c4-UnZ!MFa?8gnw~a=}^wq4Fxm+2DH^%Y3Bs{2M zG6Qc>fBlR{^T%l$e9sIu=|_7p6IDN^8Uoe+Atv4^)nkL5*xFSEpsY%oSPbp~(vKQ*h zRP*G7IEBK1gp(-$0mQ05TP2xRK3}x>BJ?M6*Xw{G%dJA=Kh?g12mvQ^Y!rG<-2*~6 zbVw(J+2T|Ngb*b{m<&bJ@x)lsV~A-JC}0K#nm=|EfTe-SW{Ugh;JzC#a&~EN9(Oit z>Zo%)ry*H-yh)%I^bU)cka7MHP2HF-sVEFlX-%7Nu~Y?B2qbz#CNEa#csn=&NVhR0 zqM~7tovI%9tm6tbC(i2&C94es^E`n)dh?h4taZ=SEj`Z))(}-Lk*wUQfyu7N|VJWx~$WcLZpG zk1!Vd&`~U~Y^X+3v8Z5yS%SzAj(3_%W(Pa9rv%Rm0yejHxTf?)wbnGCQG_i0HgqCO zM}aR*Pm?zoZNRKr4>1Du(5%*|XrKL(r(5Kml$cKK`qiGEz4S{<;*he0!=@SQiUByBW_;2Zss? zr22$eb){%b8Hs?#+p$&UJp6*_@6afU_CR-PdcAT*&5djqw9~$O+%vW99AJLh|15Y` zt89dj&lHF^H?4Izq1g-JqOnNV!P`o2#SrP%?%}F@Gmji}9b~o4(2Zbj47JX-vOa|s zZ+DQXhE(ItbUvW+8*NzyQ?KBVGp2KqifVB`T@&6;VmXz-ABoKTOXYlb`ed zVHjRd8b>KW?0(GbW{37WUl6)SPj#%PwFx=qi&&ejSeqLU>w4&5DO$wZlo6-$F*GrX4W7j^reNlAZ;wnwG${C>a0@5HrBv3iDA8+)m-HMqa)5{iL zEJ8}}sY_#HW&0HE>F0&cNt1kW65R5{7ZYgd5$^K*|_5Cs9cFraBx;bO1saE(9NO>CUBsT`bd~! z*{NVrAXhLL%x{ie2)N3ud(5a=5Wp0KwPXy~lYU&Lm6CKY16`lE5h+JCSYBC3r>wd^ z;yqlieOl`4?qM)MT^lm@sFCW?H`qTTBSQeVz+$eC^B@?7nw%VhjfP{~$n7#Aj(;BJN!)a`y#N>adm5)Kb2Hc0n16!0Y3&C*YqTdnCp3?d$SMmG5nH13o3J#Duhy@MP76^jBx$y-bI4!@ zGTE$XQ};VoOz6vr4f2+RB;HJczrx5=mw_4OXVN&!iX~O5rykVW4tRR#6<098^v=_c z9$KS4%(_2Ks_B@bZ{=_yPHU8=HIkE&bUdw0`Lw6LB&_Q2exY*rvQk}(Z(6R34a8AZ zb!co5JyD1DGSd^2**L6iV|R1&6CSb3bMg*e;<4AP|ZSq_I@MAOQVPRP-Mzyf|ZQk23n+) zrg2ESf`s^eEfz$(?cQ%}geoTVYe&8$M7*SJ3cgz#e78c!yuo+#=}j9uu~Ng1`F&u! zjaVslUeuQ=mMoAJF-uD|9i9u%3}xoJqy$2XU_w|jus~8$z#jBqWi31+J>O7FPC!O7eq`;CN;eJ* z)bekFp^^S($W;6oZ#i4oUKwnM0*qzRKK`<&U!}!__|t*Y7#TUD6vp~YT^JnSrUc9v r5LnRf^mT<1g3pu76UAg=!8wajnV6)?e2jEeZK@mrzJdNIJTWCU$%ShSX-U zGhiDdo+uyf=n`4mX*Z^UR3=TU1YLl$AFUds{n;uQlUiw1o0N8-ssCUf+9q{l=bW2X z2~>z#Iv@9*=XuWazURHibIY!|<@)&zmBQN=izQ3jXTQdU4!{6e_B9(gmlhI%*>eC|rh+hjvlU^+uh)in9U?}M`BH?Ii zDpmYdV=o%$X?V5x-^NCrPEHqGB9|Cr5{3~EaJv8=1tZf+lc@9FqtH{F8>?7uJIQgic-f4J{I#3Tg(k=JV=$FqkUd zH6>0J!%aGqI8m?zmk2P4WFj1#icCedP|65UiBN#X;MX5>$d;Z@(;_*!ot>Y!Q!$yV=YiK^|+wJ27J8>YVj|0sMxd`>-H2n8?8>m1BJtDL`RI=>~tPCcS60s z-#Gl)Ugql!eIv1+(U>|mG`M3drVbAE$9AdiwHQuyQmR`W9(rK$H=Ur`JjbIoR%dG~ zd{4*k>Cg!4@h3XGUp1Z1?B6*%Gn1Xoq|%u^ec4PNUkyPWekTNfThy%0FmPO~vN}B; zc%>(wNKPATw_f{HyJB{>0niEZ+3P%wT-42x{~d$QM}toUsC#NgSlvJCM!f4D1WT2tK|U zs^yhDD-k$WvDP>KX$10R$26PO+0p_hn-^{3A*fW{sq~C7nNMdkrk{@D4ate;Hp5Fo zrV{VeL4z80;@v`K9X=cd4fjQ%9$$^ZMr6NgiQ>{u&^rs|^;YMz^wfP{aO&f^?Ci{> z@nAKXW*tnvcEx$|n&50+Yuw%LUM=AE%+wVN@FWg*L5v>pkuEq6&f*b4$I&eihuUJi zBxJ4A%Vp#x+dpq5}PzZnjJ$Me!ZGuDO<_&mUv9_jDoo;s*K6g@Z zlICUjSb>dr_a4zhv^(%-6E$`lnCfl#+*mt74uN{`P+P(W93pav$RX?=-?TTcZo)T? z2p;S|4V9#!TX=_u(t&Rd3vC$1g;8=eE*w)zv|N5Bo7vNa?_8CuNz;UIOMxfwUycba z_*=8oQ`Z6E$9xZo?;-I$uFMOk9{8&v*Utz((bm9D1EdQU>B3^Oi**+&;QJ@>IYZ4wK?epSW+=+uz946%=qjXV9A69Q6dTL9ed1{p$ zRcWG@DGGY8+!1fGI=NlN*Q)s1C6q+)B{GT78JiTNLIX4=MB#(@BbP9S=VQVf+&9j#~XkpXQHjO*@{>|IDMRXtLpBerkwY3egq3$X zpG_qWe?0ek4mbD9RYXGw^x=fc7@;4B?NDijtlQwBDmC4XJ Q86it=p%sG42`VT250m0{WdHyG diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e74346e..a66f8fa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,6 +62,9 @@ model SemanticLink { confidence Float status String @default("pending") // pending, approved, rejected reasoning String + contextBefore String? + contextAfter String? + analysisOutput String? // Raw analysis output from Claude createdAt DateTime @default(now()) reviewedAt DateTime? reviewedBy String? @@ -79,11 +82,17 @@ model SemanticLink { model AnalysisJob { id String @id @default(cuid()) postId Int + analysisType String @default("full") // full, incoming, outgoing status String @default("queued") // queued, processing, completed, failed progress Float @default(0) + queuePosition Int? + batchId String? startedAt DateTime? completedAt DateTime? error String? + analysisSummary String? // Summary of what was analyzed + linksFound Int @default(0) // Count of links found + linksAboveThreshold Int @default(0) // Count of links above 70% threshold createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -92,4 +101,5 @@ model AnalysisJob { @@index([status]) @@index([postId]) + @@index([batchId]) } \ No newline at end of file diff --git a/src/app/api/analysis/analyze-post/route.ts b/src/app/api/analysis/analyze-post/route.ts new file mode 100644 index 0000000..4fd732d --- /dev/null +++ b/src/app/api/analysis/analyze-post/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; +import { initializeJobProcessor } from "@/lib/job-processor"; + +const prisma = new PrismaClient(); + +export async function POST(request: Request) { + try { + const { postId } = await request.json(); + + if (!postId || typeof postId !== "number") { + return NextResponse.json({ success: false, error: "Invalid postId" }, { status: 400 }); + } + + // Verify post exists + const post = await prisma.blogPost.findUnique({ + where: { id: postId }, + }); + + if (!post) { + return NextResponse.json({ success: false, error: "Post not found" }, { status: 404 }); + } + + // Generate batch ID for this analysis + const batchId = `batch_${postId}_${Date.now()}`; + + // Get current queue size for position assignment + const queuedJobsCount = await prisma.analysisJob.count({ + where: { status: "queued" }, + }); + + // Get all other posts for bi-directional analysis + const otherPosts = await prisma.blogPost.findMany({ + where: { + id: { not: postId }, + content: { not: null }, + }, + select: { id: true }, + }); + + const jobs = []; + let queuePosition = queuedJobsCount; + + // Create job for finding links FROM this post TO other posts (outgoing) + const outgoingJob = await prisma.analysisJob.create({ + data: { + postId, + analysisType: "outgoing", + status: "queued", + progress: 0, + batchId, + queuePosition: queuePosition++, + }, + }); + jobs.push(outgoingJob); + + // Create jobs for finding links FROM other posts TO this post (incoming) + for (const otherPost of otherPosts) { + const incomingJob = await prisma.analysisJob.create({ + data: { + postId: otherPost.id, + analysisType: "incoming", + status: "queued", + progress: 0, + batchId, + queuePosition: queuePosition++, + }, + }); + jobs.push(incomingJob); + } + + // Start job processing in background + const queue = initializeJobProcessor(); + if (!queue.isRunning()) { + queue.start().catch((error) => { + console.error("Queue processing error:", error); + }); + } + + return NextResponse.json({ + success: true, + message: `Created ${jobs.length} analysis jobs for post ${postId}`, + batchId, + jobs: jobs.map((job) => ({ + id: job.id, + type: job.analysisType, + postId: job.postId, + queuePosition: job.queuePosition, + })), + }); + } catch (error) { + console.error("Error creating analysis jobs:", error); + return NextResponse.json( + { success: false, error: "Failed to create analysis jobs" }, + { status: 500 } + ); + } finally { + await prisma.$disconnect(); + } +} diff --git a/src/app/api/analysis/queue/route.ts b/src/app/api/analysis/queue/route.ts new file mode 100644 index 0000000..546bf7b --- /dev/null +++ b/src/app/api/analysis/queue/route.ts @@ -0,0 +1,145 @@ +import { NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const batchId = searchParams.get("batchId"); + const postId = searchParams.get("postId"); + + // Build query filters + interface WhereFilter { + batchId?: string; + postId?: number; + } + const where: WhereFilter = {}; + if (batchId) where.batchId = batchId; + if (postId) where.postId = parseInt(postId); + + // Get all jobs with their related posts and semantic links + const jobs = await prisma.analysisJob.findMany({ + where, + include: { + post: { + include: { + linksFrom: { + select: { + id: true, + linkText: true, + confidence: true, + reasoning: true, + analysisOutput: true, + targetPost: { + select: { + name: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + take: 10, // Limit to most recent 10 links per job + }, + }, + }, + }, + orderBy: [ + { status: "asc" }, // Show queued first, then processing, then completed + { queuePosition: "asc" }, + { createdAt: "asc" }, + ], + }); + + // Get queue statistics + const stats = await prisma.analysisJob.groupBy({ + by: ["status"], + where: batchId ? { batchId } : undefined, + _count: true, + }); + + const statsMap = stats.reduce( + (acc, stat) => { + acc[stat.status] = stat._count; + return acc; + }, + {} as Record + ); + + // Get active batches if no specific batch requested + let activeBatches: { batchId: string; count: number; minPosition: number | null }[] = []; + if (!batchId) { + const batchGroups = await prisma.analysisJob.groupBy({ + by: ["batchId"], + where: { + status: { in: ["queued", "processing"] }, + batchId: { not: null }, + }, + _count: true, + _min: { + queuePosition: true, + }, + }); + + activeBatches = batchGroups + .filter((group) => group.batchId !== null) + .map((group) => ({ + batchId: group.batchId!, + count: group._count, + minPosition: group._min.queuePosition, + })) + .sort((a, b) => (a.minPosition || 0) - (b.minPosition || 0)); + } + + // Format response + const response = { + stats: { + queued: statsMap.queued || 0, + processing: statsMap.processing || 0, + completed: statsMap.completed || 0, + failed: statsMap.failed || 0, + total: jobs.length, + }, + activeBatches, + jobs: jobs.map((job) => ({ + id: job.id, + postId: job.postId, + postName: job.post.name, + postSlug: job.post.slug, + analysisType: job.analysisType, + status: job.status, + progress: job.progress, + queuePosition: job.queuePosition, + batchId: job.batchId, + error: job.error, + analysisSummary: job.analysisSummary, + linksFound: job.linksFound, + linksAboveThreshold: job.linksAboveThreshold, + createdAt: job.createdAt, + startedAt: job.startedAt, + completedAt: job.completedAt, + semanticLinks: job.post.linksFrom + ? job.post.linksFrom.map((link) => ({ + id: link.id, + linkText: link.linkText, + confidence: link.confidence, + reasoning: link.reasoning, + analysisOutput: link.analysisOutput, + targetPostName: link.targetPost.name, + })) + : [], + })), + }; + + return NextResponse.json(response); + } catch (error) { + console.error("Error fetching queue status:", error); + return NextResponse.json( + { success: false, error: "Failed to fetch queue status" }, + { status: 500 } + ); + } finally { + await prisma.$disconnect(); + } +} diff --git a/src/app/components/AnalysisTab.tsx b/src/app/components/AnalysisTab.tsx new file mode 100644 index 0000000..207c25a --- /dev/null +++ b/src/app/components/AnalysisTab.tsx @@ -0,0 +1,351 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Check, X, ArrowRight, ArrowLeft } from "lucide-react"; +import LinkPreview from "./LinkPreview"; + +interface SemanticLink { + id: string; + sourcePostId: number; + targetPostId: number; + linkText: string; + linkPosition: number; + altText: string; + confidence: number; + status: string; + reasoning: string; + contextBefore?: string; + contextAfter?: string; + analysisOutput?: string; + sourcePost: { + id: number; + name: string; + slug: string; + }; + targetPost: { + id: number; + name: string; + slug: string; + }; +} + +interface AnalysisTabProps { + postId: number; +} + +export default function AnalysisTab({ postId }: AnalysisTabProps) { + const [incomingLinks, setIncomingLinks] = useState([]); + const [outgoingLinks, setOutgoingLinks] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<"incoming" | "outgoing">("outgoing"); + const [expandedAnalysis, setExpandedAnalysis] = useState(null); + + useEffect(() => { + fetchLinks(); + }, [postId]); + + const fetchLinks = async () => { + setLoading(true); + try { + const response = await fetch(`/api/links?postId=${postId}`); + const data = await response.json(); + + // Separate incoming and outgoing links + const incoming = data.links.filter((link: SemanticLink) => link.targetPostId === postId); + const outgoing = data.links.filter((link: SemanticLink) => link.sourcePostId === postId); + + setIncomingLinks(incoming); + setOutgoingLinks(outgoing); + } catch (error) { + console.error("Error fetching links:", error); + } finally { + setLoading(false); + } + }; + + const updateLinkStatus = async (linkId: string, status: "approved" | "rejected" | "pending") => { + try { + const response = await fetch(`/api/links/${linkId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + + if (response.ok) { + // Update local state + const updateLinks = (links: SemanticLink[]) => + links.map((link) => (link.id === linkId ? { ...link, status } : link)); + + setIncomingLinks(updateLinks); + setOutgoingLinks(updateLinks); + } + } catch (error) { + console.error("Error updating link:", error); + } + }; + + const bulkApproveHighConfidence = async () => { + const links = activeTab === "incoming" ? incomingLinks : outgoingLinks; + const highConfidenceLinks = links.filter( + (link) => link.confidence >= 85 && link.status === "pending" + ); + + try { + const response = await fetch("/api/links/bulk-update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ids: highConfidenceLinks.map((link) => link.id), + status: "approved", + }), + }); + + if (response.ok) { + await fetchLinks(); + } + } catch (error) { + console.error("Error bulk approving:", error); + } + }; + + const getConfidenceColor = (confidence: number) => { + if (confidence >= 85) return "text-green-600 bg-green-50"; + if (confidence >= 70) return "text-yellow-600 bg-yellow-50"; + return "text-gray-600 bg-gray-50"; + }; + + const renderLinkGroup = (links: SemanticLink[], direction: "incoming" | "outgoing") => { + const pendingLinks = links.filter((link) => link.status === "pending"); + const approvedLinks = links.filter((link) => link.status === "approved"); + const rejectedLinks = links.filter((link) => link.status === "rejected"); + + return ( +
+ {pendingLinks.length > 0 && ( +
+
+

Pending Review ({pendingLinks.length})

+ {pendingLinks.some((link) => link.confidence >= 85) && ( + + )} +
+
+ {pendingLinks.map((link) => ( +
+
+
+
+ {direction === "incoming" ? ( + <> + From: + {link.sourcePost.name} + + This post + + ) : ( + <> + From: + This post + + {link.targetPost.name} + + )} +
+
+ Link text: + + {link.linkText} + + + {link.confidence}% confidence + +
+

{link.reasoning}

+ {link.contextBefore && link.contextAfter && ( + + )} + {link.analysisOutput && ( +
+ + {expandedAnalysis === link.id && ( +
+
+                                {link.analysisOutput}
+                              
+
+ )} +
+ )} +
+
+ + +
+
+
+ ))} +
+
+ )} + + {approvedLinks.length > 0 && ( +
+

+ Approved ({approvedLinks.length}) +

+
+ {approvedLinks.map((link) => ( +
+
+
+ {link.linkText} + + + {direction === "incoming" ? link.sourcePost.name : link.targetPost.name} + +
+ +
+
+ ))} +
+
+ )} + + {rejectedLinks.length > 0 && ( +
+

+ Rejected ({rejectedLinks.length}) +

+
+ {rejectedLinks.map((link) => ( +
+
+
+ {link.linkText} + + + {direction === "incoming" ? link.sourcePost.name : link.targetPost.name} + +
+ +
+
+ ))} +
+
+ )} +
+ ); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+ + +
+ +
+ {activeTab === "incoming" && ( +
+

+ Links from other posts that could point to this post +

+ {incomingLinks.length === 0 ? ( +

No incoming links found

+ ) : ( + renderLinkGroup(incomingLinks, "incoming") + )} +
+ )} + + {activeTab === "outgoing" && ( +
+

Links from this post to other posts

+ {outgoingLinks.length === 0 ? ( +

No outgoing links found

+ ) : ( + renderLinkGroup(outgoingLinks, "outgoing") + )} +
+ )} +
+
+ ); +} diff --git a/src/app/components/EnhancedBlogViewer.tsx b/src/app/components/EnhancedBlogViewer.tsx index 49e9403..1af9754 100644 --- a/src/app/components/EnhancedBlogViewer.tsx +++ b/src/app/components/EnhancedBlogViewer.tsx @@ -4,8 +4,10 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { EnhancedBlogSidebar } from "./EnhancedBlogSidebar"; import { BlogPost } from "@prisma/client"; -import { Check, X, ExternalLink } from "lucide-react"; +import { Check, X, ExternalLink, Sparkles, FileText } from "lucide-react"; import { DatabaseInfoPanel } from "./DatabaseInfoPanel"; +import AnalysisTab from "./AnalysisTab"; +import QueueProgress from "./QueueProgress"; interface SemanticLink { id: string; @@ -17,6 +19,7 @@ interface SemanticLink { confidence: number; status: string; reasoning: string; + analysisOutput?: string; sourcePost: { id: number; name: string; @@ -71,10 +74,12 @@ export function EnhancedBlogViewer({ initialIndex = 0, initialSlug }: EnhancedBl const [links, setLinks] = useState([]); const [analysisStatus, setAnalysisStatus] = useState(null); const [isAnalyzing, setIsAnalyzing] = useState(false); - const [activeTab, setActiveTab] = useState<"content" | "updated" | "from" | "to" | "database">( - "content" - ); + const [activeTab, setActiveTab] = useState< + "content" | "updated" | "from" | "to" | "database" | "analysis" + >("content"); const [updatedContent, setUpdatedContent] = useState(""); + const [postAnalysisBatchId, setPostAnalysisBatchId] = useState(null); + const [showQueueProgress, setShowQueueProgress] = useState(false); // Check if any analysis has been performed const hasAnalysis = analysisStatus && analysisStatus.total > 0; @@ -91,6 +96,8 @@ export function EnhancedBlogViewer({ initialIndex = 0, initialSlug }: EnhancedBl useEffect(() => { if (post && hasAnalysis) { fetchLinksForPost(post.id); + // Check if there's an active analysis for this post + checkActiveAnalysisForPost(post.id); } }, [post, hasAnalysis]); @@ -109,6 +116,16 @@ export function EnhancedBlogViewer({ initialIndex = 0, initialSlug }: EnhancedBl } }, [isAnalyzing]); + useEffect(() => { + // Poll for new links during analysis + if (isAnalyzing && post) { + const interval = setInterval(() => { + fetchLinksForPost(post.id); + }, 3000); // Poll every 3 seconds + return () => clearInterval(interval); + } + }, [isAnalyzing, post]); + useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { if (e.key === "ArrowLeft" && navigation?.hasPrevious) { @@ -200,6 +217,29 @@ export function EnhancedBlogViewer({ initialIndex = 0, initialSlug }: EnhancedBl } }; + const checkActiveAnalysisForPost = async (postId: number) => { + try { + const response = await fetch(`/api/analysis/queue?postId=${postId}`); + const data = await response.json(); + + if (data.jobs && data.jobs.length > 0) { + // Check if any jobs are in progress for this post + const activeJob = data.jobs.find( + (job: { status: string; batchId: string }) => + job.status === "queued" || job.status === "processing" + ); + + if (activeJob) { + setIsAnalyzing(true); + setPostAnalysisBatchId(activeJob.batchId); + setShowQueueProgress(true); + } + } + } catch (error) { + console.error("Error checking active analysis for post:", error); + } + }; + const fetchLinksForPost = async (postId: number) => { try { const response = await fetch(`/api/links?postId=${postId}`); @@ -271,6 +311,33 @@ export function EnhancedBlogViewer({ initialIndex = 0, initialSlug }: EnhancedBl } }; + const analyzeCurrentPost = async () => { + if (!post) return; + + try { + setIsAnalyzing(true); + const response = await fetch("/api/analysis/analyze-post", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ postId: post.id }), + }); + + const data = await response.json(); + if (data.success) { + setPostAnalysisBatchId(data.batchId); + setShowQueueProgress(true); + // Don't set isAnalyzing to false here - let checkAnalysisStatus handle it + } else { + alert("Failed to start analysis"); + setIsAnalyzing(false); + } + } catch (error) { + console.error("Error analyzing post:", error); + alert("Error analyzing post"); + setIsAnalyzing(false); + } + }; + const handleLinkUpdate = async (linkId: string, status: string) => { try { const response = await fetch(`/api/links/${linkId}`, { @@ -420,6 +487,7 @@ export function EnhancedBlogViewer({ initialIndex = 0, initialSlug }: EnhancedBl const LinkItem = ({ link, type }: { link: SemanticLink; type: "from" | "to" }) => { const isFrom = type === "from"; const relatedPost = isFrom ? link.sourcePost : link.targetPost; + const [showAnalysis, setShowAnalysis] = useState(false); return (
@@ -483,7 +551,25 @@ export function EnhancedBlogViewer({ initialIndex = 0, initialSlug }: EnhancedBl View + {link.analysisOutput && ( + + )}
+ + {showAnalysis && link.analysisOutput && ( +
+
Claude's Analysis:
+
+              {link.analysisOutput}
+            
+
+ )} ); }; @@ -560,6 +646,17 @@ export function EnhancedBlogViewer({ initialIndex = 0, initialSlug }: EnhancedBl > Database Info + )} @@ -638,6 +735,46 @@ export function EnhancedBlogViewer({ initialIndex = 0, initialSlug }: EnhancedBl )} {activeTab === "database" && } + + {activeTab === "analysis" && ( +
+
+
+

Semantic Link Analysis

+

+ Analyze this post to find potential links to and from other posts +

+
+ +
+ + {showQueueProgress && postAnalysisBatchId && ( + { + setShowQueueProgress(false); + // Refresh links when closing queue progress + if (post) { + fetchLinksForPost(post.id); + } + }} + /> + )} + + +
+ )} )} diff --git a/src/app/components/LinkPreview.tsx b/src/app/components/LinkPreview.tsx new file mode 100644 index 0000000..3c2cf9e --- /dev/null +++ b/src/app/components/LinkPreview.tsx @@ -0,0 +1,37 @@ +"use client"; + +interface LinkPreviewProps { + contextBefore: string; + contextAfter: string; + linkText: string; + targetTitle: string; +} + +export default function LinkPreview({ + contextBefore, + contextAfter, + linkText, + targetTitle, +}: LinkPreviewProps) { + return ( +
+

Preview:

+
+ {contextBefore} + + e.preventDefault()} + > + {linkText} + + + {targetTitle} + + + {contextAfter} +
+
+ ); +} diff --git a/src/app/components/QueueProgress.tsx b/src/app/components/QueueProgress.tsx new file mode 100644 index 0000000..39c79ab --- /dev/null +++ b/src/app/components/QueueProgress.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Clock, CheckCircle, XCircle, Loader2 } from "lucide-react"; + +interface SemanticLink { + id: string; + linkText: string; + confidence: number; + reasoning: string; + analysisOutput: string | null; + targetPostName: string; +} + +interface AnalysisJob { + id: string; + postId: number; + postName: string; + postSlug: string; + analysisType: string; + status: string; + progress: number; + queuePosition: number | null; + batchId: string | null; + error: string | null; + analysisSummary: string | null; + linksFound: number; + linksAboveThreshold: number; + createdAt: string; + startedAt: string | null; + completedAt: string | null; + semanticLinks: SemanticLink[]; +} + +interface QueueStats { + queued: number; + processing: number; + completed: number; + failed: number; + total: number; +} + +interface QueueProgressProps { + batchId?: string; + postId?: number; + onClose?: () => void; +} + +export default function QueueProgress({ batchId, postId, onClose }: QueueProgressProps) { + const [jobs, setJobs] = useState([]); + const [stats, setStats] = useState({ + queued: 0, + processing: 0, + completed: 0, + failed: 0, + total: 0, + }); + const [loading, setLoading] = useState(true); + const [expandedSummaries, setExpandedSummaries] = useState>(new Set()); + const [expandedClaudeOutputs, setExpandedClaudeOutputs] = useState>(new Set()); + + useEffect(() => { + fetchQueueStatus(); + const interval = setInterval(fetchQueueStatus, 2000); // Poll every 2 seconds + return () => clearInterval(interval); + }, [batchId, postId]); + + const fetchQueueStatus = async () => { + try { + const params = new URLSearchParams(); + if (batchId) params.append("batchId", batchId); + if (postId) params.append("postId", postId.toString()); + + const response = await fetch(`/api/analysis/queue?${params}`); + const data = await response.json(); + + setJobs(data.jobs || []); + setStats(data.stats || stats); + } catch (error) { + console.error("Error fetching queue status:", error); + } finally { + setLoading(false); + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case "queued": + return ; + case "processing": + return ; + case "completed": + return ; + case "failed": + return ; + default: + return null; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case "queued": + return "text-gray-600 bg-gray-50"; + case "processing": + return "text-blue-600 bg-blue-50"; + case "completed": + return "text-green-600 bg-green-50"; + case "failed": + return "text-red-600 bg-red-50"; + default: + return "text-gray-600 bg-gray-50"; + } + }; + + const formatDuration = (start: string | null, end: string | null) => { + if (!start) return "-"; + const startTime = new Date(start).getTime(); + const endTime = end ? new Date(end).getTime() : Date.now(); + const duration = Math.floor((endTime - startTime) / 1000); + + if (duration < 60) return `${duration}s`; + const minutes = Math.floor(duration / 60); + const seconds = duration % 60; + return `${minutes}m ${seconds}s`; + }; + + if (loading) { + return ( +
+ +
+ ); + } + + const progressPercentage = + stats.total > 0 ? Math.round(((stats.completed + stats.failed) / stats.total) * 100) : 0; + + return ( +
+ {/* Overall Progress */} +
+
+

Analysis Progress

+ {onClose && ( + + )} +
+ +
+
+ Overall Progress + {progressPercentage}% +
+
+
+
+
+ +
+
+

{stats.queued}

+

Queued

+
+
+

{stats.processing}

+

Processing

+
+
+

{stats.completed}

+

Completed

+
+
+

{stats.failed}

+

Failed

+
+
+
+ + {/* Job List */} +
+
+

Analysis Jobs

+
+
+ {jobs.length === 0 ? ( +
No analysis jobs found
+ ) : ( + jobs.map((job) => ( +
+
+
+ {getStatusIcon(job.status)} +
+

{job.postName}

+

+ {job.analysisType === "incoming" ? "Incoming links" : "Outgoing links"} + {job.queuePosition !== null && + job.status === "queued" && + ` • Position ${job.queuePosition}`} +

+
+
+
+ {job.status === "processing" && job.progress > 0 && ( +
+
+ {Math.round(job.progress)}% +
+
+
+
+
+ )} +
+ + {job.status} + +

+ {formatDuration(job.startedAt, job.completedAt)} +

+
+
+
+ {job.error &&

{job.error}

} + {job.analysisSummary && ( +
+ + {expandedSummaries.has(job.id) && ( +
+
+                          {job.analysisSummary}
+                        
+
+ )} +
+ )} + {job.semanticLinks && job.semanticLinks.length > 0 && ( +
+ + {expandedClaudeOutputs.has(job.id) && ( +
+ {job.semanticLinks.map((link) => ( +
+
+ + Link to "{link.targetPostName}": + + + "{link.linkText}" ({link.confidence}% confidence) + +
+ {link.analysisOutput && ( +
+
Claude's Analysis:
+
+                                  {link.analysisOutput}
+                                
+
+ )} +
+ ))} +
+ )} +
+ )} +
+ )) + )} +
+
+
+ ); +} diff --git a/src/lib/job-processor.ts b/src/lib/job-processor.ts index 20ebea9..fcd9387 100644 --- a/src/lib/job-processor.ts +++ b/src/lib/job-processor.ts @@ -1,98 +1,65 @@ import { PrismaClient, AnalysisJob } from "@prisma/client"; -import { SemanticAnalyzer } from "./semantic-analyzer"; +import { SemanticLinkAnalyzer } from "./semantic-link-analyzer"; import { getJobQueue } from "./job-queue"; const prisma = new PrismaClient(); export async function processAnalysisJob(job: AnalysisJob): Promise { - const analyzer = new SemanticAnalyzer(); + const analyzer = new SemanticLinkAnalyzer(); try { - // Get the source post - const sourcePost = await prisma.blogPost.findUnique({ - where: { id: job.postId }, - }); - - if (!sourcePost || !sourcePost.content) { - throw new Error("Source post not found or has no content"); - } - - // Get all other posts to analyze against - const targetPosts = await prisma.blogPost.findMany({ - where: { - id: { not: job.postId }, - content: { not: null }, + // Update job status to processing + await prisma.analysisJob.update({ + where: { id: job.id }, + data: { + status: "processing", + startedAt: new Date(), }, }); - const totalTargets = targetPosts.length; - let processedTargets = 0; + // Get the post for this job + const post = await prisma.blogPost.findUnique({ + where: { id: job.postId }, + }); - // Analyze against each target post - for (const targetPost of targetPosts) { - if (!targetPost.content) continue; + if (!post || !post.content) { + throw new Error("Post not found or has no content"); + } - try { - // Check if link already exists - const existingLink = await prisma.semanticLink.findFirst({ - where: { - sourcePostId: sourcePost.id, - targetPostId: targetPost.id, - }, + // Handle different analysis types + if (job.analysisType === "outgoing") { + // Find links FROM this post TO other posts + await processOutgoingLinks( + analyzer, + { id: post.id, name: post.name, content: post.content }, + job + ); + } else if (job.analysisType === "incoming") { + // Find links FROM other posts TO this specific post + // Get the target post based on batchId + const batchMatch = job.batchId?.match(/batch_(\d+)_/); + if (batchMatch) { + const targetPostId = parseInt(batchMatch[1]); + const targetPost = await prisma.blogPost.findUnique({ + where: { id: targetPostId }, }); - if (!existingLink) { - // Analyze semantic relationship - const result = await analyzer.analyzeSemanticLink( - sourcePost.content, - targetPost.content, - sourcePost.name, - targetPost.name + if (targetPost && targetPost.content) { + await processIncomingLink( + analyzer, + { id: post.id, name: post.name, content: post.content }, + { id: targetPost.id, name: targetPost.name, content: targetPost.content }, + job ); - - if (result.shouldLink && result.confidence >= 70) { - // Find exact position of link text - const position = analyzer.findLinkPosition(sourcePost.content, result.linkText); - - if (position !== -1) { - // Check if text is already linked - const alreadyLinked = analyzer.isAlreadyLinked( - sourcePost.content, - position, - result.linkText.length - ); - - if (!alreadyLinked) { - // Create semantic link record - await prisma.semanticLink.create({ - data: { - sourcePostId: sourcePost.id, - targetPostId: targetPost.id, - linkText: result.linkText, - linkPosition: position, - altText: result.altText, - confidence: result.confidence, - reasoning: result.reasoning, - status: "pending", - }, - }); - } - } - } } - - processedTargets++; - - // Update job progress - const progress = (processedTargets / totalTargets) * 100; - await prisma.analysisJob.update({ - where: { id: job.id }, - data: { progress }, - }); - } catch (error) { - console.error(`Error analyzing ${sourcePost.name} -> ${targetPost.name}:`, error); - // Continue with next target } + } else { + // Full analysis (legacy mode) + await processFullAnalysis( + analyzer, + { id: post.id, name: post.name, content: post.content }, + job + ); } // Mark job as completed @@ -121,6 +88,242 @@ export async function processAnalysisJob(job: AnalysisJob): Promise { } } +async function processOutgoingLinks( + analyzer: SemanticLinkAnalyzer, + sourcePost: { id: number; name: string; content: string }, + job: AnalysisJob +): Promise { + // Get all other posts as potential targets + const targetPosts = await prisma.blogPost.findMany({ + where: { + id: { not: sourcePost.id }, + content: { not: null }, + }, + }); + + const totalTargets = targetPosts.length; + let processedTargets = 0; + + for (const targetPost of targetPosts) { + if (!targetPost.content) continue; + + try { + // Analyze for outgoing links + const results = await analyzer.analyzeSemanticLink( + sourcePost.content, + targetPost.content, + sourcePost.name, + targetPost.name, + { analysisType: "outgoing" } + ); + + // Save all suggested links + for (const result of results) { + if (result.shouldLink && result.confidence >= 70 && result.linkPosition !== undefined) { + // Check if link already exists + const existingLink = await prisma.semanticLink.findFirst({ + where: { + sourcePostId: sourcePost.id, + targetPostId: targetPost.id, + linkText: result.linkText, + linkPosition: result.linkPosition, + }, + }); + + if (!existingLink) { + await prisma.semanticLink.create({ + data: { + sourcePostId: sourcePost.id, + targetPostId: targetPost.id, + linkText: result.linkText, + linkPosition: result.linkPosition, + altText: result.altText, + confidence: result.confidence, + reasoning: result.reasoning, + contextBefore: result.contextBefore, + contextAfter: result.contextAfter, + status: "pending", + }, + }); + } + } + } + + processedTargets++; + const progress = (processedTargets / totalTargets) * 100; + await prisma.analysisJob.update({ + where: { id: job.id }, + data: { progress }, + }); + } catch (error) { + console.error(`Error analyzing ${sourcePost.name} -> ${targetPost.name}:`, error); + } + } +} + +async function processIncomingLink( + analyzer: SemanticLinkAnalyzer, + sourcePost: { id: number; name: string; content: string }, + targetPost: { id: number; name: string; content: string }, + job: AnalysisJob +): Promise { + try { + // Analyze for incoming links (from sourcePost TO targetPost) + const results = await analyzer.analyzeSemanticLink( + sourcePost.content, + targetPost.content, + sourcePost.name, + targetPost.name, + { analysisType: "incoming", targetPostId: targetPost.id } + ); + + const foundLink = results.length > 0 && results[0].shouldLink && results[0].confidence >= 70; + + console.log(`Analysis results for ${sourcePost.name} -> ${targetPost.name}:`, { + foundLink, + linkText: foundLink ? results[0].linkText : "none", + confidence: foundLink ? results[0].confidence : 0, + }); + + // Update job with analysis summary + const analysisSummary = + `Analyzed "${sourcePost.name}" → "${targetPost.name}"\n` + + `Claude analyzed the entire posts for the best semantic link.\n` + + (foundLink + ? `\nFound link: "${results[0].linkText}" (${results[0].confidence}% confidence)\n` + + `Reasoning: ${results[0].reasoning}` + : "\nNo semantic link found. Claude determined there was no strong enough connection between these posts."); + + await prisma.analysisJob.update({ + where: { id: job.id }, + data: { + analysisSummary, + linksFound: results.length, + linksAboveThreshold: foundLink ? 1 : 0, + }, + }); + + // Save all suggested links + for (const result of results) { + if (result.shouldLink && result.confidence >= 70 && result.linkPosition !== undefined) { + // Check if link already exists + const existingLink = await prisma.semanticLink.findFirst({ + where: { + sourcePostId: sourcePost.id, + targetPostId: targetPost.id, + linkText: result.linkText, + linkPosition: result.linkPosition, + }, + }); + + if (!existingLink) { + console.log( + `Creating semantic link: "${result.linkText}" at position ${result.linkPosition} with confidence ${result.confidence}` + ); + await prisma.semanticLink.create({ + data: { + sourcePostId: sourcePost.id, + targetPostId: targetPost.id, + linkText: result.linkText, + linkPosition: result.linkPosition, + altText: result.altText, + confidence: result.confidence, + reasoning: result.reasoning, + contextBefore: result.contextBefore, + contextAfter: result.contextAfter, + analysisOutput: result.analysisOutput, + status: "pending", + }, + }); + } else { + console.log( + `Skipping duplicate link: "${result.linkText}" at position ${result.linkPosition}` + ); + } + } + } + + // Update progress to 100% when done + await prisma.analysisJob.update({ + where: { id: job.id }, + data: { progress: 100 }, + }); + } catch (error) { + console.error(`Error analyzing incoming link ${sourcePost.name} -> ${targetPost.name}:`, error); + throw error; + } +} + +async function processFullAnalysis( + analyzer: SemanticLinkAnalyzer, + sourcePost: { id: number; name: string; content: string }, + job: AnalysisJob +): Promise { + // Legacy full analysis mode + const targetPosts = await prisma.blogPost.findMany({ + where: { + id: { not: sourcePost.id }, + content: { not: null }, + }, + }); + + const totalTargets = targetPosts.length; + let processedTargets = 0; + + for (const targetPost of targetPosts) { + if (!targetPost.content) continue; + + try { + const results = await analyzer.analyzeSemanticLink( + sourcePost.content, + targetPost.content, + sourcePost.name, + targetPost.name, + { analysisType: "full" } + ); + + for (const result of results) { + if (result.shouldLink && result.confidence >= 70 && result.linkPosition !== undefined) { + const existingLink = await prisma.semanticLink.findFirst({ + where: { + sourcePostId: sourcePost.id, + targetPostId: targetPost.id, + linkText: result.linkText, + linkPosition: result.linkPosition, + }, + }); + + if (!existingLink) { + await prisma.semanticLink.create({ + data: { + sourcePostId: sourcePost.id, + targetPostId: targetPost.id, + linkText: result.linkText, + linkPosition: result.linkPosition, + altText: result.altText, + confidence: result.confidence, + reasoning: result.reasoning, + contextBefore: result.contextBefore, + contextAfter: result.contextAfter, + status: "pending", + }, + }); + } + } + } + + processedTargets++; + const progress = (processedTargets / totalTargets) * 100; + await prisma.analysisJob.update({ + where: { id: job.id }, + data: { progress }, + }); + } catch (error) { + console.error(`Error analyzing ${sourcePost.name} -> ${targetPost.name}:`, error); + } + } +} + // Initialize the job queue with the processor export function initializeJobProcessor() { const queue = getJobQueue(); diff --git a/src/lib/job-queue.ts b/src/lib/job-queue.ts index 5f3dc5f..3e19076 100644 --- a/src/lib/job-queue.ts +++ b/src/lib/job-queue.ts @@ -28,15 +28,16 @@ export class JobQueue extends EventEmitter { throw new Error("No processor set for job queue"); } + console.log(`Starting job queue with ${this.workerCount} workers`); this.isProcessing = true; this.emit("started"); - // Start worker loops - const workers = Array(this.workerCount) - .fill(null) - .map((_, index) => this.workerLoop(index)); - - await Promise.all(workers); + // Start worker loops in background - don't await them + for (let i = 0; i < this.workerCount; i++) { + this.workerLoop(i).catch((error) => { + console.error(`Worker ${i} crashed:`, error); + }); + } } stop() { @@ -45,6 +46,7 @@ export class JobQueue extends EventEmitter { } private async workerLoop(workerId: number) { + console.log(`Worker ${workerId} started`); while (this.isProcessing) { try { // Get next available job @@ -56,6 +58,7 @@ export class JobQueue extends EventEmitter { continue; } + console.log(`Worker ${workerId} processing job ${job.id} for post ${job.postId}`); this.activeWorkers++; this.emit("job:started", { workerId, job }); @@ -63,6 +66,7 @@ export class JobQueue extends EventEmitter { await this.processor!(job); this.activeWorkers--; + console.log(`Worker ${workerId} completed job ${job.id}`); this.emit("job:completed", { workerId, job }); } catch (error) { this.activeWorkers--; @@ -70,6 +74,7 @@ export class JobQueue extends EventEmitter { this.emit("job:failed", { workerId, error }); } } + console.log(`Worker ${workerId} stopped`); } private async getNextJob(): Promise { diff --git a/src/lib/prompts/semantic-link-prompt.ts b/src/lib/prompts/semantic-link-prompt.ts new file mode 100644 index 0000000..aaa0c2c --- /dev/null +++ b/src/lib/prompts/semantic-link-prompt.ts @@ -0,0 +1,75 @@ +export const SEMANTIC_LINK_ANALYSIS_PROMPT = ` +You are an intelligent semantic link analyst reviewing blog content for potential cross-references. + +Your task is to analyze two blog posts and identify the SINGLE BEST phrase in the source post that should link to the target post. + +CRITICAL: Only suggest a link if ALL of the following criteria are met: +1. STRONG SEMANTIC RELEVANCE - The content is directly and specifically related +2. USER VALUE - The link provides clear value to the reader at that specific point +3. NATURAL CONTEXT FLOW - The link fits naturally within the sentence flow +4. SPECIFIC CONNECTION - The relationship is concrete, not generic or tangential +5. CLEAR USER INTENT - A reader would likely want to explore this connection +6. NO EXISTING LINK - The text is not already part of an HTML link (look for tags) +7. LINK TEXT LENGTH - The selected text must be 2-4 words only + +Additional considerations: +- Links should enhance understanding, not distract +- Avoid linking common terms or generic concepts +- Focus on unique insights, methodologies, or specific topics +- Consider the reader's journey and information needs +- Look for phrases that reference concepts explained in detail in the target post +- Prioritize actionable phrases (e.g., "implement authentication", "optimize performance") +- Choose the MOST VALUABLE link location - where users would benefit most + +You will analyze the ENTIRE source post and select the SINGLE BEST link to the target post. + +Respond with a JSON object containing either one suggested link or no link: +{ + "shouldLink": true/false, + "link": { + "linkText": "exact 2-4 word phrase from source post", + "confidence": 85, + "reasoning": "This phrase directly relates to the authentication system explained in the target post", + "contextBefore": "50 chars before the phrase", + "contextAfter": "50 chars after the phrase" + } +} + +IMPORTANT: +- Return only ONE link per source-target pair +- Choose the most valuable and relevant link location +- If no good link exists, set shouldLink to false and link to null +- The link must be highly relevant and valuable to justify inclusion`; + +export interface SemanticLinkSuggestion { + linkText: string; + confidence: number; + reasoning: string; + contextBefore: string; + contextAfter: string; +} + +export interface SemanticLinkPromptResponse { + shouldLink: boolean; + link: SemanticLinkSuggestion | null; +} + +export function buildSemanticLinkPrompt( + sourceContent: string, + targetContent: string, + sourceTitle: string, + targetTitle: string +): string { + return `${SEMANTIC_LINK_ANALYSIS_PROMPT} + +SOURCE POST: "${sourceTitle}" +TARGET POST: "${targetTitle}" + +SOURCE POST CONTENT: +${sourceContent} + +TARGET POST CONTENT: +${targetContent} + +Analyze the source post and identify ALL phrases that should link to the target post.`; +} diff --git a/src/lib/semantic-analyzer.ts b/src/lib/semantic-analyzer.ts deleted file mode 100644 index 213eae1..0000000 --- a/src/lib/semantic-analyzer.ts +++ /dev/null @@ -1,151 +0,0 @@ -import Anthropic from "@anthropic-ai/sdk"; - -interface AnalysisResult { - shouldLink: boolean; - linkText: string; - altText: string; - confidence: number; - reasoning: string; - linkPosition?: number; -} - -export class SemanticAnalyzer { - private anthropic: Anthropic; - - constructor() { - this.anthropic = new Anthropic({ - apiKey: process.env.ANTHROPIC_API_KEY || "", - }); - } - - async analyzeSemanticLink( - sourceContent: string, - targetContent: string, - sourceName: string, - targetName: string - ): Promise { - const prompt = `You are a conservative semantic link analyst. Most pages should NOT have links added unless there's a compelling reason. - -Source Post: "${sourceName}" -Target Post: "${targetName}" - -SOURCE CONTENT: - -${sourceContent} - - -TARGET CONTENT: - -${targetContent} - - -CRITICAL: Be highly selective. Only suggest a link if ALL these criteria are met: -1. STRONG SEMANTIC RELEVANCE - The source directly discusses concepts that the target page explains in detail -2. USER VALUE - A reader would genuinely benefit from accessing the target content at that specific point -3. NATURAL CONTEXT - The surrounding text naturally leads to needing the information in the target -4. SPECIFIC CONNECTION - Not just generic mentions of broad topics -5. CLEAR USER INTENT - The link helps users accomplish their specific goal on the source page -6. NO EXISTING LINK - The text you want to link MUST NOT already be part of a link to any URL -7. LINK TEXT LENGTH - The text to be linked MUST be 2-4 words only (e.g., "SBA loan", "buy a business", "loan application") - -IMPORTANT: The source content may contain HTML. Look for existing links in the format text. - -Default to "No suitable location for semantic link" unless there's exceptional relevance. - -Respond with ONLY a JSON object: -{ - "shouldLink": true/false, - "linkText": "exact 2-4 word text where link should be placed", - "altText": "best in class SEO alt text for the link", - "confidence": 0-100 (only suggest if 70%+ confident), - "reasoning": "Brief explanation", - "linkPosition": approximate character position in source content where link should go (optional) -}`; - - try { - const response = await this.anthropic.messages.create({ - model: "claude-3-5-sonnet-20241022", - max_tokens: 500, - temperature: 0.3, - messages: [ - { - role: "user", - content: prompt, - }, - ], - }); - - // Extract JSON from response - const content = response.content[0]; - if (content.type === "text") { - const jsonMatch = content.text.match(/\{[\s\S]*\}/); - if (jsonMatch) { - const result = JSON.parse(jsonMatch[0]); - - // Only return links with high confidence - if (result.shouldLink && result.confidence >= 70) { - return { - shouldLink: true, - linkText: result.linkText || "", - altText: result.altText || "", - confidence: result.confidence, - reasoning: result.reasoning || "", - linkPosition: result.linkPosition, - }; - } - } - } - - return { - shouldLink: false, - linkText: "", - altText: "", - confidence: 0, - reasoning: "No suitable location for semantic link", - }; - } catch (error) { - console.error("Error analyzing with Claude:", error); - throw new Error("Failed to analyze semantic link"); - } - } - - /** - * Find the exact position of link text in content - */ - findLinkPosition(content: string, linkText: string): number { - // First try exact match - let position = content.indexOf(linkText); - if (position !== -1) return position; - - // Try case-insensitive match - const lowerContent = content.toLowerCase(); - const lowerLinkText = linkText.toLowerCase(); - position = lowerContent.indexOf(lowerLinkText); - - return position; - } - - /** - * Check if text at position is already part of a link - */ - isAlreadyLinked(content: string, position: number, length: number): boolean { - // Look for tags before and after the position - const beforeText = content.substring(0, position); - const afterText = content.substring(position + length); - - // Check if we're inside an tag - const lastOpenTag = beforeText.lastIndexOf(""); - - if (lastOpenTag > lastCloseTag) { - // We have an unclosed tag before our position - // Check if it closes after our position - const nextCloseTag = afterText.indexOf(""); - if (nextCloseTag !== -1) { - return true; // We're inside a link - } - } - - return false; - } -} diff --git a/src/lib/semantic-link-analyzer.ts b/src/lib/semantic-link-analyzer.ts new file mode 100644 index 0000000..c3d25aa --- /dev/null +++ b/src/lib/semantic-link-analyzer.ts @@ -0,0 +1,130 @@ +import { query, type SDKMessage } from "@anthropic-ai/claude-code"; +import { + buildSemanticLinkPrompt, + type SemanticLinkPromptResponse, +} from "./prompts/semantic-link-prompt"; + +interface AnalysisResult { + shouldLink: boolean; + linkText: string; + altText: string; + confidence: number; + reasoning: string; + linkPosition?: number; + contextBefore?: string; + contextAfter?: string; + analysisOutput?: string; // Raw output from Claude +} + +interface AnalysisOptions { + analysisType: "incoming" | "outgoing" | "full"; + targetPostId?: number; +} + +export class SemanticLinkAnalyzer { + constructor() { + // No initialization needed for claude-code + } + + /** + * Analyze potential semantic links between posts + */ + async analyzeSemanticLink( + sourceContent: string, + targetContent: string, + sourceName: string, + targetName: string, + _options?: AnalysisOptions + ): Promise { + const results: AnalysisResult[] = []; + + // Analyze for a single best link from source to target + const result = await this.analyzePostPair(sourceContent, targetContent, sourceName, targetName); + + if (result) { + results.push(result); + } + + return results; + } + + /** + * Analyze entire post pair for the single best link + */ + private async analyzePostPair( + sourceContent: string, + targetContent: string, + sourceName: string, + targetName: string + ): Promise { + console.log(`Analyzing for best link from "${sourceName}" to "${targetName}"`); + + const prompt = buildSemanticLinkPrompt(sourceContent, targetContent, sourceName, targetName); + + try { + // Use claude-code query API + const messages: SDKMessage[] = []; + const controller = new AbortController(); + + for await (const message of query({ + prompt: prompt, + abortController: controller, + options: { + maxTurns: 20, + }, + })) { + messages.push(message); + } + + console.log({ messages }); + + // Extract JSON from the response + if (messages.length > 0) { + // Look for the result message which contains the actual response + const resultMessage = messages.find((msg) => msg.type === "result" && "result" in msg); + let responseText = ""; + + if (resultMessage && typeof resultMessage.result === "string") { + responseText = resultMessage.result; + } else { + // Fallback to checking other message types + const lastMessage = messages[messages.length - 1]; + if ("text" in lastMessage && typeof lastMessage.text === "string") { + responseText = lastMessage.text; + } else if ("content" in lastMessage && typeof lastMessage.content === "string") { + responseText = lastMessage.content; + } + } + + const jsonMatch = responseText.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const result: SemanticLinkPromptResponse = JSON.parse(jsonMatch[0]); + + if (result.shouldLink && result.link) { + // Find the position of the link text in the source content + const linkPosition = sourceContent.indexOf(result.link.linkText); + + if (linkPosition !== -1) { + return { + shouldLink: true, + linkText: result.link.linkText, + altText: `Read more about ${targetName}`, + confidence: result.link.confidence, + reasoning: result.link.reasoning, + linkPosition: linkPosition, + contextBefore: result.link.contextBefore, + contextAfter: result.link.contextAfter, + analysisOutput: responseText, + }; + } + } + } + } + + return null; + } catch (error) { + console.error("Error analyzing with Claude:", error); + throw new Error("Failed to analyze semantic link"); + } + } +} From c60de67ae25afed22d00cbaa4c51fb0e0479853c Mon Sep 17 00:00:00 2001 From: Alvin Cheung Date: Sun, 27 Jul 2025 00:46:16 -0500 Subject: [PATCH 2/3] Add retry functionality for failed analyses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added retry API endpoint to reset and requeue failed analysis jobs - Added retry button with RefreshCw icon in QueueProgress for failed jobs - Show failed analyses in Analysis tab with error messages - Failed jobs now display in red with clear error information - Users can retry failed analyses directly from the UI This improvement ensures no analyses are permanently lost due to temporary failures. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .claude/settings.local.json | 4 +- prisma/database/blog.db | Bin 503808 -> 548864 bytes src/app/api/analysis/retry/route.ts | 62 +++++++++++++++++++++++++++ src/app/components/AnalysisTab.tsx | 55 +++++++++++++++++++++--- src/app/components/QueueProgress.tsx | 40 ++++++++++++++++- 5 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 src/app/api/analysis/retry/route.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2ceddfc..a01f553 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -43,7 +43,9 @@ "Bash(pkill:*)", "Bash(cd:*)", "Bash(cd:*)", - "Bash(cd:*)" + "Bash(cd:*)", + "Bash(git fetch:*)", + "Bash(git merge:*)" ] }, "enableAllProjectMcpServers": true, diff --git a/prisma/database/blog.db b/prisma/database/blog.db index c215ddc682b5d70d0b711ede7d8a84f3613bd8ef..e6b582c2bcac0f52c6182b24788382e82b895160 100644 GIT binary patch delta 25883 zcmeHv33MFQm8hy(UDa!qweM0%wya(5R%^Aq$qU%V##pjrZ0y*r?oz9*-qed0Ge*cr zUcioR1iU1y0VbaimJla#J`lhe2mz7|gd{@<9|PGSfdukH0@?WYzN+r7mRj>OGs%SH z%sKc`ty{}`_uY5*d#`+@{L05FPZe{PLm3$vhfhl@Sj(XY_T70>w@NrY!=I9z4)Gh} z3gI2$$HIBx`@-|WH-#sKbHYQy7lr$U&j@!2Hw#2KExs%^iQg92ig$^X;tS%B#7^;T zu}1v8SR(#R{DLToUla4h$3&y}pr{k?6K@xOD-z-7;x*!F;g4r3C8IE**D(ycijFJk zxPp$$>9~xJ-E>?^$1XZ{(y@b%?R0FTV=EmUbZo&f+fJ`-IA$%uF}E2L|L{s+rCAe}T77XVh9ZFP$-bt``1peOCCKaEtYv zFm3(3Fe>b}-XUxjx~EvC@gjH9;xPtzWPmap&yp&&yQ+`#a z{HnE{&k#f5pi7QKya7)tu&OHM-inHppQWXmpA)jXs7Ui$cIW2{X6>s}=d_S9>57;E zr`IRDYldU8(5SsPb;gSp;SB-p4GU8~RarSa5tCE?BuObh%gf;vVnK$HE;R*Imh!74 zIj!OpKw%-gA>}Ux1)85TPd{>r!3?*=0Q)mlkG{68R?Gp)7bk$?r{K_!!-0BJpZ5=S7fmfr(~M=TR&T?rGC18yMGRhsLSn(Gys>*bp3Wt!`5 z&Gk~vb(`k8Rdel7uP5-sEgFDbb8XXHFVS2#Yp$C#*NvL%2F-Q7=DJREU2E1&BvxOe z0W8*BS8J{pX|D6~G(QRpH9tyAH9yMBH9sngG(YI|%=F7SMOm7^c4)4PXHL9aU~Sj@ zrEKOaFBiW8lE%UZ8&ZiFswIJ@bB{f#y>2sH_M{l z{3D^0xuV_?Z**MpIK6>L z%^fT20J;I7+2nVpm~zXKCsx$LUoM2dWRY*)$CTAK-LyRZh6NEh5cT?DkemUhZ#?3S z)CHGS!~H#QzeviiXNv0`Kk1I&FRbg^EQNf|=s+;+k4TZ2Yq062rHkSIHE_T61~XG) zF`n;Q2!B`!htxtYVwhr6XC?gZf*Z^v0Oz;fu(>1tLQ^c@9q`I-$?c2`_6MC|ccgNr zy#nrUgZqu-o#TA2#q_JT_>_23aSKztHn*lVjvo#Mg-=e*s_-sHDLlRe9@j@?r{4#n zL>di-hX#DX(TepgH2_@4GTAKI+sl_)Oi$Sx;4hou_DphaQlCRA?q;l(+8@|dtYxv} zfsMK{3v+IX`j=dmymO9&8~3rMoJD(^SHkl#mJwNbEGh>gUQ&5bS4nJdau!S8bd&m! zz>@O^^<}QkM;g`DS=jz?(CL=L(uQr)Kr8_J7z{+X%m#J(RyHDsN4zds3Wnsc6QOx4 z>WkoGbu43KH*C{xh4G7Z>N4~!x&JJaf5WA$$x{Aot@<$oOC~SZOy#=hGlp*W&6$UMKBv966qWoU04o}I9Mi=^#-D{ z2c|E>7Dt`ZKse|xZmz0;&vmd&uG8%dMb9jF^Vd3Et@)i9{_Q$Y0jy70KWja09kuQO z##?6=>U^!>`UzQV=h-If`rKQ?pHopUG_!H)dB3%d5?~J+z_qOb{ceFX$x*Tne z&bH33+Ib;0^FkKS3#pz5GSS(!XdX~!*TQ)rRr5kB=Y>e=AQN=073n|}Qa&%FY+gv| zypWPPkcl|Di|2sikfM1Zh4VrR=7r>^LM)mc&zo`nspw4hpMKS2$Q5q_uAOms#$qc3 zP2b3WR{XU1De)#?trs(A5A`s`LM_0jm%}jC0mD^x7$%!wxUwFGD-e3H8ivaklE=3& zt8}XjlZ3y7S+l~TH~-Z9l=-vf6Xu|KtJz`BGyT@|ebYmxn@tlYr)iC;+Qb{*Fg|0v z*Lc!6Z0w^F;(fyp3|}$aYM6uxyw6XR=~gl>zuVUtc87*+ww7pYe8@Q*>-UZiAMmV7 zK!>~Bod69Zv~#HCK-Xy0wK4$>4YlF3A%x2E_k(QzHWr>Fa?iLq7_aijef1uyt9}0CRpng|}8=!*--G89d z1aElk9c7zVO$824WaHu5#b&n6V+HCd% z2z89QBmJY{c6$;U8@2&-450&lzqhref1=%%1V>{|0FNMe%1{#Gm*(rU< zc3b~wXa8Vx5<2V$eAp>{3?3K^gUR31lsQ2OWy z`s~Beu9oT~)IBBxl+s6NeA{djQ2< zqm(F*kJ;M$$A*%~1E?KvODVCXqpiz69vn;}51;_FQA%{T?GM;mUG_NgfF<f6L81IJjGfl(5a15qNn|U!!_J8T%?5pjoT=v8X9le?-}dd-C3vw17o*QN^Exr z9UVj7u>vg=$k;~dqr)X1h`Rl;d@U3>m5tIzXneTS@ALQP9r?^b2L7?|PY3@p;h+BO zXAW|EstltUrn{L@-P0hKyiE4gC2aOeBk1yzex_`yL9fpv(e=z{4RI5R*pKtoBw5eEvOw7;lVQHt5*(1CVP_zMMi+64`dsqkCzvua*~XVHp_+F4GkWnddrlb3QTN0z`06K+K}n^y6svs#a{`ql#Svu|+G)J>w+99lh@RZ?vidG34MV!A(h z&Zra}1Uu3@5{x>1l0S%MF?nSZQ&0&u47IcYA7}wNBlONl&=*6i4Dd&!$C!d`&C-S_ z;FZ2b4iCt#sO0koh9qCmMa?%rrppOOMuNQnyARCL!JxE17J=JjXE*>>u(LlHQ>PIQ z_WNXigsgv(Sx6ph9il{T}k)u*5 z2rozX25eUZHreR{8h{7A5y=}sa#H3s>WvOcZaGT*pKu#k_yG!uNI-iEwE!~YFc3yW zrkf9I3CIK9XhflcO2&=|&+_Iipj&D{D#$Pxk_T=MTVlzh|H>8Cr8{veVE6WHtfxC! zPOlp_)}KA%=AUEFKJVo#bZ0O1@cY>>g?Mg(Fr(*IF*CYf81`CkGp`3mb_J6^HFaq| zvXW|catTv%cpb2ktnLglyoV_|`_whOiEV?o?r`ry{?#lY-8X^*#0?t*)CK#Ff03px zF2Hxxu#?My9l?U~&JG;of67qa=e>mTzFUs+Eo9;vzBFgWR7oD*-px){G9^<7@%BHR z;J>EazWWY(`|)E;IeG0QU!EgP%Z0hh+q+6hFPwtz?VDNLk>XHcD#M< zb^LD?MC~|4Z|}Z=Z_bydR+i$Ou#14w=>oXZ@68}z*$ulmd?SAkePZiVAJ8WjysIxE zCl4`sWb@7R+0t?q7cuxs9Xz{ra|W5jXUPrxKP%6kBlOv2ALm!Yj(8+|Vhi2xBYik8 z_i6r^g1wz*=$&uP@asg1I2~iouwJ6Okud|x{b@lTp3b26y7 zaV0d*q~bZ(WkO#40=JvI_y(6tUcQmfs)$6vX`;C1C|97IClvCHBW?CD{u=J3{XN9hXl&b(|?TZ3hC6P<}(+5cn!SE(Z3#&NY!YRx=_w_d_m^ z+*i-%WNz%8@sMFzmrox53{yw!w=o4btkGEs`x&OSimAQUv51xQWJzeFKwE1s`MJ^NG~YWWtoG zv_!#_i7q7Ke331%HcEkDRAHVVo36Zt&n=+Nw-Ff_9OYo(4V4CiqZ0Y~8Ky$O(>>yi zc&Qt8oLqg9Eh8ZxUqGI{i8mG@)D;8{HwNaI58e=_QX2R_bC|E~n$7#-J}qSas(_?y z92|L(@d)f1<%+!?J$a~`?G(3!JsyuNz=nAIo<9s_@GndFQCKY3^kifD69MB<){Lr zXbMz7OMU9GkXlhOl*1s`QMSX(0h>SoukWr;vruzh*N z7UDPytPp0ZEQyrg!jxoD!Kb07ePLY2Utm(@m8*ATB!C$sUk;JcceF7ia9@8uU--_h!DIIg{Cx(V%-y7tqWKcGh zAONLUs%@se-Z{YMkxL4Aft>mmE|WOF#pN&&V%-SZ((?>kPENJztVSi1zF#EXrpWu)FP;eAk z=ic`jNl#TrQW@ln3-N%a)1*n|1$+*9xtQn43(s)YnU?N?3FH;3F7I@OgTRwC%m>xL z*B2|(n;tLHEcWU;?PBNn8T1N)Xbq*zw1$!eKk?=o{KQM0YHg{|iS^9OJzpWsr1{ zsJ$p7Q6NU>hSGCvRNWC0s4O{rA+<2W@*u`IQO#1&Urmv7bW&f8mS2$p?f;8XEWbH^ zG7up^1+WGs;YL3{<}2{!yRNu^8m85V0WeI_3j^AX-w6`XMdc>$((81Wc1@617x8)d z)SWr!Xpqg{;EE^GFhY6?w!V&}wDrxkhh2plQo82P@nyhbkg#dGlUq0Mg5F%hd>r&_ zkBMCTIJY;IiqcA4bRYdaN!*M6!xC3@Akb6nv7kxH>Tb#~_2@8IcS^VJL{&8!mtfO^ z;c?gjhDUHxe{!p0d87#k%#E5u1UqSWplxL)`h~ps_wI|!Q8{VC7uS3HqvYiiOu?cJ z1JZafR^x`VF(iW{3%GzHlEBP@bP-@djS~6f38sZyTMW+FKCmg!piEdCkNR{fKKM{hRn4s~q=ctzR9#6#UEIeQk&!&bgB-9rj@z^O7>*z zI9UH9XSotyRqAHyyUWSXdwIwDKa=!PIHyd39tMs3G<-M{Jk6m%8KGk;GmJ0lUtlUhO5`y1 zW39D#P&O+E#Rvz*x%XIya!}HQqcurVZ27Z^Q&RgO&~LL+_1oT;?8I~_+d#g$gRLc> z+Rm>apF7LsVR{!?C-AF2ENZzaIc?n75@o(;R)Ryyaw%K8OtXJSCTei6+UQbA?b@OwA2YQ`cDi-O4^mgfIJRnPdx9r5eL4X-%@;7rsHRzg%Z${Z7(zxvq3U zm9z|`Y7q2suX}k_)e79A<%;bH2RV=YX%pY@*P3nFzdKtpxrHf;&r`R%N+PHB^0jpK z%Qd@keBoknTth}6ikKr6up1MTS;uRXqmw2XLz6ei>Za+IFndWQLF@KL-ta$RwEuh< zt@m3(BYFIBrjrH7c<t_7Bl7A7tv_N;u8a`3h*3r=Mx=EGLapwbd zSED}wpNM;pA?cXhyIkK^)};EjRfjlu)tx@C2mBZ4+ap_#12eb`I0E%>C1M=la-S7L zP9!#~Mqndd*urT?r3Q7!%CxAPlSpKpi#kgjbww!|!SM3l@C!KpbSHVEiJ#^1w8R4@ zI4k0>M{ z2EBGAx#&4=Lnz$;daKPwO7;-8GGG%lRo`zcb4}zKZ<#JKR7AxSi++b6&sd(sJzwJ=zb>$xtH) z_AzPw(eDi+0fe6>_wQr8DchlZZ~{J9r`!Tca78kEW=;TLddou1e2#6`lF*z)K5;Gy z328|PUBNxllYkCfZ^MB%Kv#A;rCl;C)03ie{w% zQ7D{>oLj*cn;=69JO`L0Mb0hbtF3dLn3?FrBrhM-H?$yI0S95im5B#S>&pZODy&aQ zXHt~9nb5M_VwLA0*}!5H$J40{^BUbLFe#>VYfswhQB7S!6{D#RP>ei7H&_+DC{1>3 z+Hwt4k4_|2^uH<#;c+FsKix)7dNP~HW>0408em78g25s5)PTnqkPW#<(a|7H3yNTX zH6TOM5x8~+yfn@#OMM$IzHrkzY4y6kZBl43?2M4CJkU4J6hnacs~aI~{lOHkr(ukl zs$~n&zHP|DO-yz7oL;E>=I-Gc#oDsGM&Yf>1S%l zKf(N~t1vbS6hbEf0VGIRa?8L<)D)_vrY1cej?5Pw(Y7w0BII;#5{(L_FR6e6Ru#1%a7+{H{msroL&>YSw!h-59= z1{4VM+eY03G^v1k+G!R?P?HM)%hAe)__;v(b$cNd38U4C=rMH_q{^tQPOz@_KEk!n z%ZvYF@`)Pjl&D~X9zd%Osce%*D>}Bc4v$NnVu7Qd*0tx@a^<+BNf)16z?3*Ef1~)K zg`S@uzD_g2NabC+QgY8DIunaYlMVV@GW3Wpn=0f&O6)R%YI#1{_XyWUs;}j9|J&oD zbZb&)+42x10zhOZxr!pqPAUGA^BLxG?rP>dFuUiN&SSMrxO*{$+3khh+wMHpKzGlb zrph2qts9%C7=r}ub0U6}H-C6|y@B%jzU%bFc!hou`RqY`J>AJPLMmajf5?_V6BCq~ z%|Snw*?=^mINGMhfhP9sJZ4mAB2D$rdn%B-bg57U%|`n@F-^U}<*T%GG4(Gx~IS1 z)8VDn8hhXxtvh0FjuENw^Xcr9-WOg(ZTZe^#9PT z^Ow!qgG60A`hXnNPDdv(poH#%63PX4P>K)Z;#qobBd!xF1`uI!#M7LuP#6+23>`j$JGdo=c| zmLTWWlKrRrV@V$e>1g14W}%A81He*S|Mb9O&0bxt)9%%ra=PrfrgGN2TlEqqSM@#I zP9F5|M`nE=yAP0-M!r7bWy18GIXgu&HE|~>#X)W{6AMUuyy-0(S=?SejV@{Fa zg1IOW=S9i4@(?Bcva&hk{%Ss$f|#VA15#!1M)0S8J4q*hDMtppJK@Jj(g|8@t7S-B zAgdZ6!}0IcwK0+E+5qpyq`qdJ?5w%vq>zUMYzRnB$@0ffk9-krkIam#GR*Jr*_pq{xa#!ji2dlG9o3X|itSMYYKlC1)UT*1X-ccr z&K<1jsf5L7{u>+}O_3ZPoxX?LAf20>&*$pMmAzmAeSRCbOlUBWp7ex~_&1oUA%4oq z69x598W(>E}iFa!>iK$9kHf#z6)FGjF>N40ASDRAJ;+|X3-mdMP!q)zP zT(g7!sA34xeBslbY=JnS=;p|se9)?fyt0O`umZnJB)PYcQ>&R)O&V~^MNCdPIDX;+ zM)4RWn&DvXJ$b8yEvf-A1--5!SY7~rEVv&V`bhG?0Hpi_8>E?veRqAGG~UfLtXl@D zluE322L_OU={8Eob}!LbPtYEKxJIj2}pH2zsiEylkim1q55MCIwb zvzK)LM&Cew{jO?NQ%z74tdu^&XaBvagFXLc+$bJ1iWHjJvg|kddac2J)Y6av8tFhW^KMWe4HL2nC-MN(9aXplm?Zkm*to-U6mrdIxB@FO} z4~=fht23wR63Uz-2f)Ai&)0Je6&i+!l8a;dGOX8`M-0Ey-{O2#&|sRLPM zmr^WQ2|F3tsj$j)?M&1rSlNcny-)?LWM?X6zr^w#?gDCO^SdDM8jFsTD~kAhJ?QI@ zoq5Rw3-yhVvo?VaYkv@o4_X10N~qv6aSefS4a zFtv!(&p{w%5Vd;su~|)>)M_Xn6rc>L@j1m#kcs6yx2V(U9>Ir!|Ega{|BZ_VAELpa zkLn~lUWUjqa$U`o$UR1;&;-04LKK)sN|qX##cK~QXh)<%+Aox{9&rLv$0t;zrs)F1 zrtKp}+*ACZMhv05#{d=z6u|`t$ea6^Lb7OyF4r1Q^@++(D7eO2LNYeZmu0C{+v+MK zQVm%#s4F7-U(l^FL5Py3*VoA8b1&#t+G~_rv6PsWnii{CtWe$t35gU#@MwTIzs%)> zGyS(`m>xqjR<-Q7b`R-!hRq?rx}90b*4LBP+nEmARC^mD0-QKlii?GpPN#~9G!1Xm zmT9c%SL92F`HwIcpwHbv`7PO849vP@fh3C7j}7qE+1fu1 zhJ!KBAbI6uY%clW2+NU)6_AhuZ3qgD&_=*3kATuh-aZb6o8f+@YL}(}2m~|yhc*n@ zGtdewA<`3d96LU8yHfn1igHvZ!I7sL@}OF+hH5^nXjdwR- zW>{5Gw9S6vy?$7wbsU>pLteOs-^x&rC(T(hQ<)u6$|%v4f{KlPG`XNDo7;(&En2+J z5Co&bP=EQF>tOXblMDUTfSL$O*#a`!=0Kq!ZBB0q&(+B*nYxC3g>XtJ2I6Dqj9e zA#D5h!_tqKI1uYl=!|9Vmm{}ZqtIiz9!l)o z_I#xEWBf)umEDlPi>@K{K+;KLaWmQa9X1C-(!dRpZT)CX8O^UvSj0}LW>)XNnna@n za|vZ^gXmqtv=lgSYUycMmN9XoJkfEFrUOi`N`ax{;=2W0j`Xv zm~=h>MR9+)nX~egk*eiWRybRl49qQ`01M=UHN1&D{4-8m8xL_RWuKU%GOI6Qx@b`GI+Sy1B!Y%b9~1S-zKTEq%j@@hY<;7KRjKx5I{HeExm_&l^f*?JpOo2$sr z?`BwM%)JIb*QWb``7X%Mb09x!mLn=Jr2PP{8Uj@MRf- z*F^!6RBs$*IM5em%!$@RJOwIIq}7Pk4tcf&Yj?(?gFz_Ng3}C^11vD?Zo)d?ajang zUl;B30Yam5H($01$O1B27E@}B9~=m$yJbIk1%WY81~ED~4n40nP@M z$mk5atV<+6evq+|dmm&<)=oK=As#^FVaI~)c(PV1B$cjP8=JOwC%NYa{xl8aEVy{n zB`J+VNzX~XY{4u}{3)IG&boB9}0M;8%^cWV#Z0?yLGD#4|PlfzBd>F{rxd~7 z^Zv;)Wzp%H(5Wp+S;tSvl{fNriiZYSNn@&)8BWTQlqfA6ewaCy4MJ)!}cUP0*Tczp!RFz5SQo!H5VPI}3 z3!;E0lpY{~;m?$Spw)^y0yZser?bodBMJMqKs8?2>lutTOW;$u?|P=3X(s2cW7cf| zK{(yN6ccuVOt2}&azNQ+_o3wq&eFB!W79TmMf`n~yaF20G9^s`owd;WAfEpR+G70B zAic%~cl-dw5kgT(*zYnqgzMr9OXo*nL^*OKavxu0rJ0A&ONM>~+3~i-IYRZ(7o%8G z5gcdAz;x8AcT_T=O^X{UB(U9slQs~bTuDh6LE77SW&?TgL1qW3zmK_`1vKIrvrVn& z!ZQXPu27poU(=APGo3Z(6{rtP;B9dY@Kv0W8%Vx&CbN)STfyg;!a?X>L7M@QN57C+ zWKP6AfI1J}$`=Z%RYF%{I7CX!%#yX66G}hTRN?u6G_}7I7qn7o4ugK0NS{IrwAJFQ zPK#e*?CU<(N##zaIWeu~tl4nVbtu}crGY9tQ}OlfHt%0#gdNF*Agc9B%$2?pIe2BFcr zM4-oUZh_wkPW3QQ6itgTypdrYHa8jG0MYaVL(f#tD#R`*J5b|46+!HdD%efexid|# zX7Z_f`CW>xsfn(e65+5Ixw@aLEohcd82|>M3f*{{7VOqFpV#uHxvAe7frWu=3#yHtfc?^ryU2@?m-`@V7fljnS2S@sH3S6mG+It~PmP8F zojwzX3|D^&r84U_r+TjIEat#qAC)3>iigq4%c`}w8h z_xJLZYc&<|5*RP2@If3DEk&sY2xxZuYsH3`g)4H?mm!TC6QW7|)w~tu8>}HLLw4H& zXo{Q#yB|ZV7i2d!iynY>UTdKXk0fy%wQjOgnK#f}ggk2l(+AFW&c9pnpUVT1b{~AJ zncQ+0({7u#tUv^9U=I}mg6aT4a!^4~x@N9w2k-19PatumcnUVL+z5G&=j#@%hVvm0 zftnEx0!hP)UNvxK6`1a);htCs2S*7F5}I>K1S6Nh)yL995ZvFpEoAZK^qo4 ze+joM3U_E{fya@2rIW9mi0fF1TNM3B$f4#|@bN~PV delta 1575 zcmY*YYitx%6rMY?J9l3*r#tNSvD?Q^S3)bus}e#8(27_}ERUjz6sitsQ~}7ltPjf?14{UH#~sD@Bwr{D{O^kXoR(}8mi$f zSOg29wAWoIfl+$&IE6I9qb9Np+n0I0df`$%h0xUN8oz;<(IVng*`A|v9y>da6iT`)$mo1B6HUK3 z&EOJpL-I&4$Q_ONk34`uRT3Xq`_1&F~ zsF~mq@kn?qc&z53X1OBQ78M+aZOcfsS5H|#9LB^jE9*TLYq6lTIDW9@TZ;*`G+DeB zuc4>>Wm26^B&`3UTw#6}q??IYCGk0j|0}%O5$wh#IiqVOwK_M38_2Gb zO`r8ujd&_8seMMO4N0}`K2&WCujktCQ#i#6wl73dSSOEZs&XqhGMutY+gXnj@K0a;Ykv zsHbv@dRb8JICtA$lQ&p&av4jUCR%KckN2KNBGa&U4ewpPgW5$FYNr#mSXl;VCy0@X z|JkqFsYhh}9dw))U!3kic9a;ILj2pj+`D!=O%+*Z8$F@LUQXj)WO-$t>O_qGLD*oN z&h^ITrgCzm7#WAwSxCC43T$l!$uPf(P=`2nY=A$$F@egus=&JHn6UR+Nj59oPP07zs{t(SHcrx{HqW#%69hm9vVoCzm&yyg?B9Bz?JBVkjMmq?d86HgKyl? zPV$(ui&lAKlQMY=+z!qdPeXC2R$zTxdVLrDoUoMpRzK^A5-&UV5k`uOey*Q3bP;jB zlbW!=e&zZZojmxlHu3@s8MMH`cS4kMK`FL45>TCyS9H%tg^^E;~YfBFl@B KDJ*f67X1fbH^%M& diff --git a/src/app/api/analysis/retry/route.ts b/src/app/api/analysis/retry/route.ts new file mode 100644 index 0000000..9d4fa56 --- /dev/null +++ b/src/app/api/analysis/retry/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PrismaClient } from "@prisma/client"; +import { getJobQueue } from "@/lib/job-queue"; + +const prisma = new PrismaClient(); + +export async function POST(request: NextRequest) { + try { + const { jobIds } = await request.json(); + + if (!jobIds || !Array.isArray(jobIds)) { + return NextResponse.json({ error: "jobIds array is required" }, { status: 400 }); + } + + // Get failed jobs + const failedJobs = await prisma.analysisJob.findMany({ + where: { + id: { in: jobIds }, + status: "failed", + }, + include: { + post: true, + }, + }); + + if (failedJobs.length === 0) { + return NextResponse.json({ message: "No failed jobs found to retry" }); + } + + // Reset jobs to queued status + await prisma.analysisJob.updateMany({ + where: { + id: { in: failedJobs.map((job) => job.id) }, + }, + data: { + status: "queued", + error: null, + progress: 0, + startedAt: null, + completedAt: null, + analysisSummary: null, + linksFound: 0, + linksAboveThreshold: 0, + }, + }); + + // Start the job queue to process retried jobs + const queue = getJobQueue(); + queue.start(); + + return NextResponse.json({ + success: true, + retriedCount: failedJobs.length, + jobIds: failedJobs.map((job) => job.id), + }); + } catch (error) { + console.error("Error retrying jobs:", error); + return NextResponse.json({ error: "Failed to retry jobs" }, { status: 500 }); + } finally { + await prisma.$disconnect(); + } +} diff --git a/src/app/components/AnalysisTab.tsx b/src/app/components/AnalysisTab.tsx index 207c25a..cadd3bb 100644 --- a/src/app/components/AnalysisTab.tsx +++ b/src/app/components/AnalysisTab.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Check, X, ArrowRight, ArrowLeft } from "lucide-react"; +import { Check, X, ArrowRight, ArrowLeft, XCircle } from "lucide-react"; import LinkPreview from "./LinkPreview"; interface SemanticLink { @@ -33,12 +33,19 @@ interface AnalysisTabProps { postId: number; } +interface FailedJob { + id: string; + postName: string; + error: string; +} + export default function AnalysisTab({ postId }: AnalysisTabProps) { const [incomingLinks, setIncomingLinks] = useState([]); const [outgoingLinks, setOutgoingLinks] = useState([]); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState<"incoming" | "outgoing">("outgoing"); const [expandedAnalysis, setExpandedAnalysis] = useState(null); + const [failedJobs, setFailedJobs] = useState([]); useEffect(() => { fetchLinks(); @@ -47,15 +54,31 @@ export default function AnalysisTab({ postId }: AnalysisTabProps) { const fetchLinks = async () => { setLoading(true); try { - const response = await fetch(`/api/links?postId=${postId}`); - const data = await response.json(); + // Fetch semantic links + const linksResponse = await fetch(`/api/links?postId=${postId}`); + const linksData = await linksResponse.json(); // Separate incoming and outgoing links - const incoming = data.links.filter((link: SemanticLink) => link.targetPostId === postId); - const outgoing = data.links.filter((link: SemanticLink) => link.sourcePostId === postId); + const incoming = linksData.links.filter((link: SemanticLink) => link.targetPostId === postId); + const outgoing = linksData.links.filter((link: SemanticLink) => link.sourcePostId === postId); setIncomingLinks(incoming); setOutgoingLinks(outgoing); + + // Fetch failed jobs for this post + const queueResponse = await fetch(`/api/analysis/queue?postId=${postId}`); + const queueData = await queueResponse.json(); + + const failed = + queueData.jobs + ?.filter((job: any) => job.status === "failed") + .map((job: any) => ({ + id: job.id, + postName: job.postName, + error: job.error || "Analysis failed", + })) || []; + + setFailedJobs(failed); } catch (error) { console.error("Error fetching links:", error); } finally { @@ -346,6 +369,28 @@ export default function AnalysisTab({ postId }: AnalysisTabProps) {
)}
+ + {failedJobs.length > 0 && ( +
+
+ +

+ Failed Analyses ({failedJobs.length}) +

+
+
+ {failedJobs.map((job) => ( +
+

{job.postName}

+

{job.error}

+

+ Retry this analysis from the Queue Progress sidebar +

+
+ ))} +
+
+ )} ); } diff --git a/src/app/components/QueueProgress.tsx b/src/app/components/QueueProgress.tsx index 39c79ab..51695aa 100644 --- a/src/app/components/QueueProgress.tsx +++ b/src/app/components/QueueProgress.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Clock, CheckCircle, XCircle, Loader2 } from "lucide-react"; +import { Clock, CheckCircle, XCircle, Loader2, RefreshCw } from "lucide-react"; interface SemanticLink { id: string; @@ -58,6 +58,7 @@ export default function QueueProgress({ batchId, postId, onClose }: QueueProgres const [loading, setLoading] = useState(true); const [expandedSummaries, setExpandedSummaries] = useState>(new Set()); const [expandedClaudeOutputs, setExpandedClaudeOutputs] = useState>(new Set()); + const [retryingJobs, setRetryingJobs] = useState>(new Set()); useEffect(() => { fetchQueueStatus(); @@ -83,6 +84,25 @@ export default function QueueProgress({ batchId, postId, onClose }: QueueProgres } }; + const retryFailedJobs = async (jobIds: string[]) => { + setRetryingJobs(new Set(jobIds)); + try { + const response = await fetch("/api/analysis/retry", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jobIds }), + }); + + if (response.ok) { + await fetchQueueStatus(); + } + } catch (error) { + console.error("Error retrying jobs:", error); + } finally { + setRetryingJobs(new Set()); + } + }; + const getStatusIcon = (status: string) => { switch (status) { case "queued": @@ -232,7 +252,23 @@ export default function QueueProgress({ batchId, postId, onClose }: QueueProgres - {job.error &&

{job.error}

} + {job.error && ( +
+

{job.error}

+ {job.status === "failed" && ( + + )} +
+ )} {job.analysisSummary && (
)}
-
+
{pendingLinks.map((link) => ( -
-
-
-
- {direction === "incoming" ? ( - <> - From: - {link.sourcePost.name} - - This post - - ) : ( - <> - From: - This post - - {link.targetPost.name} - - )} +
+
+
+ {/* Link Direction Header */} +
+
+ From: + {direction === "incoming" ? ( + <> + + {link.sourcePost.name} + + + This post + + ) : ( + <> + This post + + + {link.targetPost.name} + + + )} +
-
- Link text: - - {link.linkText} - - - {link.confidence}% confidence - + + {/* Link Text and Confidence */} +
+
+ Link text: + + {link.linkText} + + + {link.confidence}% confidence + +
+ + {/* Reasoning */} +
+

{link.reasoning}

+
-

{link.reasoning}

+ + {/* Preview Section */} {link.contextBefore && link.contextAfter && ( - +
+

+ Preview: +

+ +
)} + + {/* Claude's Analysis */} {link.analysisOutput && ( -
+
{expandedAnalysis === link.id && ( -
-
+                            
+
                                 {link.analysisOutput}
                               
@@ -221,17 +252,19 @@ export default function AnalysisTab({ postId }: AnalysisTabProps) {
)}
-
+ + {/* Action Buttons */} +
@@ -274,24 +314,33 @@ export default function AnalysisTab({ postId }: AnalysisTabProps) { )} {rejectedLinks.length > 0 && ( -
-

- Rejected ({rejectedLinks.length}) +
+

+ Rejected + + ({rejectedLinks.length}) +

-
+
{rejectedLinks.map((link) => ( -
+
-
- {link.linkText} +
+ + + {link.linkText} + - + {direction === "incoming" ? link.sourcePost.name : link.targetPost.name}
@@ -315,43 +364,59 @@ export default function AnalysisTab({ postId }: AnalysisTabProps) { return (
-
- - + {/* Tab Navigation */} +
+
+ + +
+ {/* Tab Content */}
{activeTab === "incoming" && (
-

+

Links from other posts that could point to this post

{incomingLinks.length === 0 ? ( -

No incoming links found

+
+

No incoming links found

+
) : ( renderLinkGroup(incomingLinks, "incoming") )} @@ -360,9 +425,13 @@ export default function AnalysisTab({ postId }: AnalysisTabProps) { {activeTab === "outgoing" && (
-

Links from this post to other posts

+

+ Links from this post to other posts +

{outgoingLinks.length === 0 ? ( -

No outgoing links found

+
+

No outgoing links found

+
) : ( renderLinkGroup(outgoingLinks, "outgoing") )} diff --git a/src/app/components/EnhancedBlogViewer.tsx b/src/app/components/EnhancedBlogViewer.tsx index 1af9754..74b7cb8 100644 --- a/src/app/components/EnhancedBlogViewer.tsx +++ b/src/app/components/EnhancedBlogViewer.tsx @@ -740,8 +740,10 @@ export function EnhancedBlogViewer({ initialIndex = 0, initialSlug }: EnhancedBl
-

Semantic Link Analysis

-

+

+ Semantic Link Analysis +

+

Analyze this post to find potential links to and from other posts

diff --git a/src/app/components/LinkPreview.tsx b/src/app/components/LinkPreview.tsx index 3c2cf9e..8e76cee 100644 --- a/src/app/components/LinkPreview.tsx +++ b/src/app/components/LinkPreview.tsx @@ -14,23 +14,23 @@ export default function LinkPreview({ targetTitle, }: LinkPreviewProps) { return ( -
-

Preview:

-
- {contextBefore} - +
+
+ {contextBefore} + e.preventDefault()} > {linkText} - - {targetTitle} + + {targetTitle} + - {contextAfter} + {contextAfter}
); diff --git a/src/app/components/QueueProgress.tsx b/src/app/components/QueueProgress.tsx index 51695aa..c20178a 100644 --- a/src/app/components/QueueProgress.tsx +++ b/src/app/components/QueueProgress.tsx @@ -161,18 +161,18 @@ export default function QueueProgress({ batchId, postId, onClose }: QueueProgres {/* Overall Progress */}
-

Analysis Progress

+

Analysis Progress

{onClose && ( - )}
-
- Overall Progress - {progressPercentage}% +
+ Overall Progress + {progressPercentage}%
-

{stats.queued}

-

Queued

+

{stats.queued}

+

Queued

{stats.processing}

-

Processing

+

Processing

{stats.completed}

-

Completed

+

Completed

{stats.failed}

-

Failed

+

Failed

@@ -205,11 +205,13 @@ export default function QueueProgress({ batchId, postId, onClose }: QueueProgres {/* Job List */}
-

Analysis Jobs

+

Analysis Jobs

{jobs.length === 0 ? ( -
No analysis jobs found
+
+ No analysis jobs found +
) : ( jobs.map((job) => (
@@ -217,8 +219,8 @@ export default function QueueProgress({ batchId, postId, onClose }: QueueProgres
{getStatusIcon(job.status)}
-

{job.postName}

-

+

{job.postName}

+

{job.analysisType === "incoming" ? "Incoming links" : "Outgoing links"} {job.queuePosition !== null && job.status === "queued" && @@ -229,8 +231,8 @@ export default function QueueProgress({ batchId, postId, onClose }: QueueProgres

{job.status === "processing" && job.progress > 0 && (
-
- {Math.round(job.progress)}% +
+ {Math.round(job.progress)}%
{job.status} -

+

{formatDuration(job.startedAt, job.completedAt)}

@@ -281,13 +283,13 @@ export default function QueueProgress({ batchId, postId, onClose }: QueueProgres } setExpandedSummaries(newExpanded); }} - className="text-xs text-blue-600 hover:text-blue-800 font-medium" + className="text-xs text-blue-700 hover:text-blue-900 font-medium" > {expandedSummaries.has(job.id) ? "Hide" : "Show"} Analysis Summary {expandedSummaries.has(job.id) && (
-
+                        
                           {job.analysisSummary}
                         
@@ -306,7 +308,7 @@ export default function QueueProgress({ batchId, postId, onClose }: QueueProgres } setExpandedClaudeOutputs(newExpanded); }} - className="text-xs text-purple-600 hover:text-purple-800 font-medium" + className="text-xs text-purple-700 hover:text-purple-900 font-medium" > {expandedClaudeOutputs.has(job.id) ? "Hide" : "Show"} Claude's Raw Output @@ -318,14 +320,14 @@ export default function QueueProgress({ batchId, postId, onClose }: QueueProgres Link to "{link.targetPostName}": - + "{link.linkText}" ({link.confidence}% confidence)
{link.analysisOutput && (
Claude's Analysis:
-
+                                
                                   {link.analysisOutput}