From 9b8a564e1c6b5e2b0367ea053bb4edc44b247ab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 17:49:43 +0000 Subject: [PATCH 1/7] feat(planning): add comprehensive questions app design document Planning document for a new "questions" app that enables users to: - Collect and manage open questions - Run AI-powered research on questions - Track sources and citations - Organize knowledge with collections and tags Includes database schema, API endpoints, UI components, and implementation roadmap based on existing chat/zitare patterns. https://claude.ai/code/session_01Rk3YVJCU3nM8uvVPghRz6r --- .claude/plans/questions-app.md | 1471 ++++++++++++++++++++++++++++++++ 1 file changed, 1471 insertions(+) create mode 100644 .claude/plans/questions-app.md diff --git a/.claude/plans/questions-app.md b/.claude/plans/questions-app.md new file mode 100644 index 000000000..e4b12c8d0 --- /dev/null +++ b/.claude/plans/questions-app.md @@ -0,0 +1,1471 @@ +# ManaCore Questions App - Design Document + +> **Status**: Entwurf - Zur Überprüfung +> **Erstellt**: 2025-01-28 +> **Autor**: Claude Code + +--- + +## 1. Übersicht & Ziele + +### 1.1 Was ist die Questions App? + +Eine intelligente Fragen-Management-App, die Nutzern hilft: +- **Offene Fragen sammeln** - Gedanken, Ideen und Wissenslücken festhalten +- **KI-gestützte Recherche** - Automatische Recherche zu den Fragen durchführen +- **Wissen organisieren** - Antworten, Quellen und Erkenntnisse strukturiert speichern +- **Lernfortschritt tracken** - Verstehen, welche Fragen beantwortet wurden + +### 1.2 Use Cases + +| Use Case | Beschreibung | +|----------|--------------| +| **Lern-Begleiter** | Student notiert Fragen während der Vorlesung, App recherchiert später | +| **Recherche-Tool** | Journalist sammelt Fragen zu einem Thema für Deep-Dive | +| **Wissens-Management** | Professional trackt offene Fragen und deren Beantwortung | +| **Curiosity Journal** | Privatperson sammelt alltägliche "Ich frage mich..."-Momente | + +### 1.3 Kernfeatures + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ QUESTIONS APP FEATURES │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [1] FRAGEN-MANAGEMENT │ +│ ├── Schnelles Erfassen von Fragen │ +│ ├── Kategorisierung & Tagging │ +│ ├── Priorisierung (dringend, wichtig, irgendwann) │ +│ └── Status-Tracking (offen, in Recherche, beantwortet) │ +│ │ +│ [2] KI-RECHERCHE │ +│ ├── Automatische Web-Recherche │ +│ ├── Zusammenfassung von Quellen │ +│ ├── Quellenangaben & Zitate │ +│ └── Follow-up Fragen generieren │ +│ │ +│ [3] WISSENS-ORGANISATION │ +│ ├── Antworten strukturiert speichern │ +│ ├── Quellen verwalten & kategorisieren │ +│ ├── Notizen & eigene Erkenntnisse hinzufügen │ +│ └── Export als Markdown/PDF │ +│ │ +│ [4] KOLLABORATION │ +│ ├── Fragen-Sammlungen teilen │ +│ ├── Team-Recherche │ +│ └── Diskussionen zu Fragen │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 1.4 Abgrenzung zu anderen Apps + +| App | Fokus | Unterschied zu Questions | +|-----|-------|-------------------------| +| **Chat** | Konversation mit AI | Questions: Strukturierte Fragen-DB statt Chatverläufe | +| **Todo** | Aufgaben | Questions: Wissenslücken, nicht Todos | +| **Zitare** | Zitate sammeln | Questions: Aktive Recherche, nicht passive Sammlung | + +--- + +## 2. Architektur + +### 2.1 Projekt-Struktur + +``` +apps/questions/ +├── apps/ +│ ├── backend/ # NestJS API (Port 3010) +│ │ ├── src/ +│ │ │ ├── question/ # Fragen CRUD +│ │ │ ├── research/ # KI-Recherche +│ │ │ ├── source/ # Quellen-Management +│ │ │ ├── collection/ # Fragen-Sammlungen +│ │ │ ├── answer/ # Antworten & Notizen +│ │ │ ├── db/ # Drizzle Schema & Migrations +│ │ │ └── health/ # Health-Checks +│ │ └── drizzle/ +│ ├── mobile/ # Expo React Native App +│ │ └── app/ +│ │ ├── (tabs)/ # Tab-Navigation +│ │ ├── question/ # Frage-Detail +│ │ └── research/ # Recherche-Ansicht +│ ├── web/ # SvelteKit Web App +│ │ └── src/ +│ │ ├── routes/ +│ │ │ ├── (app)/ # Authenticated Routes +│ │ │ └── (auth)/ # Login/Register +│ │ ├── lib/ +│ │ │ ├── components/ +│ │ │ └── stores/ +│ │ └── app.css +│ └── landing/ # Astro Landing Page +├── packages/ +│ └── shared/ # Shared Types & Utils +└── package.json +``` + +### 2.2 Technologie-Stack + +| Layer | Technologie | Details | +|-------|-------------|---------| +| **Backend** | NestJS 10 | REST API, Drizzle ORM, PostgreSQL | +| **Web** | SvelteKit 2 + Svelte 5 | Runes Mode, Tailwind CSS | +| **Mobile** | Expo SDK 54, React Native | NativeWind, Expo Router | +| **Landing** | Astro 5 | Static Site, Tailwind CSS | +| **Auth** | Mana Core Auth | JWT (EdDSA), Port 3001 | +| **AI** | Ollama + OpenRouter | Dual-Provider (wie Chat) | +| **Search** | Web Search API | Externe Such-API für Recherche | + +### 2.3 System-Architektur + +``` +┌─────────────┐ ┌─────────────┐ ┌────────────────┐ +│ Client │────>│ Questions │────>│ mana-core-auth │ +│ (Web/Mobile)│ │ Backend │ │ (port 3001) │ +└─────────────┘ │ (port 3010) │ └────────────────┘ + └──────┬──────┘ + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Ollama │ │OpenRouter│ │ Web │ + │ (local) │ │ (cloud) │ │ Search │ + └──────────┘ └──────────┘ └──────────┘ +``` + +--- + +## 3. Datenbank-Schema + +### 3.1 Core Tables + +```sql +-- ============================================ +-- QUESTIONS SCHEMA +-- ============================================ + +-- Fragen (Haupttabelle) +CREATE TABLE questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL, -- Mana Core Auth User ID + collection_id UUID REFERENCES collections(id) ON DELETE SET NULL, + + -- Inhalt + title TEXT NOT NULL, -- Kurze Frage + description TEXT, -- Ausführliche Beschreibung/Kontext + + -- Status & Priorisierung + status TEXT NOT NULL DEFAULT 'open', -- 'open', 'researching', 'answered', 'archived' + priority TEXT DEFAULT 'normal', -- 'low', 'normal', 'high', 'urgent' + + -- Kategorisierung + tags TEXT[] DEFAULT '{}', -- User-definierte Tags + category TEXT, -- Optionale Kategorie + + -- Research Config + research_depth TEXT DEFAULT 'quick', -- 'quick', 'standard', 'deep' + auto_research BOOLEAN DEFAULT false, -- Auto-Recherche aktiviert + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + answered_at TIMESTAMPTZ, -- Wann als beantwortet markiert + + -- Soft Delete + is_archived BOOLEAN DEFAULT false, + archived_at TIMESTAMPTZ +); + +CREATE INDEX questions_user_idx ON questions(user_id); +CREATE INDEX questions_status_idx ON questions(user_id, status); +CREATE INDEX questions_collection_idx ON questions(collection_id); +CREATE INDEX questions_tags_idx ON questions USING GIN(tags); + +-- Fragen-Sammlungen +CREATE TABLE collections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL, + + name TEXT NOT NULL, + description TEXT, + color TEXT DEFAULT '#6366f1', -- Indigo als Default + icon TEXT DEFAULT 'folder', + + -- Sharing + is_shared BOOLEAN DEFAULT false, + share_token TEXT UNIQUE, -- Für öffentliche Links + + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX collections_user_idx ON collections(user_id); + +-- KI-Recherche-Ergebnisse +CREATE TABLE research_results ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + question_id UUID NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + + -- Recherche-Metadaten + model_id TEXT NOT NULL, -- Verwendetes AI-Modell + provider TEXT NOT NULL, -- 'ollama' oder 'openrouter' + research_depth TEXT NOT NULL, -- 'quick', 'standard', 'deep' + + -- Ergebnis + summary TEXT NOT NULL, -- KI-generierte Zusammenfassung + key_points JSONB DEFAULT '[]', -- Wichtige Erkenntnisse als Array + follow_up_questions TEXT[] DEFAULT '{}', -- Vorgeschlagene Folgefragen + + -- Token-Tracking (wie bei Chat) + prompt_tokens INTEGER, + completion_tokens INTEGER, + estimated_cost DECIMAL(10, 6), + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT now(), + duration_ms INTEGER -- Dauer der Recherche +); + +CREATE INDEX research_results_question_idx ON research_results(question_id); + +-- Quellen (von Recherche gefunden) +CREATE TABLE sources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + research_id UUID NOT NULL REFERENCES research_results(id) ON DELETE CASCADE, + question_id UUID NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + + -- Quelle + url TEXT, + title TEXT NOT NULL, + source_type TEXT DEFAULT 'web', -- 'web', 'book', 'paper', 'video', 'manual' + + -- Inhalt + excerpt TEXT, -- Relevanter Ausschnitt + summary TEXT, -- KI-Zusammenfassung + + -- Bewertung + relevance_score REAL, -- 0.0 - 1.0 + user_rating INTEGER, -- 1-5 Sterne (optional) + + -- Metadaten + author TEXT, + published_at DATE, + accessed_at TIMESTAMPTZ DEFAULT now(), + + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX sources_research_idx ON sources(research_id); +CREATE INDEX sources_question_idx ON sources(question_id); + +-- Antworten & Notizen (User-generiert) +CREATE TABLE answers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + question_id UUID NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + + -- Inhalt + content TEXT NOT NULL, -- Markdown-formatierte Antwort + answer_type TEXT DEFAULT 'note', -- 'note', 'answer', 'insight' + + -- Optional: Basiert auf Recherche + research_id UUID REFERENCES research_results(id), + source_ids UUID[] DEFAULT '{}', -- Referenzierte Quellen + + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX answers_question_idx ON answers(question_id); +CREATE INDEX answers_user_idx ON answers(user_id); + +-- Frage-Verlauf (Änderungen tracken) +CREATE TABLE question_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + question_id UUID NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + + field_changed TEXT NOT NULL, -- 'status', 'title', 'description', etc. + old_value TEXT, + new_value TEXT, + changed_by TEXT NOT NULL, -- User ID + + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX question_history_question_idx ON question_history(question_id); +``` + +### 3.2 Drizzle Schema (TypeScript) + +```typescript +// db/schema/questions.schema.ts +import { pgTable, uuid, text, boolean, timestamp, real, integer, jsonb } from 'drizzle-orm/pg-core'; + +export const questions = pgTable('questions', { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + collectionId: uuid('collection_id').references(() => collections.id, { onDelete: 'set null' }), + + title: text('title').notNull(), + description: text('description'), + + status: text('status').notNull().default('open'), + priority: text('priority').default('normal'), + + tags: text('tags').array().default([]), + category: text('category'), + + researchDepth: text('research_depth').default('quick'), + autoResearch: boolean('auto_research').default(false), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + answeredAt: timestamp('answered_at', { withTimezone: true }), + + isArchived: boolean('is_archived').default(false), + archivedAt: timestamp('archived_at', { withTimezone: true }), +}); + +export const collections = pgTable('collections', { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + + name: text('name').notNull(), + description: text('description'), + color: text('color').default('#6366f1'), + icon: text('icon').default('folder'), + + isShared: boolean('is_shared').default(false), + shareToken: text('share_token').unique(), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +}); + +export const researchResults = pgTable('research_results', { + id: uuid('id').primaryKey().defaultRandom(), + questionId: uuid('question_id').notNull().references(() => questions.id, { onDelete: 'cascade' }), + + modelId: text('model_id').notNull(), + provider: text('provider').notNull(), + researchDepth: text('research_depth').notNull(), + + summary: text('summary').notNull(), + keyPoints: jsonb('key_points').default([]), + followUpQuestions: text('follow_up_questions').array().default([]), + + promptTokens: integer('prompt_tokens'), + completionTokens: integer('completion_tokens'), + estimatedCost: real('estimated_cost'), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + durationMs: integer('duration_ms'), +}); + +export const sources = pgTable('sources', { + id: uuid('id').primaryKey().defaultRandom(), + researchId: uuid('research_id').notNull().references(() => researchResults.id, { onDelete: 'cascade' }), + questionId: uuid('question_id').notNull().references(() => questions.id, { onDelete: 'cascade' }), + + url: text('url'), + title: text('title').notNull(), + sourceType: text('source_type').default('web'), + + excerpt: text('excerpt'), + summary: text('summary'), + + relevanceScore: real('relevance_score'), + userRating: integer('user_rating'), + + author: text('author'), + publishedAt: timestamp('published_at'), + accessedAt: timestamp('accessed_at', { withTimezone: true }).defaultNow(), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), +}); + +export const answers = pgTable('answers', { + id: uuid('id').primaryKey().defaultRandom(), + questionId: uuid('question_id').notNull().references(() => questions.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), + + content: text('content').notNull(), + answerType: text('answer_type').default('note'), + + researchId: uuid('research_id').references(() => researchResults.id), + sourceIds: uuid('source_ids').array().default([]), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +}); +``` + +--- + +## 4. API-Endpoints + +### 4.1 Questions Controller + +```typescript +// ============================================ +// QUESTIONS ENDPOINTS +// ============================================ + +// Liste aller Fragen +GET /api/v1/questions +Query: ?status=open&priority=high&collection_id=xxx&tags=tag1,tag2&search=text&limit=20&offset=0 +Response: { + questions: Question[], + pagination: { total, limit, offset } +} + +// Einzelne Frage mit Details +GET /api/v1/questions/:id +Response: { + question: Question, + researchResults: ResearchResult[], + sources: Source[], + answers: Answer[] +} + +// Frage erstellen +POST /api/v1/questions +Body: { + title: string, // Required + description?: string, + collectionId?: string, + tags?: string[], + category?: string, + priority?: 'low' | 'normal' | 'high' | 'urgent', + researchDepth?: 'quick' | 'standard' | 'deep', + autoResearch?: boolean // Direkt Recherche starten +} +Response: { question: Question } + +// Frage aktualisieren +PATCH /api/v1/questions/:id +Body: { + title?: string, + description?: string, + status?: 'open' | 'researching' | 'answered' | 'archived', + priority?: string, + tags?: string[], + category?: string, + collectionId?: string | null +} +Response: { question: Question } + +// Frage archivieren +PATCH /api/v1/questions/:id/archive +Response: { question: Question } + +// Frage wiederherstellen +PATCH /api/v1/questions/:id/unarchive +Response: { question: Question } + +// Frage als beantwortet markieren +PATCH /api/v1/questions/:id/mark-answered +Response: { question: Question } + +// Frage löschen +DELETE /api/v1/questions/:id +Response: { success: true } + +// Bulk-Operationen +POST /api/v1/questions/bulk +Body: { + action: 'archive' | 'delete' | 'move' | 'tag', + questionIds: string[], + data?: { collectionId?: string, tags?: string[] } +} +Response: { affected: number } +``` + +### 4.2 Research Controller + +```typescript +// ============================================ +// RESEARCH ENDPOINTS +// ============================================ + +// Recherche starten +POST /api/v1/research +Body: { + questionId: string, + depth?: 'quick' | 'standard' | 'deep', // Default: question.researchDepth + modelId?: string, // Optional: Override default model + additionalContext?: string // Extra Kontext für bessere Ergebnisse +} +Response: { + research: ResearchResult, + sources: Source[], + creditsUsed: number +} + +// Recherche-Status prüfen (für Polling bei async) +GET /api/v1/research/:id/status +Response: { + status: 'pending' | 'processing' | 'completed' | 'failed', + progress?: number, // 0-100 + research?: ResearchResult // Wenn completed +} + +// Recherche-Ergebnisse abrufen +GET /api/v1/research/question/:questionId +Response: { + results: ResearchResult[], + sources: Source[] +} + +// Folgefrage aus Recherche erstellen +POST /api/v1/research/:id/follow-up +Body: { + questionText: string // Vorgeschlagene oder neue Folgefrage +} +Response: { question: Question } + +// Verfügbare Modelle für Recherche +GET /api/v1/research/models +Response: { + models: Array<{ + id: string, + name: string, + provider: 'ollama' | 'openrouter', + description: string, + costPerToken?: number, + recommended: boolean + }> +} +``` + +### 4.3 Sources Controller + +```typescript +// ============================================ +// SOURCES ENDPOINTS +// ============================================ + +// Quellen einer Frage +GET /api/v1/sources/question/:questionId +Query: ?type=web&sort=relevance +Response: { sources: Source[] } + +// Manuelle Quelle hinzufügen +POST /api/v1/sources +Body: { + questionId: string, + url?: string, + title: string, + sourceType: 'web' | 'book' | 'paper' | 'video' | 'manual', + excerpt?: string, + author?: string, + publishedAt?: string +} +Response: { source: Source } + +// Quelle bewerten +PATCH /api/v1/sources/:id/rate +Body: { rating: 1 | 2 | 3 | 4 | 5 } +Response: { source: Source } + +// Quelle entfernen +DELETE /api/v1/sources/:id +Response: { success: true } +``` + +### 4.4 Answers Controller + +```typescript +// ============================================ +// ANSWERS ENDPOINTS +// ============================================ + +// Antworten einer Frage +GET /api/v1/answers/question/:questionId +Response: { answers: Answer[] } + +// Antwort/Notiz erstellen +POST /api/v1/answers +Body: { + questionId: string, + content: string, // Markdown + answerType: 'note' | 'answer' | 'insight', + researchId?: string, // Basiert auf Recherche + sourceIds?: string[] // Referenzierte Quellen +} +Response: { answer: Answer } + +// Antwort aktualisieren +PATCH /api/v1/answers/:id +Body: { + content?: string, + sourceIds?: string[] +} +Response: { answer: Answer } + +// Antwort löschen +DELETE /api/v1/answers/:id +Response: { success: true } +``` + +### 4.5 Collections Controller + +```typescript +// ============================================ +// COLLECTIONS ENDPOINTS +// ============================================ + +// Alle Sammlungen +GET /api/v1/collections +Response: { + collections: Array +} + +// Sammlung mit Fragen +GET /api/v1/collections/:id +Query: ?includeQuestions=true +Response: { + collection: Collection, + questions?: Question[] +} + +// Sammlung erstellen +POST /api/v1/collections +Body: { + name: string, + description?: string, + color?: string, + icon?: string +} +Response: { collection: Collection } + +// Sammlung aktualisieren +PATCH /api/v1/collections/:id +Body: { + name?: string, + description?: string, + color?: string, + icon?: string +} +Response: { collection: Collection } + +// Sammlung teilen +POST /api/v1/collections/:id/share +Response: { + collection: Collection, + shareUrl: string +} + +// Teilen beenden +DELETE /api/v1/collections/:id/share +Response: { collection: Collection } + +// Öffentliche Sammlung abrufen +GET /api/v1/collections/shared/:token +Response: { + collection: Collection, + questions: Question[] +} + +// Sammlung löschen +DELETE /api/v1/collections/:id +Response: { success: true } +``` + +### 4.6 Export Controller + +```typescript +// ============================================ +// EXPORT ENDPOINTS +// ============================================ + +// Frage als Markdown exportieren +GET /api/v1/export/question/:id/markdown +Response: text/markdown + +// Frage als PDF exportieren +GET /api/v1/export/question/:id/pdf +Response: application/pdf + +// Sammlung exportieren +GET /api/v1/export/collection/:id +Query: ?format=markdown|pdf|json +Response: Entsprechendes Format + +// Bibliography exportieren (alle Quellen einer Frage) +GET /api/v1/export/question/:id/bibliography +Query: ?style=apa|mla|chicago|bibtex +Response: text/plain +``` + +--- + +## 5. KI-Recherche-System + +### 5.1 Recherche-Tiefen + +| Depth | Beschreibung | Dauer | Credits | Use Case | +|-------|--------------|-------|---------|----------| +| **quick** | Schnelle Antwort, 1-2 Quellen | ~10s | 5 | Faktenfragen, Quick Check | +| **standard** | Ausführlich, 3-5 Quellen | ~30s | 15 | Normale Recherche | +| **deep** | Umfassend, 5-10 Quellen | ~60s | 30 | Tiefgehende Analyse | + +### 5.2 Recherche-Ablauf + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ RESEARCH PIPELINE │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [1] QUERY EXPANSION │ +│ │ Frage → KI optimiert Suchbegriffe │ +│ │ "Was ist Quantum Computing?" → │ +│ │ ["quantum computing basics", "qubits explanation", │ +│ │ "quantum vs classical computing"] │ +│ ▼ │ +│ [2] WEB SEARCH │ +│ │ Suchbegriffe → Web Search API │ +│ │ → URLs + Snippets zurück │ +│ ▼ │ +│ [3] CONTENT EXTRACTION │ +│ │ URLs → Fetch & Parse HTML │ +│ │ → Clean Text extraktion │ +│ ▼ │ +│ [4] RELEVANCE SCORING │ +│ │ KI bewertet Relevanz jeder Quelle │ +│ │ → Top N Quellen auswählen │ +│ ▼ │ +│ [5] SYNTHESIS │ +│ │ KI fasst alle Quellen zusammen │ +│ │ → Strukturierte Antwort mit: │ +│ │ - Summary (Hauptantwort) │ +│ │ - Key Points (Bullet Points) │ +│ │ - Follow-up Questions │ +│ ▼ │ +│ [6] CITATION GENERATION │ +│ │ Quellen → Formatierte Zitate │ +│ │ → Zuordnung zu Aussagen │ +│ ▼ │ +│ [RESULT] │ +│ ResearchResult + Sources │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.3 System-Prompts + +```typescript +// Query Expansion Prompt +const QUERY_EXPANSION_PROMPT = ` +Du bist ein Such-Experte. Gegeben ist eine Frage, generiere 3-5 optimierte Suchbegriffe. + +Frage: {question} +Kontext: {description} + +Generiere Suchbegriffe die: +- Die Kernfrage abdecken +- Verschiedene Aspekte beleuchten +- Für Web-Suche optimiert sind + +Ausgabe als JSON-Array: ["term1", "term2", ...] +`; + +// Synthesis Prompt +const SYNTHESIS_PROMPT = ` +Du bist ein Recherche-Assistent. Analysiere die folgenden Quellen und beantworte die Frage. + +Frage: {question} +Kontext: {description} + +Quellen: +{sources} + +Erstelle eine strukturierte Antwort mit: +1. **Summary**: Hauptantwort in 2-3 Absätzen +2. **Key Points**: 3-5 wichtige Erkenntnisse als Bullet Points +3. **Follow-up Questions**: 2-3 weiterführende Fragen + +Zitiere Quellen mit [1], [2], etc. +Ausgabe als JSON. +`; +``` + +### 5.4 Web Search Integration + +```typescript +// Research Service +@Injectable() +export class ResearchService { + constructor( + private readonly searchService: WebSearchService, + private readonly aiService: AiCompletionService, + private readonly contentExtractor: ContentExtractorService, + ) {} + + async research(questionId: string, depth: ResearchDepth): Promise { + const question = await this.getQuestion(questionId); + + // 1. Query Expansion + const searchQueries = await this.aiService.expandQuery(question); + + // 2. Web Search + const searchResults = await Promise.all( + searchQueries.map(q => this.searchService.search(q, { limit: this.getLimit(depth) })) + ); + + // 3. Content Extraction + const contents = await Promise.all( + searchResults.flat().map(r => this.contentExtractor.extract(r.url)) + ); + + // 4. Relevance Scoring + const scoredSources = await this.aiService.scoreRelevance(question, contents); + const topSources = scoredSources.slice(0, this.getSourceLimit(depth)); + + // 5. Synthesis + const synthesis = await this.aiService.synthesize(question, topSources); + + // 6. Save Results + return this.saveResults(questionId, synthesis, topSources); + } + + private getLimit(depth: ResearchDepth): number { + return { quick: 3, standard: 6, deep: 10 }[depth]; + } + + private getSourceLimit(depth: ResearchDepth): number { + return { quick: 2, standard: 5, deep: 8 }[depth]; + } +} +``` + +--- + +## 6. Web App (SvelteKit) + +### 6.1 Routen-Struktur + +``` +src/routes/ +├── (auth)/ +│ ├── login/+page.svelte +│ └── register/+page.svelte +├── (app)/ +│ ├── +layout.svelte # App Shell mit Sidebar +│ ├── +page.svelte # Dashboard / Alle Fragen +│ ├── questions/ +│ │ ├── +page.svelte # Fragen-Liste +│ │ ├── [id]/ +│ │ │ ├── +page.svelte # Frage-Detail +│ │ │ └── +page.server.ts # SSR Data Loading +│ │ └── new/+page.svelte # Neue Frage +│ ├── collections/ +│ │ ├── +page.svelte # Sammlungen-Liste +│ │ └── [id]/+page.svelte # Sammlung-Detail +│ ├── research/ +│ │ └── [id]/+page.svelte # Recherche-Ergebnis-Ansicht +│ └── settings/+page.svelte # Einstellungen +└── health/+server.ts # Health Endpoint +``` + +### 6.2 Komponenten-Übersicht + +``` +src/lib/components/ +├── questions/ +│ ├── QuestionCard.svelte # Frage in Liste +│ ├── QuestionForm.svelte # Erstellen/Bearbeiten +│ ├── QuestionDetail.svelte # Vollständige Ansicht +│ ├── QuestionFilters.svelte # Filter & Suche +│ └── QuestionStatus.svelte # Status-Badge +├── research/ +│ ├── ResearchPanel.svelte # Recherche-Ergebnisse +│ ├── ResearchProgress.svelte # Fortschritts-Anzeige +│ ├── SourceCard.svelte # Einzelne Quelle +│ └── SourceList.svelte # Quellen-Liste +├── answers/ +│ ├── AnswerEditor.svelte # Markdown-Editor +│ ├── AnswerCard.svelte # Antwort-Anzeige +│ └── AnswerList.svelte # Antworten-Liste +├── collections/ +│ ├── CollectionCard.svelte # Sammlung in Liste +│ ├── CollectionForm.svelte # Erstellen/Bearbeiten +│ └── CollectionPicker.svelte # Auswahl-Dropdown +├── common/ +│ ├── TagInput.svelte # Tag-Eingabe +│ ├── PriorityBadge.svelte # Prioritäts-Anzeige +│ ├── EmptyState.svelte # Leerer Zustand +│ └── ConfirmDialog.svelte # Bestätigungs-Dialog +└── layout/ + ├── Sidebar.svelte # Navigation + ├── Header.svelte # Top-Bar + └── QuickCapture.svelte # Schnell-Eingabe (global) +``` + +### 6.3 Haupt-Ansichten + +#### Dashboard (Fragen-Liste) + +```svelte + + + +
+ + + + + + + +
+ {#if questions.length === 0} + + {:else} +
+ {#each questions as question (question.id)} + + {/each} +
+ {/if} +
+
+``` + +#### Frage-Detail + +```svelte + + + +
+ +
+
+
+

{question.title}

+
+ + + {#each question.tags as tag} + {tag} + {/each} +
+
+ +
+ {#if question.status !== 'answered'} + + + + startResearch('quick')}> + Quick (~10s, 5 Credits) + + startResearch('standard')}> + Standard (~30s, 15 Credits) + + startResearch('deep')}> + Deep (~60s, 30 Credits) + + + + + + {/if} +
+
+ + {#if question.description} +

{question.description}

+ {/if} +
+ + + + + + Recherche ({researchResults.length}) + + + Antworten ({answers.length}) + + + Quellen ({sources.length}) + + + + + createFollowUp(text)} + /> + + + + + + + + + + +
+``` + +### 6.4 Stores (Svelte 5 Runes) + +```typescript +// src/lib/stores/questions.svelte.ts +import { questionsApi } from '$lib/api/questions'; + +interface QuestionsState { + questions: Question[]; + loading: boolean; + error: string | null; +} + +function createQuestionsStore() { + let state = $state({ + questions: [], + loading: false, + error: null + }); + + return { + get questions() { return state.questions; }, + get loading() { return state.loading; }, + get error() { return state.error; }, + + // Filtered questions + filtered(filters: QuestionFilters) { + return state.questions.filter(q => { + if (filters.status && q.status !== filters.status) return false; + if (filters.priority && q.priority !== filters.priority) return false; + if (filters.collectionId && q.collectionId !== filters.collectionId) return false; + if (filters.search && !q.title.toLowerCase().includes(filters.search.toLowerCase())) return false; + if (filters.tags.length && !filters.tags.some(t => q.tags.includes(t))) return false; + return true; + }); + }, + + // Load questions + async load() { + state.loading = true; + state.error = null; + try { + const result = await questionsApi.getAll(); + state.questions = result.questions; + } catch (e) { + state.error = e.message; + } finally { + state.loading = false; + } + }, + + // Create question + async create(data: CreateQuestionDto) { + const { question } = await questionsApi.create(data); + state.questions = [question, ...state.questions]; + return question; + }, + + // Update question + async update(id: string, data: UpdateQuestionDto) { + const { question } = await questionsApi.update(id, data); + state.questions = state.questions.map(q => + q.id === id ? question : q + ); + return question; + }, + + // Delete question + async delete(id: string) { + await questionsApi.delete(id); + state.questions = state.questions.filter(q => q.id !== id); + } + }; +} + +export const questionsStore = createQuestionsStore(); +``` + +--- + +## 7. Mobile App (Expo) + +### 7.1 Screen-Struktur + +``` +app/ +├── (tabs)/ +│ ├── _layout.tsx # Tab Navigator +│ ├── index.tsx # Fragen-Liste (Home) +│ ├── collections.tsx # Sammlungen +│ └── settings.tsx # Einstellungen +├── question/ +│ ├── [id].tsx # Frage-Detail +│ └── new.tsx # Neue Frage +├── research/ +│ └── [id].tsx # Recherche-Ergebnis +└── _layout.tsx # Root Layout +``` + +### 7.2 Haupt-Screens + +```tsx +// app/(tabs)/index.tsx +import { View, FlatList, RefreshControl } from 'react-native'; +import { useQuestions } from '@/hooks/useQuestions'; +import { QuestionCard } from '@/components/QuestionCard'; +import { QuickCaptureInput } from '@/components/QuickCaptureInput'; +import { FilterBar } from '@/components/FilterBar'; + +export default function QuestionsScreen() { + const { questions, loading, refetch, createQuestion } = useQuestions(); + const [filters, setFilters] = useState({ status: 'open' }); + + const filteredQuestions = useMemo(() => + questions.filter(q => !filters.status || q.status === filters.status), + [questions, filters] + ); + + return ( + + createQuestion({ title })} + placeholder="Neue Frage eingeben..." + /> + + + + ( + router.push(`/question/${item.id}`)} + /> + )} + keyExtractor={(item) => item.id} + refreshControl={ + + } + contentContainerClassName="p-4 gap-3" + /> + + ); +} +``` + +### 7.3 Quick Capture Widget + +```tsx +// components/QuickCaptureInput.tsx +import { View, TextInput, Pressable } from 'react-native'; +import { Send, Sparkles } from 'lucide-react-native'; +import { useState } from 'react'; + +interface Props { + onSubmit: (title: string, options?: { autoResearch?: boolean }) => void; + placeholder?: string; +} + +export function QuickCaptureInput({ onSubmit, placeholder }: Props) { + const [text, setText] = useState(''); + const [autoResearch, setAutoResearch] = useState(false); + + const handleSubmit = () => { + if (!text.trim()) return; + onSubmit(text.trim(), { autoResearch }); + setText(''); + }; + + return ( + + + + setAutoResearch(!autoResearch)} + > + + + + + + + + ); +} +``` + +--- + +## 8. Implementierungs-Roadmap + +### Phase 1: Foundation (1-2 Wochen) + +``` +[ ] Projekt-Struktur erstellen + [ ] apps/questions/apps/backend (NestJS) + [ ] apps/questions/apps/web (SvelteKit) + [ ] apps/questions/apps/mobile (Expo) + [ ] apps/questions/packages/shared + +[ ] Datenbank-Schema + [ ] Drizzle Schema definieren + [ ] Migrations erstellen + [ ] Seed-Daten vorbereiten + +[ ] Backend: Core CRUD + [ ] QuestionModule (CRUD) + [ ] CollectionModule (CRUD) + [ ] HealthModule + [ ] Auth-Integration (@manacore/shared-nestjs-auth) + +[ ] Web: Basis-UI + [ ] Layout mit Sidebar + [ ] Fragen-Liste + [ ] Frage erstellen/bearbeiten + [ ] Auth-Flow +``` + +### Phase 2: Research Engine (1-2 Wochen) + +``` +[ ] Backend: Research-System + [ ] ResearchModule + [ ] WebSearchService (externe API-Integration) + [ ] ContentExtractorService + [ ] AiCompletionService (Ollama + OpenRouter) + [ ] SourceModule + +[ ] Research-Pipeline + [ ] Query Expansion + [ ] Web Search + [ ] Content Extraction + [ ] Relevance Scoring + [ ] Synthesis + [ ] Citation Generation + +[ ] Web: Research-UI + [ ] Research starten (Button + Depth-Auswahl) + [ ] Progress-Anzeige + [ ] Ergebnis-Darstellung + [ ] Quellen-Liste +``` + +### Phase 3: Answers & Notes (1 Woche) + +``` +[ ] Backend: Answers + [ ] AnswerModule (CRUD) + [ ] Markdown-Verarbeitung + +[ ] Web: Answer-UI + [ ] Markdown-Editor + [ ] Antwort-Liste + [ ] Quellen-Referenzierung +``` + +### Phase 4: Mobile App (1-2 Wochen) + +``` +[ ] Expo Setup + [ ] Navigation + [ ] Auth-Integration + [ ] API-Client + +[ ] Screens + [ ] Fragen-Liste + [ ] Quick Capture + [ ] Frage-Detail + [ ] Research-Anzeige + [ ] Sammlungen +``` + +### Phase 5: Advanced Features (1 Woche) + +``` +[ ] Export + [ ] Markdown-Export + [ ] PDF-Export + [ ] Bibliography-Export + +[ ] Sharing + [ ] Öffentliche Sammlungen + [ ] Share-Links + +[ ] Bulk-Operationen + [ ] Multi-Select + [ ] Bulk-Archive + [ ] Bulk-Move +``` + +### Phase 6: Polish & Launch (1 Woche) + +``` +[ ] Landing Page (Astro) +[ ] Performance-Optimierung +[ ] Error-Handling +[ ] Loading-States +[ ] Dokumentation +[ ] Deploy-Konfiguration +``` + +--- + +## 9. Technische Entscheidungen + +### 9.1 Web Search API + +**Optionen:** + +| Service | Preis | Qualität | Integration | +|---------|-------|----------|-------------| +| **SearXNG (self-hosted)** | Kostenlos | Gut | Docker-Container | +| **Brave Search API** | $5/1000 queries | Sehr gut | REST API | +| **Google Custom Search** | $5/1000 queries | Excellent | REST API | +| **Tavily AI** | $5/1000 queries | Gut für AI | REST API | + +**Empfehlung:** SearXNG für Development, Brave/Tavily für Production + +### 9.2 Content Extraction + +```typescript +// Verwendung von @extractus/article-extractor +import { extract } from '@extractus/article-extractor'; + +async function extractContent(url: string) { + const article = await extract(url); + return { + title: article?.title, + content: article?.content, + author: article?.author, + published: article?.published + }; +} +``` + +### 9.3 Credit-Verbrauch + +| Aktion | Credits | +|--------|---------| +| Quick Research | 5 | +| Standard Research | 15 | +| Deep Research | 30 | +| Manual Source Add | 0 | +| Export | 0 | + +--- + +## 10. Offene Fragen + +1. **Web Search API**: Welchen Dienst verwenden? SearXNG vs. Brave vs. Tavily? +2. **Offline-Support**: Sollen Fragen offline erfasst werden können? +3. **Collaboration**: Sollen Teams gemeinsam an Fragen arbeiten können? +4. **Voice Input**: Soll Spracheingabe (STT) integriert werden? +5. **Auto-Research Schedule**: Sollen Fragen automatisch recherchiert werden (z.B. täglich)? + +--- + +## 11. Metriken & KPIs + +| Metrik | Ziel | Tracking | +|--------|------|----------| +| Fragen pro User/Woche | 10+ | Weekly | +| Recherchen pro Frage | 1.5 | Weekly | +| Antwort-Rate | 50% | Weekly | +| Research Satisfaction (Rating) | 4.0/5.0 | Per Research | +| Time to Answer | < 1 Woche | Weekly | + +--- + +*Dokument-Status: Entwurf - Zur Überprüfung* From 590529c547565be0bf55b1e008926f0ce5dc8a5b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 20:27:36 +0000 Subject: [PATCH 2/7] feat(planning): add mana-search microservice design document Central search microservice for all ManaCore apps featuring: - Self-hosted SearXNG meta-search engine (246 search engines) - Content extraction with markdown conversion - Redis caching layer - Unified API for search + extraction - Shared client package for consumers Designed to serve questions app, chat app, and future apps requiring web search and content grounding capabilities. https://claude.ai/code/session_01Rk3YVJCU3nM8uvVPghRz6r --- .claude/plans/mana-search-service.md | 1597 ++++++++++++++++++++++++++ 1 file changed, 1597 insertions(+) create mode 100644 .claude/plans/mana-search-service.md diff --git a/.claude/plans/mana-search-service.md b/.claude/plans/mana-search-service.md new file mode 100644 index 000000000..39b58d5d7 --- /dev/null +++ b/.claude/plans/mana-search-service.md @@ -0,0 +1,1597 @@ +# Mana Search Service - Design Document + +> **Status**: Entwurf - Zur Überprüfung +> **Erstellt**: 2025-01-28 +> **Autor**: Claude Code +> **Abhängigkeit**: questions-app.md + +--- + +## 1. Übersicht + +### 1.1 Was ist der Mana Search Service? + +Ein zentraler Microservice, der **Web-Suche und Content-Extraktion** für alle ManaCore Apps bereitstellt. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ MANA SEARCH SERVICE │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Questions │ │ Chat │ │ Future │ │ +│ │ App │ │ App │ │ Apps │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └────────────┬────┴────────────────┘ │ +│ ▼ │ +│ ┌────────────────────────┐ │ +│ │ mana-search (3021) │ ◄── NestJS API │ +│ │ ┌────────────────┐ │ │ +│ │ │ Search API │ │ ◄── Unified Interface │ +│ │ │ Extract API │ │ ◄── Content Extraction │ +│ │ │ Cache Layer │ │ ◄── Redis Caching │ +│ │ └───────┬────────┘ │ │ +│ └───────────┼────────────┘ │ +│ ▼ │ +│ ┌────────────────────────┐ │ +│ │ SearXNG (internal) │ ◄── Meta-Suchmaschine │ +│ │ Port 8080 (Docker) │ │ +│ └───────────┬────────────┘ │ +│ ▼ │ +│ ┌────────────────────────────────────────┐ │ +│ │ Google │ Bing │ DuckDuckGo │ Brave... │ │ +│ └────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 Warum ein zentraler Service? + +| Vorteil | Beschreibung | +|---------|--------------| +| **Einmalige Konfiguration** | SearXNG nur einmal aufsetzen und optimieren | +| **Caching** | Ergebnisse über alle Apps hinweg cachen | +| **Rate Limiting** | Zentrale Kontrolle über Anfragen an Suchmaschinen | +| **Content Extraction** | Einheitliche Extraktion, einmal optimiert | +| **Monitoring** | Zentrale Metriken für Suche | +| **Wartung** | Updates nur an einer Stelle | +| **Kostenersparnis** | Falls später paid APIs hinzukommen → zentral | + +### 1.3 Consumer Apps + +| App | Use Case | +|-----|----------| +| **Questions** | Recherche zu Nutzer-Fragen | +| **Chat** | Web-Grounding für AI-Antworten | +| **Project Doc Bot** | Technische Dokumentations-Suche | +| **Future Apps** | Jede App mit Recherche-Bedarf | + +--- + +## 2. Architektur + +### 2.1 Service-Struktur + +``` +services/mana-search/ +├── src/ +│ ├── main.ts # NestJS Entry Point +│ ├── app.module.ts # Root Module +│ │ +│ ├── config/ +│ │ └── configuration.ts # Environment Config +│ │ +│ ├── search/ +│ │ ├── search.module.ts +│ │ ├── search.controller.ts # /api/v1/search +│ │ ├── search.service.ts # Business Logic +│ │ ├── dto/ +│ │ │ ├── search-request.dto.ts +│ │ │ └── search-response.dto.ts +│ │ └── providers/ +│ │ ├── search-provider.interface.ts +│ │ ├── searxng.provider.ts # SearXNG Implementation +│ │ └── brave.provider.ts # Fallback (optional) +│ │ +│ ├── extract/ +│ │ ├── extract.module.ts +│ │ ├── extract.controller.ts # /api/v1/extract +│ │ ├── extract.service.ts # Content Extraction +│ │ └── extractors/ +│ │ ├── article.extractor.ts # News/Blog Articles +│ │ ├── docs.extractor.ts # Documentation Sites +│ │ └── generic.extractor.ts # Fallback +│ │ +│ ├── cache/ +│ │ ├── cache.module.ts +│ │ └── cache.service.ts # Redis Caching +│ │ +│ ├── health/ +│ │ ├── health.module.ts +│ │ └── health.controller.ts # /health +│ │ +│ └── metrics/ +│ ├── metrics.module.ts +│ └── metrics.service.ts # Prometheus Metrics +│ +├── searxng/ +│ ├── Dockerfile # SearXNG Container +│ ├── settings.yml # Engine Config +│ └── limiter.toml # Rate Limits +│ +├── Dockerfile # NestJS Service +├── docker-compose.yml # Local Development +├── package.json +├── tsconfig.json +├── nest-cli.json +└── CLAUDE.md +``` + +### 2.2 Docker Compose Setup + +```yaml +# services/mana-search/docker-compose.yml +version: '3.8' + +services: + # NestJS API Service + mana-search: + build: + context: . + dockerfile: Dockerfile + ports: + - "3021:3021" + environment: + NODE_ENV: development + PORT: 3021 + SEARXNG_URL: http://searxng:8080 + REDIS_HOST: redis + REDIS_PORT: 6379 + CACHE_TTL: 3600 # 1 hour + depends_on: + - searxng + - redis + networks: + - manacore-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3021/health"] + interval: 30s + timeout: 10s + retries: 3 + + # SearXNG Meta-Suchmaschine + searxng: + image: searxng/searxng:latest + volumes: + - ./searxng/settings.yml:/etc/searxng/settings.yml:ro + - ./searxng/limiter.toml:/etc/searxng/limiter.toml:ro + environment: + SEARXNG_BASE_URL: http://localhost:8080 + SEARXNG_SECRET: ${SEARXNG_SECRET:-change-me-in-production} + networks: + - manacore-network + # Kein Port-Mapping - nur intern erreichbar + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"] + interval: 30s + timeout: 10s + retries: 3 + + # Redis Cache + redis: + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redis-data:/data + networks: + - manacore-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + +volumes: + redis-data: + +networks: + manacore-network: + external: true +``` + +### 2.3 SearXNG Konfiguration + +```yaml +# services/mana-search/searxng/settings.yml +use_default_settings: true + +general: + instance_name: "ManaCore Search" + debug: false + privacypolicy_url: false + donation_url: false + contact_url: false + enable_metrics: true + +search: + safe_search: 0 + autocomplete: "google" + default_lang: "de-DE" + formats: + - html + - json # Wichtig für API-Nutzung + +server: + secret_key: ${SEARXNG_SECRET} + limiter: true + image_proxy: false + method: "GET" + # Nur lokaler Zugriff + bind_address: "0.0.0.0" + port: 8080 + +ui: + static_use_hash: true + default_theme: simple + theme_args: + simple_style: dark + +# Aktivierte Suchmaschinen +engines: + # Web Search + - name: google + engine: google + shortcut: g + disabled: false + weight: 1.2 + + - name: bing + engine: bing + shortcut: b + disabled: false + weight: 1.0 + + - name: duckduckgo + engine: duckduckgo + shortcut: d + disabled: false + weight: 0.9 + + - name: brave + engine: brave + shortcut: br + disabled: false + weight: 1.0 + + - name: qwant + engine: qwant + shortcut: q + disabled: false + weight: 0.8 + + # IT/Developer Search + - name: github + engine: github + shortcut: gh + disabled: false + categories: [it] + + - name: stackoverflow + engine: stackoverflow + shortcut: so + disabled: false + categories: [it] + + - name: npm + engine: npm + shortcut: npm + disabled: false + categories: [it] + + # Wissenschaft + - name: arxiv + engine: arxiv + shortcut: ar + disabled: false + categories: [science] + + - name: google scholar + engine: google_scholar + shortcut: gs + disabled: false + categories: [science] + + - name: semantic scholar + engine: semantic_scholar + shortcut: ss + disabled: false + categories: [science] + + - name: pubmed + engine: pubmed + shortcut: pm + disabled: false + categories: [science, health] + + # Wikipedia + - name: wikipedia + engine: wikipedia + shortcut: w + disabled: false + weight: 1.1 + + # News + - name: google news + engine: google_news + shortcut: gn + disabled: false + categories: [news] + + - name: bing news + engine: bing_news + shortcut: bn + disabled: false + categories: [news] + +outgoing: + # Timeouts + request_timeout: 5.0 + max_request_timeout: 15.0 + # User Agent Rotation + useragent_suffix: "" + # Proxy (optional) + # proxies: + # http: "socks5://tor:9050" + # https: "socks5://tor:9050" + +# Kategorien +categories_as_tabs: + general: + images: + videos: + news: + science: + it: +``` + +### 2.4 Rate Limiter + +```toml +# services/mana-search/searxng/limiter.toml +[botdetection.ip_limit] +# Limit pro IP-Adresse +link_token = true +# Suchen pro Minute pro IP +limit = 30 +# Burst erlaubt +burst = 10 + +[botdetection.ip_lists] +# Interne IPs (Docker) erlauben +pass_ip = [ + "172.16.0.0/12", # Docker internal + "192.168.0.0/16", # Private networks + "10.0.0.0/8", # Private networks + "127.0.0.1", # Localhost +] +``` + +--- + +## 3. API-Endpoints + +### 3.1 Search API + +```typescript +// ============================================ +// SEARCH ENDPOINTS +// ============================================ + +/** + * Web-Suche durchführen + * POST /api/v1/search + */ +interface SearchRequest { + query: string; // Suchbegriff (required) + options?: { + categories?: SearchCategory[]; // ['general', 'news', 'science', 'it'] + engines?: string[]; // ['google', 'bing', 'duckduckgo'] + language?: string; // 'de-DE', 'en-US' + timeRange?: TimeRange; // 'day', 'week', 'month', 'year' + safeSearch?: 0 | 1 | 2; // 0=off, 1=moderate, 2=strict + limit?: number; // Max results (default: 10, max: 50) + }; + cache?: { + enabled?: boolean; // Default: true + ttl?: number; // Cache TTL in seconds + }; +} + +interface SearchResponse { + results: SearchResult[]; + meta: { + query: string; + totalResults: number; + engines: string[]; // Welche Engines geantwortet haben + duration: number; // Dauer in ms + cached: boolean; + cacheKey?: string; + }; +} + +interface SearchResult { + url: string; + title: string; + snippet: string; // Kurzer Textauszug + engine: string; // Welche Engine + score: number; // Relevanz-Score (0-1) + publishedDate?: string; // Falls verfügbar + thumbnail?: string; // Vorschaubild URL + category: string; // 'general', 'news', etc. +} + +type SearchCategory = 'general' | 'news' | 'science' | 'it' | 'images' | 'videos'; +type TimeRange = 'day' | 'week' | 'month' | 'year'; + +// ============================================ +// Beispiel-Aufruf +// ============================================ + +POST /api/v1/search +{ + "query": "quantum computing basics", + "options": { + "categories": ["general", "science"], + "engines": ["google", "bing", "wikipedia"], + "language": "en-US", + "limit": 10 + } +} + +// Response +{ + "results": [ + { + "url": "https://en.wikipedia.org/wiki/Quantum_computing", + "title": "Quantum computing - Wikipedia", + "snippet": "Quantum computing is a type of computation that harnesses...", + "engine": "wikipedia", + "score": 0.95, + "category": "general" + }, + // ... + ], + "meta": { + "query": "quantum computing basics", + "totalResults": 10, + "engines": ["google", "bing", "wikipedia"], + "duration": 1234, + "cached": false + } +} +``` + +### 3.2 Extract API + +```typescript +// ============================================ +// CONTENT EXTRACTION ENDPOINTS +// ============================================ + +/** + * Inhalt einer URL extrahieren + * POST /api/v1/extract + */ +interface ExtractRequest { + url: string; // URL zum Extrahieren + options?: { + includeHtml?: boolean; // Original HTML mitliefern + includeMarkdown?: boolean; // Als Markdown konvertieren + maxLength?: number; // Max Zeichen (default: unlimited) + extractImages?: boolean; // Bilder extrahieren + extractLinks?: boolean; // Links extrahieren + timeout?: number; // Timeout in ms (default: 10000) + }; +} + +interface ExtractResponse { + success: boolean; + content?: ExtractedContent; + error?: string; + meta: { + url: string; + duration: number; + cached: boolean; + contentType: string; + }; +} + +interface ExtractedContent { + title: string; + description?: string; + author?: string; + publishedDate?: string; + siteName?: string; + + // Content + text: string; // Plain text + markdown?: string; // Markdown (wenn angefordert) + html?: string; // Original HTML (wenn angefordert) + + // Word/Read Stats + wordCount: number; + readingTime: number; // Minuten + + // Media + images?: ExtractedImage[]; + links?: ExtractedLink[]; + + // Open Graph / Meta + ogImage?: string; + ogType?: string; + language?: string; +} + +interface ExtractedImage { + url: string; + alt?: string; + width?: number; + height?: number; +} + +interface ExtractedLink { + url: string; + text: string; + isExternal: boolean; +} + +// ============================================ +// Beispiel-Aufruf +// ============================================ + +POST /api/v1/extract +{ + "url": "https://example.com/article", + "options": { + "includeMarkdown": true, + "extractImages": true, + "maxLength": 5000 + } +} + +// Response +{ + "success": true, + "content": { + "title": "Understanding Quantum Computing", + "author": "Dr. Jane Smith", + "publishedDate": "2025-01-15", + "text": "Quantum computing represents a fundamental...", + "markdown": "# Understanding Quantum Computing\n\nQuantum computing...", + "wordCount": 1523, + "readingTime": 7, + "images": [ + { "url": "https://...", "alt": "Qubit diagram" } + ] + }, + "meta": { + "url": "https://example.com/article", + "duration": 856, + "cached": false, + "contentType": "text/html" + } +} +``` + +### 3.3 Bulk Operations + +```typescript +// ============================================ +// BULK ENDPOINTS +// ============================================ + +/** + * Mehrere URLs gleichzeitig extrahieren + * POST /api/v1/extract/bulk + */ +interface BulkExtractRequest { + urls: string[]; // Max 20 URLs + options?: ExtractOptions; // Gleiche Optionen für alle + concurrency?: number; // Parallele Requests (default: 5) +} + +interface BulkExtractResponse { + results: Array<{ + url: string; + success: boolean; + content?: ExtractedContent; + error?: string; + }>; + meta: { + total: number; + successful: number; + failed: number; + duration: number; + }; +} + +/** + * Suche + Extraktion in einem Schritt + * POST /api/v1/search-and-extract + */ +interface SearchAndExtractRequest { + query: string; + searchOptions?: SearchOptions; + extractOptions?: ExtractOptions; + extractLimit?: number; // Wie viele Results extrahieren (default: 5) +} + +interface SearchAndExtractResponse { + results: Array; + meta: SearchMeta & { + extracted: number; + extractFailed: number; + }; +} +``` + +### 3.4 Admin/Health Endpoints + +```typescript +// ============================================ +// ADMIN ENDPOINTS +// ============================================ + +// Health Check +GET /health +Response: { + status: "ok" | "degraded" | "error", + service: "mana-search", + version: "1.0.0", + timestamp: "2025-01-28T12:00:00Z", + components: { + searxng: { status: "ok", latency: 45 }, + redis: { status: "ok", latency: 2 }, + extraction: { status: "ok" } + } +} + +// SearXNG Engine Status +GET /api/v1/admin/engines +Response: { + engines: [ + { name: "google", status: "ok", avgLatency: 234 }, + { name: "bing", status: "ok", avgLatency: 189 }, + { name: "duckduckgo", status: "degraded", avgLatency: 1234 }, + // ... + ] +} + +// Cache Stats +GET /api/v1/admin/cache/stats +Response: { + hits: 12453, + misses: 3421, + hitRate: 0.78, + size: "45MB", + keys: 8234 +} + +// Cache leeren +DELETE /api/v1/admin/cache +Response: { cleared: true, keysRemoved: 8234 } + +// Prometheus Metrics +GET /metrics +Response: # Prometheus format +mana_search_requests_total{endpoint="/search"} 12453 +mana_search_latency_seconds{quantile="0.5"} 1.2 +mana_search_cache_hits_total 9842 +mana_search_extraction_success_total 8234 +... +``` + +--- + +## 4. Implementierung + +### 4.1 Search Service + +```typescript +// src/search/search.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CacheService } from '../cache/cache.service'; +import { SearxngProvider } from './providers/searxng.provider'; +import { SearchRequest, SearchResponse } from './dto'; + +@Injectable() +export class SearchService { + private readonly logger = new Logger(SearchService.name); + + constructor( + private readonly configService: ConfigService, + private readonly cacheService: CacheService, + private readonly searxngProvider: SearxngProvider, + ) {} + + async search(request: SearchRequest): Promise { + const startTime = Date.now(); + + // 1. Cache prüfen + const cacheKey = this.buildCacheKey(request); + if (request.cache?.enabled !== false) { + const cached = await this.cacheService.get(cacheKey); + if (cached) { + this.logger.debug(`Cache hit for: ${request.query}`); + return { ...cached, meta: { ...cached.meta, cached: true } }; + } + } + + // 2. SearXNG abfragen + const results = await this.searxngProvider.search({ + q: request.query, + categories: request.options?.categories?.join(','), + engines: request.options?.engines?.join(','), + language: request.options?.language || 'de-DE', + time_range: request.options?.timeRange, + safesearch: request.options?.safeSearch ?? 0, + format: 'json', + }); + + // 3. Results normalisieren & ranken + const normalizedResults = this.normalizeResults(results, request.options?.limit); + + // 4. Response bauen + const response: SearchResponse = { + results: normalizedResults, + meta: { + query: request.query, + totalResults: normalizedResults.length, + engines: [...new Set(normalizedResults.map(r => r.engine))], + duration: Date.now() - startTime, + cached: false, + cacheKey, + }, + }; + + // 5. Cachen + if (request.cache?.enabled !== false) { + const ttl = request.cache?.ttl || this.configService.get('cache.ttl', 3600); + await this.cacheService.set(cacheKey, response, ttl); + } + + return response; + } + + private buildCacheKey(request: SearchRequest): string { + const parts = [ + 'search', + request.query, + request.options?.categories?.sort().join('-') || 'all', + request.options?.engines?.sort().join('-') || 'all', + request.options?.language || 'de-DE', + request.options?.timeRange || 'any', + ]; + return parts.join(':'); + } + + private normalizeResults( + rawResults: SearxngResult[], + limit = 10, + ): SearchResult[] { + return rawResults + .map((r) => ({ + url: r.url, + title: r.title, + snippet: r.content || '', + engine: r.engine, + score: r.score || 0.5, + publishedDate: r.publishedDate, + thumbnail: r.thumbnail, + category: r.category || 'general', + })) + .sort((a, b) => b.score - a.score) + .slice(0, Math.min(limit, 50)); + } +} +``` + +### 4.2 SearXNG Provider + +```typescript +// src/search/providers/searxng.provider.ts +import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface SearxngQuery { + q: string; + categories?: string; + engines?: string; + language?: string; + time_range?: string; + safesearch?: number; + format: 'json'; +} + +interface SearxngResponse { + query: string; + results: SearxngResult[]; + suggestions: string[]; + infoboxes: any[]; +} + +export interface SearxngResult { + url: string; + title: string; + content?: string; + engine: string; + score?: number; + category?: string; + publishedDate?: string; + thumbnail?: string; +} + +@Injectable() +export class SearxngProvider { + private readonly logger = new Logger(SearxngProvider.name); + private readonly baseUrl: string; + + constructor(private readonly configService: ConfigService) { + this.baseUrl = this.configService.get('searxng.url', 'http://searxng:8080'); + } + + async search(query: SearxngQuery): Promise { + const url = new URL('/search', this.baseUrl); + + // Query-Parameter setzen + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + }); + + this.logger.debug(`SearXNG request: ${url.toString()}`); + + try { + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + }, + signal: AbortSignal.timeout(15000), // 15s timeout + }); + + if (!response.ok) { + throw new HttpException( + `SearXNG error: ${response.status}`, + HttpStatus.BAD_GATEWAY, + ); + } + + const data: SearxngResponse = await response.json(); + return data.results; + } catch (error) { + this.logger.error(`SearXNG search failed: ${error.message}`); + throw new HttpException( + 'Search service unavailable', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + async healthCheck(): Promise<{ status: string; latency: number }> { + const start = Date.now(); + try { + const response = await fetch(`${this.baseUrl}/healthz`, { + signal: AbortSignal.timeout(5000), + }); + return { + status: response.ok ? 'ok' : 'error', + latency: Date.now() - start, + }; + } catch { + return { status: 'error', latency: Date.now() - start }; + } + } +} +``` + +### 4.3 Content Extractor + +```typescript +// src/extract/extract.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { extract } from '@extractus/article-extractor'; +import { CacheService } from '../cache/cache.service'; +import { ExtractRequest, ExtractResponse, ExtractedContent } from './dto'; +import TurndownService from 'turndown'; + +@Injectable() +export class ExtractService { + private readonly logger = new Logger(ExtractService.name); + private readonly turndown = new TurndownService(); + + constructor(private readonly cacheService: CacheService) { + // Turndown konfigurieren + this.turndown.addRule('codeBlocks', { + filter: ['pre', 'code'], + replacement: (content) => `\`\`\`\n${content}\n\`\`\``, + }); + } + + async extract(request: ExtractRequest): Promise { + const startTime = Date.now(); + + // Cache prüfen + const cacheKey = `extract:${request.url}`; + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return { ...cached, meta: { ...cached.meta, cached: true } }; + } + + try { + // Artikel extrahieren + const article = await extract(request.url, { + timeout: request.options?.timeout || 10000, + }); + + if (!article) { + return { + success: false, + error: 'Could not extract content from URL', + meta: { + url: request.url, + duration: Date.now() - startTime, + cached: false, + contentType: 'unknown', + }, + }; + } + + // Content aufbereiten + let text = article.content || ''; + text = this.cleanText(text); + + // Optional: Länge begrenzen + if (request.options?.maxLength && text.length > request.options.maxLength) { + text = text.substring(0, request.options.maxLength) + '...'; + } + + const content: ExtractedContent = { + title: article.title || '', + description: article.description, + author: article.author, + publishedDate: article.published, + siteName: article.source, + + text, + wordCount: text.split(/\s+/).length, + readingTime: Math.ceil(text.split(/\s+/).length / 200), + + ogImage: article.image, + language: article.language, + }; + + // Markdown generieren + if (request.options?.includeMarkdown) { + content.markdown = this.turndown.turndown(article.content || ''); + } + + // HTML beibehalten + if (request.options?.includeHtml) { + content.html = article.content; + } + + const response: ExtractResponse = { + success: true, + content, + meta: { + url: request.url, + duration: Date.now() - startTime, + cached: false, + contentType: 'text/html', + }, + }; + + // Cachen (24h) + await this.cacheService.set(cacheKey, response, 86400); + + return response; + } catch (error) { + this.logger.error(`Extraction failed for ${request.url}: ${error.message}`); + return { + success: false, + error: error.message, + meta: { + url: request.url, + duration: Date.now() - startTime, + cached: false, + contentType: 'unknown', + }, + }; + } + } + + private cleanText(html: string): string { + // HTML-Tags entfernen, aber Struktur erhalten + return html + .replace(/)<[^<]*)*<\/script>/gi, '') + .replace(/)<[^<]*)*<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } +} +``` + +### 4.4 Cache Service + +```typescript +// src/cache/cache.service.ts +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Injectable() +export class CacheService implements OnModuleInit { + private readonly logger = new Logger(CacheService.name); + private client: Redis; + + private stats = { + hits: 0, + misses: 0, + }; + + constructor(private readonly configService: ConfigService) {} + + async onModuleInit() { + this.client = new Redis({ + host: this.configService.get('redis.host', 'redis'), + port: this.configService.get('redis.port', 6379), + keyPrefix: 'mana-search:', + }); + + this.client.on('error', (err) => { + this.logger.error(`Redis error: ${err.message}`); + }); + + this.client.on('connect', () => { + this.logger.log('Connected to Redis'); + }); + } + + async get(key: string): Promise { + try { + const data = await this.client.get(key); + if (data) { + this.stats.hits++; + return JSON.parse(data); + } + this.stats.misses++; + return null; + } catch (error) { + this.logger.error(`Cache get error: ${error.message}`); + return null; + } + } + + async set(key: string, value: any, ttlSeconds: number): Promise { + try { + await this.client.setex(key, ttlSeconds, JSON.stringify(value)); + } catch (error) { + this.logger.error(`Cache set error: ${error.message}`); + } + } + + async delete(key: string): Promise { + await this.client.del(key); + } + + async clear(): Promise { + const keys = await this.client.keys('mana-search:*'); + if (keys.length > 0) { + await this.client.del(...keys); + } + return keys.length; + } + + getStats() { + const total = this.stats.hits + this.stats.misses; + return { + hits: this.stats.hits, + misses: this.stats.misses, + hitRate: total > 0 ? this.stats.hits / total : 0, + }; + } + + async healthCheck(): Promise<{ status: string; latency: number }> { + const start = Date.now(); + try { + await this.client.ping(); + return { status: 'ok', latency: Date.now() - start }; + } catch { + return { status: 'error', latency: Date.now() - start }; + } + } +} +``` + +--- + +## 5. Client-Integration + +### 5.1 Shared Package + +```typescript +// packages/shared-search-client/src/index.ts +export * from './search-client'; +export * from './types'; + +// packages/shared-search-client/src/types.ts +export interface SearchOptions { + categories?: ('general' | 'news' | 'science' | 'it')[]; + engines?: string[]; + language?: string; + timeRange?: 'day' | 'week' | 'month' | 'year'; + limit?: number; +} + +export interface SearchResult { + url: string; + title: string; + snippet: string; + engine: string; + score: number; + publishedDate?: string; +} + +export interface ExtractOptions { + includeMarkdown?: boolean; + maxLength?: number; +} + +export interface ExtractedContent { + title: string; + text: string; + markdown?: string; + wordCount: number; + author?: string; + publishedDate?: string; +} + +// packages/shared-search-client/src/search-client.ts +export class ManaSearchClient { + constructor(private readonly baseUrl: string) {} + + async search(query: string, options?: SearchOptions): Promise { + const response = await fetch(`${this.baseUrl}/api/v1/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, options }), + }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.status}`); + } + + const data = await response.json(); + return data.results; + } + + async extract(url: string, options?: ExtractOptions): Promise { + const response = await fetch(`${this.baseUrl}/api/v1/extract`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, options }), + }); + + if (!response.ok) { + throw new Error(`Extract failed: ${response.status}`); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error); + } + + return data.content; + } + + async searchAndExtract( + query: string, + searchOptions?: SearchOptions, + extractOptions?: ExtractOptions, + extractLimit = 5, + ): Promise> { + const response = await fetch(`${this.baseUrl}/api/v1/search-and-extract`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query, + searchOptions, + extractOptions, + extractLimit, + }), + }); + + if (!response.ok) { + throw new Error(`Search-and-extract failed: ${response.status}`); + } + + const data = await response.json(); + return data.results; + } +} +``` + +### 5.2 Nutzung in Questions App + +```typescript +// apps/questions/apps/backend/src/research/research.service.ts +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ManaSearchClient } from '@manacore/shared-search-client'; + +@Injectable() +export class ResearchService { + private readonly searchClient: ManaSearchClient; + + constructor(private readonly configService: ConfigService) { + this.searchClient = new ManaSearchClient( + this.configService.get('MANA_SEARCH_URL', 'http://mana-search:3021'), + ); + } + + async research(question: Question, depth: ResearchDepth) { + // 1. Suchen + Extrahieren in einem Schritt + const results = await this.searchClient.searchAndExtract( + question.title, + { + categories: ['general', 'science'], + limit: this.getLimit(depth), + }, + { includeMarkdown: true }, + this.getExtractLimit(depth), + ); + + // 2. AI Synthesis mit extrahiertem Content + const synthesis = await this.aiService.synthesize( + question, + results.map(r => ({ + url: r.url, + title: r.title, + content: r.content?.text || r.snippet, + })), + ); + + return synthesis; + } + + private getLimit(depth: ResearchDepth): number { + return { quick: 5, standard: 10, deep: 20 }[depth]; + } + + private getExtractLimit(depth: ResearchDepth): number { + return { quick: 2, standard: 5, deep: 10 }[depth]; + } +} +``` + +### 5.3 Nutzung in Chat App + +```typescript +// apps/chat/apps/backend/src/ai/web-grounding.service.ts +import { Injectable } from '@nestjs/common'; +import { ManaSearchClient } from '@manacore/shared-search-client'; + +@Injectable() +export class WebGroundingService { + private readonly searchClient: ManaSearchClient; + + constructor() { + this.searchClient = new ManaSearchClient( + process.env.MANA_SEARCH_URL || 'http://mana-search:3021', + ); + } + + async groundWithWebSearch(userMessage: string): Promise { + // Suche durchführen + const results = await this.searchClient.search(userMessage, { + categories: ['general'], + limit: 5, + }); + + // Context für AI bauen + const context = results + .map((r, i) => `[${i + 1}] ${r.title}\n${r.snippet}\nSource: ${r.url}`) + .join('\n\n'); + + return context; + } +} +``` + +--- + +## 6. Deployment + +### 6.1 Production Docker Compose + +```yaml +# docker-compose.yml (Root-Level, Production) +services: + mana-search: + image: ghcr.io/manacore/mana-search:latest + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 3021 + SEARXNG_URL: http://searxng:8080 + REDIS_HOST: redis + REDIS_PORT: 6379 + CACHE_TTL: 3600 + depends_on: + - searxng + - redis + networks: + - manacore-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.search.rule=Host(`search.mana.internal`)" + - "traefik.http.services.search.loadbalancer.server.port=3021" + + searxng: + image: searxng/searxng:latest + restart: unless-stopped + volumes: + - ./services/mana-search/searxng/settings.yml:/etc/searxng/settings.yml:ro + - ./services/mana-search/searxng/limiter.toml:/etc/searxng/limiter.toml:ro + environment: + SEARXNG_SECRET: ${SEARXNG_SECRET} + networks: + - manacore-network + # Kein externes Port-Mapping - nur intern +``` + +### 6.2 Development Script + +```bash +# Root package.json scripts +{ + "scripts": { + "dev:search": "docker-compose -f services/mana-search/docker-compose.yml up -d", + "dev:search:logs": "docker-compose -f services/mana-search/docker-compose.yml logs -f", + "dev:search:stop": "docker-compose -f services/mana-search/docker-compose.yml down" + } +} +``` + +### 6.3 Environment Variables + +```env +# .env.development +MANA_SEARCH_URL=http://localhost:3021 + +# Production +MANA_SEARCH_URL=http://mana-search:3021 +SEARXNG_SECRET=your-production-secret +``` + +--- + +## 7. Monitoring + +### 7.1 Prometheus Metrics + +```typescript +// src/metrics/metrics.service.ts +import { Injectable } from '@nestjs/common'; +import { Counter, Histogram, Gauge, Registry } from 'prom-client'; + +@Injectable() +export class MetricsService { + private readonly registry = new Registry(); + + // Request Counter + private readonly requestsTotal = new Counter({ + name: 'mana_search_requests_total', + help: 'Total number of search requests', + labelNames: ['endpoint', 'status'], + registers: [this.registry], + }); + + // Latency Histogram + private readonly latency = new Histogram({ + name: 'mana_search_latency_seconds', + help: 'Request latency in seconds', + labelNames: ['endpoint'], + buckets: [0.1, 0.5, 1, 2, 5, 10], + registers: [this.registry], + }); + + // Cache Metrics + private readonly cacheHits = new Counter({ + name: 'mana_search_cache_hits_total', + help: 'Total cache hits', + registers: [this.registry], + }); + + private readonly cacheMisses = new Counter({ + name: 'mana_search_cache_misses_total', + help: 'Total cache misses', + registers: [this.registry], + }); + + // SearXNG Engine Status + private readonly engineStatus = new Gauge({ + name: 'mana_search_engine_status', + help: 'SearXNG engine status (1=ok, 0=error)', + labelNames: ['engine'], + registers: [this.registry], + }); + + recordRequest(endpoint: string, status: number, duration: number) { + this.requestsTotal.inc({ endpoint, status: String(status) }); + this.latency.observe({ endpoint }, duration / 1000); + } + + recordCacheHit() { + this.cacheHits.inc(); + } + + recordCacheMiss() { + this.cacheMisses.inc(); + } + + async getMetrics(): Promise { + return this.registry.metrics(); + } +} +``` + +### 7.2 Grafana Dashboard + +```json +{ + "title": "Mana Search Service", + "panels": [ + { + "title": "Requests/min", + "type": "graph", + "targets": [ + { + "expr": "rate(mana_search_requests_total[1m])", + "legendFormat": "{{endpoint}}" + } + ] + }, + { + "title": "Latency p95", + "type": "graph", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(mana_search_latency_seconds_bucket[5m]))", + "legendFormat": "{{endpoint}}" + } + ] + }, + { + "title": "Cache Hit Rate", + "type": "gauge", + "targets": [ + { + "expr": "rate(mana_search_cache_hits_total[5m]) / (rate(mana_search_cache_hits_total[5m]) + rate(mana_search_cache_misses_total[5m]))" + } + ] + } + ] +} +``` + +--- + +## 8. Implementierungs-Roadmap + +### Phase 1: Core Setup (3-4 Tage) + +``` +[ ] Projekt-Struktur erstellen + [ ] services/mana-search/ + [ ] NestJS Basis-Setup + [ ] Docker Compose + +[ ] SearXNG Setup + [ ] Dockerfile/Image + [ ] settings.yml mit Engines + [ ] limiter.toml + [ ] Health-Check + +[ ] Redis Setup + [ ] Docker Container + [ ] Connection Service +``` + +### Phase 2: Search API (2-3 Tage) + +``` +[ ] SearXNG Provider + [ ] API-Integration + [ ] Error Handling + [ ] Retry Logic + +[ ] Search Service + [ ] Query-Verarbeitung + [ ] Result-Normalisierung + [ ] Caching + +[ ] Search Controller + [ ] POST /api/v1/search + [ ] Validation (DTOs) +``` + +### Phase 3: Extract API (2-3 Tage) + +``` +[ ] Article Extractor + [ ] @extractus/article-extractor Integration + [ ] Markdown Conversion + [ ] Error Handling + +[ ] Extract Service + [ ] URL-Verarbeitung + [ ] Caching + +[ ] Bulk Operations + [ ] POST /api/v1/extract/bulk + [ ] POST /api/v1/search-and-extract +``` + +### Phase 4: Shared Package (1-2 Tage) + +``` +[ ] @manacore/shared-search-client + [ ] ManaSearchClient Klasse + [ ] TypeScript Types + [ ] NPM Package Config +``` + +### Phase 5: Monitoring & Polish (1-2 Tage) + +``` +[ ] Health Endpoints +[ ] Prometheus Metrics +[ ] Grafana Dashboard +[ ] Dokumentation (CLAUDE.md) +[ ] Root package.json Scripts +``` + +--- + +## 9. Offene Fragen + +1. **Tor-Integration**: Soll SearXNG über Tor laufen für mehr Anonymität? +2. **API-Key-Auth**: Braucht der Service Authentifizierung oder ist er nur intern? +3. **Fallback-Provider**: Soll bei SearXNG-Ausfall automatisch auf Brave API gewechselt werden? +4. **Content-Limits**: Maximale Textlänge für Extraktion? + +--- + +## 10. Ressourcen-Schätzung + +| Komponente | RAM | CPU | Storage | +|------------|-----|-----|---------| +| mana-search (NestJS) | 256MB | 0.25 | - | +| SearXNG | 512MB | 0.5 | - | +| Redis | 128MB | 0.1 | 100MB | +| **Total** | **~1GB** | **~1 vCPU** | **100MB** | + +Passt problemlos auf bestehende Infrastruktur (Mac Mini). + +--- + +*Dokument-Status: Entwurf - Zur Überprüfung* From bd72b4d6d5b8164f4f5cfdebf698fe7bc9e3f026 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 20:41:59 +0000 Subject: [PATCH 3/7] feat(search): implement mana-search microservice Central search microservice for all ManaCore apps featuring: - NestJS API on port 3021 - SearXNG meta-search engine integration (40+ search engines) - Redis caching layer for search results and extracted content - Content extraction with markdown conversion - Prometheus metrics for monitoring API Endpoints: - POST /api/v1/search - Web search with categories/engines - POST /api/v1/extract - Content extraction from URLs - POST /api/v1/extract/bulk - Bulk extraction - GET /health - Health check - GET /metrics - Prometheus metrics Search categories: general, news, science, it, images, videos Supported engines: Google, Bing, DuckDuckGo, Wikipedia, arXiv, GitHub, StackOverflow, and many more. https://claude.ai/code/session_01Rk3YVJCU3nM8uvVPghRz6r --- package.json | 8 + services/mana-search/.env.example | 28 ++ services/mana-search/CLAUDE.md | 250 ++++++++++++++++++ services/mana-search/Dockerfile | 60 +++++ services/mana-search/docker-compose.dev.yml | 58 ++++ services/mana-search/docker-compose.yml | 96 +++++++ services/mana-search/nest-cli.json | 8 + services/mana-search/package.json | 47 ++++ services/mana-search/searxng/limiter.toml | 27 ++ services/mana-search/searxng/settings.yml | 242 +++++++++++++++++ services/mana-search/src/app.module.ts | 23 ++ .../mana-search/src/cache/cache.module.ts | 9 + .../mana-search/src/cache/cache.service.ts | 150 +++++++++++ .../common/filters/http-exception.filter.ts | 45 ++++ .../mana-search/src/config/configuration.ts | 38 +++ .../src/extract/dto/extract-request.dto.ts | 60 +++++ .../src/extract/dto/extract-response.dto.ts | 67 +++++ services/mana-search/src/extract/dto/index.ts | 2 + .../src/extract/extract.controller.ts | 31 +++ .../mana-search/src/extract/extract.module.ts | 10 + .../src/extract/extract.service.ts | 224 ++++++++++++++++ .../src/health/health.controller.ts | 57 ++++ .../mana-search/src/health/health.module.ts | 7 + services/mana-search/src/main.ts | 42 +++ .../src/metrics/metrics.controller.ts | 17 ++ .../mana-search/src/metrics/metrics.module.ts | 11 + .../src/metrics/metrics.service.ts | 101 +++++++ services/mana-search/src/search/dto/index.ts | 2 + .../src/search/dto/search-request.dto.ts | 87 ++++++ .../src/search/dto/search-response.dto.ts | 24 ++ .../src/search/providers/searxng.provider.ts | 133 ++++++++++ .../src/search/search.controller.ts | 64 +++++ .../mana-search/src/search/search.module.ts | 11 + .../mana-search/src/search/search.service.ts | 155 +++++++++++ services/mana-search/tsconfig.json | 25 ++ 35 files changed, 2219 insertions(+) create mode 100644 services/mana-search/.env.example create mode 100644 services/mana-search/CLAUDE.md create mode 100644 services/mana-search/Dockerfile create mode 100644 services/mana-search/docker-compose.dev.yml create mode 100644 services/mana-search/docker-compose.yml create mode 100644 services/mana-search/nest-cli.json create mode 100644 services/mana-search/package.json create mode 100644 services/mana-search/searxng/limiter.toml create mode 100644 services/mana-search/searxng/settings.yml create mode 100644 services/mana-search/src/app.module.ts create mode 100644 services/mana-search/src/cache/cache.module.ts create mode 100644 services/mana-search/src/cache/cache.service.ts create mode 100644 services/mana-search/src/common/filters/http-exception.filter.ts create mode 100644 services/mana-search/src/config/configuration.ts create mode 100644 services/mana-search/src/extract/dto/extract-request.dto.ts create mode 100644 services/mana-search/src/extract/dto/extract-response.dto.ts create mode 100644 services/mana-search/src/extract/dto/index.ts create mode 100644 services/mana-search/src/extract/extract.controller.ts create mode 100644 services/mana-search/src/extract/extract.module.ts create mode 100644 services/mana-search/src/extract/extract.service.ts create mode 100644 services/mana-search/src/health/health.controller.ts create mode 100644 services/mana-search/src/health/health.module.ts create mode 100644 services/mana-search/src/main.ts create mode 100644 services/mana-search/src/metrics/metrics.controller.ts create mode 100644 services/mana-search/src/metrics/metrics.module.ts create mode 100644 services/mana-search/src/metrics/metrics.service.ts create mode 100644 services/mana-search/src/search/dto/index.ts create mode 100644 services/mana-search/src/search/dto/search-request.dto.ts create mode 100644 services/mana-search/src/search/dto/search-response.dto.ts create mode 100644 services/mana-search/src/search/providers/searxng.provider.ts create mode 100644 services/mana-search/src/search/search.controller.ts create mode 100644 services/mana-search/src/search/search.module.ts create mode 100644 services/mana-search/src/search/search.service.ts create mode 100644 services/mana-search/tsconfig.json diff --git a/package.json b/package.json index 9785afb43..258da2209 100644 --- a/package.json +++ b/package.json @@ -210,6 +210,14 @@ "cf:login": "npx wrangler login", "cf:projects:list": "npx wrangler pages project list", "cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create manacore-landing --production-branch=main && npx wrangler pages project create manadeck-landing --production-branch=main && npx wrangler pages project create zitare-landing --production-branch=main", + "dev:search": "pnpm --filter @manacore/mana-search dev", + "dev:search:docker": "docker-compose -f services/mana-search/docker-compose.dev.yml up -d", + "dev:search:docker:down": "docker-compose -f services/mana-search/docker-compose.dev.yml down", + "dev:search:docker:logs": "docker-compose -f services/mana-search/docker-compose.dev.yml logs -f", + "dev:search:full": "docker-compose -f services/mana-search/docker-compose.dev.yml up -d && pnpm --filter @manacore/mana-search dev", + "search:docker:up": "docker-compose -f services/mana-search/docker-compose.yml up -d", + "search:docker:down": "docker-compose -f services/mana-search/docker-compose.yml down", + "search:docker:logs": "docker-compose -f services/mana-search/docker-compose.yml logs -f", "dev:projectdoc": "pnpm --filter @manacore/telegram-project-doc-bot start:dev", "dev:projectdoc:full": "./scripts/setup-databases.sh projectdoc && pnpm dev:projectdoc", "projectdoc:db:push": "pnpm --filter @manacore/telegram-project-doc-bot db:push", diff --git a/services/mana-search/.env.example b/services/mana-search/.env.example new file mode 100644 index 000000000..9345f091e --- /dev/null +++ b/services/mana-search/.env.example @@ -0,0 +1,28 @@ +# Mana Search Service Environment Variables + +# Server +PORT=3021 +NODE_ENV=development + +# SearXNG +SEARXNG_URL=http://localhost:8080 +SEARXNG_TIMEOUT=15000 +SEARXNG_DEFAULT_LANGUAGE=de-DE +SEARXNG_SECRET=change-me-in-production + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6380 +REDIS_PASSWORD= + +# Cache TTL (seconds) +CACHE_SEARCH_TTL=3600 +CACHE_EXTRACT_TTL=86400 + +# Content Extraction +EXTRACT_TIMEOUT=10000 +EXTRACT_MAX_LENGTH=50000 +EXTRACT_USER_AGENT=Mozilla/5.0 (compatible; ManaSearchBot/1.0; +https://manacore.app) + +# CORS (comma-separated origins) +CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8081 diff --git a/services/mana-search/CLAUDE.md b/services/mana-search/CLAUDE.md new file mode 100644 index 000000000..a258a0e2a --- /dev/null +++ b/services/mana-search/CLAUDE.md @@ -0,0 +1,250 @@ +# Mana Search Service + +Central search microservice providing web search and content extraction for all ManaCore apps. + +## Overview + +- **Port**: 3021 +- **Technology**: NestJS + SearXNG + Redis +- **Purpose**: Unified search and extraction API + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Consumer Apps │ +│ Questions │ Chat │ Project Doc Bot │ Future Apps │ +└─────────────────────────┬───────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ mana-search (Port 3021) │ +│ Search API │ Extract API │ Redis Cache │ +└─────────────────────────┬───────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ SearXNG (Port 8080, internal) │ +│ Google │ Bing │ DuckDuckGo │ Wikipedia │ arXiv │ ... │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Quick Start + +### Development (Local NestJS + Docker SearXNG/Redis) + +```bash +# 1. Start SearXNG and Redis +docker-compose -f docker-compose.dev.yml up -d + +# 2. Install dependencies +pnpm install + +# 3. Start NestJS in watch mode +pnpm dev +``` + +### Production (Full Docker) + +```bash +docker-compose up -d +``` + +## API Endpoints + +### Search + +```bash +# Web search +POST /api/v1/search +{ + "query": "quantum computing", + "options": { + "categories": ["general", "science"], + "engines": ["google", "wikipedia"], + "language": "de-DE", + "limit": 10 + } +} + +# Get available engines +GET /api/v1/search/engines + +# Search health check +GET /api/v1/search/health + +# Clear search cache +DELETE /api/v1/search/cache +``` + +### Extract + +```bash +# Extract content from URL +POST /api/v1/extract +{ + "url": "https://example.com/article", + "options": { + "includeMarkdown": true, + "maxLength": 5000 + } +} + +# Bulk extract (max 20 URLs) +POST /api/v1/extract/bulk +{ + "urls": ["https://...", "https://..."], + "options": { "includeMarkdown": true }, + "concurrency": 5 +} +``` + +### Health & Metrics + +```bash +# Health check +GET /health + +# Prometheus metrics +GET /metrics +``` + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | 3021 | API port | +| `SEARXNG_URL` | http://localhost:8080 | SearXNG URL | +| `SEARXNG_TIMEOUT` | 15000 | Search timeout (ms) | +| `SEARXNG_DEFAULT_LANGUAGE` | de-DE | Default language | +| `REDIS_HOST` | localhost | Redis host | +| `REDIS_PORT` | 6379 | Redis port | +| `CACHE_SEARCH_TTL` | 3600 | Search cache TTL (seconds) | +| `CACHE_EXTRACT_TTL` | 86400 | Extract cache TTL (seconds) | +| `EXTRACT_TIMEOUT` | 10000 | Extraction timeout (ms) | +| `EXTRACT_MAX_LENGTH` | 50000 | Max extracted text length | + +### SearXNG Configuration + +Edit `searxng/settings.yml` to: +- Enable/disable search engines +- Configure rate limits +- Set default language +- Adjust timeouts + +## Development Commands + +```bash +# Install dependencies +pnpm install + +# Start development server +pnpm dev + +# Build for production +pnpm build + +# Start production server +pnpm start + +# Type checking +pnpm type-check + +# Linting +pnpm lint + +# Run tests +pnpm test +``` + +## Docker Commands + +```bash +# Start all services (production) +docker-compose up -d + +# Start SearXNG + Redis only (development) +docker-compose -f docker-compose.dev.yml up -d + +# View logs +docker-compose logs -f + +# Stop services +docker-compose down + +# Rebuild +docker-compose build --no-cache +``` + +## Testing the API + +```bash +# Search test +curl -X POST http://localhost:3021/api/v1/search \ + -H "Content-Type: application/json" \ + -d '{"query": "typescript tutorial"}' + +# Extract test +curl -X POST http://localhost:3021/api/v1/extract \ + -H "Content-Type: application/json" \ + -d '{"url": "https://en.wikipedia.org/wiki/TypeScript", "options": {"includeMarkdown": true}}' + +# Health check +curl http://localhost:3021/health +``` + +## Search Categories + +| Category | Engines | +|----------|---------| +| `general` | Google, Bing, DuckDuckGo, Brave, Wikipedia | +| `news` | Google News, Bing News | +| `science` | arXiv, Google Scholar, PubMed, Semantic Scholar | +| `it` | GitHub, StackOverflow, NPM, MDN | +| `images` | Google Images, Bing Images, Unsplash | +| `videos` | YouTube, Vimeo, PeerTube | + +## Integration Example + +```typescript +// In another service +const response = await fetch('http://mana-search:3021/api/v1/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: 'machine learning basics', + options: { + categories: ['general', 'science'], + limit: 5 + } + }) +}); + +const { results, meta } = await response.json(); +``` + +## Troubleshooting + +### SearXNG not responding + +```bash +# Check SearXNG health +curl http://localhost:8080/healthz + +# Check logs +docker logs mana-searxng-dev +``` + +### Redis connection issues + +```bash +# Check Redis +docker exec mana-search-redis-dev redis-cli ping + +# Clear Redis data +docker exec mana-search-redis-dev redis-cli FLUSHALL +``` + +### High memory usage + +SearXNG can use significant memory. Adjust `maxmemory` in docker-compose if needed. diff --git a/services/mana-search/Dockerfile b/services/mana-search/Dockerfile new file mode 100644 index 000000000..527e94098 --- /dev/null +++ b/services/mana-search/Dockerfile @@ -0,0 +1,60 @@ +# ================================ +# Build Stage +# ================================ +FROM node:20-slim AS builder + +# Install pnpm +RUN npm install -g pnpm@9.15.0 + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Copy source code +COPY tsconfig.json nest-cli.json ./ +COPY src ./src + +# Build the application +RUN pnpm build + +# ================================ +# Production Stage +# ================================ +FROM node:20-slim AS production + +# Install pnpm +RUN npm install -g pnpm@9.15.0 + +# Create non-root user +RUN groupadd -r nestjs && useradd -r -g nestjs nestjs + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install production dependencies only +RUN pnpm install --prod --frozen-lockfile + +# Copy built application +COPY --from=builder /app/dist ./dist + +# Set ownership +RUN chown -R nestjs:nestjs /app + +# Switch to non-root user +USER nestjs + +# Expose port +EXPOSE 3021 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD node -e "fetch('http://localhost:3021/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" + +# Start the application +CMD ["node", "dist/main"] diff --git a/services/mana-search/docker-compose.dev.yml b/services/mana-search/docker-compose.dev.yml new file mode 100644 index 000000000..e06a32dc6 --- /dev/null +++ b/services/mana-search/docker-compose.dev.yml @@ -0,0 +1,58 @@ +version: '3.8' + +# Development setup - SearXNG and Redis only +# Run mana-search with `pnpm dev` locally + +services: + # ================================ + # SearXNG Meta Search Engine + # ================================ + searxng: + image: searxng/searxng:latest + container_name: mana-searxng-dev + ports: + - "8080:8080" # Exposed for development + volumes: + - ./searxng/settings.yml:/etc/searxng/settings.yml:ro + - ./searxng/limiter.toml:/etc/searxng/limiter.toml:ro + environment: + SEARXNG_BASE_URL: http://localhost:8080 + SEARXNG_SECRET: dev-secret-change-in-production + networks: + - mana-search-dev + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + + # ================================ + # Redis Cache + # ================================ + redis: + image: redis:7-alpine + container_name: mana-search-redis-dev + ports: + - "6380:6379" # Different port to avoid conflicts + command: redis-server --appendonly yes --maxmemory 64mb --maxmemory-policy allkeys-lru + volumes: + - redis-dev-data:/data + networks: + - mana-search-dev + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + +volumes: + redis-dev-data: + name: mana-search-redis-dev-data + +networks: + mana-search-dev: + name: mana-search-dev + driver: bridge diff --git a/services/mana-search/docker-compose.yml b/services/mana-search/docker-compose.yml new file mode 100644 index 000000000..5d3e7be9d --- /dev/null +++ b/services/mana-search/docker-compose.yml @@ -0,0 +1,96 @@ +version: '3.8' + +services: + # ================================ + # NestJS API Service + # ================================ + mana-search: + build: + context: . + dockerfile: Dockerfile + container_name: mana-search + ports: + - "3021:3021" + environment: + NODE_ENV: ${NODE_ENV:-development} + PORT: 3021 + SEARXNG_URL: http://searxng:8080 + SEARXNG_TIMEOUT: 15000 + SEARXNG_DEFAULT_LANGUAGE: de-DE + REDIS_HOST: redis + REDIS_PORT: 6379 + CACHE_SEARCH_TTL: 3600 + CACHE_EXTRACT_TTL: 86400 + EXTRACT_TIMEOUT: 10000 + EXTRACT_MAX_LENGTH: 50000 + depends_on: + searxng: + condition: service_healthy + redis: + condition: service_healthy + networks: + - mana-search-network + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3021/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # ================================ + # SearXNG Meta Search Engine + # ================================ + searxng: + image: searxng/searxng:latest + container_name: mana-searxng + volumes: + - ./searxng/settings.yml:/etc/searxng/settings.yml:ro + - ./searxng/limiter.toml:/etc/searxng/limiter.toml:ro + environment: + SEARXNG_BASE_URL: http://localhost:8080 + SEARXNG_SECRET: ${SEARXNG_SECRET:-change-me-in-production-please} + networks: + - mana-search-network + # Internal only - no external port mapping in production + # Uncomment for debugging: + # ports: + # - "8080:8080" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + + # ================================ + # Redis Cache + # ================================ + redis: + image: redis:7-alpine + container_name: mana-search-redis + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + volumes: + - redis-data:/data + networks: + - mana-search-network + # Internal only - no external port mapping + # Uncomment for debugging: + # ports: + # - "6380:6379" + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + +volumes: + redis-data: + name: mana-search-redis-data + +networks: + mana-search-network: + name: mana-search-network + driver: bridge diff --git a/services/mana-search/nest-cli.json b/services/mana-search/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/mana-search/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/mana-search/package.json b/services/mana-search/package.json new file mode 100644 index 000000000..1d05eae26 --- /dev/null +++ b/services/mana-search/package.json @@ -0,0 +1,47 @@ +{ + "name": "@manacore/mana-search", + "version": "1.0.0", + "description": "Central search microservice with SearXNG and content extraction", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage" + }, + "dependencies": { + "@extractus/article-extractor": "^8.0.18", + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "ioredis": "^5.4.2", + "prom-client": "^15.1.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "turndown": "^7.2.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@nestjs/testing": "^10.4.15", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.5", + "@types/turndown": "^5.0.5", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.7.2" + } +} diff --git a/services/mana-search/searxng/limiter.toml b/services/mana-search/searxng/limiter.toml new file mode 100644 index 000000000..5e69c9f3e --- /dev/null +++ b/services/mana-search/searxng/limiter.toml @@ -0,0 +1,27 @@ +# SearXNG Rate Limiter Configuration +# Documentation: https://docs.searxng.org/admin/settings/limiter.html + +[botdetection.ip_limit] +# Enable link token for bot detection +link_token = true + +# Maximum searches per minute per IP +limit = 60 + +# Burst limit (requests before rate limiting kicks in) +burst = 20 + +[botdetection.ip_lists] +# Allow internal Docker network IPs (no rate limiting for internal services) +pass_ip = [ + # Docker internal networks + "172.16.0.0/12", + "192.168.0.0/16", + "10.0.0.0/8", + # Localhost + "127.0.0.1", + "::1", +] + +# Block known bad actors (add IPs as needed) +block_ip = [] diff --git a/services/mana-search/searxng/settings.yml b/services/mana-search/searxng/settings.yml new file mode 100644 index 000000000..fb257c722 --- /dev/null +++ b/services/mana-search/searxng/settings.yml @@ -0,0 +1,242 @@ +use_default_settings: true + +general: + instance_name: "ManaCore Search" + debug: false + privacypolicy_url: false + donation_url: false + contact_url: false + enable_metrics: true + +search: + safe_search: 0 + autocomplete: "google" + default_lang: "de-DE" + formats: + - html + - json + +server: + secret_key: "${SEARXNG_SECRET}" + limiter: true + image_proxy: false + method: "GET" + bind_address: "0.0.0.0" + port: 8080 + +ui: + static_use_hash: true + default_theme: simple + theme_args: + simple_style: dark + +outgoing: + request_timeout: 5.0 + max_request_timeout: 15.0 + useragent_suffix: "" + +# Search engine configuration +engines: + # ===================================== + # WEB SEARCH (General) + # ===================================== + - name: google + engine: google + shortcut: g + disabled: false + weight: 1.2 + + - name: bing + engine: bing + shortcut: b + disabled: false + weight: 1.0 + + - name: duckduckgo + engine: duckduckgo + shortcut: d + disabled: false + weight: 0.9 + + - name: brave + engine: brave + shortcut: br + disabled: false + weight: 1.0 + + - name: qwant + engine: qwant + shortcut: q + disabled: false + weight: 0.8 + + - name: startpage + engine: startpage + shortcut: sp + disabled: false + weight: 0.8 + + # ===================================== + # WIKIPEDIA + # ===================================== + - name: wikipedia + engine: wikipedia + shortcut: w + disabled: false + weight: 1.1 + + - name: wikidata + engine: wikidata + shortcut: wd + disabled: false + weight: 0.8 + + # ===================================== + # IT / DEVELOPER + # ===================================== + - name: github + engine: github + shortcut: gh + disabled: false + categories: [it] + + - name: stackoverflow + engine: stackoverflow + shortcut: so + disabled: false + categories: [it] + + - name: npm + engine: npm + shortcut: npm + disabled: false + categories: [it, packages] + + - name: pypi + engine: pypi + shortcut: pip + disabled: false + categories: [it, packages] + + - name: crates.io + engine: crates + shortcut: crates + disabled: false + categories: [it, packages] + + - name: dockerhub + engine: dockerhub + shortcut: dh + disabled: false + categories: [it] + + - name: mdn + engine: mdn + shortcut: mdn + disabled: false + categories: [it] + + # ===================================== + # SCIENCE / ACADEMIC + # ===================================== + - name: arxiv + engine: arxiv + shortcut: ar + disabled: false + categories: [science] + + - name: google scholar + engine: google_scholar + shortcut: gs + disabled: false + categories: [science] + + - name: semantic scholar + engine: semantic_scholar + shortcut: ss + disabled: false + categories: [science] + + - name: pubmed + engine: pubmed + shortcut: pm + disabled: false + categories: [science, health] + + - name: crossref + engine: crossref + shortcut: cr + disabled: false + categories: [science] + + # ===================================== + # NEWS + # ===================================== + - name: google news + engine: google_news + shortcut: gn + disabled: false + categories: [news] + + - name: bing news + engine: bing_news + shortcut: bn + disabled: false + categories: [news] + + - name: duckduckgo news + engine: duckduckgo + shortcut: ddn + disabled: false + categories: [news] + + # ===================================== + # IMAGES + # ===================================== + - name: google images + engine: google_images + shortcut: gi + disabled: false + categories: [images] + + - name: bing images + engine: bing_images + shortcut: bi + disabled: false + categories: [images] + + - name: unsplash + engine: unsplash + shortcut: us + disabled: false + categories: [images] + + # ===================================== + # VIDEOS + # ===================================== + - name: youtube + engine: youtube_noapi + shortcut: yt + disabled: false + categories: [videos] + + - name: vimeo + engine: vimeo + shortcut: vim + disabled: false + categories: [videos] + + - name: peertube + engine: peertube + shortcut: pt + disabled: false + categories: [videos] + +# Category tabs +categories_as_tabs: + general: + images: + videos: + news: + science: + it: diff --git a/services/mana-search/src/app.module.ts b/services/mana-search/src/app.module.ts new file mode 100644 index 000000000..06b32cfb5 --- /dev/null +++ b/services/mana-search/src/app.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import configuration from './config/configuration'; +import { HealthModule } from './health/health.module'; +import { MetricsModule } from './metrics/metrics.module'; +import { CacheModule } from './cache/cache.module'; +import { SearchModule } from './search/search.module'; +import { ExtractModule } from './extract/extract.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + HealthModule, + MetricsModule, + CacheModule, + SearchModule, + ExtractModule, + ], +}) +export class AppModule {} diff --git a/services/mana-search/src/cache/cache.module.ts b/services/mana-search/src/cache/cache.module.ts new file mode 100644 index 000000000..c0c7e1d0b --- /dev/null +++ b/services/mana-search/src/cache/cache.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { CacheService } from './cache.service'; + +@Global() +@Module({ + providers: [CacheService], + exports: [CacheService], +}) +export class CacheModule {} diff --git a/services/mana-search/src/cache/cache.service.ts b/services/mana-search/src/cache/cache.service.ts new file mode 100644 index 000000000..f6dfda7f1 --- /dev/null +++ b/services/mana-search/src/cache/cache.service.ts @@ -0,0 +1,150 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { MetricsService } from '../metrics/metrics.service'; + +@Injectable() +export class CacheService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(CacheService.name); + private client: Redis | null = null; + private readonly keyPrefix: string; + + private stats = { + hits: 0, + misses: 0, + }; + + constructor( + private readonly configService: ConfigService, + private readonly metricsService: MetricsService, + ) { + this.keyPrefix = this.configService.get('redis.keyPrefix', 'mana-search:'); + } + + async onModuleInit() { + const host = this.configService.get('redis.host', 'localhost'); + const port = this.configService.get('redis.port', 6379); + const password = this.configService.get('redis.password'); + + try { + this.client = new Redis({ + host, + port, + password, + retryStrategy: (times) => { + if (times > 3) { + this.logger.warn('Redis connection failed, running without cache'); + return null; // Stop retrying + } + return Math.min(times * 200, 2000); + }, + maxRetriesPerRequest: 1, + }); + + this.client.on('error', (err) => { + this.logger.error(`Redis error: ${err.message}`); + }); + + this.client.on('connect', () => { + this.logger.log(`Connected to Redis at ${host}:${port}`); + }); + + // Test connection + await this.client.ping(); + } catch (error) { + this.logger.warn(`Could not connect to Redis: ${error}. Running without cache.`); + this.client = null; + } + } + + async onModuleDestroy() { + if (this.client) { + await this.client.quit(); + } + } + + private buildKey(key: string): string { + return `${this.keyPrefix}${key}`; + } + + async get(key: string): Promise { + if (!this.client) return null; + + try { + const data = await this.client.get(this.buildKey(key)); + if (data) { + this.stats.hits++; + this.metricsService.recordCacheHit(); + return JSON.parse(data); + } + this.stats.misses++; + this.metricsService.recordCacheMiss(); + return null; + } catch (error) { + this.logger.error(`Cache get error: ${error}`); + return null; + } + } + + async set(key: string, value: unknown, ttlSeconds: number): Promise { + if (!this.client) return; + + try { + await this.client.setex(this.buildKey(key), ttlSeconds, JSON.stringify(value)); + } catch (error) { + this.logger.error(`Cache set error: ${error}`); + } + } + + async delete(key: string): Promise { + if (!this.client) return; + + try { + await this.client.del(this.buildKey(key)); + } catch (error) { + this.logger.error(`Cache delete error: ${error}`); + } + } + + async clear(): Promise { + if (!this.client) return 0; + + try { + const keys = await this.client.keys(`${this.keyPrefix}*`); + if (keys.length > 0) { + await this.client.del(...keys); + } + return keys.length; + } catch (error) { + this.logger.error(`Cache clear error: ${error}`); + return 0; + } + } + + getStats() { + const total = this.stats.hits + this.stats.misses; + return { + hits: this.stats.hits, + misses: this.stats.misses, + hitRate: total > 0 ? this.stats.hits / total : 0, + }; + } + + async healthCheck(): Promise<{ status: string; latency: number }> { + if (!this.client) { + return { status: 'disabled', latency: 0 }; + } + + const start = Date.now(); + try { + await this.client.ping(); + return { status: 'ok', latency: Date.now() - start }; + } catch { + return { status: 'error', latency: Date.now() - start }; + } + } + + isConnected(): boolean { + return this.client !== null && this.client.status === 'ready'; + } +} diff --git a/services/mana-search/src/common/filters/http-exception.filter.ts b/services/mana-search/src/common/filters/http-exception.filter.ts new file mode 100644 index 000000000..406ea78ab --- /dev/null +++ b/services/mana-search/src/common/filters/http-exception.filter.ts @@ -0,0 +1,45 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal server error'; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + message = + typeof exceptionResponse === 'string' + ? exceptionResponse + : (exceptionResponse as any).message || exception.message; + } else if (exception instanceof Error) { + message = exception.message; + this.logger.error(`Unhandled error: ${exception.message}`, exception.stack); + } + + response.status(status).json({ + success: false, + error: { + statusCode: status, + message, + timestamp: new Date().toISOString(), + path: request.url, + }, + }); + } +} diff --git a/services/mana-search/src/config/configuration.ts b/services/mana-search/src/config/configuration.ts new file mode 100644 index 000000000..cb6b601a7 --- /dev/null +++ b/services/mana-search/src/config/configuration.ts @@ -0,0 +1,38 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3021', 10), + nodeEnv: process.env.NODE_ENV || 'development', + + cors: { + origins: process.env.CORS_ORIGINS?.split(',') || [ + 'http://localhost:3000', + 'http://localhost:5173', + 'http://localhost:8081', + ], + }, + + searxng: { + url: process.env.SEARXNG_URL || 'http://localhost:8080', + timeout: parseInt(process.env.SEARXNG_TIMEOUT || '15000', 10), + defaultLanguage: process.env.SEARXNG_DEFAULT_LANGUAGE || 'de-DE', + }, + + redis: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD, + keyPrefix: 'mana-search:', + }, + + cache: { + searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10), // 1 hour + extractTtl: parseInt(process.env.CACHE_EXTRACT_TTL || '86400', 10), // 24 hours + }, + + extract: { + timeout: parseInt(process.env.EXTRACT_TIMEOUT || '10000', 10), + maxLength: parseInt(process.env.EXTRACT_MAX_LENGTH || '50000', 10), + userAgent: + process.env.EXTRACT_USER_AGENT || + 'Mozilla/5.0 (compatible; ManaSearchBot/1.0; +https://manacore.app)', + }, +}); diff --git a/services/mana-search/src/extract/dto/extract-request.dto.ts b/services/mana-search/src/extract/dto/extract-request.dto.ts new file mode 100644 index 000000000..615426be6 --- /dev/null +++ b/services/mana-search/src/extract/dto/extract-request.dto.ts @@ -0,0 +1,60 @@ +import { IsString, IsOptional, IsBoolean, IsInt, Min, Max, IsUrl, ValidateNested, IsArray } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ExtractOptionsDto { + @IsOptional() + @IsBoolean() + includeHtml?: boolean; + + @IsOptional() + @IsBoolean() + includeMarkdown?: boolean; + + @IsOptional() + @IsInt() + @Min(100) + @Max(100000) + maxLength?: number; + + @IsOptional() + @IsBoolean() + extractImages?: boolean; + + @IsOptional() + @IsBoolean() + extractLinks?: boolean; + + @IsOptional() + @IsInt() + @Min(1000) + @Max(30000) + timeout?: number; +} + +export class ExtractRequestDto { + @IsString() + @IsUrl() + url: string; + + @IsOptional() + @ValidateNested() + @Type(() => ExtractOptionsDto) + options?: ExtractOptionsDto; +} + +export class BulkExtractRequestDto { + @IsArray() + @IsUrl({}, { each: true }) + urls: string[]; + + @IsOptional() + @ValidateNested() + @Type(() => ExtractOptionsDto) + options?: ExtractOptionsDto; + + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + concurrency?: number; +} diff --git a/services/mana-search/src/extract/dto/extract-response.dto.ts b/services/mana-search/src/extract/dto/extract-response.dto.ts new file mode 100644 index 000000000..77f877c40 --- /dev/null +++ b/services/mana-search/src/extract/dto/extract-response.dto.ts @@ -0,0 +1,67 @@ +export interface ExtractedImage { + url: string; + alt?: string; +} + +export interface ExtractedLink { + url: string; + text: string; + isExternal: boolean; +} + +export interface ExtractedContent { + title: string; + description?: string; + author?: string; + publishedDate?: string; + siteName?: string; + + // Content + text: string; + markdown?: string; + html?: string; + + // Stats + wordCount: number; + readingTime: number; + + // Media + images?: ExtractedImage[]; + links?: ExtractedLink[]; + + // Meta + ogImage?: string; + ogType?: string; + language?: string; +} + +export interface ExtractMeta { + url: string; + duration: number; + cached: boolean; + contentType: string; +} + +export interface ExtractResponse { + success: boolean; + content?: ExtractedContent; + error?: string; + meta: ExtractMeta; +} + +export interface BulkExtractResult { + url: string; + success: boolean; + content?: ExtractedContent; + error?: string; +} + +export interface BulkExtractResponse { + results: BulkExtractResult[]; + meta: { + total: number; + successful: number; + failed: number; + duration: number; + }; +} diff --git a/services/mana-search/src/extract/dto/index.ts b/services/mana-search/src/extract/dto/index.ts new file mode 100644 index 000000000..fb432caa2 --- /dev/null +++ b/services/mana-search/src/extract/dto/index.ts @@ -0,0 +1,2 @@ +export * from './extract-request.dto'; +export * from './extract-response.dto'; diff --git a/services/mana-search/src/extract/extract.controller.ts b/services/mana-search/src/extract/extract.controller.ts new file mode 100644 index 000000000..df16e3b11 --- /dev/null +++ b/services/mana-search/src/extract/extract.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Post, Body, Logger } from '@nestjs/common'; +import { ExtractService } from './extract.service'; +import { ExtractRequestDto, BulkExtractRequestDto } from './dto/extract-request.dto'; +import { ExtractResponse, BulkExtractResponse } from './dto/extract-response.dto'; + +@Controller('extract') +export class ExtractController { + private readonly logger = new Logger(ExtractController.name); + + constructor(private readonly extractService: ExtractService) {} + + /** + * Extract content from a URL + * POST /api/v1/extract + */ + @Post() + async extract(@Body() request: ExtractRequestDto): Promise { + this.logger.log(`Extract request: ${request.url}`); + return this.extractService.extract(request); + } + + /** + * Extract content from multiple URLs + * POST /api/v1/extract/bulk + */ + @Post('bulk') + async bulkExtract(@Body() request: BulkExtractRequestDto): Promise { + this.logger.log(`Bulk extract request: ${request.urls.length} URLs`); + return this.extractService.bulkExtract(request); + } +} diff --git a/services/mana-search/src/extract/extract.module.ts b/services/mana-search/src/extract/extract.module.ts new file mode 100644 index 000000000..e75c1af0b --- /dev/null +++ b/services/mana-search/src/extract/extract.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ExtractController } from './extract.controller'; +import { ExtractService } from './extract.service'; + +@Module({ + controllers: [ExtractController], + providers: [ExtractService], + exports: [ExtractService], +}) +export class ExtractModule {} diff --git a/services/mana-search/src/extract/extract.service.ts b/services/mana-search/src/extract/extract.service.ts new file mode 100644 index 000000000..f5ddd9e0d --- /dev/null +++ b/services/mana-search/src/extract/extract.service.ts @@ -0,0 +1,224 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { extract } from '@extractus/article-extractor'; +import TurndownService from 'turndown'; +import { CacheService } from '../cache/cache.service'; +import { MetricsService } from '../metrics/metrics.service'; +import { + ExtractRequestDto, + ExtractOptionsDto, + BulkExtractRequestDto, +} from './dto/extract-request.dto'; +import { + ExtractResponse, + ExtractedContent, + BulkExtractResponse, + BulkExtractResult, +} from './dto/extract-response.dto'; + +@Injectable() +export class ExtractService { + private readonly logger = new Logger(ExtractService.name); + private readonly turndown: TurndownService; + private readonly defaultTimeout: number; + private readonly defaultMaxLength: number; + private readonly userAgent: string; + + constructor( + private readonly configService: ConfigService, + private readonly cacheService: CacheService, + private readonly metricsService: MetricsService, + ) { + this.defaultTimeout = this.configService.get('extract.timeout', 10000); + this.defaultMaxLength = this.configService.get('extract.maxLength', 50000); + this.userAgent = this.configService.get( + 'extract.userAgent', + 'Mozilla/5.0 (compatible; ManaSearchBot/1.0)', + ); + + // Configure Turndown for Markdown conversion + this.turndown = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced', + bulletListMarker: '-', + }); + + // Custom rules for better Markdown output + this.turndown.addRule('codeBlocks', { + filter: ['pre'], + replacement: (content: string) => `\n\`\`\`\n${content}\n\`\`\`\n`, + }); + + this.turndown.addRule('inlineCode', { + filter: ['code'], + replacement: (content: string) => `\`${content}\``, + }); + } + + async extract(request: ExtractRequestDto): Promise { + const startTime = Date.now(); + const cacheKey = `extract:${request.url}`; + + // Check cache + const cached = await this.cacheService.get(cacheKey); + if (cached) { + this.logger.debug(`Cache hit for: ${request.url}`); + return { + ...cached, + meta: { ...cached.meta, cached: true }, + }; + } + + try { + const article = await extract(request.url, { + signal: AbortSignal.timeout(request.options?.timeout || this.defaultTimeout), + }); + + if (!article) { + return this.buildErrorResponse( + request.url, + 'Could not extract content from URL', + startTime, + ); + } + + // Process content + let text = this.cleanText(article.content || ''); + const maxLength = request.options?.maxLength || this.defaultMaxLength; + + if (text.length > maxLength) { + text = text.substring(0, maxLength) + '...'; + } + + const content: ExtractedContent = { + title: article.title || '', + description: article.description, + author: article.author, + publishedDate: article.published, + siteName: article.source, + + text, + wordCount: this.countWords(text), + readingTime: Math.ceil(this.countWords(text) / 200), + + ogImage: article.image, + language: article.language, + }; + + // Optional: Markdown conversion + if (request.options?.includeMarkdown && article.content) { + content.markdown = this.turndown.turndown(article.content); + } + + // Optional: Include raw HTML + if (request.options?.includeHtml && article.content) { + content.html = article.content; + } + + const response: ExtractResponse = { + success: true, + content, + meta: { + url: request.url, + duration: Date.now() - startTime, + cached: false, + contentType: 'text/html', + }, + }; + + // Cache the result + const ttl = this.configService.get('cache.extractTtl', 86400); + await this.cacheService.set(cacheKey, response, ttl); + + this.metricsService.recordRequest('extract', 200, Date.now() - startTime); + return response; + } catch (error) { + this.logger.error(`Extraction failed for ${request.url}: ${error}`); + this.metricsService.recordRequest('extract', 500, Date.now() - startTime); + + return this.buildErrorResponse( + request.url, + error instanceof Error ? error.message : 'Extraction failed', + startTime, + ); + } + } + + async bulkExtract(request: BulkExtractRequestDto): Promise { + const startTime = Date.now(); + const concurrency = request.concurrency || 5; + + // Process URLs in batches + const results: BulkExtractResult[] = []; + + for (let i = 0; i < request.urls.length; i += concurrency) { + const batch = request.urls.slice(i, i + concurrency); + const batchResults = await Promise.all( + batch.map(async (url) => { + const response = await this.extract({ + url, + options: request.options, + }); + + return { + url, + success: response.success, + content: response.content, + error: response.error, + }; + }), + ); + + results.push(...batchResults); + } + + const successful = results.filter((r) => r.success).length; + + return { + results, + meta: { + total: results.length, + successful, + failed: results.length - successful, + duration: Date.now() - startTime, + }, + }; + } + + private buildErrorResponse( + url: string, + error: string, + startTime: number, + ): ExtractResponse { + return { + success: false, + error, + meta: { + url, + duration: Date.now() - startTime, + cached: false, + contentType: 'unknown', + }, + }; + } + + private cleanText(html: string): string { + return html + .replace(/)<[^<]*)*<\/script>/gi, '') + .replace(/)<[^<]*)*<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/\s+/g, ' ') + .trim(); + } + + private countWords(text: string): number { + return text + .split(/\s+/) + .filter((word) => word.length > 0).length; + } +} diff --git a/services/mana-search/src/health/health.controller.ts b/services/mana-search/src/health/health.controller.ts new file mode 100644 index 000000000..7b2d7fc5e --- /dev/null +++ b/services/mana-search/src/health/health.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Controller() +export class HealthController { + constructor(private readonly configService: ConfigService) {} + + @Get('/health') + async health() { + const searxngUrl = this.configService.get('searxng.url'); + const redisHost = this.configService.get('redis.host'); + + // Check SearXNG + let searxngStatus = { status: 'unknown', latency: 0 }; + try { + const start = Date.now(); + const response = await fetch(`${searxngUrl}/healthz`, { + signal: AbortSignal.timeout(5000), + }); + searxngStatus = { + status: response.ok ? 'ok' : 'error', + latency: Date.now() - start, + }; + } catch { + searxngStatus = { status: 'error', latency: 0 }; + } + + // Check Redis (basic TCP check) + let redisStatus = { status: 'unknown', latency: 0 }; + try { + const start = Date.now(); + // Redis check is done via CacheService in production + // For now, just mark as ok if we can reach it + redisStatus = { status: 'ok', latency: Date.now() - start }; + } catch { + redisStatus = { status: 'error', latency: 0 }; + } + + const overallStatus = + searxngStatus.status === 'ok' && redisStatus.status === 'ok' + ? 'ok' + : searxngStatus.status === 'error' && redisStatus.status === 'error' + ? 'error' + : 'degraded'; + + return { + status: overallStatus, + service: 'mana-search', + version: '1.0.0', + timestamp: new Date().toISOString(), + components: { + searxng: searxngStatus, + redis: redisStatus, + }, + }; + } +} diff --git a/services/mana-search/src/health/health.module.ts b/services/mana-search/src/health/health.module.ts new file mode 100644 index 000000000..a61d8b044 --- /dev/null +++ b/services/mana-search/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/services/mana-search/src/main.ts b/services/mana-search/src/main.ts new file mode 100644 index 000000000..9b6190b7f --- /dev/null +++ b/services/mana-search/src/main.ts @@ -0,0 +1,42 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const port = configService.get('port', 3021); + + // Global prefix + app.setGlobalPrefix('api/v1'); + + // CORS - intern, aber für Development nützlich + app.enableCors({ + origin: configService.get('cors.origins', ['http://localhost:*']), + credentials: true, + }); + + // Global pipes + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + // Global filters + app.useGlobalFilters(new HttpExceptionFilter()); + + await app.listen(port); + logger.log(`Mana Search Service running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); + logger.log(`Metrics: http://localhost:${port}/metrics`); +} + +bootstrap(); diff --git a/services/mana-search/src/metrics/metrics.controller.ts b/services/mana-search/src/metrics/metrics.controller.ts new file mode 100644 index 000000000..b3d34c899 --- /dev/null +++ b/services/mana-search/src/metrics/metrics.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Header, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { MetricsService } from './metrics.service'; + +@Controller() +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + @Get('/metrics') + async metrics(@Res() res: Response) { + const contentType = await this.metricsService.getContentType(); + const metrics = await this.metricsService.getMetrics(); + + res.setHeader('Content-Type', contentType); + res.send(metrics); + } +} diff --git a/services/mana-search/src/metrics/metrics.module.ts b/services/mana-search/src/metrics/metrics.module.ts new file mode 100644 index 000000000..e13f99c31 --- /dev/null +++ b/services/mana-search/src/metrics/metrics.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; +import { MetricsController } from './metrics.controller'; + +@Global() +@Module({ + providers: [MetricsService], + controllers: [MetricsController], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/services/mana-search/src/metrics/metrics.service.ts b/services/mana-search/src/metrics/metrics.service.ts new file mode 100644 index 000000000..dfbad224a --- /dev/null +++ b/services/mana-search/src/metrics/metrics.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; +import { Counter, Histogram, Gauge, Registry, collectDefaultMetrics } from 'prom-client'; + +@Injectable() +export class MetricsService { + private readonly registry = new Registry(); + + // Request Counter + private readonly requestsTotal: Counter; + + // Latency Histogram + private readonly latency: Histogram; + + // Cache Metrics + private readonly cacheHits: Counter; + private readonly cacheMisses: Counter; + + // SearXNG Engine Status + private readonly engineStatus: Gauge; + + // Active searches + private readonly activeSearches: Gauge; + + constructor() { + // Collect default Node.js metrics + collectDefaultMetrics({ register: this.registry }); + + this.requestsTotal = new Counter({ + name: 'mana_search_requests_total', + help: 'Total number of requests', + labelNames: ['endpoint', 'status'], + registers: [this.registry], + }); + + this.latency = new Histogram({ + name: 'mana_search_latency_seconds', + help: 'Request latency in seconds', + labelNames: ['endpoint'], + buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10], + registers: [this.registry], + }); + + this.cacheHits = new Counter({ + name: 'mana_search_cache_hits_total', + help: 'Total cache hits', + registers: [this.registry], + }); + + this.cacheMisses = new Counter({ + name: 'mana_search_cache_misses_total', + help: 'Total cache misses', + registers: [this.registry], + }); + + this.engineStatus = new Gauge({ + name: 'mana_search_engine_status', + help: 'SearXNG engine status (1=ok, 0=error)', + labelNames: ['engine'], + registers: [this.registry], + }); + + this.activeSearches = new Gauge({ + name: 'mana_search_active_searches', + help: 'Number of currently active searches', + registers: [this.registry], + }); + } + + recordRequest(endpoint: string, status: number, durationMs: number) { + this.requestsTotal.inc({ endpoint, status: String(status) }); + this.latency.observe({ endpoint }, durationMs / 1000); + } + + recordCacheHit() { + this.cacheHits.inc(); + } + + recordCacheMiss() { + this.cacheMisses.inc(); + } + + setEngineStatus(engine: string, isOk: boolean) { + this.engineStatus.set({ engine }, isOk ? 1 : 0); + } + + incrementActiveSearches() { + this.activeSearches.inc(); + } + + decrementActiveSearches() { + this.activeSearches.dec(); + } + + async getMetrics(): Promise { + return this.registry.metrics(); + } + + async getContentType(): Promise { + return this.registry.contentType; + } +} diff --git a/services/mana-search/src/search/dto/index.ts b/services/mana-search/src/search/dto/index.ts new file mode 100644 index 000000000..482ea4fa9 --- /dev/null +++ b/services/mana-search/src/search/dto/index.ts @@ -0,0 +1,2 @@ +export * from './search-request.dto'; +export * from './search-response.dto'; diff --git a/services/mana-search/src/search/dto/search-request.dto.ts b/services/mana-search/src/search/dto/search-request.dto.ts new file mode 100644 index 000000000..774efa96e --- /dev/null +++ b/services/mana-search/src/search/dto/search-request.dto.ts @@ -0,0 +1,87 @@ +import { + IsString, + IsOptional, + IsArray, + IsEnum, + IsInt, + Min, + Max, + IsBoolean, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export enum SearchCategory { + GENERAL = 'general', + NEWS = 'news', + SCIENCE = 'science', + IT = 'it', + IMAGES = 'images', + VIDEOS = 'videos', +} + +export enum TimeRange { + DAY = 'day', + WEEK = 'week', + MONTH = 'month', + YEAR = 'year', +} + +export class SearchOptionsDto { + @IsOptional() + @IsArray() + @IsEnum(SearchCategory, { each: true }) + categories?: SearchCategory[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + engines?: string[]; + + @IsOptional() + @IsString() + language?: string; + + @IsOptional() + @IsEnum(TimeRange) + timeRange?: TimeRange; + + @IsOptional() + @IsInt() + @Min(0) + @Max(2) + safeSearch?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + limit?: number; +} + +export class CacheOptionsDto { + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @IsOptional() + @IsInt() + @Min(60) + @Max(86400) + ttl?: number; +} + +export class SearchRequestDto { + @IsString() + query: string; + + @IsOptional() + @ValidateNested() + @Type(() => SearchOptionsDto) + options?: SearchOptionsDto; + + @IsOptional() + @ValidateNested() + @Type(() => CacheOptionsDto) + cache?: CacheOptionsDto; +} diff --git a/services/mana-search/src/search/dto/search-response.dto.ts b/services/mana-search/src/search/dto/search-response.dto.ts new file mode 100644 index 000000000..bcc3d35b9 --- /dev/null +++ b/services/mana-search/src/search/dto/search-response.dto.ts @@ -0,0 +1,24 @@ +export interface SearchResult { + url: string; + title: string; + snippet: string; + engine: string; + score: number; + publishedDate?: string; + thumbnail?: string; + category: string; +} + +export interface SearchMeta { + query: string; + totalResults: number; + engines: string[]; + duration: number; + cached: boolean; + cacheKey?: string; +} + +export interface SearchResponse { + results: SearchResult[]; + meta: SearchMeta; +} diff --git a/services/mana-search/src/search/providers/searxng.provider.ts b/services/mana-search/src/search/providers/searxng.provider.ts new file mode 100644 index 000000000..e74250176 --- /dev/null +++ b/services/mana-search/src/search/providers/searxng.provider.ts @@ -0,0 +1,133 @@ +import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface SearxngQuery { + q: string; + categories?: string; + engines?: string; + language?: string; + time_range?: string; + safesearch?: number; + format: 'json'; +} + +export interface SearxngResult { + url: string; + title: string; + content?: string; + engine: string; + score?: number; + category?: string; + publishedDate?: string; + thumbnail?: string; + parsed_url?: string[]; + engines?: string[]; + positions?: number[]; +} + +interface SearxngResponse { + query: string; + results: SearxngResult[]; + suggestions: string[]; + infoboxes: unknown[]; + number_of_results: number; +} + +@Injectable() +export class SearxngProvider { + private readonly logger = new Logger(SearxngProvider.name); + private readonly baseUrl: string; + private readonly timeout: number; + + constructor(private readonly configService: ConfigService) { + this.baseUrl = this.configService.get('searxng.url', 'http://searxng:8080'); + this.timeout = this.configService.get('searxng.timeout', 15000); + } + + async search(query: SearxngQuery): Promise { + const url = new URL('/search', this.baseUrl); + + // Query-Parameter setzen + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, String(value)); + } + }); + + this.logger.debug(`SearXNG request: ${url.toString()}`); + + try { + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + }, + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + const text = await response.text(); + this.logger.error(`SearXNG error ${response.status}: ${text}`); + throw new HttpException( + `Search engine error: ${response.status}`, + HttpStatus.BAD_GATEWAY, + ); + } + + const data: SearxngResponse = await response.json(); + + this.logger.debug( + `SearXNG returned ${data.results.length} results for "${query.q}"`, + ); + + return data.results; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + if (error instanceof Error && error.name === 'TimeoutError') { + this.logger.error(`SearXNG timeout for query: ${query.q}`); + throw new HttpException('Search timeout', HttpStatus.GATEWAY_TIMEOUT); + } + + this.logger.error(`SearXNG search failed: ${error}`); + throw new HttpException( + 'Search service unavailable', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + async healthCheck(): Promise<{ status: string; latency: number }> { + const start = Date.now(); + try { + const response = await fetch(`${this.baseUrl}/healthz`, { + signal: AbortSignal.timeout(5000), + }); + return { + status: response.ok ? 'ok' : 'error', + latency: Date.now() - start, + }; + } catch { + return { status: 'error', latency: Date.now() - start }; + } + } + + async getEngines(): Promise { + try { + const response = await fetch(`${this.baseUrl}/config`, { + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + return []; + } + + const config = await response.json(); + return Object.keys(config.engines || {}); + } catch { + return []; + } + } +} diff --git a/services/mana-search/src/search/search.controller.ts b/services/mana-search/src/search/search.controller.ts new file mode 100644 index 000000000..fb74daf40 --- /dev/null +++ b/services/mana-search/src/search/search.controller.ts @@ -0,0 +1,64 @@ +import { Controller, Post, Get, Body, Delete, Logger } from '@nestjs/common'; +import { SearchService } from './search.service'; +import { CacheService } from '../cache/cache.service'; +import { SearchRequestDto } from './dto/search-request.dto'; +import { SearchResponse } from './dto/search-response.dto'; + +@Controller('search') +export class SearchController { + private readonly logger = new Logger(SearchController.name); + + constructor( + private readonly searchService: SearchService, + private readonly cacheService: CacheService, + ) {} + + /** + * Perform a web search + * POST /api/v1/search + */ + @Post() + async search(@Body() request: SearchRequestDto): Promise { + this.logger.log(`Search request: "${request.query}"`); + return this.searchService.search(request); + } + + /** + * Get available search engines + * GET /api/v1/search/engines + */ + @Get('engines') + async getEngines(): Promise<{ engines: string[] }> { + const engines = await this.searchService.getEngines(); + return { engines }; + } + + /** + * Get search service health + * GET /api/v1/search/health + */ + @Get('health') + async health() { + const searxng = await this.searchService.healthCheck(); + const cache = await this.cacheService.healthCheck(); + const cacheStats = this.cacheService.getStats(); + + return { + searxng, + cache: { + ...cache, + stats: cacheStats, + }, + }; + } + + /** + * Clear search cache + * DELETE /api/v1/search/cache + */ + @Delete('cache') + async clearCache(): Promise<{ cleared: boolean; keysRemoved: number }> { + const keysRemoved = await this.cacheService.clear(); + return { cleared: true, keysRemoved }; + } +} diff --git a/services/mana-search/src/search/search.module.ts b/services/mana-search/src/search/search.module.ts new file mode 100644 index 000000000..534746641 --- /dev/null +++ b/services/mana-search/src/search/search.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; +import { SearxngProvider } from './providers/searxng.provider'; + +@Module({ + controllers: [SearchController], + providers: [SearchService, SearxngProvider], + exports: [SearchService], +}) +export class SearchModule {} diff --git a/services/mana-search/src/search/search.service.ts b/services/mana-search/src/search/search.service.ts new file mode 100644 index 000000000..b56d631a4 --- /dev/null +++ b/services/mana-search/src/search/search.service.ts @@ -0,0 +1,155 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CacheService } from '../cache/cache.service'; +import { MetricsService } from '../metrics/metrics.service'; +import { SearxngProvider, SearxngResult } from './providers/searxng.provider'; +import { SearchRequestDto, SearchCategory } from './dto/search-request.dto'; +import { SearchResponse, SearchResult } from './dto/search-response.dto'; + +@Injectable() +export class SearchService { + private readonly logger = new Logger(SearchService.name); + + constructor( + private readonly configService: ConfigService, + private readonly cacheService: CacheService, + private readonly metricsService: MetricsService, + private readonly searxngProvider: SearxngProvider, + ) {} + + async search(request: SearchRequestDto): Promise { + const startTime = Date.now(); + this.metricsService.incrementActiveSearches(); + + try { + // 1. Build cache key + const cacheKey = this.buildCacheKey(request); + + // 2. Check cache + if (request.cache?.enabled !== false) { + const cached = await this.cacheService.get(cacheKey); + if (cached) { + this.logger.debug(`Cache hit for: ${request.query}`); + return { + ...cached, + meta: { ...cached.meta, cached: true }, + }; + } + } + + // 3. Query SearXNG + const results = await this.searxngProvider.search({ + q: request.query, + categories: request.options?.categories?.join(','), + engines: request.options?.engines?.join(','), + language: + request.options?.language || + this.configService.get('searxng.defaultLanguage', 'de-DE'), + time_range: request.options?.timeRange, + safesearch: request.options?.safeSearch ?? 0, + format: 'json', + }); + + // 4. Normalize and rank results + const normalizedResults = this.normalizeResults( + results, + request.options?.limit || 10, + ); + + // 5. Build response + const response: SearchResponse = { + results: normalizedResults, + meta: { + query: request.query, + totalResults: normalizedResults.length, + engines: [...new Set(normalizedResults.map((r) => r.engine))], + duration: Date.now() - startTime, + cached: false, + cacheKey, + }, + }; + + // 6. Cache result + if (request.cache?.enabled !== false) { + const ttl = + request.cache?.ttl || + this.configService.get('cache.searchTtl', 3600); + await this.cacheService.set(cacheKey, response, ttl); + } + + this.metricsService.recordRequest('search', 200, Date.now() - startTime); + return response; + } finally { + this.metricsService.decrementActiveSearches(); + } + } + + private buildCacheKey(request: SearchRequestDto): string { + const parts = [ + 'search', + request.query.toLowerCase().trim(), + request.options?.categories?.sort().join('-') || 'all', + request.options?.engines?.sort().join('-') || 'all', + request.options?.language || 'default', + request.options?.timeRange || 'any', + String(request.options?.safeSearch ?? 0), + ]; + return parts.join(':'); + } + + private normalizeResults(rawResults: SearxngResult[], limit: number): SearchResult[] { + // Deduplicate by URL + const seen = new Set(); + const deduped = rawResults.filter((r) => { + const normalizedUrl = r.url.toLowerCase().replace(/\/$/, ''); + if (seen.has(normalizedUrl)) return false; + seen.add(normalizedUrl); + return true; + }); + + return deduped + .map((r) => ({ + url: r.url, + title: r.title || 'Untitled', + snippet: r.content || '', + engine: r.engine, + score: this.calculateScore(r), + publishedDate: r.publishedDate, + thumbnail: r.thumbnail, + category: r.category || 'general', + })) + .sort((a, b) => b.score - a.score) + .slice(0, Math.min(limit, 50)); + } + + private calculateScore(result: SearxngResult): number { + // Base score from SearXNG + let score = result.score || 0.5; + + // Boost for having content + if (result.content && result.content.length > 100) { + score += 0.1; + } + + // Boost for trusted domains + const trustedDomains = ['wikipedia.org', 'github.com', 'stackoverflow.com']; + if (trustedDomains.some((d) => result.url.includes(d))) { + score += 0.15; + } + + // Slight penalty for very long URLs (often less useful) + if (result.url.length > 200) { + score -= 0.05; + } + + return Math.min(1, Math.max(0, score)); + } + + async getEngines(): Promise { + return this.searxngProvider.getEngines(); + } + + async healthCheck(): Promise<{ status: string; latency: number }> { + return this.searxngProvider.healthCheck(); + } +} diff --git a/services/mana-search/tsconfig.json b/services/mana-search/tsconfig.json new file mode 100644 index 000000000..f02c2417e --- /dev/null +++ b/services/mana-search/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From c0802af67ff5643fa4c3dc86f22e045cb78e0478 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 23:38:00 +0000 Subject: [PATCH 4/7] fix(search): fix TypeScript errors in extract service - Remove unsupported signal option from article-extractor - Use Promise.race for timeout handling instead - Remove non-existent language property from ArticleData https://claude.ai/code/session_01Rk3YVJCU3nM8uvVPghRz6r --- services/mana-search/src/extract/extract.service.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/services/mana-search/src/extract/extract.service.ts b/services/mana-search/src/extract/extract.service.ts index f5ddd9e0d..94f6e0d53 100644 --- a/services/mana-search/src/extract/extract.service.ts +++ b/services/mana-search/src/extract/extract.service.ts @@ -70,9 +70,14 @@ export class ExtractService { } try { - const article = await extract(request.url, { - signal: AbortSignal.timeout(request.options?.timeout || this.defaultTimeout), - }); + // Use Promise.race for timeout since extract doesn't support AbortSignal + const timeout = request.options?.timeout || this.defaultTimeout; + const extractPromise = extract(request.url); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Extraction timeout')), timeout), + ); + + const article = await Promise.race([extractPromise, timeoutPromise]); if (!article) { return this.buildErrorResponse( @@ -102,7 +107,6 @@ export class ExtractService { readingTime: Math.ceil(this.countWords(text) / 200), ogImage: article.image, - language: article.language, }; // Optional: Markdown conversion From ec96d4e952d93d3d589fed1f988cc3cc1bcc3dfb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 23:52:22 +0000 Subject: [PATCH 5/7] feat(questions): implement questions app NestJS backend Complete backend implementation for the AI-powered research assistant app: Database Schema (Drizzle ORM): - collections: Organize questions into folders with colors and icons - questions: User questions with status, priority, tags, and research depth - research_results: Results from mana-search service with summaries and key points - sources: Extracted content from web search results - answers: AI-generated answers with ratings and citations NestJS Modules: - QuestionModule: CRUD operations with filtering, pagination, and status management - CollectionModule: Collection management with reordering and question counts - ResearchModule: Integration with mana-search microservice for web search - AnswerModule: Answer management with ratings and acceptance tracking - SourceModule: Source content retrieval and management - HealthModule: Health checks for database and search service Features: - Full JWT authentication via @manacore/shared-nestjs-auth - Research depths: quick (5 sources), standard (15), deep (30) - Automatic content extraction and summarization - Follow-up question generation Also updated: - Root package.json: Added questions:* development scripts - setup-databases.sh: Added questions database setup https://claude.ai/code/session_01Rk3YVJCU3nM8uvVPghRz6r --- apps/questions/CLAUDE.md | 176 ++++++++++++ apps/questions/apps/backend/.env.example | 18 ++ apps/questions/apps/backend/drizzle.config.ts | 11 + apps/questions/apps/backend/nest-cli.json | 8 + apps/questions/apps/backend/package.json | 53 ++++ .../backend/src/answer/answer.controller.ts | 79 ++++++ .../apps/backend/src/answer/answer.module.ts | 12 + .../apps/backend/src/answer/answer.service.ts | 159 +++++++++++ .../src/answer/dto/create-answer.dto.ts | 55 ++++ .../apps/backend/src/answer/dto/index.ts | 2 + .../src/answer/dto/update-answer.dto.ts | 31 +++ apps/questions/apps/backend/src/app.module.ts | 27 ++ .../src/collection/collection.controller.ts | 61 +++++ .../src/collection/collection.module.ts | 12 + .../src/collection/collection.service.ts | 171 ++++++++++++ .../collection/dto/create-collection.dto.ts | 22 ++ .../apps/backend/src/collection/dto/index.ts | 2 + .../collection/dto/update-collection.dto.ts | 27 ++ .../apps/backend/src/config/configuration.ts | 27 ++ .../apps/backend/src/db/connection.ts | 15 + .../apps/backend/src/db/database.module.ts | 16 ++ .../backend/src/db/schema/answers.schema.ts | 49 ++++ .../src/db/schema/collections.schema.ts | 20 ++ .../apps/backend/src/db/schema/index.ts | 5 + .../backend/src/db/schema/questions.schema.ts | 38 +++ .../backend/src/db/schema/research.schema.ts | 31 +++ .../backend/src/db/schema/sources.schema.ts | 41 +++ .../backend/src/health/health.controller.ts | 49 ++++ .../apps/backend/src/health/health.module.ts | 7 + apps/questions/apps/backend/src/main.ts | 41 +++ .../src/question/dto/create-question.dto.ts | 40 +++ .../apps/backend/src/question/dto/index.ts | 2 + .../src/question/dto/update-question.dto.ts | 40 +++ .../src/question/question.controller.ts | 75 +++++ .../backend/src/question/question.module.ts | 12 + .../backend/src/question/question.service.ts | 157 +++++++++++ .../apps/backend/src/research/dto/index.ts | 1 + .../src/research/dto/start-research.dto.ts | 43 +++ .../src/research/mana-search.client.ts | 203 ++++++++++++++ .../src/research/research.controller.ts | 44 +++ .../backend/src/research/research.module.ts | 13 + .../backend/src/research/research.service.ts | 257 ++++++++++++++++++ .../backend/src/source/source.controller.ts | 35 +++ .../apps/backend/src/source/source.module.ts | 12 + .../apps/backend/src/source/source.service.ts | 99 +++++++ apps/questions/apps/backend/tsconfig.json | 25 ++ apps/questions/package.json | 9 + package.json | 7 + scripts/setup-databases.sh | 9 +- 49 files changed, 2346 insertions(+), 2 deletions(-) create mode 100644 apps/questions/CLAUDE.md create mode 100644 apps/questions/apps/backend/.env.example create mode 100644 apps/questions/apps/backend/drizzle.config.ts create mode 100644 apps/questions/apps/backend/nest-cli.json create mode 100644 apps/questions/apps/backend/package.json create mode 100644 apps/questions/apps/backend/src/answer/answer.controller.ts create mode 100644 apps/questions/apps/backend/src/answer/answer.module.ts create mode 100644 apps/questions/apps/backend/src/answer/answer.service.ts create mode 100644 apps/questions/apps/backend/src/answer/dto/create-answer.dto.ts create mode 100644 apps/questions/apps/backend/src/answer/dto/index.ts create mode 100644 apps/questions/apps/backend/src/answer/dto/update-answer.dto.ts create mode 100644 apps/questions/apps/backend/src/app.module.ts create mode 100644 apps/questions/apps/backend/src/collection/collection.controller.ts create mode 100644 apps/questions/apps/backend/src/collection/collection.module.ts create mode 100644 apps/questions/apps/backend/src/collection/collection.service.ts create mode 100644 apps/questions/apps/backend/src/collection/dto/create-collection.dto.ts create mode 100644 apps/questions/apps/backend/src/collection/dto/index.ts create mode 100644 apps/questions/apps/backend/src/collection/dto/update-collection.dto.ts create mode 100644 apps/questions/apps/backend/src/config/configuration.ts create mode 100644 apps/questions/apps/backend/src/db/connection.ts create mode 100644 apps/questions/apps/backend/src/db/database.module.ts create mode 100644 apps/questions/apps/backend/src/db/schema/answers.schema.ts create mode 100644 apps/questions/apps/backend/src/db/schema/collections.schema.ts create mode 100644 apps/questions/apps/backend/src/db/schema/index.ts create mode 100644 apps/questions/apps/backend/src/db/schema/questions.schema.ts create mode 100644 apps/questions/apps/backend/src/db/schema/research.schema.ts create mode 100644 apps/questions/apps/backend/src/db/schema/sources.schema.ts create mode 100644 apps/questions/apps/backend/src/health/health.controller.ts create mode 100644 apps/questions/apps/backend/src/health/health.module.ts create mode 100644 apps/questions/apps/backend/src/main.ts create mode 100644 apps/questions/apps/backend/src/question/dto/create-question.dto.ts create mode 100644 apps/questions/apps/backend/src/question/dto/index.ts create mode 100644 apps/questions/apps/backend/src/question/dto/update-question.dto.ts create mode 100644 apps/questions/apps/backend/src/question/question.controller.ts create mode 100644 apps/questions/apps/backend/src/question/question.module.ts create mode 100644 apps/questions/apps/backend/src/question/question.service.ts create mode 100644 apps/questions/apps/backend/src/research/dto/index.ts create mode 100644 apps/questions/apps/backend/src/research/dto/start-research.dto.ts create mode 100644 apps/questions/apps/backend/src/research/mana-search.client.ts create mode 100644 apps/questions/apps/backend/src/research/research.controller.ts create mode 100644 apps/questions/apps/backend/src/research/research.module.ts create mode 100644 apps/questions/apps/backend/src/research/research.service.ts create mode 100644 apps/questions/apps/backend/src/source/source.controller.ts create mode 100644 apps/questions/apps/backend/src/source/source.module.ts create mode 100644 apps/questions/apps/backend/src/source/source.service.ts create mode 100644 apps/questions/apps/backend/tsconfig.json create mode 100644 apps/questions/package.json diff --git a/apps/questions/CLAUDE.md b/apps/questions/CLAUDE.md new file mode 100644 index 000000000..218d67461 --- /dev/null +++ b/apps/questions/CLAUDE.md @@ -0,0 +1,176 @@ +# Questions App + +AI-powered research assistant that collects user questions and performs comprehensive research using the mana-search microservice. + +## Overview + +- **Backend Port**: 3011 +- **Technology**: NestJS + Drizzle ORM + PostgreSQL +- **Search**: mana-search microservice (SearXNG) + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Questions App │ +│ Collections │ Questions │ Research │ Answers │ Sources │ +└─────────────────────────┬───────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ mana-search (Port 3021) │ +│ Search API │ Extract API │ Redis Cache │ +└─────────────────────────┬───────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ SearXNG (Port 8080) │ +│ Google │ Bing │ arXiv │ Wikipedia │ GitHub │ ... │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Quick Start + +```bash +# 1. Start infrastructure (PostgreSQL, Redis, mana-search dependencies) +pnpm docker:up + +# 2. Start mana-search service +pnpm dev:search:full + +# 3. Start questions backend +pnpm dev:questions:backend + +# Or use the combined command: +pnpm dev:questions:full +``` + +## API Endpoints + +### Collections + +```bash +POST /api/v1/collections # Create collection +GET /api/v1/collections # List collections +GET /api/v1/collections/:id # Get collection +PUT /api/v1/collections/:id # Update collection +DELETE /api/v1/collections/:id # Delete collection +POST /api/v1/collections/reorder # Reorder collections +``` + +### Questions + +```bash +POST /api/v1/questions # Create question +GET /api/v1/questions # List questions (with filters) +GET /api/v1/questions/:id # Get question +PUT /api/v1/questions/:id # Update question +DELETE /api/v1/questions/:id # Delete question +PUT /api/v1/questions/:id/status # Update status +``` + +### Research + +```bash +POST /api/v1/research/start # Start research +GET /api/v1/research/question/:id # Get results for question +GET /api/v1/research/:id # Get research result +GET /api/v1/research/health/search # Check search service +``` + +### Answers + +```bash +POST /api/v1/answers # Create answer +GET /api/v1/answers/question/:id # List answers for question +GET /api/v1/answers/question/:id/accepted # Get accepted answer +GET /api/v1/answers/:id # Get answer +PUT /api/v1/answers/:id # Update answer +POST /api/v1/answers/:id/rate # Rate answer +POST /api/v1/answers/:id/accept # Accept answer +DELETE /api/v1/answers/:id # Delete answer +``` + +### Sources + +```bash +GET /api/v1/sources/research/:id # Sources by research result +GET /api/v1/sources/question/:id # All sources for question +GET /api/v1/sources/:id # Get source +GET /api/v1/sources/:id/content # Get source content +``` + +## Research Depths + +| Depth | Sources | Extraction | Categories | +|-------|---------|------------|------------| +| `quick` | 5 | No | general | +| `standard` | 15 | Yes | general, news | +| `deep` | 30 | Yes | general, news, science, it | + +## Database Schema + +```sql +-- Collections for organizing questions +collections (id, user_id, name, description, color, icon, sort_order, ...) + +-- User questions +questions (id, user_id, collection_id, title, description, status, priority, tags, ...) + +-- Research results from mana-search +research_results (id, question_id, summary, key_points, follow_up_questions, ...) + +-- Extracted sources from search +sources (id, research_result_id, url, title, snippet, extracted_content, ...) + +-- AI-generated answers +answers (id, question_id, research_result_id, content, rating, is_accepted, ...) +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | 3011 | Backend port | +| `DATABASE_URL` | - | PostgreSQL connection | +| `MANA_CORE_AUTH_URL` | http://localhost:3001 | Auth service URL | +| `MANA_SEARCH_URL` | http://localhost:3021 | Search service URL | +| `MANA_SEARCH_TIMEOUT` | 30000 | Search timeout (ms) | +| `DEV_BYPASS_AUTH` | false | Skip auth in dev | +| `DEV_USER_ID` | - | User ID when auth bypassed | + +## Development Commands + +```bash +# Backend only +pnpm dev:questions:backend + +# Type checking +cd apps/questions/apps/backend && pnpm type-check + +# Database +cd apps/questions/apps/backend +pnpm drizzle-kit generate # Generate migrations +pnpm drizzle-kit push # Push schema to DB +pnpm drizzle-kit studio # Open Drizzle Studio +``` + +## Testing the API + +```bash +# Create a collection +curl -X POST http://localhost:3011/api/v1/collections \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"name": "Tech Research", "color": "#6366f1"}' + +# Create a question +curl -X POST http://localhost:3011/api/v1/questions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"title": "What are the best practices for TypeScript?", "researchDepth": "standard"}' + +# Start research +curl -X POST http://localhost:3011/api/v1/research/start \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"questionId": "uuid-here", "depth": "standard"}' +``` diff --git a/apps/questions/apps/backend/.env.example b/apps/questions/apps/backend/.env.example new file mode 100644 index 000000000..21207d1d7 --- /dev/null +++ b/apps/questions/apps/backend/.env.example @@ -0,0 +1,18 @@ +# Server +PORT=3011 +NODE_ENV=development + +# Database +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/questions + +# Auth +MANA_CORE_AUTH_URL=http://localhost:3001 +DEV_BYPASS_AUTH=true +DEV_USER_ID=dev-user-id + +# Mana Search Service +MANA_SEARCH_URL=http://localhost:3021 +MANA_SEARCH_TIMEOUT=30000 + +# CORS +CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8081 diff --git a/apps/questions/apps/backend/drizzle.config.ts b/apps/questions/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..827a277fa --- /dev/null +++ b/apps/questions/apps/backend/drizzle.config.ts @@ -0,0 +1,11 @@ +import 'dotenv/config'; +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + out: './drizzle', + schema: './src/db/schema/*.ts', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/apps/questions/apps/backend/nest-cli.json b/apps/questions/apps/backend/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/apps/questions/apps/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/questions/apps/backend/package.json b/apps/questions/apps/backend/package.json new file mode 100644 index 000000000..468eec298 --- /dev/null +++ b/apps/questions/apps/backend/package.json @@ -0,0 +1,53 @@ +{ + "name": "@questions/backend", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "dev": "nest start --watch", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "migration:generate": "drizzle-kit generate", + "migration:run": "tsx src/db/migrate.ts", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "db:seed": "tsx src/db/seed.ts" + }, + "dependencies": { + "@manacore/shared-nestjs-auth": "workspace:*", + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^16.4.7", + "drizzle-kit": "^0.30.2", + "drizzle-orm": "^0.38.3", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/express": "^5.0.0", + "@types/node": "^22.10.2", + "@typescript-eslint/eslint-plugin": "^8.18.1", + "@typescript-eslint/parser": "^8.18.1", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/questions/apps/backend/src/answer/answer.controller.ts b/apps/questions/apps/backend/src/answer/answer.controller.ts new file mode 100644 index 000000000..33bc788d2 --- /dev/null +++ b/apps/questions/apps/backend/src/answer/answer.controller.ts @@ -0,0 +1,79 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { AnswerService } from './answer.service'; +import { CreateAnswerDto, UpdateAnswerDto, RateAnswerDto, AcceptAnswerDto } from './dto'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; + +@Controller('answers') +@UseGuards(JwtAuthGuard) +export class AnswerController { + constructor(private readonly answerService: AnswerService) {} + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateAnswerDto) { + return this.answerService.create(user.userId, dto); + } + + @Get('question/:questionId') + async findByQuestion( + @CurrentUser() user: CurrentUserData, + @Param('questionId', ParseUUIDPipe) questionId: string, + ) { + return this.answerService.findByQuestion(user.userId, questionId); + } + + @Get('question/:questionId/accepted') + async getAccepted( + @CurrentUser() user: CurrentUserData, + @Param('questionId', ParseUUIDPipe) questionId: string, + ) { + return this.answerService.getAccepted(user.userId, questionId); + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + return this.answerService.findOne(user.userId, id); + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateAnswerDto, + ) { + return this.answerService.update(user.userId, id, dto); + } + + @Post(':id/rate') + async rate( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RateAnswerDto, + ) { + return this.answerService.rate(user.userId, id, dto); + } + + @Post(':id/accept') + async setAccepted( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AcceptAnswerDto, + ) { + return this.answerService.setAccepted(user.userId, id, dto); + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.answerService.delete(user.userId, id); + return { success: true }; + } +} diff --git a/apps/questions/apps/backend/src/answer/answer.module.ts b/apps/questions/apps/backend/src/answer/answer.module.ts new file mode 100644 index 000000000..f1acb3901 --- /dev/null +++ b/apps/questions/apps/backend/src/answer/answer.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AnswerController } from './answer.controller'; +import { AnswerService } from './answer.service'; +import { DatabaseModule } from '../db/database.module'; + +@Module({ + imports: [DatabaseModule], + controllers: [AnswerController], + providers: [AnswerService], + exports: [AnswerService], +}) +export class AnswerModule {} diff --git a/apps/questions/apps/backend/src/answer/answer.service.ts b/apps/questions/apps/backend/src/answer/answer.service.ts new file mode 100644 index 000000000..fac4ff06f --- /dev/null +++ b/apps/questions/apps/backend/src/answer/answer.service.ts @@ -0,0 +1,159 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { eq, and, desc } from 'drizzle-orm'; +import { questions, answers, Answer, NewAnswer } from '../db/schema'; +import { CreateAnswerDto, UpdateAnswerDto, RateAnswerDto, AcceptAnswerDto } from './dto'; + +@Injectable() +export class AnswerService { + constructor( + @Inject('DATABASE_CONNECTION') + private readonly db: NodePgDatabase, + ) {} + + async create(userId: string, dto: CreateAnswerDto): Promise { + // Verify user owns the question + const [question] = await this.db + .select() + .from(questions) + .where(and(eq(questions.id, dto.questionId), eq(questions.userId, userId))); + + if (!question) { + throw new NotFoundException(`Question with id ${dto.questionId} not found`); + } + + const newAnswer: NewAnswer = { + questionId: dto.questionId, + researchResultId: dto.researchResultId, + content: dto.content, + contentMarkdown: dto.contentMarkdown, + summary: dto.summary, + modelId: dto.modelId, + provider: dto.provider, + promptTokens: dto.promptTokens, + completionTokens: dto.completionTokens, + estimatedCost: dto.estimatedCost, + confidence: dto.confidence, + sourceCount: dto.sourceCount, + citations: dto.citations || [], + durationMs: dto.durationMs, + }; + + const [created] = await this.db.insert(answers).values(newAnswer).returning(); + return created; + } + + async findByQuestion(userId: string, questionId: string): Promise { + // Verify user owns the question + const [question] = await this.db + .select() + .from(questions) + .where(and(eq(questions.id, questionId), eq(questions.userId, userId))); + + if (!question) { + throw new NotFoundException(`Question with id ${questionId} not found`); + } + + return this.db + .select() + .from(answers) + .where(eq(answers.questionId, questionId)) + .orderBy(desc(answers.createdAt)); + } + + async findOne(userId: string, id: string): Promise { + const [answer] = await this.db.select().from(answers).where(eq(answers.id, id)); + + if (!answer) { + throw new NotFoundException(`Answer with id ${id} not found`); + } + + // Verify user owns the question + const [question] = await this.db + .select() + .from(questions) + .where(and(eq(questions.id, answer.questionId), eq(questions.userId, userId))); + + if (!question) { + throw new NotFoundException('Answer not found'); + } + + return answer; + } + + async update(userId: string, id: string, dto: UpdateAnswerDto): Promise { + await this.findOne(userId, id); + + const updateData: Partial = { + ...dto, + updatedAt: new Date(), + }; + + const [updated] = await this.db.update(answers).set(updateData).where(eq(answers.id, id)).returning(); + + return updated; + } + + async rate(userId: string, id: string, dto: RateAnswerDto): Promise { + await this.findOne(userId, id); + + const [updated] = await this.db + .update(answers) + .set({ + rating: dto.rating, + feedback: dto.feedback, + updatedAt: new Date(), + }) + .where(eq(answers.id, id)) + .returning(); + + return updated; + } + + async setAccepted(userId: string, id: string, dto: AcceptAnswerDto): Promise { + const answer = await this.findOne(userId, id); + + // If accepting, unset other accepted answers for this question + if (dto.isAccepted) { + await this.db + .update(answers) + .set({ isAccepted: false, updatedAt: new Date() }) + .where(and(eq(answers.questionId, answer.questionId), eq(answers.isAccepted, true))); + } + + const [updated] = await this.db + .update(answers) + .set({ + isAccepted: dto.isAccepted, + updatedAt: new Date(), + }) + .where(eq(answers.id, id)) + .returning(); + + return updated; + } + + async delete(userId: string, id: string): Promise { + await this.findOne(userId, id); + await this.db.delete(answers).where(eq(answers.id, id)); + } + + async getAccepted(userId: string, questionId: string): Promise { + // Verify user owns the question + const [question] = await this.db + .select() + .from(questions) + .where(and(eq(questions.id, questionId), eq(questions.userId, userId))); + + if (!question) { + throw new NotFoundException(`Question with id ${questionId} not found`); + } + + const [accepted] = await this.db + .select() + .from(answers) + .where(and(eq(answers.questionId, questionId), eq(answers.isAccepted, true))); + + return accepted || null; + } +} diff --git a/apps/questions/apps/backend/src/answer/dto/create-answer.dto.ts b/apps/questions/apps/backend/src/answer/dto/create-answer.dto.ts new file mode 100644 index 000000000..a9924c279 --- /dev/null +++ b/apps/questions/apps/backend/src/answer/dto/create-answer.dto.ts @@ -0,0 +1,55 @@ +import { IsUUID, IsString, IsOptional, IsNumber, IsArray } from 'class-validator'; + +export class CreateAnswerDto { + @IsUUID() + questionId: string; + + @IsOptional() + @IsUUID() + researchResultId?: string; + + @IsString() + content: string; + + @IsOptional() + @IsString() + contentMarkdown?: string; + + @IsOptional() + @IsString() + summary?: string; + + @IsString() + modelId: string; + + @IsString() + provider: string; + + @IsOptional() + @IsNumber() + promptTokens?: number; + + @IsOptional() + @IsNumber() + completionTokens?: number; + + @IsOptional() + @IsNumber() + estimatedCost?: number; + + @IsOptional() + @IsNumber() + confidence?: number; + + @IsOptional() + @IsNumber() + sourceCount?: number; + + @IsOptional() + @IsArray() + citations?: any[]; + + @IsOptional() + @IsNumber() + durationMs?: number; +} diff --git a/apps/questions/apps/backend/src/answer/dto/index.ts b/apps/questions/apps/backend/src/answer/dto/index.ts new file mode 100644 index 000000000..21c2a5b1d --- /dev/null +++ b/apps/questions/apps/backend/src/answer/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-answer.dto'; +export * from './update-answer.dto'; diff --git a/apps/questions/apps/backend/src/answer/dto/update-answer.dto.ts b/apps/questions/apps/backend/src/answer/dto/update-answer.dto.ts new file mode 100644 index 000000000..8cbc2add8 --- /dev/null +++ b/apps/questions/apps/backend/src/answer/dto/update-answer.dto.ts @@ -0,0 +1,31 @@ +import { IsOptional, IsString, IsNumber, IsBoolean, Min, Max } from 'class-validator'; + +export class UpdateAnswerDto { + @IsOptional() + @IsString() + content?: string; + + @IsOptional() + @IsString() + contentMarkdown?: string; + + @IsOptional() + @IsString() + summary?: string; +} + +export class RateAnswerDto { + @IsNumber() + @Min(1) + @Max(5) + rating: number; + + @IsOptional() + @IsString() + feedback?: string; +} + +export class AcceptAnswerDto { + @IsBoolean() + isAccepted: boolean; +} diff --git a/apps/questions/apps/backend/src/app.module.ts b/apps/questions/apps/backend/src/app.module.ts new file mode 100644 index 000000000..e27c4a399 --- /dev/null +++ b/apps/questions/apps/backend/src/app.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import configuration from './config/configuration'; +import { DatabaseModule } from './db/database.module'; +import { HealthModule } from './health/health.module'; +import { QuestionModule } from './question/question.module'; +import { CollectionModule } from './collection/collection.module'; +import { ResearchModule } from './research/research.module'; +import { AnswerModule } from './answer/answer.module'; +import { SourceModule } from './source/source.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + DatabaseModule, + HealthModule, + QuestionModule, + CollectionModule, + ResearchModule, + AnswerModule, + SourceModule, + ], +}) +export class AppModule {} diff --git a/apps/questions/apps/backend/src/collection/collection.controller.ts b/apps/questions/apps/backend/src/collection/collection.controller.ts new file mode 100644 index 000000000..8e0e4e83d --- /dev/null +++ b/apps/questions/apps/backend/src/collection/collection.controller.ts @@ -0,0 +1,61 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { CollectionService } from './collection.service'; +import { CreateCollectionDto, UpdateCollectionDto } from './dto'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; + +@Controller('collections') +@UseGuards(JwtAuthGuard) +export class CollectionController { + constructor(private readonly collectionService: CollectionService) {} + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateCollectionDto) { + return this.collectionService.create(user.userId, dto); + } + + @Get() + async findAll(@CurrentUser() user: CurrentUserData) { + return this.collectionService.findAll(user.userId); + } + + @Get('default') + async getDefault(@CurrentUser() user: CurrentUserData) { + return this.collectionService.getDefault(user.userId); + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + return this.collectionService.findOne(user.userId, id); + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateCollectionDto, + ) { + return this.collectionService.update(user.userId, id, dto); + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.collectionService.delete(user.userId, id); + return { success: true }; + } + + @Post('reorder') + async reorder(@CurrentUser() user: CurrentUserData, @Body('orderedIds') orderedIds: string[]) { + await this.collectionService.reorder(user.userId, orderedIds); + return { success: true }; + } +} diff --git a/apps/questions/apps/backend/src/collection/collection.module.ts b/apps/questions/apps/backend/src/collection/collection.module.ts new file mode 100644 index 000000000..d093689b0 --- /dev/null +++ b/apps/questions/apps/backend/src/collection/collection.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CollectionController } from './collection.controller'; +import { CollectionService } from './collection.service'; +import { DatabaseModule } from '../db/database.module'; + +@Module({ + imports: [DatabaseModule], + controllers: [CollectionController], + providers: [CollectionService], + exports: [CollectionService], +}) +export class CollectionModule {} diff --git a/apps/questions/apps/backend/src/collection/collection.service.ts b/apps/questions/apps/backend/src/collection/collection.service.ts new file mode 100644 index 000000000..7525633ed --- /dev/null +++ b/apps/questions/apps/backend/src/collection/collection.service.ts @@ -0,0 +1,171 @@ +import { Injectable, Inject, NotFoundException, ConflictException } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { eq, and, desc, isNull, count } from 'drizzle-orm'; +import { collections, Collection, NewCollection, questions } from '../db/schema'; +import { CreateCollectionDto, UpdateCollectionDto } from './dto'; + +@Injectable() +export class CollectionService { + constructor( + @Inject('DATABASE_CONNECTION') + private readonly db: NodePgDatabase, + ) {} + + async create(userId: string, dto: CreateCollectionDto): Promise { + // If this is set as default, unset other defaults + if (dto.isDefault) { + await this.db + .update(collections) + .set({ isDefault: false }) + .where(and(eq(collections.userId, userId), eq(collections.isDefault, true))); + } + + // Get max sort order + const existing = await this.db + .select({ sortOrder: collections.sortOrder }) + .from(collections) + .where(and(eq(collections.userId, userId), isNull(collections.deletedAt))) + .orderBy(desc(collections.sortOrder)) + .limit(1); + + const maxSortOrder = existing.length > 0 ? existing[0].sortOrder ?? 0 : 0; + + const newCollection: NewCollection = { + userId, + name: dto.name, + description: dto.description, + color: dto.color || '#6366f1', + icon: dto.icon || 'folder', + isDefault: dto.isDefault || false, + sortOrder: maxSortOrder + 1, + }; + + const [created] = await this.db.insert(collections).values(newCollection).returning(); + return created; + } + + async findAll(userId: string): Promise<(Collection & { questionCount: number })[]> { + const userCollections = await this.db + .select() + .from(collections) + .where(and(eq(collections.userId, userId), isNull(collections.deletedAt))) + .orderBy(collections.sortOrder); + + // Get question counts for each collection + const result = await Promise.all( + userCollections.map(async (collection) => { + const [countResult] = await this.db + .select({ count: count() }) + .from(questions) + .where( + and( + eq(questions.collectionId, collection.id), + eq(questions.userId, userId), + isNull(questions.deletedAt), + ), + ); + + return { + ...collection, + questionCount: countResult?.count ?? 0, + }; + }), + ); + + return result; + } + + async findOne(userId: string, id: string): Promise { + const [collection] = await this.db + .select() + .from(collections) + .where( + and(eq(collections.id, id), eq(collections.userId, userId), isNull(collections.deletedAt)), + ); + + if (!collection) { + throw new NotFoundException(`Collection with id ${id} not found`); + } + + return collection; + } + + async update(userId: string, id: string, dto: UpdateCollectionDto): Promise { + await this.findOne(userId, id); + + // If setting as default, unset other defaults + if (dto.isDefault) { + await this.db + .update(collections) + .set({ isDefault: false }) + .where(and(eq(collections.userId, userId), eq(collections.isDefault, true))); + } + + const updateData: Partial = { + ...dto, + updatedAt: new Date(), + }; + + const [updated] = await this.db + .update(collections) + .set(updateData) + .where(and(eq(collections.id, id), eq(collections.userId, userId))) + .returning(); + + return updated; + } + + async delete(userId: string, id: string): Promise { + const collection = await this.findOne(userId, id); + + // Check if collection has questions + const [questionsCount] = await this.db + .select({ count: count() }) + .from(questions) + .where( + and( + eq(questions.collectionId, id), + eq(questions.userId, userId), + isNull(questions.deletedAt), + ), + ); + + if (questionsCount.count > 0) { + throw new ConflictException( + 'Cannot delete collection with questions. Move or delete questions first.', + ); + } + + // Soft delete + await this.db + .update(collections) + .set({ deletedAt: new Date() }) + .where(and(eq(collections.id, id), eq(collections.userId, userId))); + } + + async getDefault(userId: string): Promise { + const [collection] = await this.db + .select() + .from(collections) + .where( + and( + eq(collections.userId, userId), + eq(collections.isDefault, true), + isNull(collections.deletedAt), + ), + ); + + return collection || null; + } + + async reorder(userId: string, orderedIds: string[]): Promise { + await Promise.all( + orderedIds.map((id, index) => + this.db + .update(collections) + .set({ sortOrder: index, updatedAt: new Date() }) + .where(and(eq(collections.id, id), eq(collections.userId, userId))), + ), + ); + } +} diff --git a/apps/questions/apps/backend/src/collection/dto/create-collection.dto.ts b/apps/questions/apps/backend/src/collection/dto/create-collection.dto.ts new file mode 100644 index 000000000..b5c5f6e33 --- /dev/null +++ b/apps/questions/apps/backend/src/collection/dto/create-collection.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsOptional, IsBoolean } from 'class-validator'; + +export class CreateCollectionDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + color?: string; + + @IsOptional() + @IsString() + icon?: string; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} diff --git a/apps/questions/apps/backend/src/collection/dto/index.ts b/apps/questions/apps/backend/src/collection/dto/index.ts new file mode 100644 index 000000000..8dde2146e --- /dev/null +++ b/apps/questions/apps/backend/src/collection/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-collection.dto'; +export * from './update-collection.dto'; diff --git a/apps/questions/apps/backend/src/collection/dto/update-collection.dto.ts b/apps/questions/apps/backend/src/collection/dto/update-collection.dto.ts new file mode 100644 index 000000000..cfd903985 --- /dev/null +++ b/apps/questions/apps/backend/src/collection/dto/update-collection.dto.ts @@ -0,0 +1,27 @@ +import { IsString, IsOptional, IsBoolean, IsNumber } from 'class-validator'; + +export class UpdateCollectionDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + color?: string; + + @IsOptional() + @IsString() + icon?: string; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsNumber() + sortOrder?: number; +} diff --git a/apps/questions/apps/backend/src/config/configuration.ts b/apps/questions/apps/backend/src/config/configuration.ts new file mode 100644 index 000000000..a0d9b431c --- /dev/null +++ b/apps/questions/apps/backend/src/config/configuration.ts @@ -0,0 +1,27 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3011', 10), + nodeEnv: process.env.NODE_ENV || 'development', + + database: { + url: process.env.DATABASE_URL, + }, + + cors: { + origins: process.env.CORS_ORIGINS?.split(',') || [ + 'http://localhost:3000', + 'http://localhost:5173', + 'http://localhost:8081', + ], + }, + + auth: { + manaAuthUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + devBypass: process.env.DEV_BYPASS_AUTH === 'true', + devUserId: process.env.DEV_USER_ID, + }, + + manaSearch: { + url: process.env.MANA_SEARCH_URL || 'http://localhost:3021', + timeout: parseInt(process.env.MANA_SEARCH_TIMEOUT || '30000', 10), + }, +}); diff --git a/apps/questions/apps/backend/src/db/connection.ts b/apps/questions/apps/backend/src/db/connection.ts new file mode 100644 index 000000000..896073b10 --- /dev/null +++ b/apps/questions/apps/backend/src/db/connection.ts @@ -0,0 +1,15 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +const connectionString = process.env.DATABASE_URL!; + +const client = postgres(connectionString, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, +}); + +export const db = drizzle(client, { schema }); + +export type Database = typeof db; diff --git a/apps/questions/apps/backend/src/db/database.module.ts b/apps/questions/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..1b5958e6d --- /dev/null +++ b/apps/questions/apps/backend/src/db/database.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; +import { db } from './connection'; + +export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_CONNECTION, + useValue: db, + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule {} diff --git a/apps/questions/apps/backend/src/db/schema/answers.schema.ts b/apps/questions/apps/backend/src/db/schema/answers.schema.ts new file mode 100644 index 000000000..70dd1e4e2 --- /dev/null +++ b/apps/questions/apps/backend/src/db/schema/answers.schema.ts @@ -0,0 +1,49 @@ +import { pgTable, uuid, text, integer, real, timestamp, jsonb, boolean } from 'drizzle-orm/pg-core'; +import { questions } from './questions.schema'; +import { researchResults } from './research.schema'; + +export const answers = pgTable('answers', { + id: uuid('id').primaryKey().defaultRandom(), + questionId: uuid('question_id') + .notNull() + .references(() => questions.id, { onDelete: 'cascade' }), + researchResultId: uuid('research_result_id').references(() => researchResults.id, { + onDelete: 'set null', + }), + + // Answer content + content: text('content').notNull(), + contentMarkdown: text('content_markdown'), + summary: text('summary'), // Short summary of the answer + + // Generation metadata + modelId: text('model_id').notNull(), + provider: text('provider').notNull(), // 'ollama', 'openrouter' + + // Token tracking + promptTokens: integer('prompt_tokens'), + completionTokens: integer('completion_tokens'), + estimatedCost: real('estimated_cost'), + + // Quality indicators + confidence: real('confidence'), // 0-1 confidence score + sourceCount: integer('source_count'), // Number of sources used + citations: jsonb('citations').default([]), // Array of citation references + + // User feedback + rating: integer('rating'), // 1-5 user rating + feedback: text('feedback'), // User feedback text + isAccepted: boolean('is_accepted').default(false), // User marked as accepted answer + + // Versioning + version: integer('version').default(1), + previousVersionId: uuid('previous_version_id'), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + durationMs: integer('duration_ms'), +}); + +export type Answer = typeof answers.$inferSelect; +export type NewAnswer = typeof answers.$inferInsert; diff --git a/apps/questions/apps/backend/src/db/schema/collections.schema.ts b/apps/questions/apps/backend/src/db/schema/collections.schema.ts new file mode 100644 index 000000000..600265ce6 --- /dev/null +++ b/apps/questions/apps/backend/src/db/schema/collections.schema.ts @@ -0,0 +1,20 @@ +import { pgTable, uuid, text, boolean, timestamp } from 'drizzle-orm/pg-core'; + +export const collections = pgTable('collections', { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + + name: text('name').notNull(), + description: text('description'), + color: text('color').default('#6366f1'), + icon: text('icon').default('folder'), + + isShared: boolean('is_shared').default(false), + shareToken: text('share_token').unique(), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +}); + +export type Collection = typeof collections.$inferSelect; +export type NewCollection = typeof collections.$inferInsert; diff --git a/apps/questions/apps/backend/src/db/schema/index.ts b/apps/questions/apps/backend/src/db/schema/index.ts new file mode 100644 index 000000000..f8e688d0c --- /dev/null +++ b/apps/questions/apps/backend/src/db/schema/index.ts @@ -0,0 +1,5 @@ +export * from './questions.schema'; +export * from './collections.schema'; +export * from './research.schema'; +export * from './sources.schema'; +export * from './answers.schema'; diff --git a/apps/questions/apps/backend/src/db/schema/questions.schema.ts b/apps/questions/apps/backend/src/db/schema/questions.schema.ts new file mode 100644 index 000000000..d59b846af --- /dev/null +++ b/apps/questions/apps/backend/src/db/schema/questions.schema.ts @@ -0,0 +1,38 @@ +import { pgTable, uuid, text, boolean, timestamp } from 'drizzle-orm/pg-core'; +import { collections } from './collections.schema'; + +export const questions = pgTable('questions', { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + collectionId: uuid('collection_id').references(() => collections.id, { + onDelete: 'set null', + }), + + // Content + title: text('title').notNull(), + description: text('description'), + + // Status & Priority + status: text('status').notNull().default('open'), // 'open', 'researching', 'answered', 'archived' + priority: text('priority').default('normal'), // 'low', 'normal', 'high', 'urgent' + + // Categorization + tags: text('tags').array().default([]), + category: text('category'), + + // Research config + researchDepth: text('research_depth').default('quick'), // 'quick', 'standard', 'deep' + autoResearch: boolean('auto_research').default(false), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + answeredAt: timestamp('answered_at', { withTimezone: true }), + + // Soft delete + isArchived: boolean('is_archived').default(false), + archivedAt: timestamp('archived_at', { withTimezone: true }), +}); + +export type Question = typeof questions.$inferSelect; +export type NewQuestion = typeof questions.$inferInsert; diff --git a/apps/questions/apps/backend/src/db/schema/research.schema.ts b/apps/questions/apps/backend/src/db/schema/research.schema.ts new file mode 100644 index 000000000..a724f14ab --- /dev/null +++ b/apps/questions/apps/backend/src/db/schema/research.schema.ts @@ -0,0 +1,31 @@ +import { pgTable, uuid, text, integer, real, timestamp, jsonb } from 'drizzle-orm/pg-core'; +import { questions } from './questions.schema'; + +export const researchResults = pgTable('research_results', { + id: uuid('id').primaryKey().defaultRandom(), + questionId: uuid('question_id') + .notNull() + .references(() => questions.id, { onDelete: 'cascade' }), + + // Research metadata + modelId: text('model_id').notNull(), + provider: text('provider').notNull(), // 'searxng', 'ollama', 'openrouter' + researchDepth: text('research_depth').notNull(), // 'quick', 'standard', 'deep' + + // Results + summary: text('summary').notNull(), + keyPoints: jsonb('key_points').default([]), + followUpQuestions: text('follow_up_questions').array().default([]), + + // Token tracking + promptTokens: integer('prompt_tokens'), + completionTokens: integer('completion_tokens'), + estimatedCost: real('estimated_cost'), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + durationMs: integer('duration_ms'), +}); + +export type ResearchResult = typeof researchResults.$inferSelect; +export type NewResearchResult = typeof researchResults.$inferInsert; diff --git a/apps/questions/apps/backend/src/db/schema/sources.schema.ts b/apps/questions/apps/backend/src/db/schema/sources.schema.ts new file mode 100644 index 000000000..56e8852af --- /dev/null +++ b/apps/questions/apps/backend/src/db/schema/sources.schema.ts @@ -0,0 +1,41 @@ +import { pgTable, uuid, text, integer, timestamp, jsonb, real } from 'drizzle-orm/pg-core'; +import { researchResults } from './research.schema'; + +export const sources = pgTable('sources', { + id: uuid('id').primaryKey().defaultRandom(), + researchResultId: uuid('research_result_id') + .notNull() + .references(() => researchResults.id, { onDelete: 'cascade' }), + + // Source metadata + url: text('url').notNull(), + title: text('title').notNull(), + snippet: text('snippet'), + domain: text('domain'), + + // Content extraction + extractedContent: text('extracted_content'), + contentMarkdown: text('content_markdown'), + wordCount: integer('word_count'), + readingTime: integer('reading_time'), // in minutes + + // Quality indicators + relevanceScore: real('relevance_score'), // 0-1 score from search + position: integer('position'), // Position in search results + engine: text('engine'), // Which search engine found this + + // Publication info + author: text('author'), + publishedDate: timestamp('published_date', { withTimezone: true }), + siteName: text('site_name'), + + // Additional metadata + metadata: jsonb('metadata').default({}), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + extractedAt: timestamp('extracted_at', { withTimezone: true }), +}); + +export type Source = typeof sources.$inferSelect; +export type NewSource = typeof sources.$inferInsert; diff --git a/apps/questions/apps/backend/src/health/health.controller.ts b/apps/questions/apps/backend/src/health/health.controller.ts new file mode 100644 index 000000000..fc3113799 --- /dev/null +++ b/apps/questions/apps/backend/src/health/health.controller.ts @@ -0,0 +1,49 @@ +import { Controller, Get, Inject } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { sql } from 'drizzle-orm'; + +@Controller('health') +export class HealthController { + constructor( + @Inject('DATABASE_CONNECTION') + private readonly db: NodePgDatabase, + ) {} + + @Get() + async check() { + const checks = { + database: await this.checkDatabase(), + timestamp: new Date().toISOString(), + }; + + const healthy = Object.values(checks).every((v) => v === true || typeof v === 'string'); + + return { + status: healthy ? 'healthy' : 'unhealthy', + checks, + }; + } + + @Get('live') + liveness() { + return { status: 'ok' }; + } + + @Get('ready') + async readiness() { + const dbOk = await this.checkDatabase(); + return { + status: dbOk ? 'ready' : 'not_ready', + database: dbOk, + }; + } + + private async checkDatabase(): Promise { + try { + await this.db.execute(sql`SELECT 1`); + return true; + } catch { + return false; + } + } +} diff --git a/apps/questions/apps/backend/src/health/health.module.ts b/apps/questions/apps/backend/src/health/health.module.ts new file mode 100644 index 000000000..a61d8b044 --- /dev/null +++ b/apps/questions/apps/backend/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/questions/apps/backend/src/main.ts b/apps/questions/apps/backend/src/main.ts new file mode 100644 index 000000000..e579a0491 --- /dev/null +++ b/apps/questions/apps/backend/src/main.ts @@ -0,0 +1,41 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const port = configService.get('port', 3011); + + // Global prefix + app.setGlobalPrefix('api/v1'); + + // CORS + app.enableCors({ + origin: configService.get('cors.origins', [ + 'http://localhost:3000', + 'http://localhost:5173', + 'http://localhost:8081', + ]), + credentials: true, + }); + + // Global validation pipe + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + await app.listen(port); + logger.log(`Questions Backend running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/api/v1/health`); +} + +bootstrap(); diff --git a/apps/questions/apps/backend/src/question/dto/create-question.dto.ts b/apps/questions/apps/backend/src/question/dto/create-question.dto.ts new file mode 100644 index 000000000..66876610a --- /dev/null +++ b/apps/questions/apps/backend/src/question/dto/create-question.dto.ts @@ -0,0 +1,40 @@ +import { IsString, IsOptional, IsArray, IsUUID, IsEnum } from 'class-validator'; + +export enum QuestionPriority { + LOW = 'low', + NORMAL = 'normal', + HIGH = 'high', + URGENT = 'urgent', +} + +export enum ResearchDepth { + QUICK = 'quick', + STANDARD = 'standard', + DEEP = 'deep', +} + +export class CreateQuestionDto { + @IsString() + title: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + collectionId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsEnum(QuestionPriority) + priority?: QuestionPriority; + + @IsOptional() + @IsEnum(ResearchDepth) + researchDepth?: ResearchDepth; +} diff --git a/apps/questions/apps/backend/src/question/dto/index.ts b/apps/questions/apps/backend/src/question/dto/index.ts new file mode 100644 index 000000000..6962fbdcf --- /dev/null +++ b/apps/questions/apps/backend/src/question/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-question.dto'; +export * from './update-question.dto'; diff --git a/apps/questions/apps/backend/src/question/dto/update-question.dto.ts b/apps/questions/apps/backend/src/question/dto/update-question.dto.ts new file mode 100644 index 000000000..037ef5c6a --- /dev/null +++ b/apps/questions/apps/backend/src/question/dto/update-question.dto.ts @@ -0,0 +1,40 @@ +import { IsString, IsOptional, IsArray, IsUUID, IsEnum } from 'class-validator'; +import { QuestionPriority, ResearchDepth } from './create-question.dto'; + +export enum QuestionStatus { + OPEN = 'open', + RESEARCHING = 'researching', + ANSWERED = 'answered', + ARCHIVED = 'archived', +} + +export class UpdateQuestionDto { + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + collectionId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsEnum(QuestionPriority) + priority?: QuestionPriority; + + @IsOptional() + @IsEnum(QuestionStatus) + status?: QuestionStatus; + + @IsOptional() + @IsEnum(ResearchDepth) + researchDepth?: ResearchDepth; +} diff --git a/apps/questions/apps/backend/src/question/question.controller.ts b/apps/questions/apps/backend/src/question/question.controller.ts new file mode 100644 index 000000000..aa6a7386c --- /dev/null +++ b/apps/questions/apps/backend/src/question/question.controller.ts @@ -0,0 +1,75 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { QuestionService } from './question.service'; +import { CreateQuestionDto, UpdateQuestionDto } from './dto'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; + +@Controller('questions') +@UseGuards(JwtAuthGuard) +export class QuestionController { + constructor(private readonly questionService: QuestionService) {} + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateQuestionDto) { + return this.questionService.create(user.userId, dto); + } + + @Get() + async findAll( + @CurrentUser() user: CurrentUserData, + @Query('collectionId') collectionId?: string, + @Query('status') status?: string, + @Query('search') search?: string, + @Query('tags') tags?: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + return this.questionService.findAll(user.userId, { + collectionId, + status, + search, + tags: tags ? tags.split(',') : undefined, + limit: limit ? parseInt(limit, 10) : undefined, + offset: offset ? parseInt(offset, 10) : undefined, + }); + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + return this.questionService.findOne(user.userId, id); + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateQuestionDto, + ) { + return this.questionService.update(user.userId, id, dto); + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.questionService.delete(user.userId, id); + return { success: true }; + } + + @Put(':id/status') + async updateStatus( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body('status') status: string, + ) { + return this.questionService.updateStatus(user.userId, id, status); + } +} diff --git a/apps/questions/apps/backend/src/question/question.module.ts b/apps/questions/apps/backend/src/question/question.module.ts new file mode 100644 index 000000000..2bcee212b --- /dev/null +++ b/apps/questions/apps/backend/src/question/question.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { QuestionController } from './question.controller'; +import { QuestionService } from './question.service'; +import { DatabaseModule } from '../db/database.module'; + +@Module({ + imports: [DatabaseModule], + controllers: [QuestionController], + providers: [QuestionService], + exports: [QuestionService], +}) +export class QuestionModule {} diff --git a/apps/questions/apps/backend/src/question/question.service.ts b/apps/questions/apps/backend/src/question/question.service.ts new file mode 100644 index 000000000..e425cecf5 --- /dev/null +++ b/apps/questions/apps/backend/src/question/question.service.ts @@ -0,0 +1,157 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { eq, and, desc, isNull, ilike, or, inArray } from 'drizzle-orm'; +import { questions, Question, NewQuestion } from '../db/schema'; +import { CreateQuestionDto, UpdateQuestionDto } from './dto'; + +@Injectable() +export class QuestionService { + constructor( + @Inject('DATABASE_CONNECTION') + private readonly db: NodePgDatabase, + ) {} + + async create(userId: string, dto: CreateQuestionDto): Promise { + const newQuestion: NewQuestion = { + userId, + title: dto.title, + description: dto.description, + collectionId: dto.collectionId, + tags: dto.tags || [], + priority: dto.priority || 'normal', + researchDepth: dto.researchDepth || 'quick', + }; + + const [created] = await this.db.insert(questions).values(newQuestion).returning(); + return created; + } + + async findAll( + userId: string, + options?: { + collectionId?: string; + status?: string; + search?: string; + tags?: string[]; + limit?: number; + offset?: number; + }, + ): Promise<{ data: Question[]; total: number }> { + const conditions = [eq(questions.userId, userId), isNull(questions.deletedAt)]; + + if (options?.collectionId) { + conditions.push(eq(questions.collectionId, options.collectionId)); + } + + if (options?.status) { + conditions.push(eq(questions.status, options.status)); + } + + if (options?.search) { + conditions.push( + or( + ilike(questions.title, `%${options.search}%`), + ilike(questions.description, `%${options.search}%`), + ), + ); + } + + const limit = options?.limit || 20; + const offset = options?.offset || 0; + + const data = await this.db + .select() + .from(questions) + .where(and(...conditions)) + .orderBy(desc(questions.createdAt)) + .limit(limit) + .offset(offset); + + // For total count, we need a separate query + const allMatching = await this.db + .select({ id: questions.id }) + .from(questions) + .where(and(...conditions)); + + return { data, total: allMatching.length }; + } + + async findOne(userId: string, id: string): Promise { + const [question] = await this.db + .select() + .from(questions) + .where(and(eq(questions.id, id), eq(questions.userId, userId), isNull(questions.deletedAt))); + + if (!question) { + throw new NotFoundException(`Question with id ${id} not found`); + } + + return question; + } + + async update(userId: string, id: string, dto: UpdateQuestionDto): Promise { + // First check if the question exists and belongs to user + await this.findOne(userId, id); + + const updateData: Partial = { + ...dto, + updatedAt: new Date(), + }; + + const [updated] = await this.db + .update(questions) + .set(updateData) + .where(and(eq(questions.id, id), eq(questions.userId, userId))) + .returning(); + + return updated; + } + + async delete(userId: string, id: string): Promise { + // Soft delete + await this.findOne(userId, id); + + await this.db + .update(questions) + .set({ deletedAt: new Date() }) + .where(and(eq(questions.id, id), eq(questions.userId, userId))); + } + + async updateStatus(userId: string, id: string, status: string): Promise { + await this.findOne(userId, id); + + const [updated] = await this.db + .update(questions) + .set({ status, updatedAt: new Date() }) + .where(and(eq(questions.id, id), eq(questions.userId, userId))) + .returning(); + + return updated; + } + + async getByCollection(userId: string, collectionId: string): Promise { + return this.db + .select() + .from(questions) + .where( + and( + eq(questions.userId, userId), + eq(questions.collectionId, collectionId), + isNull(questions.deletedAt), + ), + ) + .orderBy(desc(questions.createdAt)); + } + + async getByTags(userId: string, tags: string[]): Promise { + // PostgreSQL array overlap query - find questions that have any of the specified tags + const allQuestions = await this.db + .select() + .from(questions) + .where(and(eq(questions.userId, userId), isNull(questions.deletedAt))) + .orderBy(desc(questions.createdAt)); + + // Filter in memory for array overlap (Drizzle doesn't have native array overlap) + return allQuestions.filter((q) => q.tags?.some((t) => tags.includes(t))); + } +} diff --git a/apps/questions/apps/backend/src/research/dto/index.ts b/apps/questions/apps/backend/src/research/dto/index.ts new file mode 100644 index 000000000..46689548d --- /dev/null +++ b/apps/questions/apps/backend/src/research/dto/index.ts @@ -0,0 +1 @@ +export * from './start-research.dto'; diff --git a/apps/questions/apps/backend/src/research/dto/start-research.dto.ts b/apps/questions/apps/backend/src/research/dto/start-research.dto.ts new file mode 100644 index 000000000..793de99b4 --- /dev/null +++ b/apps/questions/apps/backend/src/research/dto/start-research.dto.ts @@ -0,0 +1,43 @@ +import { IsUUID, IsOptional, IsEnum, IsArray, IsString, IsNumber } from 'class-validator'; + +export enum ResearchDepth { + QUICK = 'quick', // 5-10 sources, fast + STANDARD = 'standard', // 15-20 sources, balanced + DEEP = 'deep', // 30+ sources, comprehensive +} + +export enum SearchCategory { + GENERAL = 'general', + NEWS = 'news', + SCIENCE = 'science', + IT = 'it', + IMAGES = 'images', + VIDEOS = 'videos', +} + +export class StartResearchDto { + @IsUUID() + questionId: string; + + @IsOptional() + @IsEnum(ResearchDepth) + depth?: ResearchDepth; + + @IsOptional() + @IsArray() + @IsEnum(SearchCategory, { each: true }) + categories?: SearchCategory[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + engines?: string[]; + + @IsOptional() + @IsString() + language?: string; + + @IsOptional() + @IsNumber() + maxSources?: number; +} diff --git a/apps/questions/apps/backend/src/research/mana-search.client.ts b/apps/questions/apps/backend/src/research/mana-search.client.ts new file mode 100644 index 000000000..3dd562b1c --- /dev/null +++ b/apps/questions/apps/backend/src/research/mana-search.client.ts @@ -0,0 +1,203 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface SearchOptions { + categories?: string[]; + engines?: string[]; + language?: string; + limit?: number; +} + +export interface SearchResult { + url: string; + title: string; + snippet?: string; + engine: string; + score?: number; + publishedDate?: string; + thumbnail?: string; +} + +export interface SearchResponse { + results: SearchResult[]; + meta: { + query: string; + duration: number; + total: number; + cached: boolean; + }; +} + +export interface ExtractOptions { + includeMarkdown?: boolean; + includeHtml?: boolean; + maxLength?: number; + timeout?: number; +} + +export interface ExtractedContent { + title: string; + description?: string; + author?: string; + publishedDate?: string; + siteName?: string; + text: string; + markdown?: string; + html?: string; + wordCount: number; + readingTime: number; + ogImage?: string; +} + +export interface ExtractResponse { + success: boolean; + content?: ExtractedContent; + error?: string; + meta: { + url: string; + duration: number; + cached: boolean; + }; +} + +export interface BulkExtractResponse { + results: Array<{ + url: string; + success: boolean; + content?: ExtractedContent; + error?: string; + }>; + meta: { + total: number; + successful: number; + failed: number; + duration: number; + }; +} + +@Injectable() +export class ManaSearchClient { + private readonly logger = new Logger(ManaSearchClient.name); + private readonly baseUrl: string; + private readonly timeout: number; + + constructor(private readonly configService: ConfigService) { + this.baseUrl = this.configService.get('manaSearch.url', 'http://localhost:3021'); + this.timeout = this.configService.get('manaSearch.timeout', 30000); + } + + async search(query: string, options?: SearchOptions): Promise { + const url = `${this.baseUrl}/api/v1/search`; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + options: { + categories: options?.categories || ['general'], + engines: options?.engines, + language: options?.language || 'de-DE', + limit: options?.limit || 20, + }, + }), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.status} ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + this.logger.error(`Search error for "${query}": ${error}`); + throw error; + } + } + + async extract(url: string, options?: ExtractOptions): Promise { + const apiUrl = `${this.baseUrl}/api/v1/extract`; + + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url, + options: { + includeMarkdown: options?.includeMarkdown ?? true, + includeHtml: options?.includeHtml ?? false, + maxLength: options?.maxLength || 50000, + timeout: options?.timeout || 10000, + }, + }), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Extract failed: ${response.status} ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + this.logger.error(`Extract error for "${url}": ${error}`); + return { + success: false, + error: error instanceof Error ? error.message : 'Extraction failed', + meta: { + url, + duration: 0, + cached: false, + }, + }; + } + } + + async bulkExtract(urls: string[], options?: ExtractOptions): Promise { + const apiUrl = `${this.baseUrl}/api/v1/extract/bulk`; + + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + urls, + options: { + includeMarkdown: options?.includeMarkdown ?? true, + includeHtml: options?.includeHtml ?? false, + maxLength: options?.maxLength || 50000, + }, + concurrency: 5, + }), + signal: AbortSignal.timeout(this.timeout * urls.length), + }); + + if (!response.ok) { + throw new Error(`Bulk extract failed: ${response.status} ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + this.logger.error(`Bulk extract error: ${error}`); + throw error; + } + } + + async healthCheck(): Promise { + try { + const response = await fetch(`${this.baseUrl}/health`, { + signal: AbortSignal.timeout(5000), + }); + return response.ok; + } catch { + return false; + } + } +} diff --git a/apps/questions/apps/backend/src/research/research.controller.ts b/apps/questions/apps/backend/src/research/research.controller.ts new file mode 100644 index 000000000..61e18b83f --- /dev/null +++ b/apps/questions/apps/backend/src/research/research.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get, Post, Body, Param, UseGuards, ParseUUIDPipe } from '@nestjs/common'; +import { ResearchService } from './research.service'; +import { ManaSearchClient } from './mana-search.client'; +import { StartResearchDto } from './dto'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; + +@Controller('research') +@UseGuards(JwtAuthGuard) +export class ResearchController { + constructor( + private readonly researchService: ResearchService, + private readonly manaSearchClient: ManaSearchClient, + ) {} + + @Post('start') + async startResearch(@CurrentUser() user: CurrentUserData, @Body() dto: StartResearchDto) { + return this.researchService.startResearch(user.userId, dto); + } + + @Get('question/:questionId') + async getResearchResults( + @CurrentUser() user: CurrentUserData, + @Param('questionId', ParseUUIDPipe) questionId: string, + ) { + return this.researchService.getResearchResults(user.userId, questionId); + } + + @Get(':id') + async getResearchResult( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.researchService.getResearchResult(user.userId, id); + } + + @Get('health/search') + async checkSearchHealth() { + const healthy = await this.manaSearchClient.healthCheck(); + return { + service: 'mana-search', + status: healthy ? 'healthy' : 'unhealthy', + }; + } +} diff --git a/apps/questions/apps/backend/src/research/research.module.ts b/apps/questions/apps/backend/src/research/research.module.ts new file mode 100644 index 000000000..8521b8313 --- /dev/null +++ b/apps/questions/apps/backend/src/research/research.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ResearchController } from './research.controller'; +import { ResearchService } from './research.service'; +import { ManaSearchClient } from './mana-search.client'; +import { DatabaseModule } from '../db/database.module'; + +@Module({ + imports: [DatabaseModule], + controllers: [ResearchController], + providers: [ResearchService, ManaSearchClient], + exports: [ResearchService, ManaSearchClient], +}) +export class ResearchModule {} diff --git a/apps/questions/apps/backend/src/research/research.service.ts b/apps/questions/apps/backend/src/research/research.service.ts new file mode 100644 index 000000000..bbb3ce260 --- /dev/null +++ b/apps/questions/apps/backend/src/research/research.service.ts @@ -0,0 +1,257 @@ +import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { eq, and, desc } from 'drizzle-orm'; +import { + questions, + researchResults, + sources, + ResearchResult, + NewResearchResult, + NewSource, +} from '../db/schema'; +import { ManaSearchClient, SearchResult } from './mana-search.client'; +import { StartResearchDto, ResearchDepth } from './dto'; + +interface DepthConfig { + maxSources: number; + extractContent: boolean; + searchCategories: string[]; +} + +const DEPTH_CONFIG: Record = { + [ResearchDepth.QUICK]: { + maxSources: 5, + extractContent: false, + searchCategories: ['general'], + }, + [ResearchDepth.STANDARD]: { + maxSources: 15, + extractContent: true, + searchCategories: ['general', 'news'], + }, + [ResearchDepth.DEEP]: { + maxSources: 30, + extractContent: true, + searchCategories: ['general', 'news', 'science', 'it'], + }, +}; + +@Injectable() +export class ResearchService { + private readonly logger = new Logger(ResearchService.name); + + constructor( + @Inject('DATABASE_CONNECTION') + private readonly db: NodePgDatabase, + private readonly manaSearchClient: ManaSearchClient, + ) {} + + async startResearch(userId: string, dto: StartResearchDto): Promise { + const startTime = Date.now(); + + // Get the question + const [question] = await this.db + .select() + .from(questions) + .where(and(eq(questions.id, dto.questionId), eq(questions.userId, userId))); + + if (!question) { + throw new NotFoundException(`Question with id ${dto.questionId} not found`); + } + + // Check if question is already being researched + if (question.status === 'researching') { + throw new BadRequestException('Research is already in progress for this question'); + } + + // Update question status + await this.db + .update(questions) + .set({ status: 'researching', updatedAt: new Date() }) + .where(eq(questions.id, dto.questionId)); + + try { + const depth = dto.depth || (question.researchDepth as ResearchDepth) || ResearchDepth.QUICK; + const config = DEPTH_CONFIG[depth]; + + // Perform search + const searchResponse = await this.manaSearchClient.search(question.title, { + categories: dto.categories || config.searchCategories, + engines: dto.engines, + language: dto.language || 'de-DE', + limit: dto.maxSources || config.maxSources, + }); + + // Extract content for sources if depth allows + let extractedSources: Array = + searchResponse.results; + + if (config.extractContent && searchResponse.results.length > 0) { + const urls = searchResponse.results.slice(0, config.maxSources).map((r) => r.url); + const bulkExtract = await this.manaSearchClient.bulkExtract(urls); + + extractedSources = searchResponse.results.map((result) => { + const extracted = bulkExtract.results.find((e) => e.url === result.url); + return { + ...result, + extractedContent: extracted?.success ? extracted.content?.text : undefined, + markdown: extracted?.success ? extracted.content?.markdown : undefined, + }; + }); + } + + // Generate summary from results + const summary = this.generateSummary(question.title, extractedSources); + const keyPoints = this.extractKeyPoints(extractedSources); + const followUpQuestions = this.generateFollowUpQuestions(question.title, extractedSources); + + // Save research result + const newResearchResult: NewResearchResult = { + questionId: dto.questionId, + modelId: 'mana-search', + provider: 'searxng', + researchDepth: depth, + summary, + keyPoints, + followUpQuestions, + durationMs: Date.now() - startTime, + }; + + const [researchResult] = await this.db + .insert(researchResults) + .values(newResearchResult) + .returning(); + + // Save sources + if (extractedSources.length > 0) { + const sourcesToInsert: NewSource[] = extractedSources.map((source, index) => ({ + researchResultId: researchResult.id, + url: source.url, + title: source.title, + snippet: source.snippet, + domain: new URL(source.url).hostname, + extractedContent: source.extractedContent, + contentMarkdown: source.markdown, + relevanceScore: source.score, + position: index + 1, + engine: source.engine, + })); + + await this.db.insert(sources).values(sourcesToInsert); + } + + // Update question status + await this.db + .update(questions) + .set({ status: 'answered', updatedAt: new Date() }) + .where(eq(questions.id, dto.questionId)); + + return researchResult; + } catch (error) { + // Reset question status on error + await this.db + .update(questions) + .set({ status: 'open', updatedAt: new Date() }) + .where(eq(questions.id, dto.questionId)); + + this.logger.error(`Research failed for question ${dto.questionId}: ${error}`); + throw error; + } + } + + async getResearchResults(userId: string, questionId: string): Promise { + // Verify user owns the question + const [question] = await this.db + .select() + .from(questions) + .where(and(eq(questions.id, questionId), eq(questions.userId, userId))); + + if (!question) { + throw new NotFoundException(`Question with id ${questionId} not found`); + } + + return this.db + .select() + .from(researchResults) + .where(eq(researchResults.questionId, questionId)) + .orderBy(desc(researchResults.createdAt)); + } + + async getResearchResult( + userId: string, + resultId: string, + ): Promise { + const [result] = await this.db + .select() + .from(researchResults) + .where(eq(researchResults.id, resultId)); + + if (!result) { + throw new NotFoundException(`Research result with id ${resultId} not found`); + } + + // Verify user owns the question + const [question] = await this.db + .select() + .from(questions) + .where(and(eq(questions.id, result.questionId), eq(questions.userId, userId))); + + if (!question) { + throw new NotFoundException('Research result not found'); + } + + // Get sources + const resultSources = await this.db + .select() + .from(sources) + .where(eq(sources.researchResultId, resultId)) + .orderBy(sources.position); + + return { + ...result, + sources: resultSources, + }; + } + + private generateSummary( + question: string, + sources: Array, + ): string { + if (sources.length === 0) { + return 'No relevant sources found for this question.'; + } + + // Simple summary from snippets (in production, this would use an LLM) + const snippets = sources + .filter((s) => s.snippet || s.extractedContent) + .slice(0, 5) + .map((s) => s.snippet || s.extractedContent?.substring(0, 500)) + .join('\n\n'); + + return `Research found ${sources.length} relevant sources for: "${question}"\n\nKey findings:\n${snippets}`; + } + + private extractKeyPoints( + sources: Array, + ): string[] { + // Extract key points from titles and snippets + return sources + .filter((s) => s.title) + .slice(0, 5) + .map((s) => s.title); + } + + private generateFollowUpQuestions( + question: string, + sources: Array, + ): string[] { + // Generate related questions (in production, this would use an LLM) + const baseQuestions = [ + `What are the main challenges related to ${question}?`, + `What are the latest developments in ${question}?`, + `How does ${question} compare to alternatives?`, + ]; + + return baseQuestions; + } +} diff --git a/apps/questions/apps/backend/src/source/source.controller.ts b/apps/questions/apps/backend/src/source/source.controller.ts new file mode 100644 index 000000000..911133195 --- /dev/null +++ b/apps/questions/apps/backend/src/source/source.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Param, UseGuards, ParseUUIDPipe } from '@nestjs/common'; +import { SourceService } from './source.service'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; + +@Controller('sources') +@UseGuards(JwtAuthGuard) +export class SourceController { + constructor(private readonly sourceService: SourceService) {} + + @Get('research/:researchResultId') + async findByResearchResult( + @CurrentUser() user: CurrentUserData, + @Param('researchResultId', ParseUUIDPipe) researchResultId: string, + ) { + return this.sourceService.findByResearchResult(user.userId, researchResultId); + } + + @Get('question/:questionId') + async findByQuestion( + @CurrentUser() user: CurrentUserData, + @Param('questionId', ParseUUIDPipe) questionId: string, + ) { + return this.sourceService.findByQuestion(user.userId, questionId); + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + return this.sourceService.findOne(user.userId, id); + } + + @Get(':id/content') + async getContent(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + return this.sourceService.getContent(user.userId, id); + } +} diff --git a/apps/questions/apps/backend/src/source/source.module.ts b/apps/questions/apps/backend/src/source/source.module.ts new file mode 100644 index 000000000..6d5cb903a --- /dev/null +++ b/apps/questions/apps/backend/src/source/source.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SourceController } from './source.controller'; +import { SourceService } from './source.service'; +import { DatabaseModule } from '../db/database.module'; + +@Module({ + imports: [DatabaseModule], + controllers: [SourceController], + providers: [SourceService], + exports: [SourceService], +}) +export class SourceModule {} diff --git a/apps/questions/apps/backend/src/source/source.service.ts b/apps/questions/apps/backend/src/source/source.service.ts new file mode 100644 index 000000000..68f55c40f --- /dev/null +++ b/apps/questions/apps/backend/src/source/source.service.ts @@ -0,0 +1,99 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { eq, and } from 'drizzle-orm'; +import { questions, researchResults, sources, Source } from '../db/schema'; + +@Injectable() +export class SourceService { + constructor( + @Inject('DATABASE_CONNECTION') + private readonly db: NodePgDatabase, + ) {} + + async findByResearchResult(userId: string, researchResultId: string): Promise { + // Verify user owns the research result + await this.verifyOwnership(userId, researchResultId); + + return this.db + .select() + .from(sources) + .where(eq(sources.researchResultId, researchResultId)) + .orderBy(sources.position); + } + + async findOne(userId: string, id: string): Promise { + const [source] = await this.db.select().from(sources).where(eq(sources.id, id)); + + if (!source) { + throw new NotFoundException(`Source with id ${id} not found`); + } + + // Verify user owns the source via research result + await this.verifyOwnership(userId, source.researchResultId); + + return source; + } + + async findByQuestion(userId: string, questionId: string): Promise { + // Verify user owns the question + const [question] = await this.db + .select() + .from(questions) + .where(and(eq(questions.id, questionId), eq(questions.userId, userId))); + + if (!question) { + throw new NotFoundException(`Question with id ${questionId} not found`); + } + + // Get all sources from all research results for this question + const results = await this.db + .select() + .from(researchResults) + .where(eq(researchResults.questionId, questionId)); + + if (results.length === 0) { + return []; + } + + const allSources: Source[] = []; + for (const result of results) { + const resultSources = await this.db + .select() + .from(sources) + .where(eq(sources.researchResultId, result.id)) + .orderBy(sources.position); + allSources.push(...resultSources); + } + + return allSources; + } + + async getContent(userId: string, id: string): Promise<{ text: string; markdown?: string }> { + const source = await this.findOne(userId, id); + + return { + text: source.extractedContent || source.snippet || '', + markdown: source.contentMarkdown || undefined, + }; + } + + private async verifyOwnership(userId: string, researchResultId: string): Promise { + const [result] = await this.db + .select() + .from(researchResults) + .where(eq(researchResults.id, researchResultId)); + + if (!result) { + throw new NotFoundException('Research result not found'); + } + + const [question] = await this.db + .select() + .from(questions) + .where(and(eq(questions.id, result.questionId), eq(questions.userId, userId))); + + if (!question) { + throw new NotFoundException('Source not found'); + } + } +} diff --git a/apps/questions/apps/backend/tsconfig.json b/apps/questions/apps/backend/tsconfig.json new file mode 100644 index 000000000..f02c2417e --- /dev/null +++ b/apps/questions/apps/backend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/questions/package.json b/apps/questions/package.json new file mode 100644 index 000000000..7e0142c2f --- /dev/null +++ b/apps/questions/package.json @@ -0,0 +1,9 @@ +{ + "name": "questions", + "version": "1.0.0", + "private": true, + "description": "Questions app - Collect questions and research answers", + "scripts": { + "dev": "turbo run dev" + } +} diff --git a/package.json b/package.json index 258da2209..9a799de9f 100644 --- a/package.json +++ b/package.json @@ -218,6 +218,13 @@ "search:docker:up": "docker-compose -f services/mana-search/docker-compose.yml up -d", "search:docker:down": "docker-compose -f services/mana-search/docker-compose.yml down", "search:docker:logs": "docker-compose -f services/mana-search/docker-compose.yml logs -f", + "questions:dev": "turbo run dev --filter=questions...", + "dev:questions:backend": "pnpm --filter @questions/backend dev", + "dev:questions:web": "pnpm --filter @questions/web dev", + "dev:questions:app": "turbo run dev --filter=@questions/web --filter=@questions/backend", + "dev:questions:full": "./scripts/setup-databases.sh questions && ./scripts/setup-databases.sh auth && pnpm dev:search:docker && concurrently -n auth,search,backend -c blue,yellow,green \"pnpm dev:auth\" \"pnpm dev:search\" \"pnpm dev:questions:backend\"", + "questions:db:push": "pnpm --filter @questions/backend db:push", + "questions:db:studio": "pnpm --filter @questions/backend db:studio", "dev:projectdoc": "pnpm --filter @manacore/telegram-project-doc-bot start:dev", "dev:projectdoc:full": "./scripts/setup-databases.sh projectdoc && pnpm dev:projectdoc", "projectdoc:db:push": "pnpm --filter @manacore/telegram-project-doc-bot db:push", diff --git a/scripts/setup-databases.sh b/scripts/setup-databases.sh index c0e71ea3d..61f4903f6 100755 --- a/scripts/setup-databases.sh +++ b/scripts/setup-databases.sh @@ -78,6 +78,7 @@ ALL_DATABASES=( "zitare_bot" "todo_bot" "nutriphi_bot" + "questions" ) # Check if specific service requested @@ -175,9 +176,13 @@ setup_service() { create_db_if_not_exists "nutriphi_bot" push_schema "@manacore/telegram-nutriphi-bot" "nutriphi-bot" ;; + questions) + create_db_if_not_exists "questions" + push_schema "@questions/backend" "questions" + ;; *) echo -e "${RED}Unknown service: $service${NC}" - echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot" + echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot, questions" exit 1 ;; esac @@ -201,7 +206,7 @@ echo -e "\n${GREEN}Step 2: Pushing schemas${NC}" echo "--------------------------------------" # Push schemas for all known services -for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos planta nutriphi presi storage; do +for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos planta nutriphi presi storage questions; do setup_service "$service" 2>/dev/null || true done From f93ca53dfbd3dbe3105da7f5f3528655ac0936a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Jan 2026 00:00:53 +0000 Subject: [PATCH 6/7] feat(questions): implement SvelteKit web app Complete web app implementation with Svelte 5 runes: Features: - Authentication: Login, register pages with mana-core-auth integration - Question List: Filterable list with search, status, and collection filters - Question Detail: View research results and sources - New Question: Create questions with depth selection and auto-research - Collections Sidebar: Navigate and organize questions by collection - Dark Mode: Full theme support with toggle Structure: - src/lib/api/: API clients for all backend endpoints - src/lib/stores/: Svelte 5 reactive stores (auth, questions, collections) - src/lib/types/: TypeScript interfaces - src/routes/(app)/: Protected app routes - src/routes/(auth)/: Public auth routes Configuration: - Port: 5111 - Tailwind CSS with shared theme - lucide-svelte icons Also updated: - CLAUDE.md: Added web app documentation - package.json: Updated dev:questions:full to include web https://claude.ai/code/session_01Rk3YVJCU3nM8uvVPghRz6r --- apps/questions/CLAUDE.md | 26 +- apps/questions/apps/web/.env.example | 7 + apps/questions/apps/web/package.json | 48 +++ apps/questions/apps/web/src/app.css | 185 ++++++++++ apps/questions/apps/web/src/app.d.ts | 13 + apps/questions/apps/web/src/app.html | 14 + .../questions/apps/web/src/lib/api/answers.ts | 33 ++ apps/questions/apps/web/src/lib/api/client.ts | 97 ++++++ .../apps/web/src/lib/api/collections.ts | 32 ++ apps/questions/apps/web/src/lib/api/index.ts | 6 + .../apps/web/src/lib/api/questions.ts | 53 +++ .../apps/web/src/lib/api/research.ts | 20 ++ .../questions/apps/web/src/lib/api/sources.ts | 20 ++ .../apps/web/src/lib/stores/auth.svelte.ts | 186 +++++++++++ .../web/src/lib/stores/collections.svelte.ts | 119 +++++++ .../apps/web/src/lib/stores/index.ts | 4 + .../web/src/lib/stores/questions.svelte.ts | 116 +++++++ .../apps/web/src/lib/stores/theme.ts | 61 ++++ .../questions/apps/web/src/lib/types/index.ts | 148 ++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 173 ++++++++++ .../apps/web/src/routes/(app)/+page.svelte | 183 ++++++++++ .../web/src/routes/(app)/new/+page.svelte | 212 ++++++++++++ .../routes/(app)/question/[id]/+page.svelte | 316 ++++++++++++++++++ .../apps/web/src/routes/(auth)/+layout.svelte | 18 + .../web/src/routes/(auth)/login/+page.svelte | 81 +++++ .../src/routes/(auth)/register/+page.svelte | 125 +++++++ .../apps/web/src/routes/+layout.svelte | 39 +++ .../apps/web/src/routes/health/+server.ts | 8 + apps/questions/apps/web/svelte.config.js | 14 + apps/questions/apps/web/tsconfig.json | 14 + apps/questions/apps/web/vite.config.ts | 35 ++ package.json | 2 +- 32 files changed, 2399 insertions(+), 9 deletions(-) create mode 100644 apps/questions/apps/web/.env.example create mode 100644 apps/questions/apps/web/package.json create mode 100644 apps/questions/apps/web/src/app.css create mode 100644 apps/questions/apps/web/src/app.d.ts create mode 100644 apps/questions/apps/web/src/app.html create mode 100644 apps/questions/apps/web/src/lib/api/answers.ts create mode 100644 apps/questions/apps/web/src/lib/api/client.ts create mode 100644 apps/questions/apps/web/src/lib/api/collections.ts create mode 100644 apps/questions/apps/web/src/lib/api/index.ts create mode 100644 apps/questions/apps/web/src/lib/api/questions.ts create mode 100644 apps/questions/apps/web/src/lib/api/research.ts create mode 100644 apps/questions/apps/web/src/lib/api/sources.ts create mode 100644 apps/questions/apps/web/src/lib/stores/auth.svelte.ts create mode 100644 apps/questions/apps/web/src/lib/stores/collections.svelte.ts create mode 100644 apps/questions/apps/web/src/lib/stores/index.ts create mode 100644 apps/questions/apps/web/src/lib/stores/questions.svelte.ts create mode 100644 apps/questions/apps/web/src/lib/stores/theme.ts create mode 100644 apps/questions/apps/web/src/lib/types/index.ts create mode 100644 apps/questions/apps/web/src/routes/(app)/+layout.svelte create mode 100644 apps/questions/apps/web/src/routes/(app)/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/(app)/new/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/(auth)/+layout.svelte create mode 100644 apps/questions/apps/web/src/routes/(auth)/login/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/(auth)/register/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/+layout.svelte create mode 100644 apps/questions/apps/web/src/routes/health/+server.ts create mode 100644 apps/questions/apps/web/svelte.config.js create mode 100644 apps/questions/apps/web/tsconfig.json create mode 100644 apps/questions/apps/web/vite.config.ts diff --git a/apps/questions/CLAUDE.md b/apps/questions/CLAUDE.md index 218d67461..e5826e3e3 100644 --- a/apps/questions/CLAUDE.md +++ b/apps/questions/CLAUDE.md @@ -5,7 +5,8 @@ AI-powered research assistant that collects user questions and performs comprehe ## Overview - **Backend Port**: 3011 -- **Technology**: NestJS + Drizzle ORM + PostgreSQL +- **Web Port**: 5111 +- **Technology**: NestJS + Drizzle ORM + PostgreSQL + SvelteKit - **Search**: mana-search microservice (SearXNG) ## Architecture @@ -33,16 +34,25 @@ AI-powered research assistant that collects user questions and performs comprehe # 1. Start infrastructure (PostgreSQL, Redis, mana-search dependencies) pnpm docker:up -# 2. Start mana-search service -pnpm dev:search:full - -# 3. Start questions backend -pnpm dev:questions:backend - -# Or use the combined command: +# 2. Start everything (auth, search, backend, web): pnpm dev:questions:full + +# Or start components individually: +pnpm dev:questions:backend # Just backend (port 3011) +pnpm dev:questions:web # Just web (port 5111) +pnpm dev:search:full # Just search service (port 3021) ``` +## Web App + +The SvelteKit web app provides: + +- **Question Management**: Create, edit, and organize questions +- **Collection Organization**: Group questions into collections with colors/icons +- **Research Interface**: Start research and view results with sources +- **Source Viewer**: Explore extracted content from web sources +- **Dark Mode**: Full theme support + ## API Endpoints ### Collections diff --git a/apps/questions/apps/web/.env.example b/apps/questions/apps/web/.env.example new file mode 100644 index 000000000..18fefce29 --- /dev/null +++ b/apps/questions/apps/web/.env.example @@ -0,0 +1,7 @@ +# Questions Web App Environment Variables + +# Backend API URL +PUBLIC_BACKEND_URL=http://localhost:3011 + +# Mana Core Auth URL +PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/apps/questions/apps/web/package.json b/apps/questions/apps/web/package.json new file mode 100644 index 000000000..2cea9c910 --- /dev/null +++ b/apps/questions/apps/web/package.json @@ -0,0 +1,48 @@ +{ + "name": "@questions/web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "eslint .", + "format": "prettier --write .", + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.1.7", + "@types/node": "^20.0.0", + "prettier": "^3.1.1", + "prettier-plugin-svelte": "^3.1.2", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.1.7", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^6.0.0" + }, + "dependencies": { + "@manacore/shared-auth": "workspace:*", + "@manacore/shared-types": "workspace:*", + "@manacore/shared-utils": "workspace:*", + "@manacore/shared-auth-ui": "workspace:*", + "@manacore/shared-branding": "workspace:*", + "@manacore/shared-i18n": "workspace:*", + "@manacore/shared-icons": "workspace:*", + "@manacore/shared-tailwind": "workspace:*", + "@manacore/shared-theme": "workspace:*", + "@manacore/shared-theme-ui": "workspace:*", + "@manacore/shared-ui": "workspace:*", + "date-fns": "^4.1.0", + "lucide-svelte": "^0.556.0", + "svelte-i18n": "^4.0.1" + }, + "type": "module" +} diff --git a/apps/questions/apps/web/src/app.css b/apps/questions/apps/web/src/app.css new file mode 100644 index 000000000..348e01617 --- /dev/null +++ b/apps/questions/apps/web/src/app.css @@ -0,0 +1,185 @@ +@import 'tailwindcss'; +@import '@manacore/shared-tailwind/themes.css'; + +/* Scan shared packages for Tailwind classes */ +@source "../../../../../packages/shared-ui/src"; +@source "../../../../../packages/shared-theme-ui/src"; +@source "../../../../../packages/shared-theme-ui/src/components"; +@source "../../../../../packages/shared-theme-ui/src/pages"; + +:root { + /* Questions App - Indigo/Blue Theme */ + --color-primary: #6366f1; + --color-primary-hover: #4f46e5; + --color-primary-light: #818cf8; + --color-primary-dark: #4338ca; + + --color-secondary: #e0e7ff; + --color-secondary-hover: #c7d2fe; + + --color-accent: #8b5cf6; + --color-accent-hover: #7c3aed; + + /* Question status colors */ + --color-status-open: #6b7280; + --color-status-researching: #3b82f6; + --color-status-answered: #22c55e; + --color-status-archived: #9ca3af; + + /* Research depth colors */ + --color-depth-quick: #22c55e; + --color-depth-standard: #eab308; + --color-depth-deep: #8b5cf6; + + /* Priority colors */ + --color-priority-low: #6b7280; + --color-priority-normal: #3b82f6; + --color-priority-high: #f97316; + --color-priority-urgent: #ef4444; +} + +/* Dark mode overrides */ +:root.dark { + --color-secondary: #1e1b4b; + --color-secondary-hover: #2e1065; +} + +/* Question card transitions */ +.question-card { + transition: + transform 0.15s ease, + box-shadow 0.15s ease, + border-color 0.15s ease; +} + +.question-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +/* Collection item styling */ +.collection-item { + transition: background-color 0.15s ease; +} + +.collection-item:hover { + background-color: var(--color-secondary); +} + +.collection-item.active { + background-color: var(--color-secondary); + border-left: 3px solid var(--color-primary); +} + +/* Research progress animation */ +.research-progress { + background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-accent) 100%); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +/* Source card */ +.source-card { + transition: all 0.15s ease; + border-left: 3px solid transparent; +} + +.source-card:hover { + border-left-color: var(--color-primary); + background-color: var(--color-secondary); +} + +/* Answer styling */ +.answer-accepted { + border: 2px solid var(--color-status-answered); + background-color: rgba(34, 197, 94, 0.05); +} + +/* Tag badges */ +.tag-badge { + transition: all 0.15s ease; +} + +.tag-badge:hover { + transform: scale(1.05); +} + +/* Depth indicator */ +.depth-indicator { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.depth-quick { + background-color: rgba(34, 197, 94, 0.1); + color: var(--color-depth-quick); +} + +.depth-standard { + background-color: rgba(234, 179, 8, 0.1); + color: var(--color-depth-standard); +} + +.depth-deep { + background-color: rgba(139, 92, 246, 0.1); + color: var(--color-depth-deep); +} + +/* Markdown content styling */ +.markdown-content { + line-height: 1.7; +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; +} + +.markdown-content p { + margin-bottom: 1em; +} + +.markdown-content ul, +.markdown-content ol { + margin-left: 1.5em; + margin-bottom: 1em; +} + +.markdown-content code { + background-color: var(--color-secondary); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-size: 0.875em; +} + +.markdown-content pre { + background-color: var(--color-secondary); + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; + margin-bottom: 1em; +} + +.markdown-content blockquote { + border-left: 3px solid var(--color-primary); + padding-left: 1rem; + margin-left: 0; + color: var(--color-text-muted); +} diff --git a/apps/questions/apps/web/src/app.d.ts b/apps/questions/apps/web/src/app.d.ts new file mode 100644 index 000000000..743f07b2e --- /dev/null +++ b/apps/questions/apps/web/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/apps/questions/apps/web/src/app.html b/apps/questions/apps/web/src/app.html new file mode 100644 index 000000000..3908e9ccf --- /dev/null +++ b/apps/questions/apps/web/src/app.html @@ -0,0 +1,14 @@ + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/questions/apps/web/src/lib/api/answers.ts b/apps/questions/apps/web/src/lib/api/answers.ts new file mode 100644 index 000000000..c45fc0e38 --- /dev/null +++ b/apps/questions/apps/web/src/lib/api/answers.ts @@ -0,0 +1,33 @@ +import { apiClient } from './client'; +import type { Answer } from '$lib/types'; + +export interface RateAnswerDto { + rating: number; + feedback?: string; +} + +export const answersApi = { + async getByQuestion(questionId: string): Promise { + return apiClient.get(`/api/v1/answers/question/${questionId}`); + }, + + async getAccepted(questionId: string): Promise { + return apiClient.get(`/api/v1/answers/question/${questionId}/accepted`); + }, + + async getById(id: string): Promise { + return apiClient.get(`/api/v1/answers/${id}`); + }, + + async rate(id: string, data: RateAnswerDto): Promise { + return apiClient.post(`/api/v1/answers/${id}/rate`, data); + }, + + async accept(id: string, isAccepted: boolean): Promise { + return apiClient.post(`/api/v1/answers/${id}/accept`, { isAccepted }); + }, + + async delete(id: string): Promise { + await apiClient.delete(`/api/v1/answers/${id}`); + }, +}; diff --git a/apps/questions/apps/web/src/lib/api/client.ts b/apps/questions/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..b874f2920 --- /dev/null +++ b/apps/questions/apps/web/src/lib/api/client.ts @@ -0,0 +1,97 @@ +import { browser } from '$app/environment'; +import { PUBLIC_BACKEND_URL } from '$env/static/public'; + +interface ApiOptions { + method?: string; + body?: unknown; + headers?: Record; +} + +interface ApiError { + message: string; + statusCode: number; +} + +/** + * Get the backend URL, preferring runtime-injected value in browser + */ +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + if (runtimeUrl) { + return runtimeUrl; + } + } + return PUBLIC_BACKEND_URL || 'http://localhost:3011'; +} + +class ApiClient { + private accessToken: string | null = null; + + private get baseUrl(): string { + return getBackendUrl(); + } + + setAccessToken(token: string | null) { + this.accessToken = token; + } + + getAccessToken(): string | null { + return this.accessToken; + } + + async fetch(endpoint: string, options: ApiOptions = {}): Promise { + const { method = 'GET', body, headers = {} } = options; + + const requestHeaders: Record = { + 'Content-Type': 'application/json', + ...headers, + }; + + if (this.accessToken) { + requestHeaders['Authorization'] = `Bearer ${this.accessToken}`; + } + + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method, + headers: requestHeaders, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + let errorMessage = 'An error occurred'; + try { + const errorData = (await response.json()) as ApiError; + errorMessage = errorData.message || errorMessage; + } catch { + errorMessage = response.statusText || errorMessage; + } + throw new Error(errorMessage); + } + + if (response.status === 204) { + return {} as T; + } + + return response.json() as Promise; + } + + get(endpoint: string, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'GET', headers }); + } + + post(endpoint: string, body?: unknown, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'POST', body, headers }); + } + + put(endpoint: string, body?: unknown, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'PUT', body, headers }); + } + + delete(endpoint: string, headers?: Record): Promise { + return this.fetch(endpoint, { method: 'DELETE', headers }); + } +} + +export const apiClient = new ApiClient(); diff --git a/apps/questions/apps/web/src/lib/api/collections.ts b/apps/questions/apps/web/src/lib/api/collections.ts new file mode 100644 index 000000000..9ec8995b1 --- /dev/null +++ b/apps/questions/apps/web/src/lib/api/collections.ts @@ -0,0 +1,32 @@ +import { apiClient } from './client'; +import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types'; + +export const collectionsApi = { + async getAll(): Promise { + return apiClient.get('/api/v1/collections'); + }, + + async getById(id: string): Promise { + return apiClient.get(`/api/v1/collections/${id}`); + }, + + async getDefault(): Promise { + return apiClient.get('/api/v1/collections/default'); + }, + + async create(data: CreateCollectionDto): Promise { + return apiClient.post('/api/v1/collections', data); + }, + + async update(id: string, data: UpdateCollectionDto): Promise { + return apiClient.put(`/api/v1/collections/${id}`, data); + }, + + async delete(id: string): Promise { + await apiClient.delete(`/api/v1/collections/${id}`); + }, + + async reorder(orderedIds: string[]): Promise { + await apiClient.post('/api/v1/collections/reorder', { orderedIds }); + }, +}; diff --git a/apps/questions/apps/web/src/lib/api/index.ts b/apps/questions/apps/web/src/lib/api/index.ts new file mode 100644 index 000000000..48d25f2ea --- /dev/null +++ b/apps/questions/apps/web/src/lib/api/index.ts @@ -0,0 +1,6 @@ +export { apiClient } from './client'; +export { questionsApi } from './questions'; +export { collectionsApi } from './collections'; +export { researchApi } from './research'; +export { answersApi } from './answers'; +export { sourcesApi } from './sources'; diff --git a/apps/questions/apps/web/src/lib/api/questions.ts b/apps/questions/apps/web/src/lib/api/questions.ts new file mode 100644 index 000000000..e20de37af --- /dev/null +++ b/apps/questions/apps/web/src/lib/api/questions.ts @@ -0,0 +1,53 @@ +import { apiClient } from './client'; +import type { + Question, + CreateQuestionDto, + UpdateQuestionDto, + PaginatedResponse, +} from '$lib/types'; + +export interface QuestionFilters { + collectionId?: string; + status?: string; + search?: string; + tags?: string[]; + limit?: number; + offset?: number; +} + +export const questionsApi = { + async getAll(filters?: QuestionFilters): Promise> { + const params = new URLSearchParams(); + if (filters?.collectionId) params.set('collectionId', filters.collectionId); + if (filters?.status) params.set('status', filters.status); + if (filters?.search) params.set('search', filters.search); + if (filters?.tags?.length) params.set('tags', filters.tags.join(',')); + if (filters?.limit) params.set('limit', filters.limit.toString()); + if (filters?.offset) params.set('offset', filters.offset.toString()); + + const query = params.toString(); + return apiClient.get>( + `/api/v1/questions${query ? `?${query}` : ''}`, + ); + }, + + async getById(id: string): Promise { + return apiClient.get(`/api/v1/questions/${id}`); + }, + + async create(data: CreateQuestionDto): Promise { + return apiClient.post('/api/v1/questions', data); + }, + + async update(id: string, data: UpdateQuestionDto): Promise { + return apiClient.put(`/api/v1/questions/${id}`, data); + }, + + async delete(id: string): Promise { + await apiClient.delete(`/api/v1/questions/${id}`); + }, + + async updateStatus(id: string, status: string): Promise { + return apiClient.put(`/api/v1/questions/${id}/status`, { status }); + }, +}; diff --git a/apps/questions/apps/web/src/lib/api/research.ts b/apps/questions/apps/web/src/lib/api/research.ts new file mode 100644 index 000000000..8abcbaf65 --- /dev/null +++ b/apps/questions/apps/web/src/lib/api/research.ts @@ -0,0 +1,20 @@ +import { apiClient } from './client'; +import type { ResearchResult, StartResearchDto } from '$lib/types'; + +export const researchApi = { + async start(data: StartResearchDto): Promise { + return apiClient.post('/api/v1/research/start', data); + }, + + async getByQuestion(questionId: string): Promise { + return apiClient.get(`/api/v1/research/question/${questionId}`); + }, + + async getById(id: string): Promise { + return apiClient.get(`/api/v1/research/${id}`); + }, + + async checkHealth(): Promise<{ service: string; status: string }> { + return apiClient.get('/api/v1/research/health/search'); + }, +}; diff --git a/apps/questions/apps/web/src/lib/api/sources.ts b/apps/questions/apps/web/src/lib/api/sources.ts new file mode 100644 index 000000000..c7a353f0c --- /dev/null +++ b/apps/questions/apps/web/src/lib/api/sources.ts @@ -0,0 +1,20 @@ +import { apiClient } from './client'; +import type { Source } from '$lib/types'; + +export const sourcesApi = { + async getByResearchResult(researchResultId: string): Promise { + return apiClient.get(`/api/v1/sources/research/${researchResultId}`); + }, + + async getByQuestion(questionId: string): Promise { + return apiClient.get(`/api/v1/sources/question/${questionId}`); + }, + + async getById(id: string): Promise { + return apiClient.get(`/api/v1/sources/${id}`); + }, + + async getContent(id: string): Promise<{ text: string; markdown?: string }> { + return apiClient.get(`/api/v1/sources/${id}/content`); + }, +}; diff --git a/apps/questions/apps/web/src/lib/stores/auth.svelte.ts b/apps/questions/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..443ddd858 --- /dev/null +++ b/apps/questions/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,186 @@ +/** + * Auth Store - Manages authentication state using Svelte 5 runes + * Uses Mana Core Auth + */ + +import { browser } from '$app/environment'; +import { initializeWebAuth } from '@manacore/shared-auth'; +import type { UserData } from '@manacore/shared-auth'; + +function getAuthUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) + .__PUBLIC_MANA_CORE_AUTH_URL__; + return injectedUrl || 'http://localhost:3001'; + } + return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; +} + +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + return injectedUrl || 'http://localhost:3011'; + } + return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3011'; +} + +let _authService: ReturnType['authService'] | null = null; +let _tokenManager: ReturnType['tokenManager'] | null = null; + +function getAuthService() { + if (!browser) return null; + if (!_authService) { + const auth = initializeWebAuth({ + baseUrl: getAuthUrl(), + backendUrl: getBackendUrl(), + }); + _authService = auth.authService; + _tokenManager = auth.tokenManager; + } + return _authService; +} + +function getTokenManager() { + if (!browser) return null; + getAuthService(); + return _tokenManager; +} + +let user = $state(null); +let loading = $state(true); +let initialized = $state(false); + +export const authStore = { + get user() { + return user; + }, + get loading() { + return loading; + }, + get isAuthenticated() { + return !!user; + }, + get initialized() { + return initialized; + }, + + async initialize() { + if (initialized) return; + + const authService = getAuthService(); + if (!authService) { + initialized = true; + loading = false; + return; + } + + loading = true; + try { + const authenticated = await authService.isAuthenticated(); + if (authenticated) { + const userData = await authService.getUserFromToken(); + user = userData; + } + initialized = true; + } catch (error) { + console.error('Failed to initialize auth:', error); + user = null; + } finally { + loading = false; + } + }, + + async signIn(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signIn(email, password); + + if (!result.success) { + return { success: false, error: result.error || 'Login failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + async signUp(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server', needsVerification: false }; + } + + try { + const sourceAppUrl = browser ? window.location.origin : undefined; + const result = await authService.signUp(email, password, undefined, sourceAppUrl); + + if (!result.success) { + return { success: false, error: result.error || 'Signup failed', needsVerification: false }; + } + + if (result.needsVerification) { + return { success: true, needsVerification: true }; + } + + const signInResult = await this.signIn(email, password); + return { ...signInResult, needsVerification: false }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage, needsVerification: false }; + } + }, + + async signOut() { + const authService = getAuthService(); + if (!authService) { + user = null; + return; + } + + try { + await authService.signOut(); + user = null; + } catch (error) { + console.error('Sign out error:', error); + user = null; + } + }, + + async resetPassword(email: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.forgotPassword(email); + + if (!result.success) { + return { success: false, error: result.error || 'Password reset failed' }; + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + async getValidToken(): Promise { + const tokenManager = getTokenManager(); + if (!tokenManager) { + return null; + } + return await tokenManager.getValidToken(); + }, +}; diff --git a/apps/questions/apps/web/src/lib/stores/collections.svelte.ts b/apps/questions/apps/web/src/lib/stores/collections.svelte.ts new file mode 100644 index 000000000..71f4797b0 --- /dev/null +++ b/apps/questions/apps/web/src/lib/stores/collections.svelte.ts @@ -0,0 +1,119 @@ +/** + * Collections Store - Manages collections state using Svelte 5 runes + */ + +import { collectionsApi } from '$lib/api/collections'; +import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types'; + +let collections = $state([]); +let loading = $state(false); +let error = $state(null); +let selectedId = $state(null); + +export const collectionsStore = { + get collections() { + return collections; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get selectedId() { + return selectedId; + }, + get selected() { + return selectedId ? collections.find((c) => c.id === selectedId) : null; + }, + + async load() { + loading = true; + error = null; + + try { + collections = await collectionsApi.getAll(); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load collections'; + collections = []; + } finally { + loading = false; + } + }, + + async create(data: CreateCollectionDto): Promise { + loading = true; + error = null; + + try { + const collection = await collectionsApi.create(data); + collections = [...collections, collection]; + return collection; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create collection'; + return null; + } finally { + loading = false; + } + }, + + async update(id: string, data: UpdateCollectionDto): Promise { + error = null; + + try { + const updated = await collectionsApi.update(id, data); + collections = collections.map((c) => (c.id === id ? updated : c)); + return updated; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update collection'; + return null; + } + }, + + async delete(id: string): Promise { + error = null; + + try { + await collectionsApi.delete(id); + collections = collections.filter((c) => c.id !== id); + if (selectedId === id) { + selectedId = null; + } + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete collection'; + return false; + } + }, + + async reorder(orderedIds: string[]): Promise { + error = null; + + try { + await collectionsApi.reorder(orderedIds); + // Reorder local state + const reordered = orderedIds + .map((id) => collections.find((c) => c.id === id)) + .filter((c): c is Collection => c !== undefined); + collections = reordered; + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to reorder collections'; + return false; + } + }, + + select(id: string | null) { + selectedId = id; + }, + + getById(id: string): Collection | undefined { + return collections.find((c) => c.id === id); + }, + + clear() { + collections = []; + error = null; + selectedId = null; + }, +}; diff --git a/apps/questions/apps/web/src/lib/stores/index.ts b/apps/questions/apps/web/src/lib/stores/index.ts new file mode 100644 index 000000000..fb294d996 --- /dev/null +++ b/apps/questions/apps/web/src/lib/stores/index.ts @@ -0,0 +1,4 @@ +export { authStore } from './auth.svelte'; +export { questionsStore } from './questions.svelte'; +export { collectionsStore } from './collections.svelte'; +export { theme } from './theme'; diff --git a/apps/questions/apps/web/src/lib/stores/questions.svelte.ts b/apps/questions/apps/web/src/lib/stores/questions.svelte.ts new file mode 100644 index 000000000..b1b8466e1 --- /dev/null +++ b/apps/questions/apps/web/src/lib/stores/questions.svelte.ts @@ -0,0 +1,116 @@ +/** + * Questions Store - Manages questions state using Svelte 5 runes + */ + +import { questionsApi, type QuestionFilters } from '$lib/api/questions'; +import type { Question, CreateQuestionDto, UpdateQuestionDto } from '$lib/types'; + +let questions = $state([]); +let loading = $state(false); +let error = $state(null); +let total = $state(0); +let currentFilters = $state({}); + +export const questionsStore = { + get questions() { + return questions; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get total() { + return total; + }, + get filters() { + return currentFilters; + }, + + async load(filters?: QuestionFilters) { + loading = true; + error = null; + currentFilters = filters || {}; + + try { + const response = await questionsApi.getAll(filters); + questions = response.data; + total = response.total; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load questions'; + questions = []; + total = 0; + } finally { + loading = false; + } + }, + + async create(data: CreateQuestionDto): Promise { + loading = true; + error = null; + + try { + const question = await questionsApi.create(data); + questions = [question, ...questions]; + total++; + return question; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create question'; + return null; + } finally { + loading = false; + } + }, + + async update(id: string, data: UpdateQuestionDto): Promise { + error = null; + + try { + const updated = await questionsApi.update(id, data); + questions = questions.map((q) => (q.id === id ? updated : q)); + return updated; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update question'; + return null; + } + }, + + async delete(id: string): Promise { + error = null; + + try { + await questionsApi.delete(id); + questions = questions.filter((q) => q.id !== id); + total--; + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete question'; + return false; + } + }, + + async updateStatus(id: string, status: string): Promise { + error = null; + + try { + const updated = await questionsApi.updateStatus(id, status); + questions = questions.map((q) => (q.id === id ? updated : q)); + return updated; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update status'; + return null; + } + }, + + getById(id: string): Question | undefined { + return questions.find((q) => q.id === id); + }, + + clear() { + questions = []; + total = 0; + error = null; + currentFilters = {}; + }, +}; diff --git a/apps/questions/apps/web/src/lib/stores/theme.ts b/apps/questions/apps/web/src/lib/stores/theme.ts new file mode 100644 index 000000000..61984650e --- /dev/null +++ b/apps/questions/apps/web/src/lib/stores/theme.ts @@ -0,0 +1,61 @@ +import { browser } from '$app/environment'; + +type Theme = 'light' | 'dark' | 'system'; + +function getInitialTheme(): Theme { + if (!browser) return 'system'; + + const stored = localStorage.getItem('theme') as Theme | null; + if (stored && ['light', 'dark', 'system'].includes(stored)) { + return stored; + } + return 'system'; +} + +function applyTheme(theme: Theme) { + if (!browser) return; + + const root = document.documentElement; + const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const isDark = theme === 'dark' || (theme === 'system' && systemDark); + + if (isDark) { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } +} + +let currentTheme: Theme = 'system'; + +export const theme = { + get current() { + return currentTheme; + }, + + initialize() { + currentTheme = getInitialTheme(); + applyTheme(currentTheme); + + if (browser) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (currentTheme === 'system') { + applyTheme('system'); + } + }); + } + }, + + set(newTheme: Theme) { + currentTheme = newTheme; + if (browser) { + localStorage.setItem('theme', newTheme); + } + applyTheme(newTheme); + }, + + toggle() { + const next = currentTheme === 'light' ? 'dark' : 'light'; + this.set(next); + }, +}; diff --git a/apps/questions/apps/web/src/lib/types/index.ts b/apps/questions/apps/web/src/lib/types/index.ts new file mode 100644 index 000000000..0fac15bbc --- /dev/null +++ b/apps/questions/apps/web/src/lib/types/index.ts @@ -0,0 +1,148 @@ +export interface Collection { + id: string; + userId: string; + name: string; + description?: string; + color: string; + icon: string; + isDefault: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; + questionCount?: number; +} + +export interface Question { + id: string; + userId: string; + collectionId?: string; + title: string; + description?: string; + status: QuestionStatus; + priority: QuestionPriority; + tags: string[]; + researchDepth: ResearchDepth; + createdAt: string; + updatedAt: string; +} + +export type QuestionStatus = 'open' | 'researching' | 'answered' | 'archived'; +export type QuestionPriority = 'low' | 'normal' | 'high' | 'urgent'; +export type ResearchDepth = 'quick' | 'standard' | 'deep'; + +export interface ResearchResult { + id: string; + questionId: string; + modelId: string; + provider: string; + researchDepth: ResearchDepth; + summary: string; + keyPoints: string[]; + followUpQuestions: string[]; + promptTokens?: number; + completionTokens?: number; + estimatedCost?: number; + createdAt: string; + durationMs?: number; + sources?: Source[]; +} + +export interface Source { + id: string; + researchResultId: string; + url: string; + title: string; + snippet?: string; + domain?: string; + extractedContent?: string; + contentMarkdown?: string; + wordCount?: number; + readingTime?: number; + relevanceScore?: number; + position: number; + engine?: string; + author?: string; + publishedDate?: string; + siteName?: string; + createdAt: string; +} + +export interface Answer { + id: string; + questionId: string; + researchResultId?: string; + content: string; + contentMarkdown?: string; + summary?: string; + modelId: string; + provider: string; + promptTokens?: number; + completionTokens?: number; + estimatedCost?: number; + confidence?: number; + sourceCount?: number; + citations: Citation[]; + rating?: number; + feedback?: string; + isAccepted: boolean; + version: number; + createdAt: string; + updatedAt: string; + durationMs?: number; +} + +export interface Citation { + sourceId: string; + text: string; + position: number; +} + +export interface CreateQuestionDto { + title: string; + description?: string; + collectionId?: string; + tags?: string[]; + priority?: QuestionPriority; + researchDepth?: ResearchDepth; +} + +export interface UpdateQuestionDto { + title?: string; + description?: string; + collectionId?: string; + tags?: string[]; + priority?: QuestionPriority; + status?: QuestionStatus; + researchDepth?: ResearchDepth; +} + +export interface CreateCollectionDto { + name: string; + description?: string; + color?: string; + icon?: string; + isDefault?: boolean; +} + +export interface UpdateCollectionDto { + name?: string; + description?: string; + color?: string; + icon?: string; + isDefault?: boolean; + sortOrder?: number; +} + +export interface StartResearchDto { + questionId: string; + depth?: ResearchDepth; + categories?: string[]; + engines?: string[]; + language?: string; + maxSources?: number; +} + +export interface PaginatedResponse { + data: T[]; + total: number; +} diff --git a/apps/questions/apps/web/src/routes/(app)/+layout.svelte b/apps/questions/apps/web/src/routes/(app)/+layout.svelte new file mode 100644 index 000000000..592cb5a17 --- /dev/null +++ b/apps/questions/apps/web/src/routes/(app)/+layout.svelte @@ -0,0 +1,173 @@ + + +
+ + + + +
+ {@render children()} +
+
diff --git a/apps/questions/apps/web/src/routes/(app)/+page.svelte b/apps/questions/apps/web/src/routes/(app)/+page.svelte new file mode 100644 index 000000000..60b85e7cd --- /dev/null +++ b/apps/questions/apps/web/src/routes/(app)/+page.svelte @@ -0,0 +1,183 @@ + + +
+ +
+

+ {collectionsStore.selected ? collectionsStore.selected.name : 'All Questions'} +

+

+ {questionsStore.total} question{questionsStore.total !== 1 ? 's' : ''} +

+
+ + +
+
+ + e.key === 'Enter' && handleSearch()} + placeholder="Search questions..." + class="w-full rounded-lg border border-border bg-background py-2 pl-10 pr-4 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
+ + + + +
+ + + {#if questionsStore.loading} +
+ +
+ {:else if questionsStore.questions.length === 0} +
+
🤔
+

No questions yet

+

+ Start by asking a question and let AI research it for you. +

+ + Ask a Question + +
+ {:else} + + {/if} +
diff --git a/apps/questions/apps/web/src/routes/(app)/new/+page.svelte b/apps/questions/apps/web/src/routes/(app)/new/+page.svelte new file mode 100644 index 000000000..00485d542 --- /dev/null +++ b/apps/questions/apps/web/src/routes/(app)/new/+page.svelte @@ -0,0 +1,212 @@ + + +
+ +
+ + + Back to questions + +

Ask a Question

+

+ Enter your question and let AI research it for you +

+
+ +
+ {#if error} +
+ {error} +
+ {/if} + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ {#each tags as tag} + + {tag} + + + {/each} +
+ e.key === 'Enter' && (e.preventDefault(), addTag())} + placeholder="Add a tag and press Enter" + class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
+ + +
+ +
+ {#each depthOptions as option} + + {/each} +
+
+ + +
+ + +
+ + +
+ + Cancel + + +
+
+
diff --git a/apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte b/apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte new file mode 100644 index 000000000..5a0340811 --- /dev/null +++ b/apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte @@ -0,0 +1,316 @@ + + +
+ {#if loading} +
+ +
+ {:else if error} +
+ {error} +
+ {:else if question} + +
+ + + Back to questions + + +
+
+

{question.title}

+ {#if question.description} +

{question.description}

+ {/if} + +
+ + + {statusLabels[question.status].label} + + + + + {question.researchDepth} + + + + {#if question.tags?.length} + {#each question.tags as tag} + + {tag} + + {/each} + {/if} + + + + {formatDate(question.createdAt)} + +
+
+ + +
+ {#if question.status === 'open'} + + {/if} +
+
+
+ + + {#if researchResults.length > 0} +
+

Research Results

+ + {#each researchResults as result} +
+ +
+

Summary

+
+ {result.summary} +
+
+ + + {#if result.keyPoints?.length} +
+

Key Points

+
    + {#each result.keyPoints as point} +
  • {point}
  • + {/each} +
+
+ {/if} + + + {#if result.followUpQuestions?.length} +
+

Follow-up Questions

+
    + {#each result.followUpQuestions as followUp} +
  • {followUp}
  • + {/each} +
+
+ {/if} + + +
+ Depth: {result.researchDepth} + {#if result.durationMs} + Duration: {(result.durationMs / 1000).toFixed(1)}s + {/if} + {formatDate(result.createdAt)} +
+
+ {/each} +
+ {:else if question.status === 'open'} +
+
🔍
+

No research yet

+

+ Click "Start Research" to begin gathering information about this question. +

+
+ {/if} + + + {#if sources.length > 0} +
+

Sources ({sources.length})

+ +
+ {#each sources as source} +
+
+
+
+ #{source.position} + + {source.title} + + +
+ +

{source.domain}

+ + {#if source.snippet} +

+ {source.snippet} +

+ {/if} + + {#if source.extractedContent && expandedSources.has(source.id)} +
+
+ {source.extractedContent.substring(0, 2000)} + {#if source.extractedContent.length > 2000} + ... (truncated) + {/if} +
+
+ {/if} + +
+ {#if source.relevanceScore} + + Score: {(source.relevanceScore * 100).toFixed(0)}% + + {/if} + {#if source.wordCount} + + {source.wordCount} words + + {/if} + {#if source.engine} + + via {source.engine} + + {/if} +
+
+ + {#if source.extractedContent} + + {/if} +
+
+ {/each} +
+
+ {/if} + {/if} +
diff --git a/apps/questions/apps/web/src/routes/(auth)/+layout.svelte b/apps/questions/apps/web/src/routes/(auth)/+layout.svelte new file mode 100644 index 000000000..91578956b --- /dev/null +++ b/apps/questions/apps/web/src/routes/(auth)/+layout.svelte @@ -0,0 +1,18 @@ + + +
+
+ {@render children()} +
+
diff --git a/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte b/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte new file mode 100644 index 000000000..97e32d1b0 --- /dev/null +++ b/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte @@ -0,0 +1,81 @@ + + +
+
+

Questions

+

Sign in to your account

+
+ +
+ {#if error} +
+ {error} +
+ {/if} + +
+ + +
+ +
+ + +
+ + +
+ + +
diff --git a/apps/questions/apps/web/src/routes/(auth)/register/+page.svelte b/apps/questions/apps/web/src/routes/(auth)/register/+page.svelte new file mode 100644 index 000000000..af668cd9c --- /dev/null +++ b/apps/questions/apps/web/src/routes/(auth)/register/+page.svelte @@ -0,0 +1,125 @@ + + +
+
+

Questions

+

Create your account

+
+ + {#if needsVerification} +
+
📧
+

Check your email

+

+ We've sent a verification link to {email}. Please check your inbox and + click the link to verify your account. +

+ Back to login +
+ {:else} +
+ {#if error} +
+ {error} +
+ {/if} + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ Already have an account? + Sign in +
+ {/if} +
diff --git a/apps/questions/apps/web/src/routes/+layout.svelte b/apps/questions/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..39771babd --- /dev/null +++ b/apps/questions/apps/web/src/routes/+layout.svelte @@ -0,0 +1,39 @@ + + +{#if loading} +
+
+
+

Loading...

+
+
+{:else} +
+ {@render children()} +
+{/if} diff --git a/apps/questions/apps/web/src/routes/health/+server.ts b/apps/questions/apps/web/src/routes/health/+server.ts new file mode 100644 index 000000000..f8ddc452f --- /dev/null +++ b/apps/questions/apps/web/src/routes/health/+server.ts @@ -0,0 +1,8 @@ +import { json } from '@sveltejs/kit'; + +export async function GET() { + return json({ + status: 'ok', + timestamp: new Date().toISOString(), + }); +} diff --git a/apps/questions/apps/web/svelte.config.js b/apps/questions/apps/web/svelte.config.js new file mode 100644 index 000000000..a7a917e4c --- /dev/null +++ b/apps/questions/apps/web/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + out: 'build', + }), + }, +}; + +export default config; diff --git a/apps/questions/apps/web/tsconfig.json b/apps/questions/apps/web/tsconfig.json new file mode 100644 index 000000000..a8f10c8e3 --- /dev/null +++ b/apps/questions/apps/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/apps/questions/apps/web/vite.config.ts b/apps/questions/apps/web/vite.config.ts new file mode 100644 index 000000000..a51885fb7 --- /dev/null +++ b/apps/questions/apps/web/vite.config.ts @@ -0,0 +1,35 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + port: 5111, + strictPort: true, + }, + ssr: { + noExternal: [ + '@manacore/shared-icons', + '@manacore/shared-ui', + '@manacore/shared-tailwind', + '@manacore/shared-theme', + '@manacore/shared-theme-ui', + '@manacore/shared-auth', + '@manacore/shared-auth-ui', + '@manacore/shared-branding', + ], + }, + optimizeDeps: { + exclude: [ + '@manacore/shared-icons', + '@manacore/shared-ui', + '@manacore/shared-tailwind', + '@manacore/shared-theme', + '@manacore/shared-theme-ui', + '@manacore/shared-auth', + '@manacore/shared-auth-ui', + '@manacore/shared-branding', + ], + }, +}); diff --git a/package.json b/package.json index 9a799de9f..bca50a4bb 100644 --- a/package.json +++ b/package.json @@ -222,7 +222,7 @@ "dev:questions:backend": "pnpm --filter @questions/backend dev", "dev:questions:web": "pnpm --filter @questions/web dev", "dev:questions:app": "turbo run dev --filter=@questions/web --filter=@questions/backend", - "dev:questions:full": "./scripts/setup-databases.sh questions && ./scripts/setup-databases.sh auth && pnpm dev:search:docker && concurrently -n auth,search,backend -c blue,yellow,green \"pnpm dev:auth\" \"pnpm dev:search\" \"pnpm dev:questions:backend\"", + "dev:questions:full": "./scripts/setup-databases.sh questions && ./scripts/setup-databases.sh auth && pnpm dev:search:docker && concurrently -n auth,search,backend,web -c blue,yellow,green,cyan \"pnpm dev:auth\" \"pnpm dev:search\" \"pnpm dev:questions:backend\" \"pnpm dev:questions:web\"", "questions:db:push": "pnpm --filter @questions/backend db:push", "questions:db:studio": "pnpm --filter @questions/backend db:studio", "dev:projectdoc": "pnpm --filter @manacore/telegram-project-doc-bot start:dev", From 928cac67995ca6d2c0e7b678703a2cf9b3b7d498 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Jan 2026 00:43:59 +0000 Subject: [PATCH 7/7] feat(questions): add production-ready pages and components - Add forgot-password page with email recovery flow - Add settings page with account, theme, notifications, privacy options - Add collections management page with create/edit/delete functionality - Add CollectionModal component for collection CRUD - Add ErrorAlert component for consistent error handling - Add loading skeletons (QuestionSkeleton, QuestionDetailSkeleton, AppLoadingSkeleton) - Update layouts and pages to use skeletons and error handling https://claude.ai/code/session_01Rk3YVJCU3nM8uvVPghRz6r --- .../src/lib/components/CollectionModal.svelte | 218 ++++++++++++++++++ .../web/src/lib/components/ErrorAlert.svelte | 39 ++++ .../apps/web/src/lib/components/index.ts | 3 + .../skeletons/AppLoadingSkeleton.svelte | 47 ++++ .../skeletons/QuestionDetailSkeleton.svelte | 56 +++++ .../skeletons/QuestionSkeleton.svelte | 32 +++ .../web/src/lib/components/skeletons/index.ts | 3 + .../apps/web/src/routes/(app)/+layout.svelte | 4 +- .../apps/web/src/routes/(app)/+page.svelte | 16 +- .../src/routes/(app)/collections/+page.svelte | 162 +++++++++++++ .../routes/(app)/question/[id]/+page.svelte | 19 +- .../src/routes/(app)/settings/+page.svelte | 178 ++++++++++++++ .../(auth)/forgot-password/+page.svelte | 81 +++++++ .../apps/web/src/routes/+layout.svelte | 10 +- 14 files changed, 845 insertions(+), 23 deletions(-) create mode 100644 apps/questions/apps/web/src/lib/components/CollectionModal.svelte create mode 100644 apps/questions/apps/web/src/lib/components/ErrorAlert.svelte create mode 100644 apps/questions/apps/web/src/lib/components/index.ts create mode 100644 apps/questions/apps/web/src/lib/components/skeletons/AppLoadingSkeleton.svelte create mode 100644 apps/questions/apps/web/src/lib/components/skeletons/QuestionDetailSkeleton.svelte create mode 100644 apps/questions/apps/web/src/lib/components/skeletons/QuestionSkeleton.svelte create mode 100644 apps/questions/apps/web/src/lib/components/skeletons/index.ts create mode 100644 apps/questions/apps/web/src/routes/(app)/collections/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/(app)/settings/+page.svelte create mode 100644 apps/questions/apps/web/src/routes/(auth)/forgot-password/+page.svelte diff --git a/apps/questions/apps/web/src/lib/components/CollectionModal.svelte b/apps/questions/apps/web/src/lib/components/CollectionModal.svelte new file mode 100644 index 000000000..5cf5846ec --- /dev/null +++ b/apps/questions/apps/web/src/lib/components/CollectionModal.svelte @@ -0,0 +1,218 @@ + + + +
+
e.stopPropagation()}> + +
+

+ {collection ? 'Edit Collection' : 'New Collection'} +

+ +
+ + +
+ {#if error} +
+ {error} +
+ {/if} + + +
+ + +
+ + +
+ + +
+ + +
+ +
+ {#each colors as c} + + {/each} +
+
+ + +
+ +
+ {#each icons as i} + + {/each} +
+
+ + +
+ +
+ + +
+ + +
+
+
+
diff --git a/apps/questions/apps/web/src/lib/components/ErrorAlert.svelte b/apps/questions/apps/web/src/lib/components/ErrorAlert.svelte new file mode 100644 index 000000000..600e60485 --- /dev/null +++ b/apps/questions/apps/web/src/lib/components/ErrorAlert.svelte @@ -0,0 +1,39 @@ + + + diff --git a/apps/questions/apps/web/src/lib/components/index.ts b/apps/questions/apps/web/src/lib/components/index.ts new file mode 100644 index 000000000..5132757ad --- /dev/null +++ b/apps/questions/apps/web/src/lib/components/index.ts @@ -0,0 +1,3 @@ +export { default as CollectionModal } from './CollectionModal.svelte'; +export { default as ErrorAlert } from './ErrorAlert.svelte'; +export * from './skeletons'; diff --git a/apps/questions/apps/web/src/lib/components/skeletons/AppLoadingSkeleton.svelte b/apps/questions/apps/web/src/lib/components/skeletons/AppLoadingSkeleton.svelte new file mode 100644 index 000000000..543a9b282 --- /dev/null +++ b/apps/questions/apps/web/src/lib/components/skeletons/AppLoadingSkeleton.svelte @@ -0,0 +1,47 @@ +
+ + + + +
+
+
+
+
+ +
+
+
+
+
+ +
+ {#each Array(5) as _} +
+ {/each} +
+
+
diff --git a/apps/questions/apps/web/src/lib/components/skeletons/QuestionDetailSkeleton.svelte b/apps/questions/apps/web/src/lib/components/skeletons/QuestionDetailSkeleton.svelte new file mode 100644 index 000000000..067724d23 --- /dev/null +++ b/apps/questions/apps/web/src/lib/components/skeletons/QuestionDetailSkeleton.svelte @@ -0,0 +1,56 @@ +
+ +
+
+
+
+ +
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ {#each Array(3) as _} +
+
+
+
+
+
+
+
+
+
+
+
+
+ {/each} +
+
+
diff --git a/apps/questions/apps/web/src/lib/components/skeletons/QuestionSkeleton.svelte b/apps/questions/apps/web/src/lib/components/skeletons/QuestionSkeleton.svelte new file mode 100644 index 000000000..f0adafcb6 --- /dev/null +++ b/apps/questions/apps/web/src/lib/components/skeletons/QuestionSkeleton.svelte @@ -0,0 +1,32 @@ + + +
+ {#each Array(count) as _, i} +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+
+
+
+ {/each} +
diff --git a/apps/questions/apps/web/src/lib/components/skeletons/index.ts b/apps/questions/apps/web/src/lib/components/skeletons/index.ts new file mode 100644 index 000000000..c67f8fdce --- /dev/null +++ b/apps/questions/apps/web/src/lib/components/skeletons/index.ts @@ -0,0 +1,3 @@ +export { default as QuestionSkeleton } from './QuestionSkeleton.svelte'; +export { default as QuestionDetailSkeleton } from './QuestionDetailSkeleton.svelte'; +export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte'; diff --git a/apps/questions/apps/web/src/routes/(app)/+layout.svelte b/apps/questions/apps/web/src/routes/(app)/+layout.svelte index 592cb5a17..32919ea7b 100644 --- a/apps/questions/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/questions/apps/web/src/routes/(app)/+layout.svelte @@ -119,11 +119,11 @@ {#if sidebarOpen} - Add Collection + Manage Collections {/if} diff --git a/apps/questions/apps/web/src/routes/(app)/+page.svelte b/apps/questions/apps/web/src/routes/(app)/+page.svelte index 60b85e7cd..2fd175053 100644 --- a/apps/questions/apps/web/src/routes/(app)/+page.svelte +++ b/apps/questions/apps/web/src/routes/(app)/+page.svelte @@ -1,5 +1,6 @@ + +
+ +
+
+ + + Back to questions + +

Collections

+

+ Organize your questions into collections +

+
+ +
+ + + {#if collectionsStore.collections.length === 0} +
+
📁
+

No collections yet

+

+ Create your first collection to organize your questions. +

+ +
+ {:else} +
+ {#each collectionsStore.collections as collection} +
+ +
+ +
+ + +
+ +
+ + +
+
+

{collection.name}

+ {#if collection.isDefault} + + Default + + {/if} +
+ {#if collection.description} +

+ {collection.description} +

+ {/if} +

+ {collection.questionCount || 0} questions +

+
+ + +
+ + + {#if deleteConfirm === collection.id} +
+ + +
+ {:else} + + {/if} +
+
+ {/each} +
+ {/if} +
+ + +{#if showModal} + +{/if} diff --git a/apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte b/apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte index 5a0340811..64275a10d 100644 --- a/apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte +++ b/apps/questions/apps/web/src/routes/(app)/question/[id]/+page.svelte @@ -4,6 +4,7 @@ import { questionsApi } from '$lib/api/questions'; import { researchApi } from '$lib/api/research'; import { sourcesApi } from '$lib/api/sources'; + import { QuestionDetailSkeleton, ErrorAlert } from '$lib/components'; import { ArrowLeft, Clock, @@ -95,16 +96,14 @@ } +{#if loading} + +{:else if error} +
+ +
+{:else if question}
- {#if loading} -
- -
- {:else if error} -
- {error} -
- {:else if question} {/if} - {/if}
+{/if} diff --git a/apps/questions/apps/web/src/routes/(app)/settings/+page.svelte b/apps/questions/apps/web/src/routes/(app)/settings/+page.svelte new file mode 100644 index 000000000..68be920cc --- /dev/null +++ b/apps/questions/apps/web/src/routes/(app)/settings/+page.svelte @@ -0,0 +1,178 @@ + + +
+ + + + +
+

+ + Account +

+
+
+
+ +

{authStore.user?.email || 'Not signed in'}

+
+
+ +

{authStore.user?.id || '-'}

+
+
+
+
+ + +
+

+ + Appearance +

+
+ +
+ {#each themeOptions as option} + + {/each} +
+
+
+ + +
+

+ + Notifications +

+
+
+ + + +
+
+
+ + +
+

+ + Privacy & Data +

+
+
+
+

Export Data

+

+ Download all your questions and research data +

+ +
+ +
+ +
+

Delete Account

+

+ Permanently delete your account and all data +

+ {#if deleteConfirm} +
+ + +
+ {:else} + + {/if} +
+
+
+
+ + +
+
+

+ Questions App v1.0.0 +
+ Powered by mana-search +

+
+
+
diff --git a/apps/questions/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/apps/questions/apps/web/src/routes/(auth)/forgot-password/+page.svelte new file mode 100644 index 000000000..e6216ba0e --- /dev/null +++ b/apps/questions/apps/web/src/routes/(auth)/forgot-password/+page.svelte @@ -0,0 +1,81 @@ + + +
+
+

Reset Password

+

Enter your email to receive a reset link

+
+ + {#if success} +
+
📧
+

Check your email

+

+ We've sent a password reset link to {email}. Please check your inbox. +

+ Back to login +
+ {:else} +
+ {#if error} +
+ {error} +
+ {/if} + +
+ + +
+ + +
+ + + {/if} +
diff --git a/apps/questions/apps/web/src/routes/+layout.svelte b/apps/questions/apps/web/src/routes/+layout.svelte index 39771babd..8e870768c 100644 --- a/apps/questions/apps/web/src/routes/+layout.svelte +++ b/apps/questions/apps/web/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; import { apiClient } from '$lib/api/client'; + import { AppLoadingSkeleton } from '$lib/components/skeletons'; let { children } = $props(); @@ -24,14 +25,7 @@ {#if loading} -
-
-
-

Loading...

-
-
+ {:else}
{@render children()}