ALwrity AI Blog Writer - Added Google Grounding UI Implementation
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { apiClient, aiApiClient, longRunningApiClient } from "../api/client";
|
||||
import { apiClient, aiApiClient, longRunningApiClient, pollingApiClient } from "../api/client";
|
||||
|
||||
export interface PersonaInfo {
|
||||
persona_id?: string;
|
||||
@@ -13,6 +13,8 @@ export interface ResearchSource {
|
||||
excerpt?: string;
|
||||
credibility_score?: number;
|
||||
published_at?: string;
|
||||
index?: number;
|
||||
source_type?: string;
|
||||
}
|
||||
|
||||
export interface BlogResearchRequest {
|
||||
@@ -25,14 +27,47 @@ export interface BlogResearchRequest {
|
||||
persona?: PersonaInfo;
|
||||
}
|
||||
|
||||
export interface GroundingChunk {
|
||||
title: string;
|
||||
url: string;
|
||||
confidence_score?: number;
|
||||
}
|
||||
|
||||
export interface GroundingSupport {
|
||||
confidence_scores: number[];
|
||||
grounding_chunk_indices: number[];
|
||||
segment_text: string;
|
||||
start_index?: number;
|
||||
end_index?: number;
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
citation_type: string;
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
text: string;
|
||||
source_indices: number[];
|
||||
reference: string;
|
||||
}
|
||||
|
||||
export interface GroundingMetadata {
|
||||
grounding_chunks: GroundingChunk[];
|
||||
grounding_supports: GroundingSupport[];
|
||||
citations: Citation[];
|
||||
search_entry_point?: string;
|
||||
web_search_queries: string[];
|
||||
}
|
||||
|
||||
export interface BlogResearchResponse {
|
||||
success: boolean;
|
||||
keywords?: string[];
|
||||
sources: ResearchSource[];
|
||||
keyword_analysis: Record<string, any>;
|
||||
competitor_analysis: Record<string, any>;
|
||||
suggested_angles: string[];
|
||||
search_widget?: string;
|
||||
search_queries?: string[];
|
||||
grounding_metadata?: GroundingMetadata;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
@@ -46,10 +81,81 @@ export interface BlogOutlineSection {
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export interface SourceMappingStats {
|
||||
total_sources_mapped: number;
|
||||
coverage_percentage: number;
|
||||
average_relevance_score: number;
|
||||
high_confidence_mappings: number;
|
||||
}
|
||||
|
||||
export interface GroundingInsights {
|
||||
confidence_analysis?: {
|
||||
average_confidence: number;
|
||||
confidence_distribution: { high: number; medium: number; low: number };
|
||||
high_confidence_sources_count: number;
|
||||
high_confidence_insights: string[];
|
||||
};
|
||||
authority_analysis?: {
|
||||
average_authority_score: number;
|
||||
authority_distribution: { high: number; medium: number; low: number };
|
||||
high_authority_sources: Array<{ title: string; url: string; score: number }>;
|
||||
};
|
||||
temporal_analysis?: {
|
||||
recency_score: number;
|
||||
trending_topics: string[];
|
||||
temporal_balance: string;
|
||||
};
|
||||
content_relationships?: {
|
||||
related_concepts: string[];
|
||||
content_gaps: string[];
|
||||
concept_coverage_score: number;
|
||||
gap_count: number;
|
||||
};
|
||||
citation_insights?: {
|
||||
total_citations: number;
|
||||
citation_types: Record<string, number>;
|
||||
citation_density: number;
|
||||
citation_quality_score: number;
|
||||
};
|
||||
search_intent_insights?: {
|
||||
primary_intent: string;
|
||||
user_questions: string[];
|
||||
intent_signals_count: number;
|
||||
};
|
||||
quality_indicators?: {
|
||||
overall_quality_score: number;
|
||||
quality_grade: string;
|
||||
key_quality_factors: {
|
||||
confidence: number;
|
||||
authority: number;
|
||||
citations: number;
|
||||
coverage: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface OptimizationResults {
|
||||
overall_quality_score: number;
|
||||
improvements_made: string[];
|
||||
optimization_focus: string;
|
||||
}
|
||||
|
||||
export interface ResearchCoverage {
|
||||
sources_utilized: number;
|
||||
content_gaps_identified: number;
|
||||
competitive_advantages: string[];
|
||||
}
|
||||
|
||||
export interface BlogOutlineResponse {
|
||||
success: boolean;
|
||||
title_options: string[];
|
||||
outline: BlogOutlineSection[];
|
||||
|
||||
// Additional metadata for enhanced UI
|
||||
source_mapping_stats?: SourceMappingStats;
|
||||
grounding_insights?: GroundingInsights;
|
||||
optimization_results?: OptimizationResults;
|
||||
research_coverage?: ResearchCoverage;
|
||||
}
|
||||
|
||||
export interface BlogSectionResponse {
|
||||
@@ -86,23 +192,48 @@ export interface BlogPublishResponse {
|
||||
post_id?: string;
|
||||
}
|
||||
|
||||
export interface TaskStatusResponse {
|
||||
task_id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
created_at: string;
|
||||
progress_messages: Array<{
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}>;
|
||||
result?: BlogResearchResponse;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const blogWriterApi = {
|
||||
async research(payload: BlogResearchRequest): Promise<BlogResearchResponse> {
|
||||
// Use the direct research endpoint for simplicity
|
||||
const { data } = await apiClient.post("/api/blog/research", payload);
|
||||
// Async polling endpoints
|
||||
async startResearch(payload: BlogResearchRequest): Promise<{task_id: string; status: string}> {
|
||||
const { data } = await apiClient.post("/api/blog/research/start", payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async pollResearchStatus(taskId: string): Promise<TaskStatusResponse> {
|
||||
console.log('Polling research status for task:', taskId);
|
||||
const { data } = await pollingApiClient.get(`/api/blog/research/status/${taskId}`);
|
||||
console.log('Research status response:', data);
|
||||
return data;
|
||||
},
|
||||
|
||||
async startOutlineGeneration(payload: { research: BlogResearchResponse; persona?: PersonaInfo; word_count?: number; custom_instructions?: string }): Promise<{task_id: string; status: string}> {
|
||||
const { data } = await aiApiClient.post("/api/blog/outline/start", payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async pollOutlineStatus(taskId: string): Promise<TaskStatusResponse> {
|
||||
const { data } = await pollingApiClient.get(`/api/blog/outline/status/${taskId}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
|
||||
async getContinuity(sectionId: string): Promise<{ section_id: string; continuity_metrics?: Record<string, number> }> {
|
||||
const { data } = await apiClient.get(`/api/blog/section/${encodeURIComponent(sectionId)}/continuity`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async generateOutline(payload: { research: BlogResearchResponse; persona?: PersonaInfo; word_count?: number; custom_instructions?: string }): Promise<BlogOutlineResponse> {
|
||||
// Use the direct outline generation endpoint
|
||||
const { data } = await apiClient.post("/api/blog/outline/generate", payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async refineOutline(payload: { outline: BlogOutlineSection[]; operation: string; section_id?: string; payload?: any }): Promise<BlogOutlineResponse> {
|
||||
const { data } = await apiClient.post("/api/blog/outline/refine", payload);
|
||||
|
||||
287
frontend/src/services/researchCache.ts
Normal file
287
frontend/src/services/researchCache.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Frontend Research Cache Service
|
||||
*
|
||||
* Provides persistent caching for research results to survive page refreshes,
|
||||
* browser restarts, and internet disconnections.
|
||||
*/
|
||||
|
||||
import { BlogResearchResponse } from './blogWriterApi';
|
||||
|
||||
interface CachedResearchEntry {
|
||||
result: BlogResearchResponse;
|
||||
keywords: string[];
|
||||
industry: string;
|
||||
target_audience: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
cache_key: string;
|
||||
}
|
||||
|
||||
interface CacheStats {
|
||||
total_entries: number;
|
||||
expired_entries: number;
|
||||
valid_entries: number;
|
||||
cache_size_mb: number;
|
||||
}
|
||||
|
||||
class ResearchCacheService {
|
||||
private readonly CACHE_KEY_PREFIX = 'alwrity_research_';
|
||||
private readonly CACHE_VERSION = '1.0';
|
||||
private readonly DEFAULT_TTL_HOURS = 24;
|
||||
private readonly MAX_CACHE_SIZE_MB = 50; // 50MB max cache size
|
||||
|
||||
/**
|
||||
* Generate a cache key for research parameters
|
||||
*/
|
||||
private generateCacheKey(keywords: string[], industry: string, target_audience: string): string {
|
||||
const normalized_keywords = keywords.map(k => k.toLowerCase().trim()).sort();
|
||||
const normalized_industry = (industry || 'general').toLowerCase().trim();
|
||||
const normalized_audience = (target_audience || 'general').toLowerCase().trim();
|
||||
|
||||
const cache_string = `${normalized_keywords.join(',')}|${normalized_industry}|${normalized_audience}`;
|
||||
const hash = this.simpleHash(cache_string);
|
||||
|
||||
return `${this.CACHE_KEY_PREFIX}${this.CACHE_VERSION}_${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple hash function for cache keys
|
||||
*/
|
||||
private simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cache entry is still valid
|
||||
*/
|
||||
private isEntryValid(entry: CachedResearchEntry): boolean {
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(entry.expires_at);
|
||||
return now < expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries
|
||||
*/
|
||||
private cleanupExpiredEntries(): void {
|
||||
const keys = Object.keys(localStorage);
|
||||
const expiredKeys: string[] = [];
|
||||
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
||||
try {
|
||||
const entry = JSON.parse(localStorage.getItem(key) || '{}') as CachedResearchEntry;
|
||||
if (!this.isEntryValid(entry)) {
|
||||
expiredKeys.push(key);
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid JSON, remove it
|
||||
expiredKeys.push(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expiredKeys.forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
console.log(`Removed expired cache entry: ${key}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache size in MB
|
||||
*/
|
||||
private getCacheSizeMB(): number {
|
||||
let totalSize = 0;
|
||||
const keys = Object.keys(localStorage);
|
||||
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
||||
const value = localStorage.getItem(key) || '';
|
||||
totalSize += key.length + value.length;
|
||||
}
|
||||
});
|
||||
|
||||
return totalSize / (1024 * 1024); // Convert to MB
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict oldest entries if cache is too large
|
||||
*/
|
||||
private evictOldestEntries(): void {
|
||||
const keys = Object.keys(localStorage);
|
||||
const cacheEntries: Array<{ key: string; created_at: string }> = [];
|
||||
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
||||
try {
|
||||
const entry = JSON.parse(localStorage.getItem(key) || '{}') as CachedResearchEntry;
|
||||
cacheEntries.push({
|
||||
key,
|
||||
created_at: entry.created_at
|
||||
});
|
||||
} catch (error) {
|
||||
// Invalid entry, remove it
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by creation time (oldest first)
|
||||
cacheEntries.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
|
||||
// Remove oldest entries until we're under the limit
|
||||
const maxSizeMB = this.MAX_CACHE_SIZE_MB;
|
||||
while (this.getCacheSizeMB() > maxSizeMB && cacheEntries.length > 0) {
|
||||
const oldest = cacheEntries.shift();
|
||||
if (oldest) {
|
||||
localStorage.removeItem(oldest.key);
|
||||
console.log(`Evicted oldest cache entry: ${oldest.key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached research result
|
||||
*/
|
||||
getCachedResult(keywords: string[], industry: string, target_audience: string): BlogResearchResponse | null {
|
||||
try {
|
||||
this.cleanupExpiredEntries();
|
||||
|
||||
const cacheKey = this.generateCacheKey(keywords, industry, target_audience);
|
||||
const cachedData = localStorage.getItem(cacheKey);
|
||||
|
||||
if (!cachedData) {
|
||||
console.log(`Cache miss for keywords: ${keywords.join(', ')}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = JSON.parse(cachedData) as CachedResearchEntry;
|
||||
|
||||
if (!this.isEntryValid(entry)) {
|
||||
localStorage.removeItem(cacheKey);
|
||||
console.log(`Cache entry expired for keywords: ${keywords.join(', ')}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Cache hit for keywords: ${keywords.join(', ')} (saved API call)`);
|
||||
return entry.result;
|
||||
} catch (error) {
|
||||
console.error('Error retrieving cached result:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a research result
|
||||
*/
|
||||
cacheResult(
|
||||
keywords: string[],
|
||||
industry: string,
|
||||
target_audience: string,
|
||||
result: BlogResearchResponse,
|
||||
ttlHours: number = this.DEFAULT_TTL_HOURS
|
||||
): void {
|
||||
try {
|
||||
this.cleanupExpiredEntries();
|
||||
this.evictOldestEntries();
|
||||
|
||||
const cacheKey = this.generateCacheKey(keywords, industry, target_audience);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + (ttlHours * 60 * 60 * 1000));
|
||||
|
||||
const entry: CachedResearchEntry = {
|
||||
result,
|
||||
keywords,
|
||||
industry,
|
||||
target_audience,
|
||||
created_at: now.toISOString(),
|
||||
expires_at: expiresAt.toISOString(),
|
||||
cache_key: cacheKey
|
||||
};
|
||||
|
||||
localStorage.setItem(cacheKey, JSON.stringify(entry));
|
||||
console.log(`Cached research result for keywords: ${keywords.join(', ')}`);
|
||||
} catch (error) {
|
||||
console.error('Error caching result:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getCacheStats(): CacheStats {
|
||||
this.cleanupExpiredEntries();
|
||||
|
||||
const keys = Object.keys(localStorage);
|
||||
let totalEntries = 0;
|
||||
let expiredEntries = 0;
|
||||
let validEntries = 0;
|
||||
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
||||
totalEntries++;
|
||||
try {
|
||||
const entry = JSON.parse(localStorage.getItem(key) || '{}') as CachedResearchEntry;
|
||||
if (this.isEntryValid(entry)) {
|
||||
validEntries++;
|
||||
} else {
|
||||
expiredEntries++;
|
||||
}
|
||||
} catch (error) {
|
||||
expiredEntries++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
total_entries: totalEntries,
|
||||
expired_entries: expiredEntries,
|
||||
valid_entries: validEntries,
|
||||
cache_size_mb: this.getCacheSizeMB()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached research results
|
||||
*/
|
||||
clearCache(): void {
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
console.log('Research cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached entries (for debugging)
|
||||
*/
|
||||
getAllCachedEntries(): CachedResearchEntry[] {
|
||||
const entries: CachedResearchEntry[] = [];
|
||||
const keys = Object.keys(localStorage);
|
||||
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
||||
try {
|
||||
const entry = JSON.parse(localStorage.getItem(key) || '{}') as CachedResearchEntry;
|
||||
entries.push(entry);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing cache entry ${key}:`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return entries.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const researchCache = new ResearchCacheService();
|
||||
export default researchCache;
|
||||
Reference in New Issue
Block a user